Phase 6/7: Reports builder and Audit log - all 7 routes now complete

Reports (§4.8):
- lib/reports.ts: generic server-side aggregation engine over 9 measures
  (Headcount, FTE, Eintritte, Austritte, Ø Bruttogehalt, Teilzeitquote,
  Ø Alter, Ø Zugehoerigkeit, Frauenanteil) x 10 group-by dimensions, with
  an optional second-dimension split (disabled for average-type
  measures) and per-row drill-down data.
- app/(app)/reports/page.tsx: reads filters from the URL, fetches the
  matching employees_directory rows server-side, aggregates in Node
  (not shipped raw to the client), computes the total.
- ReportsPageClient: measure/group/split/filter controls, 6 preset
  chips, saved-reports list (actions/reports.ts), CSV export
  (client-side blob download), stacked bars with a color-keyed legend
  when split is active, and click-to-drill-down into the underlying
  people (capped at 12, "+N weitere", linking to /employees/[id]).

Audit log (§4.9):
- app/(app)/audit/page.tsx + AuditFilters: search (target/details/actor)
  + action-type filter, paginated table with colored action badges,
  row links to the affected employee when target_employee_id is set,
  and the required "unveraenderbar" footer note.

This completes every route from the spec's information architecture:
Dashboard, Mitarbeiter:innen (list+detail), Organigramm (3 views),
Positionen & Bereiche, Berichte, Audit-Log, plus the Hire wizard and all
6 action panels reachable from them.

Final verification: clean npm run build + tsc --noEmit, then a full
browser walkthrough of all 6 authenticated routes as hr_admin (zero
console errors, zero 5xx responses) and a role-check pass as the
manager test account confirming action buttons and the "+ Neueinstellung"
button are hidden, and salary is masked as "... (ausgeblendet)" on the
Vertrag & Gehalt tab. Swept the database for leftover test data from
the debugging sessions above - none found, seed data is clean.
This commit is contained in:
2026-07-13 23:11:23 +02:00
parent 108da8d5e6
commit e27db5f030
6 changed files with 894 additions and 0 deletions

201
lib/reports.ts Normal file
View File

@@ -0,0 +1,201 @@
export type Measure =
| "headcount"
| "fte"
| "hires"
| "exits"
| "avg_salary"
| "parttime_rate"
| "avg_age"
| "avg_tenure"
| "female_share";
export type GroupDimension =
| "division"
| "department"
| "team"
| "location"
| "status"
| "employment_type"
| "contract_type"
| "entry_year"
| "source"
| "paygrade";
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
fte: "FTE",
hires: "Eintritte",
exits: "Austritte",
avg_salary: "Ø Bruttogehalt",
parttime_rate: "Teilzeitquote",
avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit",
female_share: "Frauenanteil",
};
export const GROUP_LABELS: Record<GroupDimension, string> = {
division: "Bereich",
department: "Abteilung",
team: "Team",
location: "Standort",
status: "Status",
employment_type: "Beschäftigung",
contract_type: "Vertragsart",
entry_year: "Eintrittsjahr",
source: "Intern/Extern",
paygrade: "Paygrade",
};
export const AVERAGE_MEASURES: Measure[] = ["avg_salary", "parttime_rate", "avg_age", "avg_tenure", "female_share"];
export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"];
export type ReportEmployee = {
id: string;
first_name: string;
last_name: string;
job_title: string;
division_id: string;
team_id: string | null;
location_id: string;
status: string;
employment_type: string;
contract_type: string;
entry_date: string;
exit_date: string | null;
weekly_hours: number;
monthly_salary_gross: number | null;
source: string;
paygrade: string;
birth_date: string;
gender: string;
};
export type OrgLookups = {
divisionName: Map<string, string>;
departmentNameByTeam: Map<string, string>;
teamName: Map<string, string>;
locationName: Map<string, string>;
};
function ageFromBirthDate(birthDate: string): number {
const d = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
if (today.getMonth() < d.getMonth() || (today.getMonth() === d.getMonth() && today.getDate() < d.getDate())) age -= 1;
return age;
}
function tenureYears(entryDate: string, exitDate: string | null): number {
const start = new Date(entryDate);
const end = exitDate ? new Date(exitDate) : new Date();
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
}
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
switch (dim) {
case "division":
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "status":
return e.status;
case "employment_type":
return e.employment_type;
case "contract_type":
return e.contract_type;
case "entry_year":
return String(new Date(e.entry_date).getFullYear());
case "source":
return e.source;
case "paygrade":
return e.paygrade;
default:
return "Unbekannt";
}
}
export function measureValue(rows: ReportEmployee[], measure: Measure): number {
if (rows.length === 0) return 0;
switch (measure) {
case "headcount":
case "hires":
case "exits":
return rows.length;
case "fte":
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
case "avg_salary": {
const withSalary = rows.filter((e) => e.monthly_salary_gross != null);
return withSalary.length ? withSalary.reduce((s, e) => s + (e.monthly_salary_gross ?? 0), 0) / withSalary.length : 0;
}
case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age":
return rows.reduce((s, e) => s + ageFromBirthDate(e.birth_date), 0) / rows.length;
case "avg_tenure":
return rows.reduce((s, e) => s + tenureYears(e.entry_date, e.exit_date), 0) / rows.length;
case "female_share":
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
default:
return 0;
}
}
export type ReportPerson = { id: string; name: string; title: string; team: string; entry_date: string };
export type ReportSplitRow = { key: string; value: number; count: number };
export type ReportRow = { key: string; value: number; count: number; people: ReportPerson[]; split?: ReportSplitRow[] };
export function aggregateReport(
employees: ReportEmployee[],
measure: Measure,
group: GroupDimension,
split: GroupDimension | null,
lookups: OrgLookups
): ReportRow[] {
const byGroup = new Map<string, ReportEmployee[]>();
for (const e of employees) {
const key = groupKeyFor(e, group, lookups);
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
}
const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) {
const value = measureValue(rowsForGroup, measure);
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.id,
name: `${e.first_name} ${e.last_name}`,
title: e.job_title,
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.entry_date,
}));
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
if (split) {
const bySplit = new Map<string, ReportEmployee[]>();
for (const e of rowsForGroup) {
const sKey = groupKeyFor(e, split, lookups);
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
}
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
key: sKey,
value: measureValue(sRows, measure),
count: sRows.length,
}));
}
rows.push(row);
}
return rows.sort((a, b) => b.value - a.value);
}
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Ø Gehalt nach Paygrade", measure: "avg_salary", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Eintritte nach Bereich", measure: "hires", group: "division" },
{ name: "Austritte nach Abteilung", measure: "exits", group: "department" },
];