diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index e4014b5..3c79862 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -2,12 +2,13 @@ import { ChevronRight } from "lucide-react"; import Link from "next/link"; import { Suspense } from "react"; import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter"; +import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe"; 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 { loadDashboardData } from "@/lib/dashboard-data"; -import { addDaysIso, fmtDate, fmtName, todayIso } from "@/lib/format"; +import { addDaysIso, fmtName, todayIso } from "@/lib/format"; import { divisionOf } from "@/lib/org"; import { deriveStatusAsOf } from "@/lib/reports"; import { currentUserId } from "@/lib/auth/session"; @@ -38,13 +39,6 @@ const DOT_STYLES: Record = { Gehaltsanpassung: "bg-warning-text", }; -const KIND_LABEL = { - hire: "Eintritt", - exit: "Austritt", - return: "Rückkehr aus Abwesenheit", - note: "Wiedervorlage", -} as const; - export default async function DashboardPage({ searchParams, }: { @@ -121,17 +115,7 @@ export default async function DashboardPage({ .sort((a, b) => b.count - a.count); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); - type UpcomingItem = { - id: string; - /** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */ - employeeId: string; - label: string; - /** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */ - hinweis?: string; - date: string; - kind: keyof typeof KIND_LABEL; - }; - const upcomingAlle: UpcomingItem[] = [ + const upcomingAlle: AnstehendEintrag[] = [ ...(upcomingHires).map((e) => ({ id: e.id, employeeId: e.id, @@ -163,12 +147,6 @@ export default async function DashboardPage({ })), ].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. // @@ -258,49 +236,15 @@ export default async function DashboardPage({ }> - - {weitere > 0 && ( -

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

- )} + diff --git a/components/dashboard/AnstehendListe.tsx b/components/dashboard/AnstehendListe.tsx new file mode 100644 index 0000000..ee701f5 --- /dev/null +++ b/components/dashboard/AnstehendListe.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { fmtDate } from "@/lib/format"; + +export const KIND_LABEL = { + hire: "Eintritt", + exit: "Austritt", + return: "Rückkehr aus Abwesenheit", + note: "Wiedervorlage", +} as const; + +export type AnstehendEintrag = { + id: string; + /** Ziel des Klicks — bei einer Wiedervorlage die Akte, nicht die Notiz. */ + employeeId: string; + label: string; + /** Zweite Zeile: bei einer Wiedervorlage der Notiztext statt der Art. */ + hinweis?: string; + date: string; + kind: keyof typeof KIND_LABEL; +}; + +/** Wie viele Zeilen die Karte zeigt, bevor aufgeklappt wird. */ +export const VORSCHAU = 8; + +/** + * Die Karte bleibt eine Übersicht — acht Zeilen, und darunter steht, wie + * viele es insgesamt sind. Neu ist, dass diese Zeile ein Knopf ist: wer alle + * sehen will, klappt sie auf, statt den Zeitraum enger zu stellen und die + * Antwort damit zu verändern. + * + * Das geschieht im Browser und nicht über die Adresse, anders als bei + * Zeitraum und Art: die Einträge sind bereits alle geladen, es gibt nichts + * nachzuholen. Ein Umweg über den Server brächte eine Wartezeit für Daten, + * die schon da sind — und einen Eintrag im Verlauf für etwas, das niemand + * zurückgehen will. + */ +export function AnstehendListe({ + eintraege, + today, + leerText, +}: { + eintraege: AnstehendEintrag[]; + today: string; + leerText: string; +}) { + const [alle, setAlle] = useState(false); + const sichtbar = alle ? eintraege : eintraege.slice(0, VORSCHAU); + const weitere = eintraege.length - sichtbar.length; + + return ( + <> +
    + {sichtbar.map((item) => { + // Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten + // haben eine untere Grenze, eine offene Aufgabe nicht. + const ueberfaellig = item.date < today; + return ( +
  • + + + {item.label} + + {KIND_LABEL[item.kind]} + {item.hinweis ? ` — ${item.hinweis}` : ""} + + + + {ueberfaellig ? "überfällig " : ""} + {fmtDate(item.date)} + + +
  • + ); + })} + {eintraege.length === 0 &&

    {leerText}

    } +
+ + {/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste + aufgeklappt, führt er wieder zurück — sonst bliebe die Karte für den + Rest der Sitzung lang, auch wenn jemand nur einmal nachsehen + wollte. */} + {(weitere > 0 || alle) && ( + + )} + + ); +} diff --git a/tests/components/AnstehendListe.test.tsx b/tests/components/AnstehendListe.test.tsx new file mode 100644 index 0000000..9802be0 --- /dev/null +++ b/tests/components/AnstehendListe.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { AnstehendListe, VORSCHAU, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe"; + +// Die Karte zeigt acht Zeilen und darunter, wie viele es insgesamt sind. +// Diese Zeile war lange ein Satz zum Lesen; jetzt ist sie ein Knopf. Wer +// alle sehen will, soll nicht den Zeitraum weiter stellen müssen — das +// änderte die Frage, nicht nur die Antwort. + +const HEUTE = "2026-09-09"; + +function eintrag(nr: number, teil: Partial = {}): AnstehendEintrag { + return { + id: `i${nr}`, + employeeId: `e${nr}`, + label: `Person ${nr}`, + date: `2026-10-${String(nr).padStart(2, "0")}`, + kind: "hire", + ...teil, + }; +} + +const viele = Array.from({ length: 13 }, (_, i) => eintrag(i + 1)); + +function zeige(eintraege: AnstehendEintrag[]) { + return render(); +} + +describe("AnstehendListe", () => { + it("zeigt zunächst nur die Vorschau", () => { + zeige(viele); + expect(screen.getAllByRole("listitem")).toHaveLength(VORSCHAU); + expect(screen.getByText("Person 8")).toBeInTheDocument(); + expect(screen.queryByText("Person 9")).not.toBeInTheDocument(); + }); + + it("nennt im Knopf, wie viele noch kommen", () => { + zeige(viele); + expect(screen.getByRole("button", { name: /und 5 weitere Ereignisse anzeigen/i })).toBeInTheDocument(); + }); + + it("klappt auf Klick alle auf", async () => { + const user = userEvent.setup(); + zeige(viele); + await user.click(screen.getByRole("button", { name: /anzeigen/i })); + + expect(screen.getAllByRole("listitem")).toHaveLength(13); + expect(screen.getByText("Person 13")).toBeInTheDocument(); + }); + + it("führt wieder zurück", async () => { + // Ohne den Weg zurück bliebe die Karte für den Rest der Sitzung lang, + // auch wenn jemand nur einmal nachsehen wollte. + const user = userEvent.setup(); + zeige(viele); + await user.click(screen.getByRole("button", { name: /anzeigen/i })); + await user.click(screen.getByRole("button", { name: /Weniger anzeigen/i })); + + expect(screen.getAllByRole("listitem")).toHaveLength(VORSCHAU); + }); + + it("meldet den Zustand an Hilfsmittel", async () => { + const user = userEvent.setup(); + zeige(viele); + const knopf = screen.getByRole("button", { name: /anzeigen/i }); + expect(knopf).toHaveAttribute("aria-expanded", "false"); + + await user.click(knopf); + expect(screen.getByRole("button", { name: /Weniger/i })).toHaveAttribute("aria-expanded", "true"); + }); + + it("zeigt keinen Knopf, wenn ohnehin alles zu sehen ist", () => { + // Ein Knopf, der nichts aufklappt, ist ein Versprechen ohne Inhalt. + zeige(viele.slice(0, VORSCHAU)); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("sagt bei einem einzelnen Ereignis Einzahl", () => { + zeige(viele.slice(0, VORSCHAU + 1)); + expect(screen.getByRole("button", { name: /und 1 weiteres Ereignis anzeigen/i })).toBeInTheDocument(); + }); + + it("zeigt den übergebenen Text, wenn nichts ansteht", () => { + zeige([]); + expect(screen.getByText("Nichts steht an.")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("kennzeichnet überfällige Wiedervorlagen", () => { + // Nur Wiedervorlagen können überfällig sein: die anderen Arten haben + // eine untere Grenze, eine offene Aufgabe nicht. + zeige([eintrag(1, { kind: "note", date: "2026-08-01", hinweis: "Dienstzettel" })]); + expect(screen.getByText(/überfällig/)).toBeInTheDocument(); + }); + + it("verlinkt auf die Akte, nicht auf die Notiz", () => { + zeige([eintrag(1, { kind: "note", employeeId: "e42" })]); + expect(screen.getByRole("link")).toHaveAttribute("href", "/employees/e42"); + }); +});