- 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("");
+ });
+});