"use client"; import { 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 { Modal } from "@/components/ui/Modal"; import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; import { AVERAGE_MEASURES, EVENT_DATE_OPEN, EVENT_GROUP_LABELS, EVENT_REPORT_PRESETS, EVENT_TYPE_LABELS, GROUP_LABELS, MEASURE_LABELS, parseStatuses, REPORT_PRESETS, sortKeysForDimension, STATUS_OPTIONS, todayIso, type EventGroupDimension, type GroupDimension, type Measure, type ReportRow, } from "@/lib/reports"; import type { HistoryEventType } from "@/lib/supabase/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 = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[]; type SavedReport = { id: string; name: string; config: Record }; type OrgOption = { id: string; name: string }; type CommonProps = { rows: ReportRow[]; total: number; recordCount: number; divisions: OrgOption[]; locations: OrgOption[]; savedReports: SavedReport[]; }; type SnapshotProps = CommonProps & { mode: "snapshot"; measure: Measure; group: GroupDimension; split: GroupDimension | ""; asOf: string; filters: { division: string; location: string; status: string; employment: string }; }; type EventsProps = CommonProps & { mode: "events"; eventGroup: EventGroupDimension; eventSplit: EventGroupDimension | ""; eventType: string; eventFilters: { division: string; location: string; from: string; to: string }; }; type ReportsPageClientProps = SnapshotProps | EventsProps; 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, divisions, locations, savedReports } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { showToast } = useToast(); const [expandedKey, setExpandedKey] = 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 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; } const config = mode === "snapshot" ? { mode, measure: props.measure, group: props.group, split: props.split, asOf: props.asOf, ...props.filters } : { mode, group: props.eventGroup, split: props.eventSplit, eventType: props.eventType, ...props.eventFilters }; 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"); } } function reportExportHref(format: "csv" | "xlsx"): string { const sp = new URLSearchParams(); sp.set("format", format); sp.set("mode", mode); if (mode === "snapshot") { sp.set("measure", props.measure); sp.set("group", props.group); if (props.split) sp.set("split", props.split); if (props.asOf) sp.set("asOf", props.asOf); for (const [k, v] of Object.entries(props.filters)) if (v) sp.set(k, v); } else { sp.set("group", props.eventGroup); if (props.eventSplit) sp.set("split", props.eventSplit); if (props.eventType) sp.set("eventType", props.eventType); for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v); } return `/api/export/report?${sp.toString()}`; } function fullExportHref(format: "csv" | "xlsx"): string { if (mode === "snapshot") { const sp = new URLSearchParams(); sp.set("format", format); if (props.asOf) sp.set("asOf", props.asOf); if (props.filters.division) sp.set("division", props.filters.division); if (props.filters.location) sp.set("location", props.filters.location); if (props.filters.status) sp.set("status", props.filters.status); if (props.filters.employment) sp.set("employment", props.filters.employment); return `/api/export/employees?${sp.toString()}`; } const sp = new URLSearchParams(); sp.set("format", format); if (props.eventType) sp.set("eventType", props.eventType); for (const [k, v] of Object.entries(props.eventFilters)) if (v) sp.set(k, v); return `/api/export/events?${sp.toString()}`; } 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 (
{mode === "snapshot" ? (
updateParams({ asOf: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> {props.asOf && ( )}

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

) : (
updateParams({ from: e.target.value })} className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface" />
updateParams({ to: e.target.value })} className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface" />
)}

Filter

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

Status (zum Stichtag)

{STATUS_OPTIONS.map((s) => ( ))}
)}

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).

)}
{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 && (
    {row.people.slice(0, 12).map((p, i) => (
  • {p.name} {p.title} · {p.team} · {fmtDate(p.entry_date)}
  • ))}
{row.people.length > 12 &&

+{row.people.length - 12} weitere

}
)}
); })} {rows.length === 0 &&

Keine Daten für diese Auswahl.

}
setSaveModalOpen(false)} title="Bericht speichern" footer={ <> } >
setNewReportName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleConfirmSaveReport()} autoFocus className="w-full rounded border border-border px-3 py-2 text-sm" />
); }