From f96773da0f16f95cd78b775f619124388acc2c5b Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Wed, 15 Jul 2026 20:34:27 +0200 Subject: [PATCH] Reports/Export builder (CSV/XLSX), plus a security fix pass Adds the Berichte export pipeline (/api/export/{report,events,employees}) with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts. Security pass alongside it: sanitize .or() search terms against PostgREST filter injection, sanitize spreadsheet cells against CSV/Excel formula injection, stop leaking raw DB error messages to clients, harden the service-role client with server-only, add baseline security headers, and bump the vulnerable nested postcss via an override. --- actions/employees.ts | 3 +- actions/positions.ts | 3 +- app/(app)/audit/page.tsx | 3 +- app/(app)/employees/page.tsx | 4 +- app/(app)/reports/page.tsx | 129 +-- app/api/cron/apply-pending-changes/route.ts | 3 +- app/api/export/employees/route.ts | 105 ++ app/api/export/events/route.ts | 62 ++ app/api/export/report/route.ts | 123 +++ components/employees/EmployeeDetail.tsx | 7 +- components/reports/ReportsPageClient.tsx | 469 ++++++--- lib/export.ts | 90 ++ lib/reports-data.ts | 127 +++ lib/reports.ts | 211 +++- lib/supabase/admin.ts | 9 +- lib/supabase/query.ts | 9 + next.config.ts | 20 +- package-lock.json | 1045 ++++++++++++++++++- package.json | 7 +- tests/unit/reports.test.ts | 78 +- tests/unit/security.test.ts | 95 ++ 21 files changed, 2323 insertions(+), 279 deletions(-) create mode 100644 app/api/export/employees/route.ts create mode 100644 app/api/export/events/route.ts create mode 100644 app/api/export/report/route.ts create mode 100644 lib/export.ts create mode 100644 lib/reports-data.ts create mode 100644 lib/supabase/query.ts create mode 100644 tests/unit/security.test.ts diff --git a/actions/employees.ts b/actions/employees.ts index 7768405..bb3a89a 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -1,6 +1,7 @@ "use server"; import { revalidatePath } from "next/cache"; +import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { Database } from "@/lib/supabase/types"; @@ -122,7 +123,7 @@ export async function searchActiveEmployees(query: string): Promise }) { const params = await searchParams; const supabase = await createClient(); + const mode = params.mode === "events" ? "events" : "snapshot"; - const measure = (params.measure as Measure) || "headcount"; - const group = (params.group as GroupDimension) || "division"; - const split = (params.split as GroupDimension) || undefined; - - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("id, name").order("name"), - supabase.from("departments").select("id, name"), - supabase.from("teams").select("id, name, department_id"), - supabase.from("locations").select("id, name").order("name"), - ]); - - const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name])); - const lookups: OrgLookups = { - divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])), - departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])), - teamName: new Map((teams ?? []).map((t) => [t.id, t.name])), - locationName: new Map((locations ?? []).map((l) => [l.id, l.name])), - }; - - let query = supabase - .from("employees") - .select( - "id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender" - ); - - if (params.division) query = query.eq("division_id", params.division); - if (params.location) query = query.eq("location_id", params.location); - if (params.status) query = query.eq("status", params.status as EmploymentStatus); - if (params.employment) query = query.eq("employment_type", params.employment as EmploymentType); - - const isDateScoped = DATE_SCOPED_MEASURES.includes(measure); - const currentYear = new Date().getFullYear(); - const from = params.from || `${currentYear}-01-01`; - const to = params.to || `${currentYear}-12-31`; - if (isDateScoped) { - if (measure === "hires") query = query.gte("entry_date", from).lte("entry_date", to); - else query = query.gte("exit_date", from).lte("exit_date", to).not("exit_date", "is", null); - } else if (!params.status) { - query = query.in("status", ["Aktiv", "Karenz"]); - } - - const { data: employeesData } = await query; - const employees = (employeesData ?? []) as ReportEmployee[]; - - const rows = aggregateReport(employees, measure, group, split ?? null, lookups); - const total = measureValueForTotal(rows, measure); - - const { - data: { user }, - } = await supabase.auth.getUser(); + const [{ lookups, divisions, locations }, { data: userRes }] = await Promise.all([loadOrgLookups(supabase), supabase.auth.getUser()]); + const user = userRes.user; const { data: savedReports } = user ? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false }) : { data: [] }; + if (mode === "events") { + const group = (params.group as EventGroupDimension) || "event_type"; + const split = (params.split as EventGroupDimension) || undefined; + const eventType = (params.eventType as HistoryEventType) || undefined; + + const events = await loadEventHistory(supabase, { eventType, division: params.division, location: params.location, from: params.from, to: params.to }); + const rows = aggregateEvents(events, group, split ?? null, lookups); + const total = sumValues(rows); + + return ( + + + + ); + } + + const measure = (params.measure as Measure) || "headcount"; + const group = (params.group as GroupDimension) || "division"; + const split = (params.split as GroupDimension) || undefined; + const asOf = params.asOf || undefined; + + const employees = await loadSnapshotEmployees(supabase, { + division: params.division, + location: params.location, + status: params.status, + employment: params.employment, + asOf, + }); + const rows = aggregateReport(employees, measure, group, split ?? null, lookups, asOf); + const total = totalForRows(rows, measure); + return ( ); } - -function measureValueForTotal(rows: { value: number; count: number }[], measure: Measure): number { - if (["headcount", "fte", "hires", "exits"].includes(measure)) { - return rows.reduce((s, r) => s + r.value, 0); - } - // averages/ratios: weight by underlying count for a sensible overall figure - const totalCount = rows.reduce((s, r) => s + r.count, 0); - if (totalCount === 0) return 0; - return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount; -} diff --git a/app/api/cron/apply-pending-changes/route.ts b/app/api/cron/apply-pending-changes/route.ts index cdc5641..2c32824 100644 --- a/app/api/cron/apply-pending-changes/route.ts +++ b/app/api/cron/apply-pending-changes/route.ts @@ -17,7 +17,8 @@ export async function GET(request: NextRequest) { const { data, error } = await supabase.rpc("apply_due_pending_changes"); if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); + console.error("apply_due_pending_changes failed:", error); + return NextResponse.json({ error: "Interner Fehler." }, { status: 500 }); } return NextResponse.json({ applied: data }); diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts new file mode 100644 index 0000000..e50fddc --- /dev/null +++ b/app/api/export/employees/route.ts @@ -0,0 +1,105 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; +import { deriveStatusAsOf, parseStatuses, type OrgLookups } from "@/lib/reports"; +import { loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; +import { createClient } from "@/lib/supabase/server"; +import type { Database, EmploymentType } from "@/lib/supabase/types"; + +type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; + +// Full raw data dump — every column on `employees`, not just the fields a +// pivot report groups by. Respects the same division/location/status/ +// employment filters as the Berichte page. With `asOf` (Stichtag), status +// filtering happens against the *derived* status as of that date rather +// than the live `status` column — see deriveStatusAsOf. +export async function GET(request: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + + const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle(); + if (profile?.role !== "hr" || profile?.is_active !== true) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + + const params = request.nextUrl.searchParams; + const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; + const asOf = params.get("asOf") ?? undefined; + const filters: ReportFilters = { + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + status: params.get("status") ?? undefined, + employment: params.get("employment") ?? undefined, + }; + + const statuses = parseStatuses(filters.status); + + let query = supabase.from("employees").select("*").order("last_name"); + if (filters.division) query = query.eq("division_id", filters.division); + if (filters.location) query = query.eq("location_id", filters.location); + if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); + if (!asOf) query = query.in("status", statuses); + + const [{ data: employees, error }, { lookups }, { data: allEmployees }] = await Promise.all([ + query, + loadOrgLookups(supabase), + supabase.from("employees").select("id, first_name, last_name"), + ]); + if (error) { + console.error("employees export query failed:", error); + return NextResponse.json({ error: "Interner Fehler." }, { status: 500 }); + } + + const managerName = new Map((allEmployees ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`])); + const rows = asOf ? (employees ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : (employees ?? []); + const columns = employeeExportColumns(lookups, managerName, asOf); + const filename = exportFilename("mitarbeiter-export", format); + + const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns); + // TS 5.9's Uint8Array vs DOM's BlobPart/ArrayBuffer<> generic + // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. + return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); +} + +function employeeExportColumns(lookups: OrgLookups, managerName: Map, asOf?: string): ExportColumn[] { + const columns: ExportColumn[] = [ + { header: "Pers.-Nr.", get: (e) => e.personnel_number }, + { header: "Vorname", get: (e) => e.first_name }, + { header: "Nachname", get: (e) => e.last_name }, + { header: "Geschlecht", get: (e) => (e.gender === "m" ? "männlich" : "weiblich") }, + { header: "Geburtsdatum", get: (e) => e.birth_date, kind: "date" }, + { header: "SV-Nummer", get: (e) => e.sv_nummer }, + { header: "Staatsbürgerschaft", get: (e) => e.nationality }, + { header: "Adresse", get: (e) => e.address }, + { header: "Wohnsitzland", get: (e) => e.address_country }, + { header: "E-Mail", get: (e) => e.email }, + { header: "Telefon", get: (e) => e.phone }, + { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, + { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, + { header: "Position", get: (e) => e.job_title }, + { header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") }, + { header: "Führungskraft", get: (e) => e.is_lead }, + { header: "Org-Level", get: (e) => e.org_level }, + { header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, + { header: "Wochenstunden", get: (e) => e.weekly_hours }, + { header: "Vertragsart", get: (e) => e.contract_type }, + { header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" }, + { header: "Paygrade", get: (e) => e.paygrade }, + { header: "Herkunft", get: (e) => e.source }, + { header: "Status", get: (e) => e.status }, + { header: "Eintrittsdatum", get: (e) => e.entry_date, kind: "date" }, + { header: "Austrittsdatum", get: (e) => e.exit_date, kind: "date" }, + { header: "Austrittsgrund", get: (e) => e.exit_reason }, + { header: "Karenzbeginn", get: (e) => e.karenz_start_date, kind: "date" }, + { header: "Karenz-Rückkehrdatum", get: (e) => e.karenz_return_date, kind: "date" }, + ]; + if (asOf) { + const statusIndex = columns.findIndex((c) => c.header === "Status"); + columns.splice(statusIndex + 1, 0, { header: `Status zum Stichtag (${asOf})`, get: (e) => deriveStatusAsOf(e, asOf) }); + } + return columns; +} diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts new file mode 100644 index 0000000..8322389 --- /dev/null +++ b/app/api/export/events/route.ts @@ -0,0 +1,62 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; +import { EVENT_TYPE_LABELS, type OrgLookups, type ReportEvent } from "@/lib/reports"; +import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; +import { createClient } from "@/lib/supabase/server"; +import type { HistoryEventType } from "@/lib/supabase/types"; + +// Full raw event-log dump — one row per employee_history entry in the +// selected period (default: current year), every event type unless one is +// picked, org columns resolved from each affected employee's current +// placement (see loadEventHistory). +export async function GET(request: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + + const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle(); + if (profile?.role !== "hr" || profile?.is_active !== true) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + + const params = request.nextUrl.searchParams; + const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; + const eventType = (params.get("eventType") as HistoryEventType) || undefined; + const from = params.get("from") ?? undefined; + const to = params.get("to") ?? undefined; + + const [{ lookups }, events] = await Promise.all([ + loadOrgLookups(supabase), + loadEventHistory(supabase, { + eventType, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from, + to, + }), + ]); + + const columns = eventExportColumns(lookups); + const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format); + const body = format === "xlsx" ? await toXlsx(events, columns, "Ereignisse") : toCsv(events, columns); + // TS 5.9's Uint8Array vs DOM's BlobPart/ArrayBuffer<> generic + // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. + return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); +} + +function eventExportColumns(lookups: OrgLookups): ExportColumn[] { + return [ + { header: "Datum", get: (e) => e.event_date, kind: "date" }, + { header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type }, + { header: "Vorname", get: (e) => e.first_name }, + { header: "Nachname", get: (e) => e.last_name }, + { header: "Position", get: (e) => e.job_title }, + { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, + { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, + { header: "Beschreibung", get: (e) => e.description }, + ]; +} diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts new file mode 100644 index 0000000..ea9e610 --- /dev/null +++ b/app/api/export/report/route.ts @@ -0,0 +1,123 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; +import { + aggregateEvents, + aggregateReport, + EVENT_GROUP_LABELS, + GROUP_LABELS, + MEASURE_LABELS, + sumValues, + totalForRows, + type EventGroupDimension, + type GroupDimension, + type Measure, + type ReportRow, +} from "@/lib/reports"; +import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; +import { createClient } from "@/lib/supabase/server"; +import type { HistoryEventType } from "@/lib/supabase/types"; + +// Exports exactly the pivot table currently on screen (same mode/measure or +// event-type/group/split/filters, read from the query string the client +// already keeps in the URL) as a flat table — one row per group, one column +// per split value if a split is active. +export async function GET(request: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); + + const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle(); + if (profile?.role !== "hr" || profile?.is_active !== true) { + return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }); + } + + const params = request.nextUrl.searchParams; + const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; + const mode = params.get("mode") === "events" ? "events" : "snapshot"; + const { lookups } = await loadOrgLookups(supabase); + + let rows: ReportRow[]; + let columns: ExportColumn[]; + let filenameBase: string; + + if (mode === "events") { + const group = (params.get("group") as EventGroupDimension) || "event_type"; + const split = (params.get("split") as EventGroupDimension) || null; + const eventType = (params.get("eventType") as HistoryEventType) || undefined; + const events = await loadEventHistory(supabase, { + eventType, + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + }); + rows = aggregateEvents(events, group, split, lookups); + columns = eventReportColumns(rows, group, split, sumValues(rows)); + filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; + } else { + const measure = (params.get("measure") as Measure) || "headcount"; + const group = (params.get("group") as GroupDimension) || "division"; + const split = (params.get("split") as GroupDimension) || null; + const asOf = params.get("asOf") ?? undefined; + const employees = await loadSnapshotEmployees(supabase, { + division: params.get("division") ?? undefined, + location: params.get("location") ?? undefined, + status: params.get("status") ?? undefined, + employment: params.get("employment") ?? undefined, + asOf, + }); + rows = aggregateReport(employees, measure, group, split, lookups, asOf); + columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure)); + filenameBase = `bericht-${measure}-${group}`; + } + + const filename = exportFilename(filenameBase, format); + const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns); + // TS 5.9's Uint8Array vs DOM's BlobPart/ArrayBuffer<> generic + // mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here. + return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); +} + +function snapshotReportColumns( + rows: ReportRow[], + measure: Measure, + group: GroupDimension, + split: GroupDimension | null, + total: number +): ExportColumn[] { + const columns: ExportColumn[] = [{ header: GROUP_LABELS[group], get: (r) => r.key }]; + if (split) { + const splitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))); + for (const key of splitKeys) { + columns.push({ header: key, get: (r) => Math.round((r.split?.find((s) => s.key === key)?.value ?? 0) * 100) / 100 }); + } + } + columns.push( + { header: MEASURE_LABELS[measure], get: (r) => Math.round(r.value * 100) / 100 }, + { header: "Anzahl", get: (r) => r.count }, + { header: "Anteil (%)", get: (r) => (total > 0 ? Math.round((r.value / total) * 1000) / 10 : 0) } + ); + return columns; +} + +function eventReportColumns( + rows: ReportRow[], + group: EventGroupDimension, + split: EventGroupDimension | null, + total: number +): ExportColumn[] { + const columns: ExportColumn[] = [{ header: EVENT_GROUP_LABELS[group], get: (r) => r.key }]; + if (split) { + const splitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))); + for (const key of splitKeys) { + columns.push({ header: key, get: (r) => r.split?.find((s) => s.key === key)?.value ?? 0 }); + } + } + columns.push( + { header: "Anzahl", get: (r) => r.count }, + { header: "Anteil (%)", get: (r) => (total > 0 ? Math.round((r.value / total) * 1000) / 10 : 0) } + ); + return columns; +} diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 14974ac..cccc985 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -1,6 +1,7 @@ "use client"; -import { ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircle } from "lucide-react"; +import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircle } from "lucide-react"; +import Link from "next/link"; import { useState } from "react"; import { Avatar } from "@/components/ui/Avatar"; import { StatusChip } from "@/components/ui/StatusChip"; @@ -56,6 +57,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) { return (
+ + Zurück zu Mitarbeiter:innen + +
diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx index 478e4bc..be76458 100644 --- a/components/reports/ReportsPageClient.tsx +++ b/components/reports/ReportsPageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { Download, Save, Trash2 } from "lucide-react"; +import { FileSpreadsheet, FileText, Save, Trash2 } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; @@ -10,34 +10,59 @@ import { useToast } from "@/components/ui/Toast"; import { fmtDate } from "@/lib/format"; import { AVERAGE_MEASURES, - DATE_SCOPED_MEASURES, + EVENT_DATE_OPEN, + EVENT_GROUP_LABELS, + EVENT_REPORT_PRESETS, + EVENT_TYPE_LABELS, GROUP_LABELS, MEASURE_LABELS, + parseStatuses, REPORT_PRESETS, + 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 ReportsPageClientProps = { - measure: Measure; - group: GroupDimension; - split: GroupDimension | ""; - filters: { division: string; location: string; status: string; employment: string; from: string; to: string }; +type CommonProps = { rows: ReportRow[]; total: number; recordCount: number; - divisions: { id: string; name: string }[]; - locations: { id: string; name: string }[]; + 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 (["headcount", "hires", "exits"].includes(measure)) return String(Math.round(value)); + 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`; @@ -45,7 +70,7 @@ function formatValue(measure: Measure, value: number): string { } export function ReportsPageClient(props: ReportsPageClientProps) { - const { measure, group, split, filters, rows, total, recordCount, divisions, locations, savedReports } = props; + const { mode, rows, total, recordCount, divisions, locations, savedReports } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -64,8 +89,24 @@ export function ReportsPageClient(props: ReportsPageClientProps) { router.push(`${pathname}?${sp.toString()}`); } - function applyPreset(preset: (typeof REPORT_PRESETS)[number]) { - router.push(`${pathname}?measure=${preset.measure}&group=${preset.group}${preset.split ? `&split=${preset.split}` : ""}`); + function switchMode(next: "snapshot" | "events") { + router.push(`${pathname}?mode=${next}`); + } + + 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()}`); } function applySavedReport(config: Record) { @@ -73,6 +114,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) { 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()}`); } @@ -81,8 +123,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) { 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: { measure, group, split, ...filters } }); + const result = await saveReport({ name: newReportName.trim(), config }); setSavingReport(false); if (result.success) { showToast("Bericht gespeichert."); @@ -104,80 +150,229 @@ export function ReportsPageClient(props: ReportsPageClientProps) { } } - function handleExportCsv() { - const header = [GROUP_LABELS[group], MEASURE_LABELS[measure], "Anzahl", "Anteil (%)"]; - const lines = [header.join(";")]; - for (const row of rows) { - const share = total > 0 ? ((row.value / total) * 100).toFixed(1) : "0"; - lines.push([row.key, formatValue(measure, row.value), String(row.count), share].join(";")); + 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); } - const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `bericht-${measure}-${group}.csv`; - a.click(); - URL.revokeObjectURL(url); + return `/api/export/report?${sp.toString()}`; } - const isAverage = AVERAGE_MEASURES.includes(measure); - const isDateScoped = DATE_SCOPED_MEASURES.includes(measure); + 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 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 (
-
-
-
- - -
-
- - -
-
- - 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) => ( + + ))} + +
+
+ + + +
+
+ + +
+
+ +
+ 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

- updateParams({ division: e.target.value })} + className="w-full rounded border border-border px-3 py-2 text-sm" + > {divisions.map((d) => ( ))} - updateParams({ location: e.target.value })} + className="w-full rounded border border-border px-3 py-2 text-sm" + > {locations.map((l) => ( ))} - - - {isDateScoped && ( -
- updateParams({ from: e.target.value })} - className="w-full rounded border border-border px-2 py-2 text-xs" - /> - updateParams({ to: e.target.value })} - className="w-full rounded border border-border px-2 py-2 text-xs" - /> -
+ {mode === "snapshot" && ( + <> +
+

Status (zum Stichtag)

+
+ {STATUS_OPTIONS.map((s) => ( + + ))} +
+
+ + )}
@@ -231,7 +427,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {

Vorlagen

- {REPORT_PRESETS.map((preset) => ( + {(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

@@ -266,20 +484,18 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
-

- {MEASURE_LABELS[measure]} nach {GROUP_LABELS[group]} -

-

{recordCount} Datensätze

+

{heading}

+

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

- + + + CSV + + + + Excel +
-

{formatValue(measure, total)}

+

{totalDisplay}

- {split && ( + {((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && (
{Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))).map((key, i) => ( @@ -310,25 +526,18 @@ export function ReportsPageClient(props: ReportsPageClientProps) { const isExpanded = expandedKey === row.key; return (
-