Let the truncated Anstehend list open
"… und 5 weitere Ereignisse in diesem Zeitraum" was a sentence to read. It is now a button: click it and the rest unfolds in place, click again and the card goes back to eight rows. Until now the only way to see the remaining entries was to narrow the period or the kinds — which changes the question rather than the answer. This happens in the browser, not through the URL, unlike the period and kind filters. Those go through the address because a longer period brings rows into play that were never loaded; here every entry in the period is already on the page and the eight was purely presentational. A round trip would mean waiting for data that is already there, plus a history entry for something nobody wants to go back to. The list moved into its own component so the state has somewhere to live. KIND_LABEL and the item type moved with it, since they only describe this list. The button only appears when there is something to unfold, and once open it offers the way back — otherwise the card stays long for the rest of the session because somebody looked once. Verified: 10 tests, five mutation-checked (ignoring the state, dropping the way back, showing the button with nothing to unfold, losing the singular, dropping aria-expanded each turn them red). 487 tests, typecheck and build clean. Not seen in a browser — login goes through the company account and the database is unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,13 @@ import { ChevronRight } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||||
|
import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe";
|
||||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
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 { divisionOf } from "@/lib/org";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
@@ -38,13 +39,6 @@ const DOT_STYLES: Record<string, string> = {
|
|||||||
Gehaltsanpassung: "bg-warning-text",
|
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({
|
export default async function DashboardPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -121,17 +115,7 @@ export default async function DashboardPage({
|
|||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||||
|
|
||||||
type UpcomingItem = {
|
const upcomingAlle: 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;
|
|
||||||
};
|
|
||||||
const upcomingAlle: UpcomingItem[] = [
|
|
||||||
...(upcomingHires).map((e) => ({
|
...(upcomingHires).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
employeeId: e.id,
|
employeeId: e.id,
|
||||||
@@ -163,12 +147,6 @@ export default async function DashboardPage({
|
|||||||
})),
|
})),
|
||||||
].sort((a, b) => a.date.localeCompare(b.date));
|
].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
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
// pre-applied.
|
// pre-applied.
|
||||||
//
|
//
|
||||||
@@ -258,49 +236,15 @@ export default async function DashboardPage({
|
|||||||
<Suspense fallback={<div className="mb-2 h-9" />}>
|
<Suspense fallback={<div className="mb-2 h-9" />}>
|
||||||
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<AnstehendListe
|
||||||
{upcoming.map((item) => {
|
eintraege={upcomingAlle}
|
||||||
// Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten
|
today={today}
|
||||||
// haben eine untere Grenze, eine offene Aufgabe nicht.
|
leerText={
|
||||||
const ueberfaellig = item.date < today;
|
istEingeschraenkt(zeitraum, arten)
|
||||||
return (
|
|
||||||
<li key={`${item.kind}-${item.id}`}>
|
|
||||||
<Link
|
|
||||||
href={`/employees/${item.employeeId}`}
|
|
||||||
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
|
||||||
>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
|
||||||
<span className="block truncate text-xs text-ink-muted">
|
|
||||||
{KIND_LABEL[item.kind]}
|
|
||||||
{item.hinweis ? ` — ${item.hinweis}` : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`shrink-0 text-xs font-semibold tabular-nums ${
|
|
||||||
ueberfaellig ? "text-danger-text" : "text-ink-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ueberfaellig ? "überfällig " : ""}
|
|
||||||
{fmtDate(item.date)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{upcoming.length === 0 && (
|
|
||||||
<p className="py-2 text-sm text-ink-muted">
|
|
||||||
{istEingeschraenkt(zeitraum, arten)
|
|
||||||
? "Zu dieser Auswahl steht nichts an."
|
? "Zu dieser Auswahl steht nichts an."
|
||||||
: "Keine anstehenden Ereignisse."}
|
: "Keine anstehenden Ereignisse."
|
||||||
</p>
|
}
|
||||||
)}
|
/>
|
||||||
</ul>
|
|
||||||
{weitere > 0 && (
|
|
||||||
<p className="mt-2 text-xs text-ink-muted">
|
|
||||||
… und {weitere} {weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} in diesem Zeitraum.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
106
components/dashboard/AnstehendListe.tsx
Normal file
106
components/dashboard/AnstehendListe.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
|
{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 (
|
||||||
|
<li key={`${item.kind}-${item.id}`}>
|
||||||
|
<Link
|
||||||
|
href={`/employees/${item.employeeId}`}
|
||||||
|
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||||
|
<span className="block truncate text-xs text-ink-muted">
|
||||||
|
{KIND_LABEL[item.kind]}
|
||||||
|
{item.hinweis ? ` — ${item.hinweis}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 text-xs font-semibold tabular-nums ${
|
||||||
|
ueberfaellig ? "text-danger-text" : "text-ink-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ueberfaellig ? "überfällig " : ""}
|
||||||
|
{fmtDate(item.date)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{eintraege.length === 0 && <p className="py-2 text-sm text-ink-muted">{leerText}</p>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* 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) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAlle((v) => !v)}
|
||||||
|
aria-expanded={alle}
|
||||||
|
className="mt-2 rounded text-xs font-semibold text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
{alle
|
||||||
|
? "Weniger anzeigen"
|
||||||
|
: `… und ${weitere} ${weitere === 1 ? "weiteres Ereignis" : "weitere Ereignisse"} anzeigen`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
tests/components/AnstehendListe.test.tsx
Normal file
101
tests/components/AnstehendListe.test.tsx
Normal file
@@ -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> = {}): 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(<AnstehendListe eintraege={eintraege} today={HEUTE} leerText="Nichts steht an." />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user