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:
201
lib/reports.ts
Normal file
201
lib/reports.ts
Normal 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" },
|
||||
];
|
||||
Reference in New Issue
Block a user