Files
alpenwerk-hr/components/reports/ReportsPageClient.tsx
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00

613 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, unknown> };
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<string | null>(null);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [newReportName, setNewReportName] = useState("");
const [savingReport, setSavingReport] = useState(false);
function updateParams(patch: Record<string, string | undefined>) {
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<string, unknown>) {
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 (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
<div className="flex flex-col gap-4">
<div className="flex rounded border border-border bg-white p-1 text-sm font-semibold">
<button
type="button"
onClick={() => switchMode("snapshot")}
className={`flex-1 rounded px-3 py-1.5 ${mode === "snapshot" ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
Bestand
</button>
<button
type="button"
onClick={() => switchMode("events")}
className={`flex-1 rounded px-3 py-1.5 ${mode === "events" ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
Ereignisse
</button>
</div>
{mode === "snapshot" ? (
<div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Kennzahl</label>
<select
value={props.measure}
onChange={(e) => updateParams({ measure: e.target.value, split: AVERAGE_MEASURES.includes(e.target.value as Measure) ? undefined : props.split })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{(Object.keys(MEASURE_LABELS) as Measure[]).map((m) => (
<option key={m} value={m}>
{MEASURE_LABELS[m]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Gruppieren nach</label>
<select value={props.group} onChange={(e) => updateParams({ group: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm">
{(Object.keys(GROUP_LABELS) as GroupDimension[]).map((g) => (
<option key={g} value={g}>
{GROUP_LABELS[g]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Aufteilen nach</label>
<select
value={props.split}
disabled={isAverage}
onChange={(e) => updateParams({ split: e.target.value || undefined })}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
>
<option value="">Keine Aufteilung</option>
{(Object.keys(GROUP_LABELS) as GroupDimension[])
.filter((g) => g !== props.group)
.map((g) => (
<option key={g} value={g}>
{GROUP_LABELS[g]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Stichtag</label>
<div className="flex items-center gap-2">
<input
type="date"
value={props.asOf || todayIso()}
onChange={(e) => updateParams({ asOf: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
{props.asOf && (
<button type="button" onClick={() => updateParams({ asOf: undefined })} className="whitespace-nowrap text-xs font-semibold text-brand-700 hover:underline">
Heute
</button>
)}
</div>
<p className="mt-1 text-xs text-ink-muted">
Bestand wird rückgerechnet (Eintritt/Austritt/Karenz); Bereich/Team zeigen die aktuelle Zuordnung.
</p>
</div>
</div>
</div>
) : (
<div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Ereignistyp</label>
<select value={props.eventType} onChange={(e) => updateParams({ eventType: e.target.value || undefined })} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="">Alle Ereignistypen</option>
{EVENT_TYPES.map((t) => (
<option key={t} value={t}>
{EVENT_TYPE_LABELS[t]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Gruppieren nach</label>
<select value={props.eventGroup} onChange={(e) => updateParams({ group: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm">
{(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]).map((g) => (
<option key={g} value={g}>
{EVENT_GROUP_LABELS[g]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Aufteilen nach</label>
<select value={props.eventSplit} onChange={(e) => updateParams({ split: e.target.value || undefined })} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="">Keine Aufteilung</option>
{(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[])
.filter((g) => g !== props.eventGroup)
.map((g) => (
<option key={g} value={g}>
{EVENT_GROUP_LABELS[g]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</label>
<div className="grid grid-cols-2 gap-2">
<div>
<input
type="date"
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
disabled={props.eventFilters.from === EVENT_DATE_OPEN}
onChange={(e) => updateParams({ from: e.target.value })}
className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface"
/>
<button
type="button"
onClick={() => updateParams({ from: props.eventFilters.from === EVENT_DATE_OPEN ? defaultEventFrom : EVENT_DATE_OPEN })}
className="mt-1 text-xs font-semibold text-brand-700 hover:underline"
>
{props.eventFilters.from === EVENT_DATE_OPEN ? "Startdatum setzen" : "Ab Anfang (offen)"}
</button>
</div>
<div>
<input
type="date"
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
disabled={props.eventFilters.to === EVENT_DATE_OPEN}
onChange={(e) => updateParams({ to: e.target.value })}
className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface"
/>
<button
type="button"
onClick={() => updateParams({ to: props.eventFilters.to === EVENT_DATE_OPEN ? defaultEventTo : EVENT_DATE_OPEN })}
className="mt-1 text-xs font-semibold text-brand-700 hover:underline"
>
{props.eventFilters.to === EVENT_DATE_OPEN ? "Enddatum setzen" : "Bis heute (offen)"}
</button>
</div>
</div>
</div>
</div>
</div>
)}
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
<div className="flex flex-col gap-2">
<select
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<select
value={mode === "snapshot" ? props.filters.location : props.eventFilters.location}
onChange={(e) => updateParams({ location: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="">Alle Standorte</option>
{locations.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
{mode === "snapshot" && (
<>
<div className="rounded border border-border px-3 py-2">
<p className="mb-1.5 text-xs font-semibold text-ink-muted">Status (zum Stichtag)</p>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{STATUS_OPTIONS.map((s) => (
<label key={s} className="flex items-center gap-1.5 text-sm text-ink-body">
<input
type="checkbox"
checked={selectedStatuses.includes(s)}
onChange={() => toggleStatus(s)}
className="h-4 w-4 rounded border-border accent-brand-500"
/>
{s}
</label>
))}
</div>
</div>
<select
value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="">Alle Beschäftigungsarten</option>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</>
)}
</div>
</div>
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vorlagen</h3>
<div className="flex flex-wrap gap-1.5">
{(mode === "snapshot" ? REPORT_PRESETS : EVENT_REPORT_PRESETS).map((preset) => (
<button
key={preset.name}
type="button"
onClick={() => applyPreset(preset)}
className="rounded-full border border-border px-2.5 py-1 text-xs font-semibold text-ink-body hover:bg-surface"
>
{preset.name}
</button>
))}
</div>
</div>
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vollständiger Datenexport</h3>
{mode === "snapshot" ? (
<>
<p className="mb-1 text-xs text-ink-muted">Alle Mitarbeiterdaten (nicht nur die Kennzahl){props.asOf ? ` zum Stichtag ${fmtDate(props.asOf)}` : ""}.</p>
<p className="mb-2 rounded bg-surface px-2 py-1.5 text-xs font-semibold text-ink-body">Status im Export: {statusExportLabel}</p>
</>
) : (
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
)}
<div className="flex gap-2">
<a href={fullExportHref("csv")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
<FileText className="h-4 w-4" />
CSV
</a>
<a href={fullExportHref("xlsx")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
</div>
</div>
{savedReports.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Gespeicherte Berichte</h3>
<ul className="flex flex-col divide-y divide-border">
{savedReports.map((r) => (
<li key={r.id} className="flex items-center justify-between py-1.5 text-sm">
<button type="button" onClick={() => applySavedReport(r.config)} className="text-left text-ink-body hover:text-brand-700 hover:underline">
{r.name}
</button>
<button type="button" onClick={() => handleDeleteReport(r.id)} aria-label="Löschen" className="text-ink-muted hover:text-danger-solid">
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
</div>
)}
</div>
<div className="rounded border border-border bg-white p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-base font-bold text-ink">{heading}</h2>
<p className="text-xs text-ink-muted">{recordCount} {mode === "snapshot" ? "Datensätze" : "Ereignisse"}</p>
</div>
<div className="flex gap-2">
<a href={reportExportHref("csv")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
<FileText className="h-4 w-4" />
CSV
</a>
<a href={reportExportHref("xlsx")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
<button
type="button"
onClick={() => setSaveModalOpen(true)}
className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
<Save className="h-4 w-4" />
Bericht speichern
</button>
</div>
</div>
<p className="mb-4 text-2xl font-extrabold text-ink">{totalDisplay}</p>
{showWeekdayMultiCountNote && (
<p className="mb-3 text-xs text-ink-muted">
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%.
</p>
)}
{((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && (
<div className="mb-3 flex flex-wrap gap-3">
{splitKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1.5 text-xs text-ink-body">
<span className={`h-2.5 w-2.5 rounded-full ${SPLIT_COLORS[i % SPLIT_COLORS.length]}`} />
{key}
</span>
))}
</div>
)}
<div className="flex flex-col gap-3">
{rows.map((row) => {
const share = total > 0 ? (row.value / total) * 100 : 0;
const isExpanded = expandedKey === row.key;
return (
<div key={row.key}>
<button type="button" onClick={() => setExpandedKey(isExpanded ? null : row.key)} className="w-full text-left">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-semibold text-ink">{row.key}</span>
<span className="text-ink-body">
{mode === "snapshot" ? formatValue(props.measure, row.value) : Math.round(row.value)}{" "}
<span className="text-xs text-ink-muted">({share.toFixed(1)}%)</span>
</span>
</div>
<div className="flex h-2.5 overflow-hidden rounded bg-surface">
{row.split ? (
row.split.map((s, i) => (
<div key={s.key} className={SPLIT_COLORS[i % SPLIT_COLORS.length]} style={{ width: `${maxValue > 0 ? (s.value / maxValue) * 100 : 0}%` }} />
))
) : (
<div className="bg-brand-500" style={{ width: `${(row.value / maxValue) * 100}%` }} />
)}
</div>
</button>
{isExpanded && (
<div className="mt-2 rounded border border-border bg-surface p-3">
<ul className="flex flex-col divide-y divide-border">
{row.people.slice(0, 12).map((p, i) => (
<li key={`${p.id}-${i}`} className="flex items-center justify-between py-1.5 text-sm">
<Link href={`/employees/${p.id}`} className="font-semibold text-ink hover:text-brand-700 hover:underline">
{p.name}
</Link>
<span className="text-xs text-ink-muted">
{p.title} · {p.team} · {fmtDate(p.entry_date)}
</span>
</li>
))}
</ul>
{row.people.length > 12 && <p className="mt-2 text-xs text-ink-muted">+{row.people.length - 12} weitere</p>}
</div>
)}
</div>
);
})}
{rows.length === 0 && <p className="text-sm text-ink-muted">Keine Daten für diese Auswahl.</p>}
</div>
</div>
<Modal
open={saveModalOpen}
onClose={() => setSaveModalOpen(false)}
title="Bericht speichern"
footer={
<>
<button onClick={() => setSaveModalOpen(false)} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
Abbrechen
</button>
<button
onClick={handleConfirmSaveReport}
disabled={savingReport}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Speichern
</button>
</>
}
>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Name für diesen Bericht*</label>
<input
value={newReportName}
onChange={(e) => setNewReportName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleConfirmSaveReport()}
autoFocus
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
</Modal>
</div>
);
}