diff --git a/components/orgchart/AsOfPicker.tsx b/components/orgchart/AsOfPicker.tsx index 816b133..3a7a4c5 100644 --- a/components/orgchart/AsOfPicker.tsx +++ b/components/orgchart/AsOfPicker.tsx @@ -2,8 +2,9 @@ import { CalendarClock } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; import { Button } from "@/components/ui/Button"; -import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { FILTER_SELECT_CLASS, istMeldbaresDatum } from "@/components/ui/Field"; import { fmtDate } from "@/lib/format"; type AsOfPickerProps = { @@ -27,6 +28,14 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false }); } + // Angeglichen während des Renderns, nicht in einem Effekt — siehe DateField. + const [entwurf, setEntwurf] = useState(asOf); + const [zuletzt, setZuletzt] = useState(asOf); + if (asOf !== zuletzt) { + setZuletzt(asOf); + setEntwurf(asOf); + } + const isToday = asOf === today; const isFuture = asOf > today; // Assignments only started being recorded when the history table was @@ -41,11 +50,23 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO Stichtag + {/* Der Tippstand bleibt hier, gemeldet wird nur ein vollständiges + Datum: jede Änderung ist eine Navigation, und beim Tippen der + Jahreszahl entstehen unterwegs 0002, 0020 und 0202 — jede davon + lud die Seite neu und setzte das Feld mitten im Tippen zurück. + Dieselbe Regel wie in DateField, dort steht auch der lange Grund. */} setAsOf(e.target.value || undefined)} + value={entwurf} + onChange={(e) => { + const wert = e.target.value; + setEntwurf(wert); + if (istMeldbaresDatum(wert) && wert !== asOf) setAsOf(wert || undefined); + }} + onBlur={() => { + if (entwurf !== asOf && istMeldbaresDatum(entwurf)) setAsOf(entwurf || undefined); + }} className={FILTER_SELECT_CLASS} /> {!isToday && ( diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 9380a23..56a3753 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -6,7 +6,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { deleteReport, saveReport } from "@/actions/reports"; import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; -import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field"; +import { CONTROL_CLASS, DateField, SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; @@ -188,17 +188,15 @@ function KriterienBlock({
{k.label}
- onChange({ [`${k.key}From`]: v || undefined })} /> - onChange({ [`${k.key}To`]: v || undefined })} /> @@ -471,11 +469,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) { />
- updateParams({ asOf: v })} /> @@ -556,10 +553,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) { Zeitraum
- updateParams({ from: v })} @@ -574,10 +570,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
- updateParams({ to: v })} diff --git a/components/ui/Field.tsx b/components/ui/Field.tsx index 8a87e58..429cc62 100644 --- a/components/ui/Field.tsx +++ b/components/ui/Field.tsx @@ -1,6 +1,13 @@ "use client"; -import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react"; +import { + useId, + useState, + type ReactNode, + type SelectHTMLAttributes, + type InputHTMLAttributes, + type TextareaHTMLAttributes, +} from "react"; // Form primitives. // @@ -112,6 +119,74 @@ export function TextField({ label, required, hint, error, dense, className, valu ); } +/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */ +const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen. + * + * Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort + * ein blankes `` steht. Zwei Fassungen derselben Regel laufen in diesem + * Projekt erfahrungsgemäss auseinander. + */ +export function istMeldbaresDatum(wert: string): boolean { + // Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen. + if (wert === "") return true; + if (!VOLLSTAENDIGES_DATUM.test(wert)) return false; + // Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt + // unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von + // einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse. + return Number(wert.slice(0, 4)) >= 1000; +} + +/** + * Ein Datumsfeld, das Zwischenstände für sich behält. + * + * Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine + * Änderung heisst `router.push`, also neu laden und neu rendern. Ein + * `` meldet beim Tippen der Jahreszahl aber viermal: + * 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und + * jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im + * Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein + * fertiges Datum setzt. + * + * Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist + * — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des + * Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist. + */ +export function DateField({ value, onChange, onBlur, ...rest }: Omit) { + const [entwurf, setEntwurf] = useState(value); + const [zuletzt, setZuletzt] = useState(value); + + // Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und + // nach dem Melden steht hier ohnehin schon dasselbe. + // + // Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt + // liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau + // den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn + // aus genau diesem Grund. + if (value !== zuletzt) { + setZuletzt(value); + setEntwurf(value); + } + + return ( + { + setEntwurf(v); + if (istMeldbaresDatum(v) && v !== value) onChange(v); + }} + onBlur={(e) => { + if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf); + onBlur?.(e); + }} + /> + ); +} + type Option = { value: string; label: string; disabled?: boolean }; type SelectFieldProps = FieldShellProps & diff --git a/tests/components/DateField.test.tsx b/tests/components/DateField.test.tsx new file mode 100644 index 0000000..a342ba4 --- /dev/null +++ b/tests/components/DateField.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DateField, istMeldbaresDatum } from "@/components/ui/Field"; + +// Der Anlass: in den Berichten hängt jedes Datumsfeld an der Adresszeile, eine +// Änderung heisst also neu laden. Ein `` meldet beim Tippen +// der Jahreszahl aber viermal — 0002, 0020, 0202, 2026 —, und die ersten drei +// setzten das Feld mitten im Tippen zurück. Mit dem Kalender ging es, weil der +// in einem Zug ein fertiges Datum setzt. + +describe("istMeldbaresDatum", () => { + it("nimmt ein vollständiges Datum an", () => { + expect(istMeldbaresDatum("2026-09-30")).toBe(true); + expect(istMeldbaresDatum("1980-01-01")).toBe(true); + }); + + it("nimmt das leere Feld an, denn kein Datum ist auch eine Aussage", () => { + expect(istMeldbaresDatum("")).toBe(true); + }); + + it("weist die Zwischenstände beim Tippen ab", () => { + for (const zwischen of ["0002-09-30", "0020-09-30", "0202-09-30"]) { + expect(istMeldbaresDatum(zwischen), zwischen).toBe(false); + } + }); + + it("weist Bruchstücke ab", () => { + expect(istMeldbaresDatum("2026-09")).toBe(false); + expect(istMeldbaresDatum("2026")).toBe(false); + }); +}); + +describe("DateField", () => { + function feld(onChange: (v: string) => void, value = "") { + render(); + return screen.getByLabelText("Von") as HTMLInputElement; + } + + it("meldet den Zwischenstand nicht, zeigt ihn aber an", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet); + + fireEvent.change(input, { target: { value: "0002-09-30" } }); + expect(gemeldet).not.toHaveBeenCalled(); + // Der Tippstand bleibt stehen: würde das Feld ihn verwerfen, kämpfte es + // gegen die Eingabe. + expect(input.value).toBe("0002-09-30"); + }); + + it("meldet, sobald die Jahreszahl vollständig ist", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet); + + fireEvent.change(input, { target: { value: "0002-09-30" } }); + fireEvent.change(input, { target: { value: "2026-09-30" } }); + expect(gemeldet).toHaveBeenCalledExactlyOnceWith("2026-09-30"); + }); + + it("meldet das Leeren", () => { + const gemeldet = vi.fn(); + const input = feld(gemeldet, "2026-09-30"); + fireEvent.change(input, { target: { value: "" } }); + expect(gemeldet).toHaveBeenCalledExactlyOnceWith(""); + }); + + it("reicht beim Verlassen nach, was liegengeblieben ist", () => { + // Kommt in der Praxis kaum vor, ist aber der Rückfall: was nicht gemeldet + // wurde, geht spätestens hier hinaus — sofern es eine Aussage ist. + const gemeldet = vi.fn(); + const input = feld(gemeldet); + fireEvent.change(input, { target: { value: "0002-09-30" } }); + fireEvent.blur(input); + expect(gemeldet).not.toHaveBeenCalled(); + }); + + it("übernimmt einen Wert, der von aussen kommt", () => { + // „Zurücksetzen" in den Kriterien leert das Feld — der Tippstand darf + // dann nicht stehenbleiben. + const { rerender } = render(); + const input = screen.getByLabelText("Von") as HTMLInputElement; + expect(input.value).toBe("2026-09-30"); + + rerender(); + expect(input.value).toBe(""); + }); +});