"use client"; import { ChevronDown, FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { deleteReport, saveReport } from "@/actions/reports"; import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field"; import { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; import { anzahlKriterien, AUSWAHL_KRITERIEN, JANEIN_KRITERIEN, kriterienParameter, ZEITRAUM_KRITERIEN, type Criteria, } from "@/lib/report-criteria"; import { AVERAGE_MEASURES, EVENT_DATE_OPEN, EVENT_GROUP_LABELS, EREIGNIS_AUSWAHL, EREIGNIS_UNTERTYP, EVENT_REPORT_PRESETS, EVENT_TYPE_LABELS, GROUP_LABELS, MEASURE_LABELS, parseStatuses, REPORT_PRESETS, sortKeysForDimension, STATUS_OPTIONS, todayIso, untertypOptionen, type EventGroupDimension, type GroupDimension, type Measure, type ReportRow, type UnitOption, } from "@/lib/reports"; import type { HistoryEventType } from "@/lib/types"; const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"]; const EVENT_TYPES = EREIGNIS_AUSWAHL; /** Wie viele Namen eine aufgeklappte Zeile zeigt, bevor der Rest auf Klick kommt. */ const VORSCHAU_NAMEN = 12; type SavedReport = { id: string; name: string; config: Record }; type OrgOption = { id: string; name: string }; type CommonProps = { rows: ReportRow[]; total: number; recordCount: number; units: UnitOption[]; locations: OrgOption[]; savedReports: SavedReport[]; }; type SnapshotProps = CommonProps & { mode: "snapshot"; measure: Measure; group: GroupDimension; split: GroupDimension | ""; asOf: string; filters: { division: string; location: string; status: string }; criteria: Criteria; }; type EventsProps = CommonProps & { mode: "events"; eventGroup: EventGroupDimension; eventSplit: EventGroupDimension | ""; eventType: string; /** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */ eventSubtype: string; eventFilters: { division: string; location: string; from: string; to: string }; }; type ReportsPageClientProps = SnapshotProps | EventsProps; /** * Alle übrigen Auswahlkriterien, eingeklappt. * * Aufgeklappt wäre die Leiste dreimal so lang wie der Bericht daneben, und * die vier Filter, die fast immer gemeint sind, würden darin untergehen. * Eingeklappt steht nur die Zahl der gesetzten Kriterien da — und die ist * das, was man wissen muss: ob gerade etwas einschränkt, das man vergessen * hat. */ function KriterienBlock({ criteria, onChange, onReset, }: { criteria: Criteria; onChange: (patch: Record) => void; onReset: () => void; }) { const [offen, setOffen] = useState(false); const anzahl = anzahlKriterien(criteria); return (
{anzahl > 0 && ( )}
{offen && (
{AUSWAHL_KRITERIEN.map((k) => ( ))}
Merkmale
{JANEIN_KRITERIEN.map((k) => (
{k.label}
))}
{ZEITRAUM_KRITERIEN.map((k) => (
{k.label}
onChange({ [`${k.key}From`]: v || undefined })} /> onChange({ [`${k.key}To`]: v || undefined })} />
))}
Wochenstunden
onChange({ hoursFrom: v || undefined })} /> onChange({ hoursTo: v || undefined })} />

Alle Kriterien gelten für den Bericht und für beide Exporte.

)}
); } function formatValue(measure: Measure, value: number): string { if (measure === "headcount") return String(Math.round(value)); if (measure === "fte") return value.toFixed(1); if (measure === "parttime_rate" || measure === "female_share") return `${value.toFixed(1)}%`; if (measure === "avg_age" || measure === "avg_tenure") return `${value.toFixed(1)} Jahre`; return value.toFixed(1); } export function ReportsPageClient(props: ReportsPageClientProps) { const { mode, rows, total, recordCount, units, locations, savedReports } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { showToast } = useToast(); const [expandedKey, setExpandedKey] = useState(null); // Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand // dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt, // ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren. const [volleListeKey, setVolleListeKey] = useState(null); const [saveModalOpen, setSaveModalOpen] = useState(false); const [newReportName, setNewReportName] = useState(""); const [savingReport, setSavingReport] = useState(false); function updateParams(patch: Record) { const sp = new URLSearchParams(searchParams.toString()); for (const [k, v] of Object.entries(patch)) { if (v) sp.set(k, v); else sp.delete(k); } router.push(`${pathname}?${sp.toString()}`, { scroll: false }); } function switchMode(next: "snapshot" | "events") { router.push(`${pathname}?mode=${next}`, { scroll: false }); } function toggleStatus(status: string) { if (mode !== "snapshot") return; const current = parseStatuses(props.filters.status); const next = current.includes(status as (typeof current)[number]) ? current.filter((s) => s !== status) : [...current, status]; updateParams({ status: next.length > 0 ? next.join(",") : undefined }); } function resetKriterien() { const patch: Record = {}; for (const p of kriterienParameter()) patch[p] = undefined; updateParams(patch); } function applyPreset(preset: { group: string; split?: string; eventType?: string; measure?: string }) { const sp = new URLSearchParams({ mode }); if (preset.measure) sp.set("measure", preset.measure); sp.set("group", preset.group); if (preset.split) sp.set("split", preset.split); if (preset.eventType) sp.set("eventType", preset.eventType); router.push(`${pathname}?${sp.toString()}`, { scroll: false }); } function applySavedReport(config: Record) { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(config)) { if (typeof v === "string" && v) sp.set(k, v); } if (!sp.get("mode")) sp.set("mode", "snapshot"); router.push(`${pathname}?${sp.toString()}`, { scroll: false }); } async function handleConfirmSaveReport() { if (!newReportName.trim()) { showToast("Bitte einen Namen angeben.", "error"); return; } // Auch hier die ganze Adresszeile: ein gespeicherter Bericht, der die // Hälfte der Kriterien vergisst, führt beim nächsten Öffnen still zu // anderen Zahlen. const config: Record = Object.fromEntries(searchParams.entries()); config.mode = mode; config.group = mode === "snapshot" ? props.group : props.eventGroup; if (mode === "snapshot") config.measure = props.measure; setSavingReport(true); const result = await saveReport({ name: newReportName.trim(), config }); setSavingReport(false); if (result.success) { showToast("Bericht gespeichert."); setSaveModalOpen(false); setNewReportName(""); router.refresh(); } else { showToast(result.error ?? "Fehler beim Speichern.", "error"); } } async function handleDeleteReport(id: string) { const result = await deleteReport(id); if (result.success) { showToast("Bericht gelöscht."); router.refresh(); } else { showToast(result.error ?? "Fehler beim Löschen.", "error"); } } // Beide Ausgänge übernehmen die Adresszeile unverändert, statt die // Parameter einzeln aufzuzählen. Genau diese Aufzählung war der Grund, // warum ein neues Kriterium im Bericht wirkte und im Export fehlte — man // musste daran denken, sie an drei Stellen zu ergänzen. Jetzt sieht der // Export das, was der Bericht gesehen hat, weil es dieselbe Auswahl ist. function exportHref(ziel: string, format: "csv" | "xlsx"): string { const sp = new URLSearchParams(searchParams.toString()); sp.set("format", format); return `${ziel}?${sp.toString()}`; } function reportExportHref(format: "csv" | "xlsx"): string { const sp = new URLSearchParams(searchParams.toString()); sp.set("format", format); sp.set("mode", mode); // Die Vorgaben stehen nicht zwingend in der Adresszeile; die Route // braucht sie aber, um dieselbe Auswertung zu bauen. sp.set("group", mode === "snapshot" ? props.group : props.eventGroup); if (mode === "snapshot") sp.set("measure", props.measure); return `/api/export/report?${sp.toString()}`; } function fullExportHref(format: "csv" | "xlsx"): string { return exportHref(mode === "snapshot" ? "/api/export/employees" : "/api/export/events", format); } const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure); const maxValue = Math.max(1, ...rows.map((r) => r.value)); const rawSplitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))); const splitKeys = mode === "snapshot" && props.split ? sortKeysForDimension(rawSplitKeys, props.split) : rawSplitKeys; const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday"); const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : []; const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", "); const currentYear = new Date().getFullYear(); const defaultEventFrom = `${currentYear}-01-01`; const defaultEventTo = `${currentYear}-12-31`; const heading = mode === "snapshot" ? `${MEASURE_LABELS[props.measure]} nach ${GROUP_LABELS[props.group]}` : `${props.eventType ? EVENT_TYPE_LABELS[props.eventType as HistoryEventType] : "Ereignisse"} nach ${EVENT_GROUP_LABELS[props.eventGroup]}`; const totalDisplay = mode === "snapshot" ? formatValue(props.measure, total) : String(Math.round(total)); return (
{(["snapshot", "events"] as const).map((m) => ( ))}
{mode === "snapshot" ? (
updateParams({ measure: v, split: AVERAGE_MEASURES.includes(v as Measure) ? undefined : props.split })} options={(Object.keys(MEASURE_LABELS) as Measure[]).map((m) => ({ value: m, label: MEASURE_LABELS[m] }))} /> updateParams({ group: v })} options={(Object.keys(GROUP_LABELS) as GroupDimension[]).map((g) => ({ value: g, label: GROUP_LABELS[g] }))} /> updateParams({ split: v || undefined })} hint={isAverage ? "Bei Durchschnittswerten nicht verfügbar." : undefined} options={[ { value: "", label: "Keine Aufteilung" }, ...(Object.keys(GROUP_LABELS) as GroupDimension[]) .filter((g) => g !== props.group) .map((g) => ({ value: g, label: GROUP_LABELS[g] })), ]} />
updateParams({ asOf: v })} /> {props.asOf && ( )}

Bestand wird rückgerechnet (Eintritt/Austritt/Langzeitabwesenheit); Bereich/Team zeigen die aktuelle Zuordnung.

) : (
updateParams({ eventType: v || undefined, eventSubtype: undefined })} options={[ { value: "", label: "Alle Ereignistypen" }, ...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })), ]} /> {/* Nur dort, wo es einen gibt: eine Versetzung hat keine Beendigungsart, und ein leeres zweites Feld wäre eine Frage ohne Antwortmöglichkeit. */} {(() => { const typ = props.eventType ? (props.eventType as HistoryEventType) : null; const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined; if (!untertyp) return null; return ( updateParams({ eventSubtype: v || undefined })} options={[ { value: "", label: untertyp.alle }, ...untertypOptionen(typ).map((o) => ({ value: o, label: o })), ]} /> ); })()} updateParams({ group: v })} options={(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]).map((g) => ({ value: g, label: EVENT_GROUP_LABELS[g] }))} /> updateParams({ split: v || undefined })} options={[ { value: "", label: "Keine Aufteilung" }, ...(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]) .filter((g) => g !== props.eventGroup) .map((g) => ({ value: g, label: EVENT_GROUP_LABELS[g] })), ]} />
Zeitraum
updateParams({ from: v })} />
updateParams({ to: v })} />
)}

Filter

{mode === "snapshot" && ( <>
Status (zum Stichtag)
{STATUS_OPTIONS.map((s) => ( ))}
)}
{mode === "snapshot" && ( )}

Vorlagen

{(mode === "snapshot" ? REPORT_PRESETS : EVENT_REPORT_PRESETS).map((preset) => ( ))}

Vollständiger Datenexport

{mode === "snapshot" ? ( <>

Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.

Status im Export: {statusExportLabel}

) : (

Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).

)}
{/* Nur im Bestand: eine Befragung richtet sich an Personen, nicht an Ereignisse. Dieselben Filter wie der vollständige Export darüber — was dort steht, steht auch hier. */} {mode === "snapshot" && (

Honestly-Report

Teilnehmerliste für die Mitarbeiterbefragung: Personalnummer, E-Mail, Name, Standort und die Organisationskette von der eigenen Einheit bis ganz oben. Sprache immer „de“, Rolle immer „Respondee“.

Status im Export: {statusExportLabel}

)} {savedReports.length > 0 && (

Gespeicherte Berichte

    {savedReports.map((r) => (
  • ))}
)}

{heading}

{recordCount} {mode === "snapshot" ? "Datensätze" : "Ereignisse"}

CSV Excel

{totalDisplay}

{showWeekdayMultiCountNote && (

Mitarbeitende mit mehreren Arbeitstagen zählen bei „Wochentag“ an jedem ihrer Arbeitstage mehrfach – Summe und Anteile ergeben daher mehr als den Gesamt-Headcount bzw. 100%.

)} {((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && (
{splitKeys.map((key, i) => ( {key} ))}
)}
{rows.map((row) => { const share = total > 0 ? (row.value / total) * 100 : 0; const isExpanded = expandedKey === row.key; return (
{isExpanded && (() => { const alle = volleListeKey === row.key; const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN); const rest = row.people.length - VORSCHAU_NAMEN; return (
{/* Bei achthundert Namen scrollt die Liste in sich, statt die Auswertung darüber aus dem Bild zu schieben. */}
    {sichtbar.map((p, i) => (
  • {p.name} {p.title} · {p.team} · {fmtDate(p.entry_date)}
  • ))}
{rest > 0 && ( )}
); })()}
); })} {rows.length === 0 &&

Keine Daten für diese Auswahl.

}
setSaveModalOpen(false)} title="Bericht speichern" footer={ <> } > e.key === "Enter" && handleConfirmSaveReport()} autoFocus />
); }