Let the overview say what "upcoming" means

Sixty days and all three kinds was a guess, and it was the only one on
offer. Payroll cares about next month; the person filling a vacancy
cares about entries and nothing else. The card now takes a period and a
set of kinds.

The choice lives in the address rather than in the browser, because it
has to: the page is built on the server, and ninety days pulls in rows
that were never loaded at sixty. Filtering client-side would silently
cap the answer at whatever the first query happened to fetch. It also
means a filtered overview can be sent to someone and opened again the
same way.

Deselecting every kind returns to all of them. An empty card is not an
answer to a question nobody asked, and the way back would otherwise be
one click further than the way in. The default period and the full set
are absent from the URL instead of written into it, so a shared link
carries only what was actually chosen.

Anything the address cannot be trusted to hold is rejected: an unknown
period falls back to sixty rather than reaching the query, which would
otherwise be an invitation to ask for ten years of rows through a link.

Eight rows still, with a count of what did not fit underneath — this is
an overview, and the employee list is where lists belong.

Not verified in a browser: the built-in preview has no company sign-in,
so the page redirects to the login before it renders. Types, lint and
386 tests pass, and the filter's behaviour is covered directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:56:05 +02:00
parent 16216c5537
commit 6957b95a97
5 changed files with 361 additions and 30 deletions

View File

@@ -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<string, string> = {
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() {
</Card>
<Card>
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
<CardTitle className="mb-2">Anstehend ({zeitraum} Tage)</CardTitle>
{/* useSearchParams braucht eine Suspense-Grenze; ohne sie fällt beim
Bauen die ganze Seite auf Rendern zur Laufzeit zurück. */}
<Suspense fallback={<div className="mb-2 h-9" />}>
<AnstehendFilter zeitraum={zeitraum} arten={arten} />
</Suspense>
<ul className="flex flex-col divide-y divide-border-subtle">
{upcoming.map((item) => (
<li key={`${item.kind}-${item.id}`}>
@@ -324,8 +357,19 @@ export default async function DashboardPage() {
</Link>
</li>
))}
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
{upcoming.length === 0 && (
<p className="py-2 text-sm text-ink-muted">
{istEingeschraenkt(zeitraum, arten)
? "Zu dieser Auswahl steht nichts an."
: "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>