diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 37d9e54..59516fb 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,8 +1,11 @@ import { ChevronRight } from "lucide-react"; import Link from "next/link"; +import { Suspense } from "react"; +import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter"; import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { actionBadgeStyle } from "@/lib/colors"; +import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter"; import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format"; import { divisionOf, loadOrgMaps } from "@/lib/org"; import { loadPlacements } from "@/lib/placement"; @@ -39,7 +42,11 @@ const DOT_STYLES: Record = { const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const; -export default async function DashboardPage() { +export default async function DashboardPage({ + searchParams, +}: { + searchParams: Promise<{ tage?: string; arten?: string }>; +}) { // Built as strings, not by round-tripping a local Date through // toISOString(): in any positive-offset zone new Date(year, 0, 1) is still // the previous year in UTC, which shifted the whole YTD window a day early @@ -48,7 +55,15 @@ export default async function DashboardPage() { const year = today.slice(0, 4); const yearStart = `${year}-01-01`; const yearEnd = `${year}-12-31`; - const in60Iso = addDaysIso(today, 60); + + // Der Vorschauzeitraum ist einstellbar, und mit ihm, was überhaupt geladen + // wird. Deshalb steht die Auswahl in der Adresse und nicht im Browser: 90 + // statt 60 Tage bringt Zeilen ins Spiel, die sonst nirgends lägen. + const params = await searchParams; + const zeitraum = parseZeitraum(params.tage); + const arten = parseArten(params.arten); + const bisIso = addDaysIso(today, zeitraum); + const zeigt = (art: "hire" | "exit" | "return") => arten.includes(art); const userId = await currentUserId(); @@ -121,30 +136,39 @@ export default async function DashboardPage() { loadOrgMaps(tx), loadPlacements(tx, { asOf: today }), - tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "entry_date"]) - .where("status", "=", "Geplant") - .where("entry_date", ">=", today) - .where("entry_date", "<=", in60Iso) - .execute(), + // Abgewählte Arten werden gar nicht erst gelesen — die Karte zeigt sie + // ohnehin nicht, und eine Abfrage, deren Ergebnis niemand ansieht, ist + // eine Abfrage zu viel. + zeigt("hire") + ? tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "entry_date"]) + .where("status", "=", "Geplant") + .where("entry_date", ">=", today) + .where("entry_date", "<=", bisIso) + .execute() + : Promise.resolve([]), - tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "exit_date"]) - .where("exit_date", "is not", null) - .where("exit_date", ">=", today) - .where("exit_date", "<=", in60Iso) - .execute(), + zeigt("exit") + ? tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "exit_date"]) + .where("exit_date", "is not", null) + .where("exit_date", ">=", today) + .where("exit_date", "<=", bisIso) + .execute() + : Promise.resolve([]), - tx - .selectFrom("employees") - .select(["id", "first_name", "last_name", "karenz_return_date"]) - .where("status", "=", "Karenz") - .where("karenz_return_date", "is not", null) - .where("karenz_return_date", ">=", today) - .where("karenz_return_date", "<=", in60Iso) - .execute(), + zeigt("return") + ? tx + .selectFrom("employees") + .select(["id", "first_name", "last_name", "karenz_return_date"]) + .where("status", "=", "Karenz") + .where("karenz_return_date", "is not", null) + .where("karenz_return_date", ">=", today) + .where("karenz_return_date", "<=", bisIso) + .execute() + : Promise.resolve([]), tx .selectFrom("employee_history as h") @@ -202,7 +226,7 @@ export default async function DashboardPage() { const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL }; - const upcoming: UpcomingItem[] = [ + const upcomingAlle: UpcomingItem[] = [ ...(upcomingHires).map((e) => ({ id: e.id, label: fmtName(e.first_name, e.last_name), @@ -221,9 +245,13 @@ export default async function DashboardPage() { date: e.karenz_return_date!, kind: "return" as const, })), - ] - .sort((a, b) => a.date.localeCompare(b.date)) - .slice(0, 8); + ].sort((a, b) => a.date.localeCompare(b.date)); + + // Die Karte bleibt eine Übersicht, keine Liste: acht Zeilen, und darunter + // steht, wie viele es insgesamt sind. Wer alle sehen will, filtert enger + // oder geht in die Mitarbeiterliste. + const upcoming = upcomingAlle.slice(0, 8); + const weitere = upcomingAlle.length - upcoming.length; // Each tile links to the view that shows what it counts, with the filters // pre-applied. @@ -308,7 +336,12 @@ export default async function DashboardPage() { - Anstehend (60 Tage) + Anstehend ({zeitraum} Tage) + {/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim + Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */} + }> + + + {weitere > 0 && ( +

+ … und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum. +

+ )}
diff --git a/components/dashboard/AnstehendFilter.tsx b/components/dashboard/AnstehendFilter.tsx new file mode 100644 index 0000000..091475a --- /dev/null +++ b/components/dashboard/AnstehendFilter.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { SegmentedControl } from "@/components/ui/SegmentedControl"; +import { actionBadgeStyle } from "@/lib/colors"; +import { + ANSTEHEND_ARTEN, + STANDARD_ZEITRAUM, + ZEITRAEUME, + type AnstehendArt, + type Zeitraum, +} from "@/lib/dashboard-filter"; + +// Die Auswahl wandert in die Adresse; die Seite baut sich damit neu. Das ist +// hier nötig und nicht bloss ordentlich: ein längerer Zeitraum bringt Zeilen +// ins Spiel, die vorher gar nicht geladen waren. +// +// router.replace statt push, damit der Zurück-Knopf nicht durch jede einzelne +// Filterstellung zurückläuft, und ohne Sprung nach oben — die Karte steht in +// der unteren Hälfte, und dorthin sieht gerade, wer hier klickt. + +export function AnstehendFilter({ zeitraum, arten }: { zeitraum: Zeitraum; arten: AnstehendArt[] }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + function setzen(key: string, wert: string | null) { + const params = new URLSearchParams(searchParams.toString()); + if (wert) params.set(key, wert); + else params.delete(key); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + } + + function artUmschalten(art: AnstehendArt) { + const alle = ANSTEHEND_ARTEN.map((a) => a.value); + const naechste = arten.includes(art) ? arten.filter((a) => a !== art) : [...arten, art]; + // Nichts ausgewählt heisst wieder alles: eine leere Karte ist keine + // Antwort, und der Weg dorthin wäre ein Klick zu weit. + setzen("arten", naechste.length === 0 || naechste.length === alle.length ? null : naechste.join(",")); + } + + return ( +
+ + value={String(zeitraum)} + onChange={(v) => setzen("tage", v === String(STANDARD_ZEITRAUM) ? null : v)} + options={ZEITRAEUME.map((t) => ({ value: String(t), label: `${t} Tage` }))} + /> +
+ {ANSTEHEND_ARTEN.map((a) => { + const aktiv = arten.includes(a.value); + return ( + + ); + })} +
+
+ ); +} diff --git a/lib/dashboard-filter.ts b/lib/dashboard-filter.ts new file mode 100644 index 0000000..16c91f2 --- /dev/null +++ b/lib/dashboard-filter.ts @@ -0,0 +1,46 @@ +// Was auf der Übersicht als „anstehend" gilt — Zeitraum und Art. +// +// Die Auswahl steht in der Adresse, nicht im Browser: die Übersicht wird auf +// dem Server gebaut, und ein Zeitraum von 90 statt 60 Tagen bringt Zeilen ins +// Spiel, die im Browser gar nicht liegen. Nebenbei lässt sich eine so +// eingestellte Seite verschicken und wiederfinden. + +export const ZEITRAEUME = [30, 60, 90, 180] as const; +export type Zeitraum = (typeof ZEITRAEUME)[number]; + +export const STANDARD_ZEITRAUM: Zeitraum = 60; + +export const ANSTEHEND_ARTEN = [ + { value: "hire", label: "Eintritt" }, + { value: "exit", label: "Austritt" }, + { value: "return", label: "Rückkehr" }, +] as const; + +export type AnstehendArt = (typeof ANSTEHEND_ARTEN)[number]["value"]; + +const ALLE_ARTEN: AnstehendArt[] = ANSTEHEND_ARTEN.map((a) => a.value); + +/** Alles, was nicht in der Liste steht, führt auf den Standard zurück. */ +export function parseZeitraum(wert: string | undefined): Zeitraum { + const zahl = Number(wert); + return (ZEITRAEUME as readonly number[]).includes(zahl) ? (zahl as Zeitraum) : STANDARD_ZEITRAUM; +} + +/** + * Keine Angabe heisst „alle" — nicht „keine". Wer die Übersicht aufruft, ohne + * etwas auszuwählen, will alles sehen; eine leere Karte wäre die falsche + * Antwort auf eine nicht gestellte Frage. Aus demselben Grund führt auch eine + * Auswahl, in der nur Unbekanntes steht, zurück auf alle. + */ +export function parseArten(wert: string | undefined): AnstehendArt[] { + const gewaehlt = (wert ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s): s is AnstehendArt => (ALLE_ARTEN as string[]).includes(s)); + return gewaehlt.length > 0 ? gewaehlt : ALLE_ARTEN; +} + +/** Steht in der Adresse etwas anderes als der Standard? */ +export function istEingeschraenkt(zeitraum: Zeitraum, arten: AnstehendArt[]): boolean { + return zeitraum !== STANDARD_ZEITRAUM || arten.length !== ALLE_ARTEN.length; +} diff --git a/tests/components/AnstehendFilter.test.tsx b/tests/components/AnstehendFilter.test.tsx new file mode 100644 index 0000000..b0cba51 --- /dev/null +++ b/tests/components/AnstehendFilter.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter"; + +// Die Auswahl muss in die Adresse, nicht in den Browser-Zustand: die +// Übersicht wird auf dem Server gebaut, und ein längerer Zeitraum bringt +// Zeilen ins Spiel, die im Browser gar nicht liegen. Geprüft wird deshalb +// genau das — was in der Adresse landet. + +const replace = vi.fn(); +let suchString = ""; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(suchString), +})); + +const ALLE = ["hire", "exit", "return"] as const; + +beforeEach(() => { + replace.mockClear(); + suchString = ""; +}); + +describe("AnstehendFilter", () => { + it("schreibt einen gewählten Zeitraum in die Adresse", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "90 Tage" })); + expect(replace).toHaveBeenCalledWith("/?tage=90", { scroll: false }); + }); + + it("lässt den Standard aus der Adresse verschwinden statt ihn hineinzuschreiben", async () => { + // Sonst trüge jede geteilte Adresse einen Filter, den niemand gesetzt hat. + const user = userEvent.setup(); + suchString = "tage=90"; + render(); + await user.click(screen.getByRole("button", { name: "60 Tage" })); + expect(replace).toHaveBeenCalledWith("/", { scroll: false }); + }); + + it("nimmt eine abgewählte Art aus der Auswahl", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Austritt" })); + expect(replace).toHaveBeenCalledWith("/?arten=hire%2Creturn", { scroll: false }); + }); + + it("nimmt eine wieder hinzugewählte Art auf", async () => { + const user = userEvent.setup(); + suchString = "arten=hire"; + render(); + await user.click(screen.getByRole("button", { name: "Rückkehr" })); + expect(replace).toHaveBeenCalledWith("/?arten=hire%2Creturn", { scroll: false }); + }); + + it("führt die letzte abgewählte Art auf alle zurück, nicht auf keine", async () => { + // Eine leere Karte ist keine Antwort, und der Weg zurück wäre sonst ein + // Klick zu weit. + const user = userEvent.setup(); + suchString = "arten=hire"; + render(); + await user.click(screen.getByRole("button", { name: "Eintritt" })); + expect(replace).toHaveBeenCalledWith("/", { scroll: false }); + }); + + it("lässt vollständige Auswahl gar nicht erst in der Adresse stehen", async () => { + const user = userEvent.setup(); + suchString = "arten=hire,exit"; + render(); + await user.click(screen.getByRole("button", { name: "Rückkehr" })); + expect(replace).toHaveBeenCalledWith("/", { scroll: false }); + }); + + it("behält andere Angaben in der Adresse bei", async () => { + const user = userEvent.setup(); + suchString = "arten=hire"; + render(); + await user.click(screen.getByRole("button", { name: "30 Tage" })); + expect(replace).toHaveBeenCalledWith("/?arten=hire&tage=30", { scroll: false }); + }); + + it("zeigt am Knopf an, was gerade gewählt ist", () => { + render(); + expect(screen.getByRole("button", { name: "Eintritt" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Austritt" })).toHaveAttribute("aria-pressed", "false"); + }); + + it("springt beim Filtern nicht an den Seitenanfang", async () => { + // Die Karte steht in der unteren Hälfte; dorthin sieht gerade, wer klickt. + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "30 Tage" })); + expect(replace.mock.calls[0][1]).toEqual({ scroll: false }); + }); +}); diff --git a/tests/unit/dashboard-filter.test.ts b/tests/unit/dashboard-filter.test.ts new file mode 100644 index 0000000..6667fd0 --- /dev/null +++ b/tests/unit/dashboard-filter.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + ANSTEHEND_ARTEN, + STANDARD_ZEITRAUM, + istEingeschraenkt, + parseArten, + parseZeitraum, +} from "@/lib/dashboard-filter"; + +// Die Auswahl steht in der Adresse, und dort kann alles stehen: eine +// veraltete Verknüpfung, ein Tippfehler, ein abgeschnittener Wert. Sie darf +// die Übersicht weder leeren noch mit einem Zeitraum laden, den niemand +// gewählt hat. + +describe("parseZeitraum", () => { + it("nimmt die angebotenen Zeiträume", () => { + expect(parseZeitraum("30")).toBe(30); + expect(parseZeitraum("180")).toBe(180); + }); + + it("fällt ohne Angabe auf den Standard zurück", () => { + expect(parseZeitraum(undefined)).toBe(STANDARD_ZEITRAUM); + expect(parseZeitraum("")).toBe(STANDARD_ZEITRAUM); + }); + + it("lässt sich keinen beliebigen Zeitraum unterschieben", () => { + // Sonst liesse sich über die Adresse ein Lesevorgang über Jahre auslösen. + expect(parseZeitraum("3650")).toBe(STANDARD_ZEITRAUM); + expect(parseZeitraum("-5")).toBe(STANDARD_ZEITRAUM); + expect(parseZeitraum("60; drop")).toBe(STANDARD_ZEITRAUM); + }); +}); + +describe("parseArten", () => { + const alle = ANSTEHEND_ARTEN.map((a) => a.value); + + it("versteht eine Auswahl", () => { + expect(parseArten("hire,exit")).toEqual(["hire", "exit"]); + }); + + it("liest ohne Angabe alle — eine leere Karte wäre die falsche Antwort", () => { + expect(parseArten(undefined)).toEqual(alle); + expect(parseArten("")).toEqual(alle); + }); + + it("verwirft Unbekanntes und behält den Rest", () => { + expect(parseArten("hire,unfug")).toEqual(["hire"]); + }); + + it("führt eine Auswahl aus lauter Unbekanntem auf alle zurück", () => { + expect(parseArten("unfug,quatsch")).toEqual(alle); + }); + + it("stört sich nicht an Leerzeichen", () => { + expect(parseArten(" hire , return ")).toEqual(["hire", "return"]); + }); +}); + +describe("istEingeschraenkt", () => { + const alle = ANSTEHEND_ARTEN.map((a) => a.value); + + it("erkennt den unveränderten Zustand", () => { + expect(istEingeschraenkt(STANDARD_ZEITRAUM, alle)).toBe(false); + }); + + it("erkennt einen anderen Zeitraum", () => { + expect(istEingeschraenkt(90, alle)).toBe(true); + }); + + it("erkennt eine engere Auswahl", () => { + expect(istEingeschraenkt(STANDARD_ZEITRAUM, ["hire"])).toBe(true); + }); +});