Files
alpenwerk-hr/components/reports/ReportsPageClient.tsx
Maximilian Stubhan d9367a8ce4 Form primitives, keyboard-operable comboboxes, dialog focus, route states
Accessibility work on the UI layer, all of it rooted in one structural gap:
there were no form primitives, so every field was hand-assembled and every
field got the same details wrong.

Form primitives
- components/ui/Field.tsx (Field/TextField/SelectField/TextareaField) and
  Button.tsx. Field generates the control id with useId and derives htmlFor
  from it, which is what makes the association impossible to omit rather
  than merely conventional.
- 92 labels existed, 4 used htmlFor, and no input carried an id at all: a
  screen reader announced an unnamed edit box and clicking a label focused
  nothing. Now every label resolves to its control (0 unassociated), and the
  input class chain that appeared verbatim 85 times appears zero times.
- Field also takes a render prop, so Lookup, CountryPicker and Picklist get
  the same wiring instead of a second, partial solution.
- SearchInput replaces three hand-rolled copies of the icon-in-a-box search
  whose input had only a placeholder — not a label — and killed its own
  focus ring with outline-none and nothing in its place.
- Toggle groups (workdays, reorg change type) became fieldsets with
  aria-pressed; colour alone was carrying the selected state.

Comboboxes
- Lookup and CountryPicker were text inputs with a div of clickable buttons
  underneath: typeable, but no keyboard path to a result and nothing telling
  a screen reader a list had appeared. Both now carry role=combobox,
  aria-expanded/controls/activedescendant and listbox semantics, with arrow
  keys, Enter and Escape. Escape stops propagation, or it would close the
  surrounding dialog along with the dropdown.

Dialogs
- useDialogFocus centralises what Modal and SlideOver each owed the
  keyboard and neither provided beyond Escape: focus into the dialog on
  open, Tab and Shift+Tab cycling within it, focus restored to the trigger
  on close.
- SlideOver stays mounted for its transition, and aria-hidden does not
  remove anything from the tab order — so every closed panel was leaving
  invisible tab stops at the end of the page. `inert` fixes that.

Route states
- loading.tsx, error.tsx, not-found.tsx and global-error.tsx. Every page in
  the (app) group is server-rendered per request, so without loading.tsx a
  navigation showed nothing at all until the server answered, and a render
  error dropped the user on Next's own screen with no way back.

Tests
- 22 component tests (vitest jsdom project). Two of them found limits of the
  environment rather than of the code: jsdom implements neither `inert` nor
  scrollIntoView, so the inert test asserts the attribute and the missing
  scrollIntoView — which was taking the whole render down from inside an
  effect — is stubbed in the setup file.
2026-07-25 13:11:09 +02:00

604 lines
26 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 { 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 {
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 role="tablist" aria-label="Berichtsart" className="flex rounded border border-border bg-white p-1 text-sm font-semibold">
{(["snapshot", "events"] as const).map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={mode === m}
onClick={() => switchMode(m)}
className={`flex-1 rounded px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 ${
mode === m ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"
}`}
>
{m === "snapshot" ? "Bestand" : "Ereignisse"}
</button>
))}
</div>
{mode === "snapshot" ? (
<div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-3">
<SelectField
label="Kennzahl"
dense
value={props.measure}
onChange={(v) => 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] }))}
/>
<SelectField
label="Gruppieren nach"
dense
value={props.group}
onChange={(v) => updateParams({ group: v })}
options={(Object.keys(GROUP_LABELS) as GroupDimension[]).map((g) => ({ value: g, label: GROUP_LABELS[g] }))}
/>
<SelectField
label="Aufteilen nach"
dense
value={props.split}
disabled={isAverage}
onChange={(v) => 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] })),
]}
/>
<div>
<div className="flex items-end gap-2">
<TextField
label="Stichtag"
dense
className="flex-1"
type="date"
value={props.asOf || todayIso()}
onChange={(v) => updateParams({ asOf: v })}
/>
{props.asOf && (
<Button
variant="ghost"
size="sm"
onClick={() => updateParams({ asOf: undefined })}
className="!px-1 whitespace-nowrap text-brand-700 hover:!bg-transparent 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">
<SelectField
label="Ereignistyp"
dense
value={props.eventType}
onChange={(v) => updateParams({ eventType: v || undefined })}
options={[
{ value: "", label: "Alle Ereignistypen" },
...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
]}
/>
<SelectField
label="Gruppieren nach"
dense
value={props.eventGroup}
onChange={(v) => updateParams({ group: v })}
options={(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]).map((g) => ({ value: g, label: EVENT_GROUP_LABELS[g] }))}
/>
<SelectField
label="Aufteilen nach"
dense
value={props.eventSplit}
onChange={(v) => 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] })),
]}
/>
<fieldset>
<legend className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</legend>
<div className="grid grid-cols-2 gap-2">
<div>
<TextField
label="Von"
dense
type="date"
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
disabled={props.eventFilters.from === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ from: v })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateParams({ from: props.eventFilters.from === EVENT_DATE_OPEN ? defaultEventFrom : EVENT_DATE_OPEN })}
className="mt-1 !px-0 text-brand-700 hover:!bg-transparent hover:underline"
>
{props.eventFilters.from === EVENT_DATE_OPEN ? "Startdatum setzen" : "Ab Anfang (offen)"}
</Button>
</div>
<div>
<TextField
label="Bis"
dense
type="date"
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
disabled={props.eventFilters.to === EVENT_DATE_OPEN}
onChange={(v) => updateParams({ to: v })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateParams({ to: props.eventFilters.to === EVENT_DATE_OPEN ? defaultEventTo : EVENT_DATE_OPEN })}
className="mt-1 !px-0 text-brand-700 hover:!bg-transparent hover:underline"
>
{props.eventFilters.to === EVENT_DATE_OPEN ? "Enddatum setzen" : "Bis heute (offen)"}
</Button>
</div>
</div>
</fieldset>
</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
aria-label="Nach Bereich filtern"
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Bereiche</option>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<select
aria-label="Nach Standort filtern"
value={mode === "snapshot" ? props.filters.location : props.eventFilters.location}
onChange={(e) => updateParams({ location: e.target.value })}
className={CONTROL_CLASS}
>
<option value="">Alle Standorte</option>
{locations.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
{mode === "snapshot" && (
<>
<fieldset className="rounded border border-border px-3 py-2">
<legend className="mb-1.5 text-xs font-semibold text-ink-muted">Status (zum Stichtag)</legend>
<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>
</fieldset>
<select
aria-label="Nach Beschäftigungsart filtern"
value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })}
className={CONTROL_CLASS}
>
<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} variant="secondary" size="sm" onClick={() => applyPreset(preset)} className="!rounded-full !px-2.5 !py-1">
{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={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={fullExportHref("xlsx")} className={LINK_BUTTON_CLASS}>
<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
variant="ghost"
size="sm"
onClick={() => applySavedReport(r.config)}
className="!px-0 !justify-start text-left hover:!bg-transparent hover:text-brand-700 hover:underline"
>
{r.name}
</Button>
<Button
variant="icon"
onClick={() => handleDeleteReport(r.id)}
aria-label={`Bericht ${r.name} löschen`}
className="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={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" />
CSV
</a>
<a href={reportExportHref("xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" />
Excel
</a>
<Button variant="secondary" size="sm" onClick={() => setSaveModalOpen(true)}>
<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 variant="ghost" onClick={() => setSaveModalOpen(false)}>
Abbrechen
</Button>
<Button onClick={handleConfirmSaveReport} pending={savingReport}>
Speichern
</Button>
</>
}
>
<TextField
label="Name für diesen Bericht"
required
value={newReportName}
onChange={setNewReportName}
onKeyDown={(e) => e.key === "Enter" && handleConfirmSaveReport()}
autoFocus
/>
</Modal>
</div>
);
}