Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
195 lines
6.1 KiB
TypeScript
195 lines
6.1 KiB
TypeScript
export type Measure =
|
||
| "headcount"
|
||
| "fte"
|
||
| "hires"
|
||
| "exits"
|
||
| "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",
|
||
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[] = ["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;
|
||
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 "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: "Headcount nach Paygrade", measure: "headcount", 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" },
|
||
];
|