Files
alpenwerk-hr/lib/reports.ts
Maximilian Stubhan e27db5f030 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.
2026-07-13 23:11:23 +02:00

202 lines
6.4 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.

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" },
];