Compare commits

1 Commits

Author SHA1 Message Date
4c5bfc223f README.md aktualisiert
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m5s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m21s
2026-08-03 07:52:05 +00:00
62 changed files with 2333 additions and 4921 deletions

View File

@@ -1,4 +1,4 @@
# Manner HR Master # Alpenwerk HR Master
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/ (Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/

View File

@@ -1,39 +1,20 @@
"use server"; "use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter export async function login(formData: FormData) {
// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg const email = String(formData.get("email") ?? "");
// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor, const password = String(formData.get("password") ?? "");
// bedingten Zugriff, Sperrung beim Austritt.
//
// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
export async function signInWithEntra() {
const supabase = await createClient(); const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
// Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen, if (error) {
// damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase redirect("/login?error=invalid_credentials");
// nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts }
// steht — ein untergeschobener Host läuft also ins Leere.
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const { data, error } = await supabase.auth.signInWithOAuth({ redirect("/");
provider: "azure",
options: {
// openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
// Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
scopes: "openid profile email",
redirectTo: `${origin}/auth/callback`,
},
});
if (error || !data.url) redirect("/login?error=sso_failed");
redirect(data.url);
} }
export async function logout() { export async function logout() {

View File

@@ -1,6 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
@@ -64,8 +65,8 @@ export async function terminateEmployee(payload: {
export async function transferEmployee(payload: { export async function transferEmployee(payload: {
employee_id: string; employee_id: string;
effective_date: string; effective_date: string;
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */ new_team_id: string;
target_position_id: string; new_title?: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
} }
@@ -152,3 +153,23 @@ export async function addEmployeeNote(payload: {
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> { export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]); return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
} }
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
// Shared by "Position besetzen" (staff an open position) and the reorg
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
// on-leave employees by name or title.
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees")
.select("id, first_name, last_name, job_title, team_id")
.in("status", ["Aktiv", "Karenz"])
.limit(20);
if (query.trim()) {
const term = sanitizeIlikeTerm(query.trim());
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
const { data } = await q;
return data ?? [];
}

View File

@@ -1,6 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string }; type ActionResult = { success: boolean; error?: string };
@@ -20,9 +21,10 @@ async function callRpc(
} }
export async function createPosition(payload: { export async function createPosition(payload: {
org_unit_id: string; title: string;
job_title: string; superior_employee_id: string;
is_chief: boolean; is_lead: boolean;
team_id?: string;
valid_from: string; valid_from: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("create_position", payload, POSITION_PATHS); return callRpc("create_position", payload, POSITION_PATHS);
@@ -32,3 +34,23 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
} }
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
// For "Position ausschreiben": superior lookup, filtered to team-leads when
// the new position is an IC role, or to division-heads/CEO when the new
// position is itself a team lead (§2).
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
const supabase = await createClient();
let q = supabase
.from("employees")
.select("id, first_name, last_name, job_title, division_id")
.eq("status", "Aktiv")
.limit(20);
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
if (query.trim()) {
const term = sanitizeIlikeTerm(query.trim());
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
const { data } = await q;
return data ?? [];
}

37
actions/reorg.ts Normal file
View File

@@ -0,0 +1,37 @@
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string };
export type ReorgMovePayload = {
kind: "emp" | "team" | "abt" | "dept";
label: string;
employee_ids: string[];
target_team_id: string;
};
export async function applyReorg(payload: {
name: string;
effective_date: string;
moves: ReorgMovePayload[];
}): Promise<ActionResult & { scenarioId?: string }> {
const supabase = await createClient();
const { data, error } = await supabase.rpc("apply_reorg", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/orgchart");
revalidatePath("/employees");
revalidatePath("/");
return { success: true, scenarioId: data as string };
}
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
const supabase = await createClient();
const { error } = await supabase.rpc("undo_reorg", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/orgchart");
revalidatePath("/employees");
revalidatePath("/");
return { success: true };
}

View File

@@ -1,35 +1,40 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { EmployeeDetail } from "@/components/employees/EmployeeDetail"; import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
import { todayIso } from "@/lib/format";
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
import { loadPlacements, type ReportingLine } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { Database } from "@/lib/supabase/types";
type PageProps = { params: Promise<{ id: string }> }; type PageProps = { params: Promise<{ id: string }> };
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
};
export default async function EmployeeDetailPage({ params }: PageProps) { export default async function EmployeeDetailPage({ params }: PageProps) {
const { id } = await params; const { id } = await params;
const supabase = await createClient(); const supabase = await createClient();
const today = todayIso();
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen // Everything here keys off the id already in the URL, and the manager
// aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es // comes back as an embedded resource on the employee row rather than as a
// wandern also neun Zeilen über die Leitung und nicht achthundert. // follow-up query — so the page is one round trip instead of two. Measured
// against the hosted database that halved the data time (120ms -> 62ms,
// median of five), because a round trip costs more than these queries do.
//
// The hand-written Database type carries no relationship metadata
// (NoRelationships), so the embed is typed at the destructure below.
const [ const [
{ data: employee }, { data: employeeRow },
{ data: ownLine }, { data: directReports },
{ data: reportLines },
{ data: history }, { data: history },
{ data: dependents }, { data: dependents },
{ data: notes }, { data: notes },
orgMaps, { data: divisions },
placements, { data: departments },
openPositions, { data: teams },
{ data: locations },
{ data: openPositions },
] = await Promise.all([ ] = await Promise.all([
supabase.from("employees").select("*").eq("id", id).single(), supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id),
supabase supabase
.from("employee_history") .from("employee_history")
.select("*") .select("*")
@@ -38,61 +43,32 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
.order("created_at", { ascending: false }), .order("created_at", { ascending: false }),
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"), supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }), supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
loadOrgMaps(supabase), supabase.from("divisions").select("*").order("name"),
loadPlacements(supabase, { asOf: today, employeeIds: [id] }), supabase.from("departments").select("*"),
loadOpenPositions(supabase), supabase.from("teams").select("*"),
supabase.from("locations").select("*").order("name"),
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
]); ]);
if (!employee) notFound(); if (!employeeRow) notFound();
const line = ownLine as ReportingLine | null; // Split the embedded manager back off so EmployeeDetail keeps receiving a
const reports = (reportLines ?? []) as ReportingLine[]; // plain employees row plus a separate manager, unchanged.
const { manager, ...employee } = employeeRow as EmployeeWithManager;
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
// formal zuständige Leitung und die direkten Berichte.
const relatedIds = Array.from(
new Set(
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
(x): x is string => Boolean(x)
)
)
);
const { data: relatedRows } = relatedIds.length
? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds)
: { data: [] };
const byId = new Map((relatedRows ?? []).map((e) => [e.id, e]));
const placement = placements.get(id) ?? null;
return ( return (
<EmployeeDetail <EmployeeDetail
employee={employee} employee={employee}
placement={ manager={manager ?? null}
placement && { directReports={directReports ?? []}
positionNumber: placement.positionNumber,
jobTitle: placement.jobTitle,
isChief: placement.isChief,
current: placement.current,
}
}
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da.
formalManager={
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
? (byId.get(line.formal_manager_id) ?? null)
: null
}
directReports={reports.flatMap((r) => {
const e = byId.get(r.employee_id);
return e ? [e] : [];
})}
history={history ?? []} history={history ?? []}
dependents={dependents ?? []} dependents={dependents ?? []}
notes={notes ?? []} notes={notes ?? []}
locations={orgMaps.locationList} divisions={divisions ?? []}
openPositions={openPositions} departments={departments ?? []}
teams={teams ?? []}
locations={locations ?? []}
openPositions={openPositions ?? []}
/> />
); );
} }

View File

@@ -7,28 +7,13 @@ import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import { loadPlacements } from "@/lib/placement"; import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { EmploymentStatus } from "@/lib/supabase/types"; import type { EmploymentStatus } from "@/lib/supabase/types";
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const COLUMNS =
"id, first_name, last_name, personnel_number, job_title, location_id, entry_date, employment_type, weekly_hours, status, absence_type";
// Was applyFilters vom Query-Builder braucht — mehr nicht.
type Narrowable = {
eq: (column: string, value: string | number) => Narrowable;
or: (filters: string) => Narrowable;
gt: (column: string, value: string) => Narrowable;
lte: (column: string, value: string) => Narrowable;
gte: (column: string, value: string) => Narrowable;
is: (column: string, value: null) => Narrowable;
not: (column: string, operator: string, value: null) => Narrowable;
};
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string }; type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
type EmployeesPageProps = { type EmployeesPageProps = {
@@ -52,73 +37,48 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
const page = Math.max(1, Number(params.page ?? "1") || 1); const page = Math.max(1, Number(params.page ?? "1") || 1);
const from = (page - 1) * PAGE_SIZE; const from = (page - 1) * PAGE_SIZE;
const to = from + PAGE_SIZE - 1; const to = from + PAGE_SIZE - 1;
const today = todayIso();
// Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum let query = supabase
// braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die .from("employees")
// Einheit selbst — dort sitzt nur die Bereichsleitung. .select(
const orgMaps = await loadOrgMaps(supabase); "id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
{ count: "exact" }
// Nach Organisationseinheit gefiltert wird über die laufende Besetzung. )
// `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung .order("last_name", { ascending: true })
// die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu .range(from, to);
// leeren. Die Einbettung ändert die Form der Zeile, deshalb steht sie im
// Select und nicht in einem nachträglichen Filter.
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
if (params.q) {
const q = params.q.trim();
if (/^\d+$/.test(q)) {
query = query.eq("personnel_number", Number(q));
} else {
const term = sanitizeIlikeTerm(q);
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
}
}
if (params.division) query = query.eq("division_id", params.division);
// Comma-separated, so a dashboard tile can link here with the same // Comma-separated, so a dashboard tile can link here with the same
// status set it counted rather than a narrower one. // status set it counted rather than a narrower one.
const statuses = (params.status ?? "") const statuses = (params.status ?? "")
.split(",") .split(",")
.map((s) => s.trim()) .map((s) => s.trim())
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
// Strukturell typisiert und generisch über den Builder, damit die beiden
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei
// getrennte Abfragen mit einer gemeinsamen Filterkette.
function applyFilters<Q extends Narrowable>(query: Q): Q {
let q = query;
if (params.q) {
const term = params.q.trim();
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q;
else {
const safe = sanitizeIlikeTerm(term);
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q;
}
}
// Derived from the dates, not read off employees.status — see // Derived from the dates, not read off employees.status — see
// lib/employee-status-filter.ts for why the two can disagree. // lib/employee-status-filter.ts for why the two can disagree.
q = applyDerivedStatusFilter(q, statuses, today); query = applyDerivedStatusFilter(query, statuses, todayIso());
if (params.location) q = q.eq("location_id", params.location) as Q; if (params.location) query = query.eq("location_id", params.location);
return q;
}
const { data: employeesData, count } = unitFilter // The org lookup tables are needed only to label the rows, so they load
? await applyFilters( // alongside the page of employees instead of before it — one round trip
supabase // saved on a page that is otherwise two fast queries.
.from("employees") const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
.select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" })
.order("last_name", { ascending: true })
.range(from, to)
.is("position_assignments.valid_to", null)
.in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter))
)
: await applyFilters(
supabase.from("employees").select(COLUMNS, { count: "exact" }).order("last_name", { ascending: true }).range(from, to)
);
const employees = employeesData ?? []; const employees = employeesData ?? [];
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
// Seite, nicht für den ganzen Bestand.
const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) });
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Suspense> <Suspense>
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} /> <EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
</Suspense> </Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p> <p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
@@ -137,9 +97,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
</thead> </thead>
<tbody> <tbody>
{employees.map((e) => { {employees.map((e) => {
const placement = placements.get(e.id); const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
const division = divisionOf(orgMaps, placement?.orgUnitId);
const unit = unitOf(orgMaps, placement?.orgUnitId);
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined; const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
return ( return (
// border-subtle between rows: the full-strength border made // border-subtle between rows: the full-strength border made
@@ -155,7 +113,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<div className="truncate font-semibold text-ink"> <div className="truncate font-semibold text-ink">
{e.first_name} {e.last_name} {e.first_name} {e.last_name}
</div> </div>
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div> <div className="truncate text-xs text-ink-muted">{e.job_title}</div>
</div> </div>
</Link> </Link>
</td> </td>
@@ -164,12 +122,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td> <td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
<td className="px-4 py-2.5 text-ink-body"> <td className="px-4 py-2.5 text-ink-body">
<div>{division?.name ?? ""}</div> <div>{division?.name ?? ""}</div>
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt — <div className="text-xs text-ink-muted">{team?.name ?? ""}</div>
eine Bereichsleitung sitzt am Bereich, nicht an einem
Team, und stand vorher deshalb ohne Zuordnung da. */}
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
{unit && unit.id !== division?.id ? unit.name : ""}
</div>
</td> </td>
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? ""}</td> <td className="px-4 py-2.5 text-ink-body">{location?.name ?? ""}</td>
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td> <td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>

View File

@@ -1,8 +1,8 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { OrgChartClient } from "@/components/orgchart/OrgChartClient"; import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
import type { OrgUnitNode } from "@/components/orgchart/types";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data"; import { loadOrgAsOf } from "@/lib/orgchart-data";
import { loadOpenPositions } from "@/lib/positions";
import { parseIsoDateParam } from "@/lib/reports"; import { parseIsoDateParam } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
@@ -20,17 +20,30 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
const supabase = await createClient(); const supabase = await createClient();
const [org, { data: units }] = await Promise.all([ const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
await Promise.all([
loadOrgAsOf(supabase, asOf), loadOrgAsOf(supabase, asOf),
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
loadOpenPositions(supabase),
supabase
.from("reorg_scenarios")
.select("id, name, effective_date, applied, applied_at")
.eq("applied", true)
.order("applied_at", { ascending: false })
.limit(5),
]); ]);
return ( return (
<Suspense> <Suspense>
<OrgChartClient <OrgChartClient
employees={org.employees} employees={org.employees}
units={(units ?? []) as OrgUnitNode[]} divisions={divisions ?? []}
vacancies={org.vacancies} departments={departments ?? []}
teams={teams ?? []}
openPositions={openPositions}
reorgScenarios={reorgScenarios ?? []}
asOf={asOf} asOf={asOf}
today={today} today={today}
projectedCount={org.projectedCount} projectedCount={org.projectedCount}

View File

@@ -4,9 +4,6 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
import { actionBadgeStyle } from "@/lib/colors"; import { actionBadgeStyle } from "@/lib/colors";
import { addDaysIso, fmtDate, todayIso } from "@/lib/format"; import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
import { divisionOf, loadOrgMaps } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import { loadOpenPositions } from "@/lib/positions";
import { deriveStatusAsOf } from "@/lib/reports"; import { deriveStatusAsOf } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { fetchAllRows } from "@/lib/supabase/query"; import { fetchAllRows } from "@/lib/supabase/query";
@@ -74,9 +71,8 @@ export default async function DashboardPage() {
staffRows, staffRows,
hiresYtdRes, hiresYtdRes,
exitsYtdRes, exitsYtdRes,
openPositions, openPositionsRes,
orgMaps, divisionsRes,
placements,
upcomingHiresRes, upcomingHiresRes,
upcomingExitsRes, upcomingExitsRes,
upcomingReturnsRes, upcomingReturnsRes,
@@ -85,7 +81,7 @@ export default async function DashboardPage() {
fetchAllRows(() => fetchAllRows(() =>
supabase supabase
.from("employees") .from("employees")
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") .select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
.order("id") .order("id")
), ),
// Entries/exits count history events, which is what the linked report // Entries/exits count history events, which is what the linked report
@@ -104,9 +100,8 @@ export default async function DashboardPage() {
.eq("event_type", "Austritt") .eq("event_type", "Austritt")
.gte("event_date", yearStart) .gte("event_date", yearStart)
.lte("event_date", yearEnd), .lte("event_date", yearEnd),
loadOpenPositions(supabase), supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
loadOrgMaps(supabase), supabase.from("divisions").select("id, name"),
loadPlacements(supabase, { asOf: today }),
supabase supabase
.from("employees") .from("employees")
.select("id, first_name, last_name, entry_date") .select("id, first_name, last_name, entry_date")
@@ -148,18 +143,12 @@ export default async function DashboardPage() {
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length; const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
// Vorfahrenkette im selben Balken.
const headcountByDivision = new Map<string, number>(); const headcountByDivision = new Map<string, number>();
for (const row of activeStaff) { for (const row of activeStaff) {
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId); if (!row.division_id) continue;
if (!division) continue; headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
} }
const divisionBars = orgMaps.unitList const divisionBars = (divisionsRes.data ?? [])
.filter((u) => u.unit_type === "Bereich")
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 })) .map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
@@ -225,7 +214,7 @@ export default async function DashboardPage() {
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
}, },
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, { label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
]; ];
return ( return (

View File

@@ -1,32 +1,21 @@
import type { UnitOption } from "@/components/positions/CreatePositionModal";
import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetweenIso } from "@/lib/format"; import { daysBetweenIso, toIsoDate } from "@/lib/format";
import { loadOrgMaps } from "@/lib/org";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
export default async function PositionsPage() { export default async function PositionsPage() {
const supabase = await createClient(); const supabase = await createClient();
const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ // Teams are only needed for the "Position ausschreiben" dialog's team
// select. The division/department/team headcount overview this page used
// to render was dropped, and with it the two employee-wide aggregation
// queries that fed it.
const [openPositions, { data: teams }] = await Promise.all([
loadOpenPositions(supabase), loadOpenPositions(supabase),
loadOrgMaps(supabase), supabase.from("teams").select("*").order("name"),
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
// Fehlermeldung nach dem Absenden.
supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null),
]); ]);
const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id)); const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
id: u.id,
name: u.name,
unit_type: u.unit_type,
depth: orgMaps.depthOf.get(u.id) ?? 0,
hasChief: withChief.has(u.id),
}));
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) })); return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
} }

View File

@@ -1,20 +1,14 @@
import { EntraSignInButton } from "@/components/auth/EntraSignInButton"; import { login, logout } from "@/actions/auth";
import { logout, signInWithEntra } from "@/actions/auth"; import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS } from "@/components/ui/Field";
// The query string is attacker-controlled, so the login page renders a message // The query string is attacker-controlled, so the login page renders a message
// looked up by code rather than whatever text ?error= carries. Reflecting the // looked up by code rather than whatever text ?error= carries. Reflecting the
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt, // raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt // rufen Sie …") on the real, correctly-branded sign-in screen.
// für die Fehlertexte, die Entra im Rückweg mitschickt.
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
no_hr_access: { no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
title: "Kein HR-Zugriff", invalid_credentials: "E-Mail oder Passwort ist falsch.",
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
},
sso_failed: {
title: "Anmeldung fehlgeschlagen",
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
},
} as const; } as const;
type ErrorCode = keyof typeof ERROR_MESSAGES; type ErrorCode = keyof typeof ERROR_MESSAGES;
@@ -29,105 +23,56 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const error = code ? ERROR_MESSAGES[code] : null; const error = code ? ERROR_MESSAGES[code] : null;
return ( return (
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte <div className="flex min-h-screen items-center justify-center bg-surface px-4">
// im ersten Moment unter dem Faltenrand sitzt. <div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]"> <h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
<BrandPanel /> <p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
<div className="w-full max-w-sm">
<div className="lg:hidden">
<Wordmark className="text-ink" />
</div>
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
<p className="mt-1.5 text-sm text-ink-muted">
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
</p>
{error && ( {error && (
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3"> <div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
<p className="text-sm font-bold text-danger-text">{error.title}</p> {error}
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
{code === "no_hr_access" && ( {code === "no_hr_access" && (
<form action={logout} className="mt-3"> <form action={logout} className="mt-2">
<button <button type="submit" className="text-xs font-semibold underline hover:no-underline">
type="submit" Abmelden und mit anderem Konto versuchen
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
>
Abmelden und mit einem anderen Konto versuchen
</button> </button>
</form> </form>
)} )}
</div> </div>
)} )}
<form action={signInWithEntra} className="mt-7"> <form action={login} className="mt-6 flex flex-col gap-4">
<EntraSignInButton /> <div>
</form> <label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
E-Mail
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted"> </label>
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung bis dahin bleiben alle <input
Personaldaten verschlossen. id="email"
</p> name="email"
</div> type="email"
</main> required
</div> autoComplete="username"
); className={CONTROL_CLASS}
}
function BrandPanel() {
return (
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
ohne von der einen Schaltfläche gegenüber abzulenken. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage:
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
}}
/> />
<div className="relative">
<Wordmark className="text-white" />
</div> </div>
<div>
<div className="relative max-w-md"> <label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white"> Passwort
Die Organisation, so wie sie heute wirklich aussieht. </label>
</p> <input
<p className="mt-4 text-sm leading-relaxed text-white/70"> id="password"
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH jederzeit auch zu einem beliebigen name="password"
Stichtag. type="password"
</p> required
autoComplete="current-password"
className={CONTROL_CLASS}
/>
</div>
<Button type="submit" fullWidth className="mt-2">
Anmelden
</Button>
</form>
</div> </div>
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
</aside>
);
}
function Wordmark({ className = "" }: { className?: string }) {
return (
<div className={`flex items-center gap-2.5 ${className}`}>
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
drei Einheiten. */}
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
</svg>
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
</div> </div>
); );
} }

View File

@@ -1,9 +1,6 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { statusLabel } from "@/lib/absence"; import { statusLabel } from "@/lib/absence";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { todayIso } from "@/lib/format";
import { subtreeOf } from "@/lib/org";
import { loadPlacements, loadReportingLines } from "@/lib/placement";
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth"; import { requireHrUser } from "@/lib/supabase/auth";
@@ -11,14 +8,7 @@ import { fetchAllRows } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// über die Planstelle und die abgeleitete Berichtslinie.
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
org_unit_id: string | null;
position_number: string | null;
is_chief: boolean;
manager_id: string | null;
};
// Full raw data dump — every column on `employees`, not just the fields a // Full raw data dump — every column on `employees`, not just the fields a
// pivot report groups by. Respects the same division/location/status/ // pivot report groups by. Respects the same division/location/status/
@@ -42,43 +32,24 @@ export async function GET(request: NextRequest) {
const statuses = parseStatuses(filters.status); const statuses = parseStatuses(filters.status);
const stichtag = asOf ?? todayIso();
function employeeQuery() { function employeeQuery() {
let query = supabase.from("employees").select("*").order("last_name").order("id"); let query = supabase.from("employees").select("*").order("last_name").order("id");
if (filters.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
if (!asOf) query = query.in("status", statuses); if (!asOf) query = query.in("status", statuses);
return query; return query;
} }
const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
fetchAllRows(employeeQuery), fetchAllRows(employeeQuery),
loadOrgLookups(supabase), loadOrgLookups(supabase),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
loadDependentsCounts(supabase), loadDependentsCounts(supabase),
loadPlacements(supabase, { asOf: stichtag }),
loadReportingLines(supabase, stichtag),
]); ]);
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])); const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
// Export für "Produktion" nur die Bereichsleitung.
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const enriched: EmployeeRow[] = employees.flatMap((e) => {
const placement = placements.get(e.id);
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) return [];
return [{
...e,
org_unit_id: orgUnitId,
position_number: placement?.positionNumber ?? null,
is_chief: placement?.isChief ?? false,
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
}];
});
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf); const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format); const filename = exportFilename("mitarbeiter-export", format);
@@ -110,14 +81,14 @@ function employeeExportColumns(
{ header: "Wohnsitzland", get: (e) => e.address_country }, { header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email }, { header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone }, { header: "Telefon", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_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: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Position", get: (e) => e.job_title }, { header: "Position", get: (e) => e.job_title },
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") }, { header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
{ header: "Planstelle", get: (e) => e.position_number }, { header: "Führungskraft", get: (e) => e.is_lead },
{ header: "Leitungsplanstelle", get: (e) => e.is_chief }, { header: "Org-Level", get: (e) => e.org_level },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours }, { header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not // work_days is stored in click order (see RoleEmploymentFields), not

View File

@@ -44,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
{ header: "Vorname", get: (e) => e.first_name }, { header: "Vorname", get: (e) => e.first_name },
{ header: "Nachname", get: (e) => e.last_name }, { header: "Nachname", get: (e) => e.last_name },
{ header: "Position", get: (e) => e.job_title }, { header: "Position", get: (e) => e.job_title },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_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: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
{ header: "Beschreibung", get: (e) => e.description }, { header: "Beschreibung", get: (e) => e.description },
]; ];

View File

@@ -1,30 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";
// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
// zurück auf /login.
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl;
// Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
// zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
// stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
if (searchParams.get("error")) {
return NextResponse.redirect(`${origin}/login?error=sso_failed`);
}
const code = searchParams.get("code");
if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
// Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
// proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
// davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
return NextResponse.redirect(`${origin}/`);
}

View File

@@ -1,32 +0,0 @@
"use client";
import { useFormStatus } from "react-dom";
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
// fremde Anmeldeseite springt.
export function EntraSignInButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
aria-busy={pending || undefined}
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
disabled:cursor-not-allowed disabled:opacity-60"
>
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</svg>
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
</button>
);
}

View File

@@ -7,7 +7,6 @@ import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format"; import { fmtFullName, tenure } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel"; import { KarenzPanel } from "./panels/KarenzPanel";
@@ -22,37 +21,42 @@ import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab"; import { VertragTab } from "./tabs/VertragTab";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"]; type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"]; type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string }; type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
/** Die Planstelle, die die Person heute innehat. */ type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
type EmployeeDetailProps = { type EmployeeDetailProps = {
employee: EmployeeRow; employee: EmployeeRow;
placement: PlacementInfo | null;
breadcrumb: string;
manager: MiniEmployee | null; manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[]; directReports: MiniEmployee[];
history: HistoryRow[]; history: HistoryRow[];
dependents: Dependent[]; dependents: Dependent[];
notes: NoteRow[]; notes: NoteRow[];
divisions: Division[];
departments: Department[];
teams: Team[];
locations: Location[]; locations: Location[];
openPositions: OpenPositionResolved[]; openPositions: OpenPosition[];
}; };
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const; const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) { export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten"); const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null); const [panel, setPanel] = useState<PanelType>(null);
const division = divisions.find((d) => d.id === employee.division_id);
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" ") || "";
const location = locations.find((l) => l.id === employee.location_id); const location = locations.find((l) => l.id === employee.location_id);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
@@ -75,17 +79,8 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
</h2> </h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} /> <StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
</div> </div>
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p> <p className="text-sm text-ink-body">{employee.job_title}</p>
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">{breadcrumb}</p>
{breadcrumb}
{placement && (
<>
{" · Planstelle "}
{placement.positionNumber}
{placement.isChief && " (Leitung)"}
</>
)}
</p>
<p className="mt-1 text-xs text-ink-muted"> <p className="mt-1 text-xs text-ink-muted">
Pers.-Nr. {employee.personnel_number} Pers.-Nr. {employee.personnel_number}
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>} {employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
@@ -147,13 +142,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />} {tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
{tab === "Vertrag" && <VertragTab employee={employee} />} {tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && ( {tab === "Organisation" && (
<OrganisationTab <OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
employeeId={employee.id}
manager={manager}
formalManager={formalManager}
directReports={directReports}
breadcrumb={breadcrumb}
/>
)} )}
{tab === "Historie" && <HistorieTab history={history} />} {tab === "Historie" && <HistorieTab history={history} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />} {tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
@@ -163,7 +152,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
open={panel === "transfer"} open={panel === "transfer"}
onClose={() => setPanel(null)} onClose={() => setPanel(null)}
employee={employee} employee={employee}
openPositions={openPositions} divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
/> />
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} /> <PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} /> <KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />

View File

@@ -6,9 +6,7 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput"; import { SearchInput } from "@/components/ui/SearchInput";
type EmployeeFiltersProps = { type EmployeeFiltersProps = {
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */ divisions: { id: string; name: string }[];
units: { id: string; name: string; unit_type: string }[];
depthOf: Map<string, number>;
locations: { id: string; name: string }[]; locations: { id: string; name: string }[];
}; };
@@ -24,7 +22,7 @@ const STATUS_OPTIONS = [
{ value: "Ausgetreten", label: "Ausgetreten" }, { value: "Ausgetreten", label: "Ausgetreten" },
] as const; ] as const;
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) { export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -57,24 +55,16 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
{/* aria-label rather than a visible label: the filter bar is a single {/* aria-label rather than a visible label: the filter bar is a single
horizontal row, and each select's first option already names it on horizontal row, and each select's first option already names it on
screen. */} screen. */}
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
kennt und die Tiefe hier beliebig ist. */}
<select <select
aria-label="Nach Organisationseinheit filtern" aria-label="Nach Bereich filtern"
defaultValue={searchParams.get("division") ?? ""} defaultValue={searchParams.get("division") ?? ""}
onChange={(e) => updateParam("division", e.target.value)} onChange={(e) => updateParam("division", e.target.value)}
className={FILTER_SELECT_CLASS} className={FILTER_SELECT_CLASS}
> >
<option value="">Alle Einheiten</option> <option value="">Alle Bereiche</option>
{units {divisions.map((d) => (
.filter((u) => u.unit_type !== "Gesellschaft") <option key={d.id} value={d.id}>
.map((u) => ( {d.name}
<option key={u.id} value={u.id}>
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
{u.name}
</option> </option>
))} ))}
</select> </select>

View File

@@ -7,51 +7,48 @@ import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field"; import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type TransferPanelProps = { type TransferPanelProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
employee: EmployeeRow; employee: EmployeeRow;
openPositions: OpenPositionResolved[]; divisions: Division[];
departments: Department[];
teams: Team[];
currentTeamId: string | null;
}; };
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
// keine Stelle gibt.
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [effectiveDate, setEffectiveDate] = useState(""); const [effectiveDate, setEffectiveDate] = useState("");
const [positionId, setPositionId] = useState(""); const [divisionId, setDivisionId] = useState(employee.division_id);
const [teamId, setTeamId] = useState(currentTeamId ?? "");
const [newTitle, setNewTitle] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const options = useMemo( const teamsInDivision = useMemo(() => {
() => const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
openPositions return teams.filter((t) => deptIds.has(t.department_id));
.slice() }, [departments, teams, divisionId]);
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
[openPositions]
);
const selected = openPositions.find((p) => p.id === positionId);
async function handleSubmit() { async function handleSubmit() {
if (!effectiveDate || !positionId) { if (!effectiveDate || !teamId) {
showToast("Bitte Datum und Zielplanstelle angeben.", "error"); showToast("Bitte Datum und Zielteam angeben.", "error");
return; return;
} }
setPending(true); setPending(true);
const result = await transferEmployee({ const result = await transferEmployee({
employee_id: employee.id, employee_id: employee.id,
effective_date: effectiveDate, effective_date: effectiveDate,
target_position_id: positionId, new_team_id: teamId,
new_title: newTitle || undefined,
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
@@ -74,7 +71,7 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</Button> </Button>
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}> <Button onClick={handleSubmit} pending={pending}>
Versetzen Versetzen
</Button> </Button>
</> </>
@@ -82,33 +79,31 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} /> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
{options.length === 0 ? (
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus legen Sie
zuerst unter Positionen eine an.
</p>
) : (
<>
<SelectField <SelectField
label="Zielplanstelle" label="Neuer Bereich"
required required
value={positionId} value={divisionId}
onChange={setPositionId} onChange={(v) => {
placeholder="Bitte wählen…" setDivisionId(v);
options={options} setTeamId("");
}}
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Neues Team"
required
value={teamId}
onChange={setTeamId}
placeholder="Bitte wählen…"
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
<TextField
label="Neuer Titel (optional)"
value={newTitle}
onChange={setNewTitle}
placeholder={employee.job_title}
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
/> />
{selected && (
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
<div className="font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
<div className="mt-1 text-xs text-ink-muted">
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
</div>
</div>
)}
</>
)}
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -8,13 +8,11 @@ type MiniEmployee = { id: string; first_name: string; last_name: string; job_tit
type OrganisationTabProps = { type OrganisationTabProps = {
employeeId: string; employeeId: string;
manager: MiniEmployee | null; manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[]; directReports: MiniEmployee[];
breadcrumb: string; breadcrumb: string;
}; };
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) { export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
@@ -32,15 +30,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
</div> </div>
<div> <div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted"> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
</h3>
{formalManager && (
<p className="mb-2 text-xs text-ink-muted">
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
besetzte Ebene.
</p>
)}
{manager ? ( {manager ? (
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface"> <Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
<Avatar firstName={manager.first_name} lastName={manager.last_name} /> <Avatar firstName={manager.first_name} lastName={manager.last_name} />

View File

@@ -2,17 +2,22 @@
import { useState } from "react"; import { useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { AsOfPicker } from "./AsOfPicker"; import { AsOfPicker } from "./AsOfPicker";
import { EmployeeTree } from "./EmployeeTree"; import { EmployeeTree } from "./EmployeeTree";
import { PositionTree } from "./PositionTree"; import { PositionTree } from "./PositionTree";
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; import { ReorgWorkbench } from "./ReorgWorkbench";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type View = "ma" | "pos"; type View = "ma" | "pos" | "reo";
type OrgChartClientProps = { type OrgChartClientProps = {
employees: OrgEmployee[]; employees: OrgEmployee[];
units: OrgUnitNode[]; divisions: OrgDivision[];
vacancies: OrgVacancy[]; departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[];
asOf: string; asOf: string;
today: string; today: string;
projectedCount: number; projectedCount: number;
@@ -23,8 +28,11 @@ type OrgChartClientProps = {
export function OrgChartClient({ export function OrgChartClient({
employees, employees,
units, divisions,
vacancies, departments,
teams,
openPositions,
reorgScenarios,
asOf, asOf,
today, today,
projectedCount, projectedCount,
@@ -32,6 +40,7 @@ export function OrgChartClient({
focusId, focusId,
}: OrgChartClientProps) { }: OrgChartClientProps) {
const [view, setView] = useState<View>("ma"); const [view, setView] = useState<View>("ma");
const isToday = asOf === today;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -40,14 +49,35 @@ export function OrgChartClient({
onChange={setView} onChange={setView}
options={[ options={[
{ value: "ma", label: "Mitarbeiter" }, { value: "ma", label: "Mitarbeiter" },
{ value: "pos", label: "Organisation" }, { value: "pos", label: "Positionen" },
{ value: "reo", label: "Reorganisation" },
]} ]}
/> />
{view !== "reo" && (
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} /> <AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
)}
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />} {view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />} {view === "pos" && (
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
)}
{view === "reo" &&
(isToday ? (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
) : (
// A reorg planned against a past or projected roster would be
// applied to the *live* org anyway — better to send the user back
// to today than to let them assemble moves from a roster that is
// not the one the change would hit.
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
<p className="mt-1 text-ink-muted">
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur wechseln
Sie zurück auf Heute, um eine zu planen.
</p>
</div>
))}
</div> </div>
); );
} }

View File

@@ -2,34 +2,56 @@
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState, type ReactNode } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
// damit eine Datenfrage und keine Änderung an dieser Datei.
//
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
type ViewMode = "list" | "graph"; type ViewMode = "list" | "graph";
function TreeRow({
depth,
expandable,
expandedNow,
onToggle,
children,
}: {
depth: number;
expandable: boolean;
expandedNow: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
{expandable ? (
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{children}
</div>
);
}
type PositionTreeProps = { type PositionTreeProps = {
employees: OrgEmployee[]; employees: OrgEmployee[];
units: OrgUnitNode[]; divisions: OrgDivision[];
vacancies: OrgVacancy[]; departments: OrgDepartment[];
teams: OrgTeam[];
openPositions: OpenPositionResolved[];
}; };
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) { export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`))); const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
const [mode, setMode] = useState<ViewMode>("list"); const [mode, setMode] = useState<ViewMode>("list");
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen // useCallback-stable: GraphOrgChart's layout memo depends on these
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein // references, so unstable functions would force a Dagre re-layout on
// neues Dagre-Layout. // every unrelated re-render.
const toggle = useCallback((id: string) => { const toggle = useCallback((id: string) => {
setExpanded((prev) => { setExpanded((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -38,178 +60,216 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
return next; return next;
}); });
}, []); }, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]); const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); const ceo = employees.find((e) => e.org_level === 0) ?? null;
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
return ( const divisionHeadByDivision = new Map<string, OrgEmployee>();
<div className="flex flex-col gap-4"> const teamLeadByTeam = new Map<string, OrgEmployee>();
<div className="flex justify-end"> const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
<SegmentedControl<ViewMode>
value={mode}
onChange={setMode}
options={[
{ value: "list", label: "Liste" },
{ value: "graph", label: "Grafisch" },
]}
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
))}
</div>
)}
</div>
);
}
function ListNode({
node,
depth,
expanded,
onToggle,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
return (
<div>
<div
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
<button
type="button"
onClick={() => onToggle(node.id)}
className="shrink-0 text-ink-muted"
aria-expanded={open}
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{node.href ? (
<Link
href={node.href}
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
>
{node.label}
</Link>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
</div>
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
</div>
);
}
/**
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
* zuletzt die unbesetzten Planstellen.
*/
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
const childUnits = new Map<string | null, OrgUnitNode[]>();
for (const u of units) {
const list = childUnits.get(u.parent_id) ?? [];
list.push(u);
childUnits.set(u.parent_id, list);
}
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
const chiefOf = new Map<string, OrgEmployee>();
const staffOf = new Map<string, OrgEmployee[]>();
for (const e of employees) { for (const e of employees) {
if (e.is_chief) chiefOf.set(e.org_unit_id, e); if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
else { if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
const list = staffOf.get(e.org_unit_id) ?? []; if (!e.is_lead && e.org_level === 3 && e.team_id) {
list.push(e); if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
staffOf.set(e.org_unit_id, list); const byTitle = icsByTeamAndTitle.get(e.team_id)!;
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
byTitle.get(e.job_title)!.push(e);
} }
} }
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
const vacantOf = new Map<string, OrgVacancy[]>(); }, [employees]);
for (const v of vacancies) { const openByTeam = useMemo(() => {
const list = vacantOf.get(v.org_unit_id) ?? []; const map = new Map<string, OpenPositionResolved[]>();
list.push(v); for (const p of openPositions) {
vacantOf.set(v.org_unit_id, list); if (!map.has(p.team_id)) map.set(p.team_id, []);
map.get(p.team_id)!.push(p);
} }
return map;
}, [openPositions]);
function build(unit: OrgUnitNode): ChartNode { // Mirrors the JSX walk below into the generic ChartNode shape for graph
const key = `unit-${unit.id}`; // mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
const chief = chiefOf.get(unit.id); // the list already uses as `expanded` keys, so one Set drives both.
const chartTree = useMemo<ChartNode[]>(() => {
if (!ceo) return [];
const subUnits = (childUnits.get(unit.id) ?? []).map(build); function buildTeam(team: OrgTeam): ChartNode {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt". id: `${teamKey}-${title}`,
const byTitle = new Map<string, OrgEmployee[]>(); kind: "group",
for (const e of staffOf.get(unit.id) ?? []) {
const list = byTitle.get(e.job_title) ?? [];
list.push(e);
byTitle.set(e.job_title, list);
}
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
.sort(([a], [b]) => a.localeCompare(b, "de"))
.map(([title, people]) => ({
id: `${key}-job-${title}`,
kind: "group" as const,
label: title, label: title,
sublabel: `${people.length}x besetzt`, sublabel: `${people.length}x besetzt`,
children: people children: people.map((p) => ({
.slice()
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
.map((p) => ({
id: p.id, id: p.id,
kind: "person" as const, kind: "person",
label: `${p.first_name} ${p.last_name}`, label: `${p.first_name} ${p.last_name}`,
href: `/employees/${p.id}`, href: `/employees/${p.id}`,
avatar: { firstName: p.first_name, lastName: p.last_name }, avatar: { firstName: p.first_name, lastName: p.last_name },
absent: p.absent,
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
children: [], children: [],
})), })),
})); }));
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? []) const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile id: `vac-${p.id}`,
.sort((a, b) => a.position_number.localeCompare(b.position_number)) kind: "vacancy",
.map((v) => ({ label: `${p.position_number} · ${p.title}`,
id: `vac-${v.position_id}`,
kind: "vacancy" as const,
label: `+ ${v.position_number} · ${v.job_title}`,
href: "/positions", href: "/positions",
vacant: true,
children: [], children: [],
})); }));
return { return {
id: key, id: teamKey,
kind: "role", kind: "role",
label: `${unit.org_number} · ${unit.name}`, label: `Teamleitung ${team.name}`,
sublabel: chief sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}` vacant: !lead,
: "Leitung vakant", children: [...titleGroups, ...vacancyNodes],
vacant: !chief,
children: [...subUnits, ...titleGroups, ...vacancyNodes],
}; };
} }
return (childUnits.get(null) ?? []).map(build); function buildDept(dept: OrgDepartment): ChartNode {
return {
id: `dept-${dept.id}`,
kind: "group",
label: `${dept.org_number} · ${dept.name}`,
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
};
}
function buildDivision(div: OrgDivision): ChartNode {
const head = divisionHeadByDivision.get(div.id);
return {
id: `div-${div.id}`,
kind: "role",
label: `Bereichsleitung ${div.name}`,
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
vacant: !head,
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
};
}
return [
{
id: "root",
kind: "role",
label: "Geschäftsführung",
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
children: divisions.map(buildDivision),
},
];
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4">
{ceo && (
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
<span className="text-xs text-ink-muted">
besetzt: {ceo.first_name} {ceo.last_name}
</span>
</TreeRow>
)}
{expanded.has("root") &&
divisions.map((div) => {
const head = divisionHeadByDivision.get(div.id);
const divKey = `div-${div.id}`;
return (
<div key={div.id}>
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
</TreeRow>
{expanded.has(divKey) &&
departments
.filter((d) => d.division_id === div.id)
.map((dept) => {
const deptKey = `dept-${dept.id}`;
return (
<div key={dept.id}>
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
<span className="text-sm text-ink">
{dept.org_number} · {dept.name}
</span>
</TreeRow>
{expanded.has(deptKey) &&
teams
.filter((t) => t.department_id === dept.id)
.map((team) => {
const teamKey = `team-${team.id}`;
const lead = teamLeadByTeam.get(team.id);
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
const openForTeam = openByTeam.get(team.id) ?? [];
return (
<div key={team.id}>
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
<span className="text-sm text-ink">Teamleitung {team.name}</span>
<span className="text-xs text-ink-muted">
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
</span>
</TreeRow>
{expanded.has(teamKey) && (
<>
{Array.from(icsByTitle.entries()).map(([title, people]) => {
const groupKey = `${teamKey}-${title}`;
return (
<div key={title}>
<TreeRow
depth={4}
expandable={people.length > 0}
expandedNow={expanded.has(groupKey)}
onToggle={() => toggle(groupKey)}
>
<span className="text-sm text-ink">{title}</span>
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
</TreeRow>
{expanded.has(groupKey) &&
people.map((p) => (
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
{p.first_name} {p.last_name}
</Link>
</div>
))}
</div>
);
})}
{openForTeam.map((p) => (
<Link
key={p.id}
href="/positions"
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
style={{ paddingLeft: 4 * 24 + 8 }}
>
+ {p.position_number} · {p.title}
</Link>
))}
</>
)}
</div>
);
})}
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
);
} }

View File

@@ -0,0 +1,404 @@
"use client";
import { RotateCcw, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type ChangeKind = "emp" | "team" | "abt" | "dept";
type PendingMove = {
id: string;
kind: ChangeKind;
label: string;
employeeIds: string[];
targetTeamId: string;
targetTeamLabel: string;
};
const KIND_LABELS: Record<ChangeKind, string> = {
emp: "Mitarbeiter:in(nen)",
team: "Ganzes Team",
abt: "Ganze Abteilung",
dept: "Ganzer Bereich",
};
type ReorgWorkbenchProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
reorgScenarios: ReorgScenarioSummary[];
};
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
const { showToast } = useToast();
const router = useRouter();
const [name, setName] = useState("");
const [effectiveDate, setEffectiveDate] = useState("");
const [changeType, setChangeType] = useState<ChangeKind>("emp");
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
const [sourceTeamId, setSourceTeamId] = useState("");
const [sourceDeptId, setSourceDeptId] = useState("");
const [sourceDivisionId, setSourceDivisionId] = useState("");
const [targetDivisionId, setTargetDivisionId] = useState("");
const [targetTeamId, setTargetTeamId] = useState("");
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
const [applying, setApplying] = useState(false);
const [undoingId, setUndoingId] = useState<string | null>(null);
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
const teamDivisionId = useMemo(() => {
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const m = new Map<string, string>();
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
return m;
}, [teams, departments]);
const teamsInTargetDivision = useMemo(
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
[teams, teamDivisionId, targetDivisionId]
);
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
return employees
.filter(
(e) =>
!selectedEmployees.some((s) => s.id === e.id) &&
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
)
.slice(0, 20);
}
function resolveEmployeeIds(): string[] {
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
if (changeType === "abt") {
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
}
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
}
function sourceLabel(): string {
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
}
function handleAddMove() {
const employeeIds = resolveEmployeeIds();
if (employeeIds.length === 0) {
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
return;
}
if (!targetTeamId) {
showToast("Bitte ein Ziel-Team wählen.", "error");
return;
}
const targetTeam = teamById.get(targetTeamId);
setPendingMoves((prev) => [
...prev,
{
id: crypto.randomUUID(),
kind: changeType,
label: sourceLabel(),
employeeIds,
targetTeamId,
targetTeamLabel: targetTeam?.name ?? "",
},
]);
setSelectedEmployees([]);
setSourceTeamId("");
setSourceDeptId("");
setSourceDivisionId("");
}
function removeMove(id: string) {
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
}
const divisionBefore = useMemo(() => {
const m = new Map<string, number>();
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
return m;
}, [employees]);
const divisionDelta = useMemo(() => {
const m = new Map<string, number>();
const employeeById = new Map(employees.map((e) => [e.id, e]));
for (const move of pendingMoves) {
const targetDivId = teamDivisionId.get(move.targetTeamId);
for (const empId of move.employeeIds) {
const emp = employeeById.get(empId);
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
}
}
return m;
}, [pendingMoves, employees, teamDivisionId]);
async function handleApply() {
if (!name || !effectiveDate || pendingMoves.length === 0) {
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
return;
}
setApplying(true);
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
kind: m.kind,
label: m.label,
employee_ids: m.employeeIds,
target_team_id: m.targetTeamId,
}));
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
setApplying(false);
if (result.success) {
showToast("Reorganisation durchgeführt.");
setPendingMoves([]);
setName("");
setEffectiveDate("");
router.refresh();
} else {
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
}
}
async function handleUndo(scenarioId: string) {
setUndoingId(scenarioId);
const result = await undoReorg({ scenario_id: scenarioId });
setUndoingId(null);
if (result.success) {
showToast("Reorganisation rückgängig gemacht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
}
}
return (
<div className="flex flex-col gap-4">
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
</div>
<fieldset className="mt-4">
<legend className="sr-only">Art der Änderung</legend>
<div className="flex flex-wrap gap-2">
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={changeType === kind}
onClick={() => setChangeType(kind)}
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
}`}
>
{KIND_LABELS[kind]}
</button>
))}
</div>
</fieldset>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
{changeType === "emp" && (
<div className="flex flex-col gap-2">
<Field label="Quelle: Mitarbeiter:innen">
{(p) => (
<Lookup<OrgEmployee>
{...p}
placeholder="Mitarbeiter:in suchen…"
onSearch={searchLocalEmployees}
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
renderResult={(e) => (
<div>
<div className="font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="text-xs text-ink-muted">{e.job_title}</div>
</div>
)}
/>
)}
</Field>
{selectedEmployees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedEmployees.map((e) => (
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
{e.first_name} {e.last_name}
<button
type="button"
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
)}
{changeType === "team" && (
<SelectField
label="Quelle: Team"
value={sourceTeamId}
onChange={setSourceTeamId}
placeholder="Team wählen…"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
)}
{changeType === "abt" && (
<SelectField
label="Quelle: Abteilung"
value={sourceDeptId}
onChange={setSourceDeptId}
placeholder="Abteilung wählen…"
options={departments.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
{changeType === "dept" && (
<SelectField
label="Quelle: Bereich"
value={sourceDivisionId}
onChange={setSourceDivisionId}
placeholder="Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
</div>
<div className="flex flex-col gap-2">
<SelectField
label="Ziel-Bereich"
required
value={targetDivisionId}
onChange={(v) => {
setTargetDivisionId(v);
setTargetTeamId("");
}}
placeholder="Ziel-Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Ziel-Team"
required
value={targetTeamId}
onChange={setTargetTeamId}
placeholder="Ziel-Team wählen…"
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
</div>
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
+ Zur Reorganisation hinzufügen
</Button>
</div>
{pendingMoves.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
<ul className="mb-4 flex flex-col divide-y divide-border">
{pendingMoves.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
<span className="text-ink">
{m.label} {m.targetTeamLabel}
</span>
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
</div>
<Button
variant="icon"
onClick={() => removeMove(m.id)}
aria-label={`${m.label} aus der Reorganisation entfernen`}
className="hover:!text-danger-solid"
>
<X className="h-4 w-4" />
</Button>
</li>
))}
</ul>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-ink-muted">
<th className="py-1 pr-3">Bereich</th>
<th className="py-1 pr-3">Vorher</th>
<th className="py-1 pr-3">Nachher</th>
<th className="py-1 pr-3">Δ</th>
</tr>
</thead>
<tbody>
{Array.from(divisionDelta.entries())
.filter(([, delta]) => delta !== 0)
.map(([divId, delta]) => {
const before = divisionBefore.get(divId) ?? 0;
return (
<tr key={divId} className="border-t border-border">
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
{delta > 0 ? `+${delta}` : delta}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setPendingMoves([])}>
Verwerfen
</Button>
<Button onClick={handleApply} pending={applying}>
Reorganisation durchführen
</Button>
</div>
</div>
)}
{reorgScenarios.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink"> Durchgeführte Reorganisationen rückgängig machbar</h2>
<ul className="flex flex-col divide-y divide-border">
{reorgScenarios.map((s) => (
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-semibold text-ink">{s.name}</span>
<span className="ml-2 text-xs text-ink-muted">
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
</span>
</div>
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
<RotateCcw className="h-3.5 w-3.5" />
Rückgängig machen
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -15,30 +15,16 @@ export type OrgEmployee = {
/** This person is on a long-term absence as of the chart's date. */ /** This person is on a long-term absence as of the chart's date. */
absent: boolean; absent: boolean;
absence_type: string | null; absence_type: string | null;
/** Die Einheit der Planstelle — die einzige Verortung, die es noch gibt. */ team_id: string | null;
org_unit_id: string; division_id: string;
/** Diese Planstelle führt ihre Einheit (SAP-OM: A012). */ is_lead: boolean;
is_chief: boolean; org_level: number;
position_id: string;
position_number: string;
}; };
/** Eine Planstelle, die am Stichtag niemand innehat. */ export type OrgDivision = { id: string; org_number: string; name: string };
export type OrgVacancy = { export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
position_id: string; export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
position_number: string; export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
job_title: string;
org_unit_id: string;
is_chief: boolean;
};
export type OrgUnitNode = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
};
// Generic tree shape both EmployeeTree and PositionTree map their own data // Generic tree shape both EmployeeTree and PositionTree map their own data
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart. // into for the graphical (React Flow + Dagre) view — see GraphOrgChart.

View File

@@ -2,64 +2,51 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { createPosition } from "@/actions/positions"; import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field"; import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean }; type Team = Database["public"]["Tables"]["teams"]["Row"];
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
// es nicht. Vorher musste hier eine vorgesetzte Person gesucht werden; die
// ergibt sich jetzt aus der Einheit, und die Frage kann gar nicht mehr falsch
// beantwortet werden.
export function CreatePositionModal({
open,
onClose,
units,
}: {
open: boolean;
onClose: () => void;
units: UnitOption[];
}) {
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [jobTitle, setJobTitle] = useState(""); const [title, setTitle] = useState("");
const [orgUnitId, setOrgUnitId] = useState(""); const [isLead, setIsLead] = useState(false);
const [isChief, setIsChief] = useState(false); const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
const [teamId, setTeamId] = useState("");
const [validFrom, setValidFrom] = useState(todayIso); const [validFrom, setValidFrom] = useState(todayIso);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const unit = units.find((u) => u.id === orgUnitId);
// Der Unique-Index lässt nur eine gültige Leitung je Einheit zu. Das hier
// vorwegzunehmen erspart eine Fehlermeldung, die in der Oberfläche nichts
// erklärt.
const chiefTaken = Boolean(unit?.hasChief);
function reset() { function reset() {
setJobTitle(""); setTitle("");
setOrgUnitId(""); setIsLead(false);
setIsChief(false); setSuperior(null);
setTeamId("");
setValidFrom(todayIso()); setValidFrom(todayIso());
} }
async function handleSubmit() { async function handleSubmit() {
if (!jobTitle || !orgUnitId || !validFrom) { if (!title || !superior || !validFrom || (isLead && !teamId)) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error"); showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return; return;
} }
setPending(true); setPending(true);
const result = await createPosition({ const result = await createPosition({
org_unit_id: orgUnitId, title,
job_title: jobTitle, superior_employee_id: superior.id,
is_chief: isChief && !chiefTaken, is_lead: isLead,
team_id: isLead ? teamId : undefined,
valid_from: validFrom, valid_from: validFrom,
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
showToast("Planstelle angelegt."); showToast("Position ausgeschrieben.");
router.refresh(); router.refresh();
onClose(); onClose();
reset(); reset();
@@ -72,46 +59,77 @@ export function CreatePositionModal({
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Planstelle anlegen" title="Position ausschreiben"
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</Button> </Button>
<Button onClick={handleSubmit} pending={pending}> <Button onClick={handleSubmit} pending={pending}>
Anlegen Ausschreiben
</Button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<SelectField <TextField label="Titel" required value={title} onChange={setTitle} />
label="Organisationseinheit"
required
value={orgUnitId}
onChange={(v) => {
setOrgUnitId(v);
setIsChief(false);
}}
placeholder="Bitte wählen…"
options={units.map((u) => ({
value: u.id,
label: `${" ".repeat(u.depth * 3)}${u.name} (${u.unit_type})`,
}))}
/>
<TextField
label="Tätigkeit"
required
value={jobTitle}
onChange={setJobTitle}
hint="Bestehende Tätigkeiten werden wiederverwendet; ein neuer Name legt einen Katalogeintrag an."
/>
<label className="flex items-center gap-2 text-sm text-ink-body"> <label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} /> <input
Leitungsplanstelle für diese Einheit type="checkbox"
checked={isLead}
onChange={(e) => {
setIsLead(e.target.checked);
setSuperior(null);
}}
/>
Führungsposition (Teamleitung)
</label> </label>
{chiefTaken && ( {!superior ? (
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p> <Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
{(p) => (
<Lookup<SuperiorSearchResult>
{...p}
placeholder="Name oder Titel…"
onSearch={(q) => searchSuperiors(q, isLead)}
onSelect={setSuperior}
renderResult={(r) => (
<div>
<div className="font-semibold text-ink">
{r.first_name} {r.last_name}
</div>
<div className="text-xs text-ink-muted">{r.job_title}</div>
</div>
)}
/>
)}
</Field>
) : (
<div>
<p className="mb-1 block text-sm font-semibold text-ink">
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
</p>
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div>
<div className="text-sm font-semibold text-ink">
{superior.first_name} {superior.last_name}
</div>
<div className="text-xs text-ink-muted">{superior.job_title}</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
Ändern
</Button>
</div>
</div>
)}
{isLead && (
<SelectField
label="Zu leitendes Team"
required
value={teamId}
onChange={setTeamId}
placeholder="Bitte wählen…"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
)} )}
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} /> <TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
</div> </div>

View File

@@ -8,16 +8,16 @@ import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal"; import { CreatePositionModal } from "./CreatePositionModal";
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number }; type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = { type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[]; openPositions: OpenPositionWithDays[];
units: UnitOption[]; teams: { id: string; org_number: string; name: string; department_id: string }[];
}; };
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) { export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -29,7 +29,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
const result = await deletePosition(id); const result = await deletePosition(id);
setDeletingId(null); setDeletingId(null);
if (result.success) { if (result.success) {
showToast("Planstelle entfernt."); showToast("Position gelöscht.");
router.refresh(); router.refresh();
} else { } else {
showToast(result.error ?? "Fehler beim Löschen.", "error"); showToast(result.error ?? "Fehler beim Löschen.", "error");
@@ -40,14 +40,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2> <h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Planstelle anlegen Position ausschreiben
</Button> </Button>
</div> </div>
{openPositions.length === 0 ? ( {openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p> <p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => { {openPositions.map((p) => {
@@ -60,7 +60,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
variant="icon" variant="icon"
onClick={() => handleDelete(p.id)} onClick={() => handleDelete(p.id)}
pending={deletingId === p.id} pending={deletingId === p.id}
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`} aria-label={`Position ${p.title} löschen`}
className="-mr-1 -mt-1 hover:!text-danger-solid" className="-mr-1 -mt-1 hover:!text-danger-solid"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -69,11 +69,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
<div className="text-xs text-ink-muted"> <div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel} {p.position_number} · {p.orgLabel}
</div> </div>
<div className="mt-1 text-xs text-ink-muted"> <div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
{p.is_chief ? "Leitungsplanstelle · " : ""}
seit {p.daysOpen} Tagen unbesetzt
</div>
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>} {notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
</div> </div>
); );
@@ -82,7 +78,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
)} )}
</div> </div>
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} /> <CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
</div> </div>
); );
} }

View File

@@ -1,148 +0,0 @@
# Umstieg auf Azure — Sicherheitsarchitektur
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
Supabase als Abhängigkeit entfernt.
## Ausgangslage, gemessen
| | Anzahl |
|---|---|
| RLS-Policies | 58 |
| `auth.uid()` / `auth.users` in Migrationen | 79 |
| Fremdschlüssel auf `auth.users` | 9 |
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
| Dateien mit Supabase-Import | 10 |
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
Proxy und nicht der Anwendungscode.
## Der entscheidende Befund
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
Stelle:
```sql
create or replace function is_hr_user() returns boolean
language sql security definer stable as $$
select exists (
select 1 from profiles p
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
);
$$;
```
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
war meine Sorge bei Variante B, und sie ist ausgeräumt.
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
## Zielarchitektur
### 1. Benutzertabelle statt `auth.users`
```sql
create table app_users (
id uuid primary key default gen_random_uuid(),
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
email text not null,
created_at timestamptz not null default now()
);
```
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
funktioniert unverändert.
### 2. Sitzungskontext statt `auth.uid()`
```sql
create or replace function current_app_user() returns uuid
language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid;
$$;
```
`auth.uid()``current_app_user()`, überall. `is_hr_user()` bleibt sonst
Wort für Wort gleich.
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
**Hier entscheidet sich, ob die Migration sicher ist.**
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
`set local app.user_id` ausführt:
```ts
await db.transaction(async (tx) => {
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
return tx.select();
});
```
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
Das muss strukturell unmöglich sein, nicht per Konvention.
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
zurück — statt alles.
### 4. Anmeldung
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
(`is_active` ist per Vorgabe `false`).
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
### 5. Datenzugriff
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
## Was bewusst gleich bleibt
- **Alle 58 RLS-Policies**, unverändert
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
- Die Geschäftslogik in den RPCs
## Reihenfolge
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
3. Entra-ID-Anmeldung
4. Die 50 Datenzugriffe umstellen
5. Supabase-Pakete entfernen
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
prüft, dass die zweite die erste nicht sieht.
## Offene Fragen an die Kunden-IT
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
- Wer betreibt und patcht?

View File

@@ -1,121 +0,0 @@
# Anmeldung über Entra ID
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
Sicherheitsgrenze wandert nicht in den Anwendungscode.
## Einrichtung im Entra-Mandanten
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
| | |
|---|---|
| Name | Alpenwerk HR |
| Kontotypen | Nur ein Mandant |
| Umleitungs-URI (Web) | `https://<projekt-ref>.supabase.co/auth/v1/callback` |
| Anwendungs-ID (Client) | `<client-id>` |
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
Die konkreten Werte stehen bewusst nicht hier, sondern in der
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt
eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die
echte Umgebung, und dieses Repository wandert weiter als sie.
Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
„Secret Value" und in keine Datei im Projekt.
Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
Supabase-Projekts.
Danach:
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
2. **API-Berechtigungen**`openid`, `profile`, `email` (Microsoft Graph,
delegiert), Administratorzustimmung erteilen.
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
## Einrichtung in Supabase
Authentication → Providers → Azure:
| Feld | Wert |
|---|---|
| Application (Client) ID | `<client-id>` |
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
| Azure Tenant URL | `https://login.microsoftonline.com/<tenant-id>` |
Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
Registrierung nur den eigenen Mandanten akzeptiert.
Authentication → URL Configuration:
- Site URL: die Produktions-URL
- Redirect URLs: `http://localhost:3000/auth/callback` und
`https://<produktion>/auth/callback`
## Freischaltung über die Entra-Gruppe
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
### Woher der Gruppen-Anspruch kommt
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
ist. Zwei Varianten:
| Variante | Lizenz | Haken |
|---|---|---|
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
Das ist kein Detail, sondern der Kern der Absicherung.
`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
des Anbieters. Nur das ist eine belastbare Quelle.
### Reihenfolge
Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
ankommt — das hängt an der gewählten Variante und an der Konfiguration des
Mandanten. Ablauf:
1. SSO in Betrieb nehmen, einmal anmelden.
2. `node --env-file=.env.local supabase/entra-claims.ts <e-mail>` zeigt, was in
`identity_data` gelandet ist.
3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
Ohne Schritt 2 wäre die Migration geraten.
### Was die Gruppe nicht kann
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
## Bestehende Zugänge
Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
zeigt sie ins Leere und die Person ist ausgesperrt.
`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
zu der es kein Konto mehr gibt.

View File

@@ -1,85 +0,0 @@
// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum.
//
// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei
// Fassungen derselben Regel driften auseinander, deshalb prüft
// tests/integration/om-reporting.test.ts beide gegen denselben Bestand.
// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin
// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je
// Stichtagswechsel nichts brächte.
//
// Regel:
// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der
// übergeordneten Einheit.
//
// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es
// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine
// Sonderbehandlung — sie wird übersprungen.
export type OmUnit = { id: string; parentId: string | null };
export type OmHolder = {
employeeId: string;
positionId: string;
orgUnitId: string;
isChief: boolean;
/** Langzeitabwesend am Stichtag. */
absent: boolean;
};
export type OmReportingLine = {
employeeId: string;
positionId: string;
orgUnitId: string;
isChief: boolean;
/** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */
formalManagerId: string | null;
/** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */
actingManagerId: string | null;
};
/**
* `units` und `holders` beschreiben den Stand zu genau einem Stichtag —
* gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst
* draußen, damit diese Funktion nur eine Sache tut.
*/
export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] {
const parentOf = new Map(units.map((u) => [u.id, u.parentId]));
const chiefOfUnit = new Map<string, OmHolder>();
for (const h of holders) {
if (h.isChief) chiefOfUnit.set(h.orgUnitId, h);
}
return holders.map((h) => {
// Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der
// eigenen — sonst berichtete eine Leitung an sich selbst.
const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId;
const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null;
// Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der
// Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche
// Spalte, und ein fehlerhafter Import kann einen Ring erzeugen.
let actingManagerId: string | null = null;
const seen = new Set<string>();
let unitId: string | null = baseUnitId;
while (unitId && !seen.has(unitId)) {
seen.add(unitId);
const chief = chiefOfUnit.get(unitId);
if (chief && !chief.absent && chief.employeeId !== h.employeeId) {
actingManagerId = chief.employeeId;
break;
}
unitId = parentOf.get(unitId) ?? null;
}
return {
employeeId: h.employeeId,
positionId: h.positionId,
orgUnitId: h.orgUnitId,
isChief: h.isChief,
formalManagerId: formal?.employeeId ?? null,
actingManagerId,
};
});
}

View File

@@ -1,119 +1,48 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "./supabase/types"; import type { Database } from "./supabase/types";
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher type Division = Database["public"]["Tables"]["divisions"]["Row"];
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt type Department = Database["public"]["Tables"]["departments"]["Row"];
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte type Team = Database["public"]["Tables"]["teams"]["Row"];
// Ebene, ohne dass hier etwas zu ändern wäre.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type OrgUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
type Location = Database["public"]["Tables"]["locations"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"];
export type OrgMaps = { export type OrgMaps = {
units: Map<string, OrgUnit>; divisions: Map<string, Division>;
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */ departments: Map<string, Department>;
unitList: OrgUnit[]; teams: Map<string, Team>;
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
depthOf: Map<string, number>;
childrenOf: Map<string | null, OrgUnit[]>;
locations: Map<string, Location>; locations: Map<string, Location>;
divisionList: Division[];
locationList: Location[]; locationList: Location[];
}; };
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden // Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. // locations) — fetched whole and joined client-side rather than per-row.
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> { export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
const [{ data: units }, { data: locations }] = await Promise.all([ const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
supabase.from("locations").select("*").order("name"), supabase.from("locations").select("*").order("name"),
]); ]);
return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []);
}
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps {
const childrenOf = new Map<string | null, OrgUnit[]>();
for (const u of units) {
const list = childrenOf.get(u.parent_id) ?? [];
list.push(u);
childrenOf.set(u.parent_id, list);
}
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
const unitList: OrgUnit[] = [];
const depthOf = new Map<string, number>();
const walk = (parentId: string | null, depth: number) => {
for (const u of childrenOf.get(parentId) ?? []) {
unitList.push(u);
depthOf.set(u.id, depth);
walk(u.id, depth + 1);
}
};
walk(null, 0);
return { return {
units: new Map(units.map((u) => [u.id, u])), divisions: new Map((divisions ?? []).map((d) => [d.id, d])),
unitList, departments: new Map((departments ?? []).map((d) => [d.id, d])),
depthOf, teams: new Map((teams ?? []).map((t) => [t.id, t])),
childrenOf, locations: new Map((locations ?? []).map((l) => [l.id, l])),
locations: new Map(locations.map((l) => [l.id, l])), divisionList: divisions ?? [],
locationList: locations, locationList: locations ?? [],
}; };
} }
/** Wurzel zuerst, die Einheit selbst zuletzt. */ export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) {
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] { const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined;
const chain: OrgUnit[] = []; const team = teamId ? orgMaps.teams.get(teamId) : undefined;
const seen = new Set<string>(); const department = team ? orgMaps.departments.get(team.department_id) : undefined;
let current = unitId ? maps.units.get(unitId) : undefined; return { division, department, team };
while (current && !seen.has(current.id)) {
seen.add(current.id);
chain.unshift(current);
current = current.parent_id ? maps.units.get(current.parent_id) : undefined;
}
return chain;
} }
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */ export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string {
export function subtreeOf(maps: OrgMaps, unitId: string): string[] { const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId);
const out: string[] = []; return [division?.name, department?.name, team?.name].filter(Boolean).join(" ") || "";
const queue = [unitId];
const seen = new Set<string>();
while (queue.length > 0) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
}
return out;
}
/**
* „Produktion Fertigung Montage". Die Gesellschaft bleibt weg: sie steht
* über allem und trägt in einer Zeile nichts bei.
*/
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
return chain.map((u) => u.name).join(" ") || "";
}
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
}
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
return unitId ? maps.units.get(unitId) : undefined;
} }

View File

@@ -1,35 +1,32 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; import type { OrgEmployee } from "@/components/orgchart/types";
import { resolveActingManagers } from "./acting-manager";
import { todayIso } from "./format"; import { todayIso } from "./format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; import { deriveStatusAsOf } from "./reports";
import { fetchAllRows } from "./supabase/query"; import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types"; import type { Database } from "./supabase/types";
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // The Organigramm as it stood (or will stand) on a given date. Three sources
// have to be reconciled, because no single one covers the whole timeline:
// //
// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den // past/today employee_assignments — the interval covering `asOf`
// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die // future pending_org_changes — effective-dated moves not yet applied
// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die // membership entry/exit/karenz — who counted as staff on that date
// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte.
// //
// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also // See supabase/migrations/*_employee_assignment_history.sql for why the
// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche // placement timeline is captured by a trigger rather than per-RPC.
// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem
// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite
// Zugehörigkeitsregel braucht es nicht mehr.
//
// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht
// in position_assignments, weil sie erst am Stichtag geschrieben werden.
/** Änderungsarten, die jemanden in der Organisation verschieben. */ const ORG_COLUMNS =
const PLACEMENT_CHANGES = ["transfer"] as const; "id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type";
/** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
export type OrgAsOfResult = { export type OrgAsOfResult = {
employees: OrgEmployee[]; employees: OrgEmployee[];
vacancies: OrgVacancy[]; /** How many placements were projected from not-yet-applied changes. */
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
projectedCount: number; projectedCount: number;
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ /** Earliest date the assignment history actually covers. */
historyStartsAt: string | null; historyStartsAt: string | null;
}; };
@@ -39,172 +36,196 @@ type EmployeeRow = {
first_name: string; first_name: string;
last_name: string; last_name: string;
job_title: string; job_title: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
is_lead: boolean;
org_level: number;
entry_date: string;
exit_date: string | null;
karenz_start_date: string | null; karenz_start_date: string | null;
karenz_return_date: string | null; karenz_return_date: string | null;
absence_type: string | null; absence_type: string | null;
}; };
type PositionRow = { type AssignmentRow = {
id: string; employee_id: string;
position_number: string; manager_id: string | null;
org_unit_id: string; team_id: string | null;
is_chief: boolean; division_id: string;
jobs: { title: string }; job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string;
}; };
type AssignmentRow = { employee_id: string; position_id: string };
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> }; type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
// their team's lead, a team lead to the division head, and anyone without a
// team to the CEO. Only used for employees a pending change actually moves —
// everyone else keeps the manager recorded on their assignment, so existing
// data that deviates from the rule is never silently "corrected".
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
if (placement.team_id && !placement.is_lead) {
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
}
if (placement.team_id && placement.is_lead) {
return (
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
null
);
}
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
}
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> { export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
const today = todayIso(); const today = todayIso();
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
fetchAllRows(() => fetchAllRows(() =>
supabase supabase
.from("om_positions") .from("employee_assignments")
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") .select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("id")
),
fetchAllRows(() =>
supabase
.from("position_assignments")
.select("employee_id, position_id")
.lte("valid_from", asOf) .lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`) .or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("employee_id") .order("employee_id")
), ),
fetchAllRows(() => fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
supabase fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
.from("employees")
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type")
.order("id")
),
asOf > today asOf > today
? fetchAllRows(() => ? fetchAllRows(() =>
supabase supabase
.from("pending_org_changes") .from("pending_org_changes")
.select("employee_id, effective_date, payload") .select("employee_id, change_type, effective_date, payload")
.eq("status", "pending") .eq("status", "pending")
.lte("effective_date", asOf) .lte("effective_date", asOf)
.in("change_type", [...PLACEMENT_CHANGES]) .in("change_type", [...PLACEMENT_CHANGES])
.order("effective_date") .order("effective_date")
) )
: Promise.resolve([]), : Promise.resolve([]),
supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(),
]); ]);
return resolveOrgSnapshot({ return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
asOf,
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
positions: positions as unknown as PositionRow[],
assignments: assignments as AssignmentRow[],
employees: employees as EmployeeRow[],
pending: pending as PendingRow[],
historyStartsAt: earliest.data?.valid_from ?? null,
});
} }
/** // The pure half of the above: everything that turns the four row sets into a
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die // snapshot, with no Supabase client in sight, so the reconciliation rules can
* Regeln direkt prüfbar sind. // be tested directly.
*/
export function resolveOrgSnapshot({ export function resolveOrgSnapshot({
asOf, asOf,
units,
positions,
assignments,
employees: allEmployees, employees: allEmployees,
assignments,
teams,
departments,
pending, pending,
historyStartsAt,
}: { }: {
asOf: string; asOf: string;
units: OmUnit[];
positions: PositionRow[];
assignments: AssignmentRow[];
employees: EmployeeRow[]; employees: EmployeeRow[];
assignments: AssignmentRow[];
teams: { id: string; department_id: string }[];
departments: { id: string; division_id: string }[];
pending: PendingRow[]; pending: PendingRow[];
historyStartsAt: string | null;
}): OrgAsOfResult { }): OrgAsOfResult {
const positionById = new Map(positions.map((p) => [p.id, p])); const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
const employeeById = new Map(allEmployees.map((e) => [e.id, e])); // A projected move names only the target team; its division follows from
// the team's department, the same way the DB trigger derives it.
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const teamDivision = new Map(
teams.flatMap((t) => {
const divisionId = departmentDivision.get(t.department_id);
return divisionId ? [[t.id, divisionId] as const] : [];
})
);
// Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), // Employed (or on leave) on that date — the same derivation the Berichte
// damit die drei nie auseinanderlaufen können. // page uses, so the two can never disagree on who counted when.
const isAbsent = (e: EmployeeRow) => const staff = allEmployees.filter((e) => {
e.karenz_start_date !== null && const status = deriveStatusAsOf(e, asOf);
e.karenz_start_date <= asOf && return status === "Aktiv" || status === "Karenz";
(e.karenz_return_date === null || asOf < e.karenz_return_date);
const positionOf = new Map<string, string>();
for (const a of assignments) {
if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id);
}
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
// Versetzung gewinnt.
const moved = new Set<string>();
for (const change of pending) {
if (!positionOf.has(change.employee_id)) continue;
const targetId = (change.payload as { target_position_id?: string }).target_position_id;
if (!targetId || !positionById.has(targetId)) continue;
positionOf.set(change.employee_id, targetId);
moved.add(change.employee_id);
}
const holders: OmHolder[] = [];
for (const [employeeId, positionId] of positionOf) {
const position = positionById.get(positionId)!;
holders.push({
employeeId,
positionId,
orgUnitId: position.org_unit_id,
isChief: position.is_chief,
absent: isAbsent(employeeById.get(employeeId)!),
}); });
}
const lines = resolveReportingLines(units, holders); const resolved = staff.map((e) => {
const a = assignmentByEmployee.get(e.id);
const employees: OrgEmployee[] = lines.map((l) => {
const e = employeeById.get(l.employeeId)!;
const position = positionById.get(l.positionId)!;
return { return {
id: e.id, employee: e,
personnel_number: e.personnel_number, managerId: a ? a.manager_id : e.manager_id,
first_name: e.first_name, placement: {
last_name: e.last_name, team_id: a ? a.team_id : e.team_id,
// Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: division_id: a ? a.division_id : e.division_id,
// bei einer projizierten Versetzung ist nur die erste schon richtig. job_title: a ? a.job_title : e.job_title,
job_title: position.jobs.title, is_lead: a ? a.is_lead : e.is_lead,
manager_id: l.actingManagerId, org_level: a ? a.org_level : e.org_level,
// Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die } satisfies Placement,
// Oberfläche zweimal dieselbe Person an.
formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId,
absent: isAbsent(e),
absence_type: e.absence_type,
org_unit_id: l.orgUnitId,
is_chief: l.isChief,
position_id: l.positionId,
position_number: position.position_number,
}; };
}); });
// Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene // Project the future. Ordered by effective_date, so a later move wins.
// Tabelle neben der Organisation; hier sind sie schlicht das Komplement. const byId = new Map(resolved.map((r) => [r.employee.id, r]));
const besetzt = new Set(positionOf.values()); const moved = new Set<string>();
const vacancies: OrgVacancy[] = positions for (const change of pending) {
.filter((p) => !besetzt.has(p.id)) const target = byId.get(change.employee_id);
.map((p) => ({ if (!target) continue;
position_id: p.id, const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
position_number: p.position_number, const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
job_title: p.jobs.title, if (newTeamId) {
org_unit_id: p.org_unit_id, target.placement.team_id = newTeamId;
is_chief: p.is_chief, const divisionId = teamDivision.get(newTeamId);
})); if (divisionId) target.placement.division_id = divisionId;
moved.add(target.employee.id);
}
if (payload.new_title) target.placement.job_title = payload.new_title;
}
return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; // Second pass: a moved employee's manager follows from the *projected*
// org, not the one they left — and the lead of their new team may itself
// have moved in this same batch.
for (const r of resolved) {
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
}
// A manager who had not joined yet, or had already left, is not in this
// set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id));
const recordedManagerOf = (r: (typeof resolved)[number]) =>
r.managerId && presentIds.has(r.managerId) ? r.managerId : null;
// While somebody is on a long-term absence their reports roll up to the
// next present level. Derived here rather than written to the database:
// the absent person stays formally in charge, and the stand-in is only a
// stand-in — which is why both ids travel to the UI.
const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
const acting = resolveActingManagers(
resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
);
const employees: OrgEmployee[] = resolved.map((r) => {
const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null };
return {
id: r.employee.id,
personnel_number: r.employee.personnel_number,
first_name: r.employee.first_name,
last_name: r.employee.last_name,
job_title: r.placement.job_title,
manager_id: actingManagerId,
formal_manager_id: coveredForId,
absent: absentIds.has(r.employee.id),
absence_type: r.employee.absence_type,
team_id: r.placement.team_id,
division_id: r.placement.division_id,
is_lead: r.placement.is_lead,
org_level: r.placement.org_level,
};
});
const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
null
);
return { employees, projectedCount: moved.size, historyStartsAt };
} }

View File

@@ -1,115 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
//
// employees ──A008──> position_assignments ──> om_positions ──> org_units
// └────────> jobs
//
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
export type Placement = {
employeeId: string;
positionId: string;
positionNumber: string;
orgUnitId: string;
isChief: boolean;
jobTitle: string;
validFrom: string;
validTo: string | null;
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
current: boolean;
};
const SELECT =
"employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))";
type Row = {
employee_id: string;
valid_from: string;
valid_to: string | null;
om_positions: {
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
jobs: { title: string };
};
};
function toPlacement(row: Row, asOf: string): Placement {
return {
employeeId: row.employee_id,
positionId: row.om_positions.id,
positionNumber: row.om_positions.position_number,
orgUnitId: row.om_positions.org_unit_id,
isChief: row.om_positions.is_chief,
jobTitle: row.om_positions.jobs.title,
validFrom: row.valid_from,
validTo: row.valid_to,
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
};
}
/**
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
*/
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
const byEmployee = new Map<string, Placement>();
for (const row of rows) {
const p = toPlacement(row, asOf);
const best = byEmployee.get(p.employeeId);
if (!best) {
byEmployee.set(p.employeeId, p);
continue;
}
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
}
return byEmployee;
}
export async function loadPlacements(
supabase: SupabaseClient<Database>,
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
): Promise<Map<string, Placement>> {
if (employeeIds?.length === 0) return new Map();
const rows = await fetchAllRows(() => {
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
return employeeIds ? q.in("employee_id", employeeIds) : q;
});
return pickPlacements(rows as unknown as Row[], asOf);
}
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
// die nächste besetzte und anwesende darüber — beides, damit sich in der
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
// stillschweigend als die echte Führungskraft auszugeben.
export type ReportingLine = {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
};
export async function loadReportingLines(
supabase: SupabaseClient<Database>,
asOf: string
): Promise<Map<string, ReportingLine>> {
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
}

View File

@@ -1,112 +1,41 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { todayIso } from "./format"; import { breadcrumbLabel, loadOrgMaps } from "./org";
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types"; import type { Database } from "./supabase/types";
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
// müsste.
export type OpenPositionResolved = { export type OpenPositionResolved = {
id: string; id: string;
position_number: string; position_number: string;
title: string; title: string;
org_unit_id: string; team_id: string;
is_chief: boolean; division_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
valid_from: string; valid_from: string;
/** Wer die Stelle nach der Berichtslinie führen wird. */ created_at: string;
managerName: string | null; managerName: string | null;
orgLabel: string; orgLabel: string;
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
vacantSince: string;
}; };
type PositionRow = { // Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page.
id: string;
position_number: string;
org_unit_id: string;
is_chief: boolean;
valid_from: string;
jobs: { title: string };
position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[];
};
/**
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
*/
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
if (!isChief) return orgUnitId;
return maps.units.get(orgUnitId)?.parent_id ?? null;
}
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> { export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
const asOf = todayIso(); const orgMaps = await loadOrgMaps(supabase);
const { data: positions } = await supabase
.from("positions")
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
.eq("status", "open")
.order("created_at", { ascending: false });
const [orgMaps, positions] = await Promise.all([ const managerIds = Array.from(
loadOrgMaps(supabase), new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
fetchAllRows(() =>
supabase
.from("om_positions")
.select(
"id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)"
)
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
.order("position_number")
),
]);
const open = (positions as unknown as PositionRow[]).filter(
(p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf))
); );
if (open.length === 0) return []; const { data: managers } = managerIds.length
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
// Die Leitung der zuständigen Einheit — genau die Planstellen, die als return (positions ?? []).map((p) => ({
// Leitung markiert und laufend besetzt sind. ...p,
const chiefUnitIds = Array.from( managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null,
new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id))) orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id),
); }));
const chiefs = chiefUnitIds.length
? ((await fetchAllRows(() =>
supabase
.from("om_positions")
.select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)")
.eq("is_chief", true)
.in("org_unit_id", chiefUnitIds)
.is("position_assignments.valid_to", null)
)) as unknown as {
org_unit_id: string;
position_assignments: { employees: { first_name: string; last_name: string } }[];
}[])
: [];
const chiefNameByUnit = new Map(
chiefs.flatMap((c) => {
const holder = c.position_assignments[0]?.employees;
return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : [];
})
);
return open.map((p) => {
const ended = p.position_assignments
.map((a) => a.valid_to)
.filter((d): d is string => d !== null)
.sort();
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
return {
id: p.id,
position_number: p.position_number,
title: p.jobs.title,
org_unit_id: p.org_unit_id,
is_chief: p.is_chief,
valid_from: p.valid_from,
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
vacantSince: ended.at(-1) ?? p.valid_from,
};
});
} }

View File

@@ -1,6 +1,4 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
import { loadPlacements } from "./placement";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import { fetchAllRows } from "./supabase/query"; import { fetchAllRows } from "./supabase/query";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
@@ -9,7 +7,6 @@ import type { Database, EmploymentType, HistoryEventType } from "./supabase/type
// what "the current view" means — same filters, same stichtag/event-window // what "the current view" means — same filters, same stichtag/event-window
// rules. // rules.
export type ReportFilters = { export type ReportFilters = {
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
division?: string; division?: string;
location?: string; location?: string;
status?: string; status?: string;
@@ -19,49 +16,33 @@ export type ReportFilters = {
export type SnapshotFilters = ReportFilters & { asOf?: string }; export type SnapshotFilters = ReportFilters & { asOf?: string };
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
/**
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
*/
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
const divisionName = new Map<string, string>();
const departmentName = new Map<string, string>();
const teamName = new Map<string, string>();
for (const unit of orgMaps.unitList) {
for (const a of ancestorsOf(orgMaps, unit.id)) {
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
}
}
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
}
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
lookups: OrgLookups; lookups: OrgLookups;
orgMaps: OrgMaps;
divisions: { id: string; name: string }[]; divisions: { id: string; name: string }[];
locations: { id: string; name: string }[]; locations: { id: string; name: string }[];
}> { }> {
const orgMaps = await loadOrgMaps(supabase); const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); 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]));
return { return {
lookups: lookupsFromOrgMaps(orgMaps, locations), lookups: {
orgMaps, divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
// ganzen Teilbaum. locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), },
locations, divisions: divisions ?? [],
locations: locations ?? [],
}; };
} }
const SNAPSHOT_EMPLOYEE_COLUMNS = const SNAPSHOT_EMPLOYEE_COLUMNS =
"id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; "id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
// employee_id -> number of employee_dependents rows. Selects only the FK // employee_id -> number of employee_dependents rows. Selects only the FK
// column (no dependent PII needed) since only per-employee counts feed the // column (no dependent PII needed) since only per-employee counts feed the
@@ -75,43 +56,29 @@ export async function loadDependentsCounts(supabase: SupabaseClient<Database>):
return counts; return counts;
} }
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. // Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
// division/team/location still reflect the employee's *current* assignment.
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> { export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso(); const asOf = filters.asOf || todayIso();
function snapshotQuery() { function snapshotQuery() {
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
if (filters.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
return query; return query;
} }
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
fetchAllRows(snapshotQuery),
loadDependentsCounts(supabase),
loadPlacements(supabase, { asOf }),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
]);
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
// Bereich selbst nur die Bereichsleitung sitzt.
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const withDerivedStatus: ReportEmployee[] = [];
for (const e of data) {
const placement = placements.get(e.id);
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
// sitzt aber auf keiner Planstelle mehr.
const orgUnitId = placement?.current ? placement.orgUnitId : null;
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
withDerivedStatus.push({
id: e.id, id: e.id,
first_name: e.first_name, first_name: e.first_name,
last_name: e.last_name, last_name: e.last_name,
job_title: placement?.jobTitle ?? e.job_title, job_title: e.job_title,
org_unit_id: orgUnitId, division_id: e.division_id,
team_id: e.team_id,
location_id: e.location_id, location_id: e.location_id,
status: deriveStatusAsOf(e, asOf), status: deriveStatusAsOf(e, asOf),
employment_type: e.employment_type, employment_type: e.employment_type,
@@ -131,17 +98,16 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
is_laterale_fuehrung: e.is_laterale_fuehrung, is_laterale_fuehrung: e.is_laterale_fuehrung,
is_c_level: e.is_c_level, is_c_level: e.is_c_level,
dependents_count: dependentsCounts.get(e.id) ?? 0, dependents_count: dependentsCounts.get(e.id) ?? 0,
}); }));
}
const statuses = parseStatuses(filters.status); const statuses = parseStatuses(filters.status);
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
} }
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie // Ereignisse: employee_history has no division_id/team_id of its own, so
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. // this joins in the affected employee's *current* org placement (two plain
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem // queries, merged in JS — the hand-written Database type has no relational
// Team stand, in das die Person nie versetzt worden war. // embedding metadata for a single nested-select query).
// //
// from/to: "" (unset) falls back to the current calendar year; the literal // from/to: "" (unset) falls back to the current calendar year; the literal
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
@@ -159,49 +125,25 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
return query; return query;
} }
const [history, employees, assignments, orgMaps] = await Promise.all([ const [history, employees] = await Promise.all([
fetchAllRows(historyQuery), fetchAllRows(historyQuery),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
fetchAllRows(() =>
supabase
.from("position_assignments")
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
.order("employee_id")
),
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
]); ]);
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
for (const a of assignments as unknown as {
employee_id: string;
valid_from: string;
valid_to: string | null;
om_positions: { org_unit_id: string };
}[]) {
const list = spans.get(a.employee_id) ?? [];
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
spans.set(a.employee_id, list);
}
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
const employeeById = new Map(employees.map((e) => [e.id, e])); const employeeById = new Map(employees.map((e) => [e.id, e]));
const events: ReportEvent[] = []; const events: ReportEvent[] = [];
for (const h of history) { for (const h of history) {
const emp = employeeById.get(h.employee_id); const emp = employeeById.get(h.employee_id);
if (!emp) continue; if (!emp) continue;
if (filters.division && emp.division_id !== filters.division) continue;
if (filters.location && emp.location_id !== filters.location) continue; if (filters.location && emp.location_id !== filters.location) continue;
const unitId =
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
events.push({ events.push({
employee_id: emp.id, employee_id: emp.id,
first_name: emp.first_name, first_name: emp.first_name,
last_name: emp.last_name, last_name: emp.last_name,
job_title: emp.job_title, job_title: emp.job_title,
org_unit_id: unitId, division_id: emp.division_id,
team_id: emp.team_id,
location_id: emp.location_id, location_id: emp.location_id,
event_date: h.event_date, event_date: h.event_date,
event_type: h.event_type, event_type: h.event_type,

View File

@@ -81,8 +81,8 @@ export type ReportEmployee = {
first_name: string; first_name: string;
last_name: string; last_name: string;
job_title: string; job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ division_id: string;
org_unit_id: string | null; team_id: string | null;
location_id: string; location_id: string;
status: string; status: string;
employment_type: string; employment_type: string;
@@ -104,26 +104,20 @@ export type ReportEmployee = {
dependents_count: number; dependents_count: number;
}; };
// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
// und wird einmal vorberechnet.
export type OrgLookups = { export type OrgLookups = {
divisionName: Map<string, string>; divisionName: Map<string, string>;
departmentName: Map<string, string>; departmentNameByTeam: Map<string, string>;
teamName: Map<string, string>; teamName: Map<string, string>;
locationName: Map<string, string>; locationName: Map<string, string>;
}; };
// Reconstructs status as of any date from the columns that actually carry a // Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`, // timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*. // which only ever reflects *today*. Division/team/location still reflect the
// // employee's *current* assignment — the schema has no history of org-unit
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit // changes over time, only free-text employee_history descriptions — so a
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung // stichtag report groups by today's org placement, not the placement as of
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie // that date. Documented in the UI rather than silently wrong.
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
export function deriveStatusAsOf( export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string asOf: string
@@ -143,11 +137,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
switch (dim) { switch (dim) {
case "division": case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : ""; return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department": case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : ""; return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team": case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : ""; return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location": case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt"; return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "status": case "status":
@@ -262,7 +256,7 @@ export function aggregateReport(
id: e.id, id: e.id,
name: `${e.first_name} ${e.last_name}`, name: `${e.first_name} ${e.last_name}`,
title: e.job_title, title: e.job_title,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "", team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.entry_date, entry_date: e.entry_date,
})); }));
const row: ReportRow = { key, value, count: rowsForGroup.length, people }; const row: ReportRow = { key, value, count: rowsForGroup.length, people };
@@ -351,8 +345,8 @@ export type ReportEvent = {
first_name: string; first_name: string;
last_name: string; last_name: string;
job_title: string; job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ division_id: string;
org_unit_id: string | null; team_id: string | null;
location_id: string; location_id: string;
event_date: string; event_date: string;
event_type: HistoryEventType; event_type: HistoryEventType;
@@ -364,11 +358,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
case "event_type": case "event_type":
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type; return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
case "division": case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : ""; return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department": case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : ""; return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team": case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : ""; return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
case "location": case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt"; return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year": case "event_year":
@@ -400,7 +394,7 @@ export function aggregateEvents(
id: e.employee_id, id: e.employee_id,
name: `${e.first_name} ${e.last_name}`, name: `${e.first_name} ${e.last_name}`,
title: e.description, title: e.description,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "", team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "",
entry_date: e.event_date, entry_date: e.event_date,
})); }));
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people }; const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };

View File

@@ -18,8 +18,6 @@ export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "
// union (not a string literal) so a future hr_admin/hr_user split, if ever // union (not a string literal) so a future hr_admin/hr_user split, if ever
// technically required, is a type-level addition, not a rewrite. // technically required, is a type-level addition, not a rewrite.
export type ProfileRole = "hr"; export type ProfileRole = "hr";
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type HistoryEventType = export type HistoryEventType =
| "Eintritt" | "Eintritt"
| "Beförderung" | "Beförderung"
@@ -32,6 +30,8 @@ export type HistoryEventType =
| "Reorganisation" | "Reorganisation"
| "Gehaltsanpassung" | "Gehaltsanpassung"
| "Rückkehr"; | "Rückkehr";
export type PositionStatus = "open" | "filled";
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
export type PendingChangeType = export type PendingChangeType =
| "transfer" | "transfer"
| "promotion" | "promotion"
@@ -49,6 +49,21 @@ type NoRelationships = { Relationships: [] };
export type Database = { export type Database = {
public: { public: {
Tables: { Tables: {
divisions: NoRelationships & {
Row: { id: string; org_number: string; name: string };
Insert: { id?: string; org_number: string; name: string };
Update: Partial<{ id: string; org_number: string; name: string }>;
};
departments: NoRelationships & {
Row: { id: string; org_number: string; name: string; division_id: string };
Insert: { id?: string; org_number: string; name: string; division_id: string };
Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>;
};
teams: NoRelationships & {
Row: { id: string; org_number: string; name: string; department_id: string };
Insert: { id?: string; org_number: string; name: string; department_id: string };
Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>;
};
locations: NoRelationships & { locations: NoRelationships & {
Row: { id: string; name: string; country: string }; Row: { id: string; name: string; country: string };
Insert: { id?: string; name: string; country: string }; Insert: { id?: string; name: string; country: string };
@@ -102,8 +117,13 @@ export type Database = {
address_country: string | null; address_country: string | null;
email: string; email: string;
phone: string | null; phone: string | null;
team_id: string | null;
division_id: string;
job_title: string; job_title: string;
location_id: string; location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: EmploymentType; employment_type: EmploymentType;
weekly_hours: number; weekly_hours: number;
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */ /** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
@@ -146,8 +166,13 @@ export type Database = {
address_country?: string | null; address_country?: string | null;
email: string; email: string;
phone?: string | null; phone?: string | null;
team_id?: string | null;
division_id?: string;
job_title: string; job_title: string;
location_id: string; location_id: string;
manager_id?: string | null;
org_level?: number;
is_lead?: boolean;
employment_type?: EmploymentType; employment_type?: EmploymentType;
weekly_hours?: number; weekly_hours?: number;
contract_type?: ContractType; contract_type?: ContractType;
@@ -183,6 +208,7 @@ export type Database = {
event_date: string; event_date: string;
event_type: HistoryEventType; event_type: HistoryEventType;
description: string; description: string;
reorg_scenario_id: string | null;
created_at: string; created_at: string;
}; };
Insert: { Insert: {
@@ -191,6 +217,7 @@ export type Database = {
event_date: string; event_date: string;
event_type: HistoryEventType; event_type: HistoryEventType;
description: string; description: string;
reorg_scenario_id?: string | null;
created_at?: string; created_at?: string;
}; };
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
@@ -247,6 +274,37 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
}; };
positions: NoRelationships & {
Row: {
id: string;
position_number: string;
title: string;
team_id: string;
division_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
status: PositionStatus;
valid_from: string;
created_at: string;
filled_at: string | null;
filled_by_employee_id: string | null;
};
Insert: {
id?: string;
position_number?: string;
title: string;
team_id: string;
division_id?: string;
is_lead?: boolean;
reports_to_employee_id?: string | null;
status?: PositionStatus;
valid_from?: string;
created_at?: string;
filled_at?: string | null;
filled_by_employee_id?: string | null;
};
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
};
hire_drafts: NoRelationships & { hire_drafts: NoRelationships & {
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string }; Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string }; Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
@@ -280,6 +338,34 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
}; };
reorg_scenarios: NoRelationships & {
Row: {
id: string;
name: string;
effective_date: string;
created_by: string | null;
applied: boolean;
applied_at: string | null;
undo_snapshot: Record<string, unknown> | null;
created_at: string;
};
Insert: {
id?: string;
name: string;
effective_date: string;
created_by?: string | null;
applied?: boolean;
applied_at?: string | null;
undo_snapshot?: Record<string, unknown> | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
};
reorg_moves: NoRelationships & {
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
};
pending_org_changes: NoRelationships & { pending_org_changes: NoRelationships & {
Row: { Row: {
id: string; id: string;
@@ -287,6 +373,7 @@ export type Database = {
change_type: PendingChangeType; change_type: PendingChangeType;
effective_date: string; effective_date: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
reorg_scenario_id: string | null;
status: PendingChangeStatus; status: PendingChangeStatus;
created_by: string | null; created_by: string | null;
created_at: string; created_at: string;
@@ -298,6 +385,7 @@ export type Database = {
change_type: PendingChangeType; change_type: PendingChangeType;
effective_date: string; effective_date: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
reorg_scenario_id?: string | null;
status?: PendingChangeStatus; status?: PendingChangeStatus;
created_by?: string | null; created_by?: string | null;
created_at?: string; created_at?: string;
@@ -305,120 +393,24 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
}; };
// ── SAP-OM-Modell ────────────────────────────────────────── // Written exclusively by trg_track_employee_assignment; RLS grants HR
// O: rekursiv über parent_id, unit_type ist nur ein Etikett. // read access only, hence no Insert/Update shapes worth modelling.
org_units: { employee_assignments: NoRelationships & {
Relationships: [
{
foreignKeyName: "org_units_parent_id_fkey";
columns: ["parent_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
];
Row: { Row: {
id: string; id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
org_number: string;
name: string;
parent_id?: string | null;
unit_type: OrgUnitType;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["org_units"]["Insert"]>;
};
// C: Katalog der Tätigkeiten.
jobs: NoRelationships & {
Row: { id: string; code: string; title: string; created_at: string };
Insert: { id?: string; code: string; title: string; created_at?: string };
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
};
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
om_positions: {
Relationships: [
{
foreignKeyName: "om_positions_org_unit_id_fkey";
columns: ["org_unit_id"];
referencedRelation: "org_units";
referencedColumns: ["id"];
},
{
foreignKeyName: "om_positions_job_id_fkey";
columns: ["job_id"];
referencedRelation: "jobs";
referencedColumns: ["id"];
},
];
Row: {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: {
id?: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief?: boolean;
valid_from?: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
};
// A008: Person besetzt Planstelle, zeitabhängig.
position_assignments: {
Row: {
id: string;
position_id: string;
employee_id: string; employee_id: string;
manager_id: string | null;
team_id: string | null;
division_id: string;
job_title: string;
is_lead: boolean;
org_level: number;
valid_from: string; valid_from: string;
valid_to: string | null; valid_to: string | null;
created_at: string; created_at: string;
}; };
Insert: { Insert: never;
id?: string; Update: never;
position_id: string;
employee_id: string;
valid_from: string;
valid_to?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
// Beide Richtungen: über die Planstelle hängt die Verortung in der
// Organisation, über die Person die Verortung in der Akte. Die
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
Relationships: [
{
foreignKeyName: "position_assignments_position_id_fkey";
columns: ["position_id"];
referencedRelation: "om_positions";
referencedColumns: ["id"];
},
{
foreignKeyName: "position_assignments_employee_id_fkey";
columns: ["employee_id"];
referencedRelation: "employees";
referencedColumns: ["id"];
},
];
}; };
}; };
Views: Record<string, never>; Views: Record<string, never>;
@@ -436,23 +428,13 @@ export type Database = {
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void }; delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string }; add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void }; complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
create_position: { Args: { payload: Record<string, unknown> }; Returns: string }; create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void }; delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number }; apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
om_reporting_lines: {
Args: { p_as_of?: string };
Returns: {
employee_id: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
formal_manager_id: string | null;
acting_manager_id: string | null;
}[];
};
}; };
}; };
}; };

3
package-lock.json generated
View File

@@ -36,9 +36,6 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"engines": {
"node": ">=22 <25"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {

View File

@@ -38,11 +38,6 @@ export async function proxy(request: NextRequest) {
const isLoginRoute = request.nextUrl.pathname.startsWith("/login"); const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
// Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
// sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
// nach /login um und die Anmeldung kommt nie zustande.
if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
if (!user) { if (!user) {
if (isLoginRoute) return response; if (isLoginRoute) return response;
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();

View File

@@ -1,149 +0,0 @@
// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen
// Bereichsdefinition des Seeds.
//
// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die
// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen
// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem
// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm
// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests.
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] };
export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
export type BuiltUnit = {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
};
export type BuiltJob = { id: string; code: string; title: string };
export type BuiltPosition = {
id: string;
position_number: string;
org_unit_id: string;
job_id: string;
is_chief: boolean;
};
export type BuiltOrg = {
units: BuiltUnit[];
jobs: BuiltJob[];
positions: BuiltPosition[];
/** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */
icPositionsByTeam: Map<string, BuiltPosition[]>;
};
// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den
// Umstieg hinweg wiedererkennbar bleiben.
const PREFIX: Record<OrgUnitType, string> = {
Gesellschaft: "10",
Bereich: "20",
Abteilung: "21",
Team: "22",
};
function orgNumber(type: OrgUnitType, counter: number): string {
return `${PREFIX[type]}${String(counter).padStart(6, "0")}`;
}
/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */
function positionNumber(counter: number): string {
return `6${String(counter).padStart(7, "0")}`;
}
function jobCode(counter: number): string {
return `J${String(counter).padStart(4, "0")}`;
}
/**
* `newId` wird hereingereicht, damit die Funktion in Tests deterministisch
* bleibt und im Seed randomUUID benutzt.
*/
export function buildOrg(
companyName: string,
divisions: DivisionDef[],
newId: () => string
): BuiltOrg {
const units: BuiltUnit[] = [];
const positions: BuiltPosition[] = [];
const icPositionsByTeam = new Map<string, BuiltPosition[]>();
// Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title
// Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen.
const jobIdByTitle = new Map<string, string>();
const jobs: BuiltJob[] = [];
function jobFor(title: string): string {
const existing = jobIdByTitle.get(title);
if (existing) return existing;
const job = { id: newId(), code: jobCode(jobs.length + 1), title };
jobs.push(job);
jobIdByTitle.set(title, job.id);
return job.id;
}
let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 };
let positionCounter = 0;
function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit {
unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 };
const unit: BuiltUnit = {
id: newId(),
org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)),
name,
parent_id: parentId,
unit_type: type,
};
units.push(unit);
return unit;
}
function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition {
positionCounter += 1;
const position: BuiltPosition = {
id: newId(),
position_number: positionNumber(positionCounter),
org_unit_id: unitId,
job_id: jobFor(title),
is_chief: isChief,
};
positions.push(position);
return position;
}
const company = addUnit(companyName, "Gesellschaft", null);
addPosition(company.id, "Geschäftsführer:in", true);
// Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau
// die Unterscheidung, die es im Altmodell nicht gab.
addPosition(company.id, "Assistenz der Geschäftsführung", false);
for (const div of divisions) {
const bereich = addUnit(div.name, "Bereich", company.id);
addPosition(bereich.id, div.headTitle, true);
for (const dept of div.departments) {
const abteilung = addUnit(dept.name, "Abteilung", bereich.id);
// Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt
// eine eigene Leitungsplanstelle.
addPosition(abteilung.id, dept.leadTitle, true);
for (const team of dept.teams) {
const teamUnit = addUnit(team.name, "Team", abteilung.id);
addPosition(teamUnit.id, team.leadTitle, true);
const size = Math.max(1, team.baseSize);
const icPositions = Array.from({ length: size }, (_, i) =>
addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false)
);
icPositionsByTeam.set(teamUnit.id, icPositions);
}
}
}
return { units, jobs, positions, icPositionsByTeam };
}

View File

@@ -1,70 +0,0 @@
// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
//
// Run with: node --env-file=.env.local supabase/entra-claims.ts <e-mail>
//
// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
// wäre er geraten.
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
}
const email = process.argv[2];
if (!email) {
console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts <e-mail>");
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
if (error) throw new Error(error.message);
const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
if (matches.length === 0) {
console.error(`Kein Konto zu ${email}. Vorhanden:`);
for (const u of data.users) console.error(` ${u.email}`);
process.exit(1);
}
// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
for (const user of matches) {
console.log(`\n── ${user.email} ──`);
console.log(` id: ${user.id}`);
console.log(` erstellt: ${user.created_at}`);
console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
for (const identity of user.identities ?? []) {
console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
console.log(
Object.entries(identity.identity_data ?? {})
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
.join("\n") || " (leer)"
);
}
// Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
// die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
// das unbrauchbar.
console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
console.log(
Object.entries(user.user_metadata ?? {})
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
.join("\n") || " (leer)"
);
}
const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
console.log(`\n── profiles zu ${email} ──`);
for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");

View File

@@ -1,123 +0,0 @@
-- Organisationsmanagement nach SAP-OM-Vorbild.
--
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
--
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
--
-- O Organisationseinheit org_units (rekursiv über parent_id)
-- C Stelle / Job jobs (Katalog)
-- S Planstelle positions (gehört zu genau einer O)
-- P Person employees (besetzt eine S)
--
-- A003 "gehört zu" positions.org_unit_id
-- A012 "ist Leiter von" positions.is_chief
-- A008 "Inhaber ist" position_assignments
--
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
-- ── O: Organisationseinheit ────────────────────────────────────────
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
create table org_units (
id uuid primary key default gen_random_uuid(),
org_number text not null unique,
name text not null,
-- Die Hierarchie selbst. Null nur für die Wurzel.
parent_id uuid references org_units(id),
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
-- und ist bewusst nicht verboten.
unit_type org_unit_type not null,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
);
create index on org_units (parent_id);
create index on org_units (unit_type);
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
-- konnten und keine Auswertung über Tätigkeiten möglich war.
create table jobs (
id uuid primary key default gen_random_uuid(),
code text not null unique,
title text not null unique,
created_at timestamptz not null default now()
);
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
-- ── S: Planstelle ──────────────────────────────────────────────────
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
-- Eigenschaft der Planstelle, kein eigenes Objekt.
create table om_positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique,
org_unit_id uuid not null references org_units(id),
job_id uuid not null references jobs(id),
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
is_chief boolean not null default false,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
);
create index on om_positions (org_unit_id);
create index on om_positions (job_id);
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
-- ── A008: Person besetzt Planstelle ────────────────────────────────
create table position_assignments (
id uuid primary key default gen_random_uuid(),
position_id uuid not null references om_positions(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
valid_from date not null,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
);
create index on position_assignments (position_id);
create index on position_assignments (employee_id);
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
alter table org_units enable row level security;
alter table jobs enable row level security;
alter table om_positions enable row level security;
alter table position_assignments enable row level security;
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;

View File

@@ -1,101 +0,0 @@
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
--
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
-- der Organisationsstruktur abweichen, und tat es auch.
--
-- SAP-OM-Regel, hier eins zu eins:
--
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
-- an die Leitung der übergeordneten Einheit.
--
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
-- übersprungen.
--
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
-- Führungskraft auszugeben.
create or replace function om_reporting_lines(p_as_of date default current_date)
returns table (
employee_id uuid,
position_id uuid,
org_unit_id uuid,
is_chief boolean,
formal_manager_id uuid,
acting_manager_id uuid
)
language sql
stable
as $$
with recursive
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
-- gültig sein.
holder as (
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
from position_assignments pa
join om_positions p on p.id = pa.position_id
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
),
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
chief as (
select h.org_unit_id, h.employee_id,
(e.karenz_start_date is not null
and e.karenz_start_date <= p_as_of
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
from holder h
join employees e on e.id = h.employee_id
where h.is_chief
),
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
ancestry as (
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
from org_units u
union all
select a.unit_id, p.id, p.parent_id, a.depth + 1
from ancestry a
join org_units p on p.id = a.parent_id
),
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
-- sonst die eigene.
base as (
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
from holder h
join org_units u on u.id = h.org_unit_id
)
select
b.employee_id,
b.position_id,
b.org_unit_id,
b.is_chief,
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
-- die Ausgangseinheit eingeschlossen.
(
select c.employee_id
from ancestry a
join chief c on c.org_unit_id = a.ancestor_id
where a.unit_id = b.base_unit_id
and not c.absent
and c.employee_id <> b.employee_id
order by a.depth
limit 1
) as acting_manager_id
from base b;
$$;
comment on function om_reporting_lines(date) is
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;

View File

@@ -1,539 +0,0 @@
-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
--
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
--
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
-- "division_id = X and team_id is null and org_level = 1" — eine
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
-- willkürlich eine von beiden.
--
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
-- das Organigramm gefüllt.
--
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
-- sind — die Berichtslinie bleibt durchgängig.
begin;
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
-- die Personen, die daran hingen, ihre Einheit behalten.
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
from divisions where name = 'Geschäftsführung';
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select d.id, d.org_number, d.name,
(select id from org_units where unit_type = 'Gesellschaft'),
'Bereich', '2000-01-01'
from divisions d
where d.name <> 'Geschäftsführung';
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select dep.id, dep.org_number, dep.name,
coalesce((select u.id from org_units u where u.id = dep.division_id),
(select id from org_units where unit_type = 'Gesellschaft')),
'Abteilung', '2000-01-01'
from departments dep;
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
from teams t;
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
-- Titel der neuen Abteilungsleitungen.
insert into jobs (code, title)
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
from (
select distinct job_title as title from employees where job_title is not null
union
select distinct title from positions where title is not null
union
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
) t
on conflict (title) do nothing;
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
-- doch verschieden.
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
create temporary table om_pos_map (
employee_id uuid primary key,
position_id uuid not null default gen_random_uuid(),
seq bigint
);
insert into om_pos_map (employee_id, seq)
select id, row_number() over (order by org_level, personnel_number) from employees;
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
select
m.position_id,
'6' || lpad(m.seq::text, 7, '0'),
case
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
end,
(select j.id from jobs j where j.title = e.job_title),
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
e.entry_date
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
-- wieder frei, die Historie bleibt.
--
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
-- position_assignments verlangt aber valid_to > valid_from. Ein
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
select m.position_id, e.id, e.entry_date,
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
u.id,
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
true,
current_date
from org_units u
where u.unit_type = 'Abteilung'
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
-- ohnehin nicht zu.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select p.position_number, p.team_id,
(select id from jobs j where j.title = p.title),
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
p.valid_from
from positions p
where p.status = 'open'
and exists (select 1 from org_units u where u.id = p.team_id);
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
-- als ein halb überführter Bestand ohne Rückweg.
do $$
declare v_fehlend int;
begin
select count(*) into v_fehlend
from employees e
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
if v_fehlend > 0 then
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
end if;
select count(*) into v_fehlend from om_positions where job_id is null;
if v_fehlend > 0 then
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
end if;
select count(*) into v_fehlend
from org_units u
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
if v_fehlend > 0 then
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
end if;
end $$;
drop table om_pos_map;
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
-- transient; halb übersetzt wären sie schlimmer als verworfen.
delete from pending_org_changes where status = 'pending';
drop trigger if exists trg_track_employee_assignment on employees;
drop function if exists fn_track_employee_assignment();
drop table if exists employee_assignments;
-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die
-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle
-- diese Rolle, der Trigger wird ersatzlos entfernt.
drop trigger if exists trg_employees_set_org_unit on employees;
drop function if exists fn_set_employee_org_unit();
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
-- om_reporting_lines() tritt an ihre Stelle.
drop view if exists employees_directory;
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
-- Umstellung der Oberfläche.
drop function if exists resolve_manager_for(uuid, boolean, uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists create_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(uuid);
drop function if exists hire_employee(jsonb);
drop function if exists terminate_employee(jsonb);
drop function if exists transfer_employee(jsonb);
drop function if exists rehire_employee(jsonb);
drop function if exists record_karenz_return(jsonb);
drop function if exists apply_due_pending_changes();
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
drop table if exists positions;
drop table if exists teams;
drop table if exists departments;
drop table if exists divisions;
-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen
-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt
-- wurde. om_positions vergibt seine Nummern selbst.
drop function if exists fn_set_position_org_unit();
drop function if exists generate_position_number();
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id and pa.valid_to is null;
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
address, postal_code, city, address_country, location_id, job_title,
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
source, status, entry_date, title_prefix, title_suffix,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
)
values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
payload->>'address', payload->>'postal_code', payload->>'city',
coalesce(payload->>'address_country', 'Österreich'),
(payload->>'location_id')::uuid,
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
nullif(payload->>'contract_end_date', '')::date,
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
coalesce((payload->>'source')::source_type, 'Extern'),
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
v_entry,
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'),
coalesce((payload->>'is_betriebsrat')::boolean, false),
coalesce((payload->>'has_dienstwagen')::boolean, false),
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
coalesce((payload->>'is_c_level')::boolean, false)
)
returning id into v_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_id, v_entry);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
(select position_number from om_positions where id = v_position_id));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung',
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$$;
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set
status = case when v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
end;
$$;
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_target_position uuid := (payload->>'target_position_id')::uuid;
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
v_name text;
v_besetzt uuid;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_position and pa.valid_to is null;
if v_besetzt is not null and v_besetzt <> v_employee_id then
raise exception 'Die Zielplanstelle ist bereits besetzt.';
end if;
if v_effective <= current_date then
update position_assignments set valid_to = v_effective
where employee_id = v_employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_target_position, v_employee_id, v_effective);
update employees set job_title =
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective,
jsonb_build_object('target_position_id', v_target_position));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
(select position_number from om_positions where id = v_target_position));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
end;
$$;
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_date date := (payload->>'rehire_date')::date;
v_position_id uuid := (payload->>'position_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
update employees set
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
entry_date = v_date,
exit_date = null,
exit_reason = null
where id = v_employee_id;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_position_id, v_employee_id, v_date);
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
end;
$$;
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by effective_date, created_at
loop
if v_rec.change_type = 'transfer' then
update position_assignments set valid_to = v_rec.effective_date
where employee_id = v_rec.employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
update employees set job_title = (
select j.title from om_positions p join jobs j on j.id = p.job_id
where p.id = (v_rec.payload->>'target_position_id')::uuid
) where id = v_rec.employee_id;
elsif v_rec.change_type = 'promotion' then
update employees set
job_title = coalesce(v_rec.payload->>'new_title', job_title),
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_start' then
update employees set
status = 'Karenz',
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
update employees set
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'dependent_add' then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
(v_rec.payload->>'relationship')::relationship_type,
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
elsif v_rec.change_type = 'dependent_remove' then
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_count := v_count + 1;
end loop;
return v_count;
end;
$$;
commit;

View File

@@ -1,150 +0,0 @@
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
-- nachziehen.
--
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
-- Aufruf würde erst zur Laufzeit scheitern.
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
drop function if exists create_position(jsonb);
drop function if exists delete_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(jsonb);
drop function if exists undo_reorg(uuid);
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
alter table employee_history drop column if exists reorg_scenario_id;
alter table pending_org_changes drop column if exists reorg_scenario_id;
drop table if exists reorg_moves;
drop table if exists reorg_scenarios;
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
-- und Schliessen sind deshalb Operationen auf om_positions.
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
-- und daran denkt dann niemand.
create or replace function next_position_number()
returns text language sql stable
set search_path = public, pg_temp
as $$
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
from om_positions
where position_number ~ '^6[0-9]{7}$';
$$;
comment on function next_position_number() is
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
create or replace function create_position(payload jsonb)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
v_job_id uuid;
v_position_id uuid;
v_unit_name text;
begin
perform require_hr_admin();
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_job_title is null then
raise exception 'Es muss eine Tätigkeit angegeben werden.';
end if;
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
-- die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
if v_job_id is null then
insert into jobs (code, title)
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
returning id into v_job_id;
end if;
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
returning id into v_position_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
v_job_title || ' (' || v_unit_name || ')',
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
return v_position_id;
end;
$$;
create or replace function delete_position(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_label text;
v_hat_historie boolean;
begin
perform require_hr_admin();
select j.title || ' (' || u.name || ')' into v_label
from om_positions p
join jobs j on j.id = p.job_id
join org_units u on u.id = p.org_unit_id
where p.id = v_position_id;
if v_label is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
end if;
select exists (select 1 from position_assignments where position_id = v_position_id)
into v_hat_historie;
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
-- Personalakte klafft eine Lücke.
if v_hat_historie then
update om_positions set valid_to = current_date where id = v_position_id;
else
delete from om_positions where id = v_position_id;
end if;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(),
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
v_label, null);
end;
$$;
grant execute on function next_position_number() to anon, authenticated, service_role;
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;

View File

@@ -1,77 +0,0 @@
-- search_path für alle eigenen Funktionen festnageln.
--
-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable".
-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier
-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id,
-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad
-- längst. Deshalb steht im Advisor auch 0 errors.
--
-- Warum das trotzdem behoben wird:
--
-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im
-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt
-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene
-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER
-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu
-- gewinnen, und die RLS-Policies greifen unverändert.
--
-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf
-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei
-- schreiben muss. In dem Moment denkt niemand mehr an den search_path.
-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten.
--
-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres
-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie
-- will.
do $$
declare
v_func record;
v_count int := 0;
begin
for v_func in
select p.oid::regprocedure as signature
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
-- Nur Funktionen, keine Prozeduren oder Aggregate.
and p.prokind = 'f'
-- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit
-- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung
-- der Erweiterung oder wird stillschweigend zurückgesetzt.
and not exists (
select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e'
)
-- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen
-- auf `search_path = public` und sollen so bleiben.
and not exists (
select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'
)
loop
execute format('alter function %s set search_path = public, pg_temp', v_func.signature);
v_count := v_count + 1;
end loop;
raise notice 'search_path festgenagelt für % Funktion(en)', v_count;
end;
$$;
-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path
-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser
-- hier, als es im Advisor stehen zu lassen.
do $$
declare v_offen int;
begin
select count(*) into v_offen
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prokind = 'f'
and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e')
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%');
if v_offen > 0 then
raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen;
end if;
end;
$$;

View File

@@ -1,72 +0,0 @@
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
--
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
--
-- anon.rpc(is_hr_user) -> false
-- anon.rpc(current_hr_user_id) -> null
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
--
-- Nur der dritte ist einer.
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
--
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
-- würde die Anwendung vollständig lahmlegen.
--
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
-- ── Wird entzogen ────────────────────────────────────────────────
--
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
--
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
-- wirkungslos.
--
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
revoke execute on function apply_due_pending_changes() from anon, authenticated;
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
do $$
begin
if exists (
select 1 from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
) then
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
end if;
end;
$$;
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
--
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
-- sondern eine Konvention.
--
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
-- Passwort, dessen Kompromittierung geprüft werden könnte.

View File

@@ -1,110 +0,0 @@
// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
// Person um.
//
// Run with: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]
//
// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
//
// Ohne --apply wird nur angezeigt, was passieren würde.
import { createClient } from "@supabase/supabase-js";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
}
const email = process.argv[2];
const apply = process.argv.includes("--apply");
if (!email) {
console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]");
process.exit(1);
}
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
const REFERENCES: { table: string; column: string }[] = [
{ table: "audit_log", column: "actor_user_id" },
{ table: "employee_notes", column: "author_user_id" },
{ table: "employee_notes", column: "done_by" },
{ table: "hire_drafts", column: "created_by" },
{ table: "saved_reports", column: "created_by" },
{ table: "pending_org_changes", column: "created_by" },
{ table: "profiles", column: "created_by" },
];
const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
if (error) throw new Error(error.message);
const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
const alt = accounts.find((u) => u.id !== entra?.id);
if (!entra) {
console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
process.exit(1);
}
if (!alt) {
console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
process.exit(0);
}
const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
if (!profile) {
console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
process.exit(1);
}
console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
console.log(`neu: ${entra.id} (azure)`);
console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
if (!apply) {
console.log("\nTrockenlauf. Mit --apply ausführen.");
process.exit(0);
}
// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
// HR-Konto, und niemand könnte eines mehr freischalten.
const { error: insertError } = await supabase.from("profiles").insert({
...profile,
id: entra.id,
email: entra.email ?? profile.email,
});
if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
console.log("profiles-Zeile für das Entra-Konto angelegt.");
for (const ref of REFERENCES) {
const { error: updateError, count } = await supabase
.from(ref.table)
.update({ [ref.column]: entra.id }, { count: "exact" })
.eq(ref.column, alt.id);
if (updateError) {
console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
continue;
}
console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
}
const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
if (deleteUserError) {
// Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
// umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
} else {
console.log("Altes Konto gelöscht.");
}
console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");

View File

@@ -10,9 +10,8 @@ import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
// Explicit .ts extension: this file is run directly by Node (type-stripping, // Explicit .ts extension: this file is run directly by Node (type-stripping,
// ESM), where an extensionless relative import does not resolve. // ESM), where an extensionless relative import does not resolve.
import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts"; import { svnrCheckDigit } from "../lib/svnr.ts";
import { ABSENCE_TYPES } from "../lib/absence.ts"; import { ABSENCE_TYPES } from "../lib/absence.ts";
import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -50,15 +49,8 @@ function addDays(d: Date, days: number): Date {
r.setDate(r.getDate() + days); r.setDate(r.getDate() + days);
return r; return r;
} }
// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen
// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet
// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach
// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das
// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt.
function isoDate(d: Date): string { function isoDate(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, "0"); return d.toISOString().slice(0, 10);
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
} }
function randomDateBetween(start: Date, end: Date): Date { function randomDateBetween(start: Date, end: Date): Date {
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime()); const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
@@ -149,8 +141,9 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])
]; ];
// ── Org structure ──────────────────────────────────────────── // ── Org structure ────────────────────────────────────────────
// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
// daraus gebaut wird. type DeptDef = { name: string; teams: TeamDef[] };
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
const SCALE = 1.44; // brings the ~556-person base roster up to ~800 const SCALE = 1.44; // brings the ~556-person base roster up to ~800
@@ -161,7 +154,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Fertigung", name: "Fertigung",
leadTitle: "Abteilungsleitung Fertigung",
teams: [ teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 }, { name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 }, { name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
@@ -170,7 +162,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Instandhaltung", name: "Instandhaltung",
leadTitle: "Abteilungsleitung Instandhaltung",
teams: [ teams: [
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 }, { name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 }, { name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
@@ -184,7 +175,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Logistik", name: "Logistik",
leadTitle: "Abteilungsleitung Logistik",
teams: [ teams: [
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 }, { name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 }, { name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
@@ -193,7 +183,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Einkauf", name: "Einkauf",
leadTitle: "Abteilungsleitung Einkauf",
teams: [ teams: [
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 }, { name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 }, { name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
@@ -207,7 +196,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Vertrieb", name: "Vertrieb",
leadTitle: "Abteilungsleitung Vertrieb",
teams: [ teams: [
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 }, { name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 }, { name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
@@ -216,7 +204,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Marketing", name: "Marketing",
leadTitle: "Abteilungsleitung Marketing",
teams: [ teams: [
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 }, { name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 }, { name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
@@ -230,7 +217,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Produktentwicklung", name: "Produktentwicklung",
leadTitle: "Abteilungsleitung Produktentwicklung",
teams: [ teams: [
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 }, { name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 }, { name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
@@ -238,7 +224,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Verfahrenstechnik", name: "Verfahrenstechnik",
leadTitle: "Abteilungsleitung Verfahrenstechnik",
teams: [ teams: [
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 }, { name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 }, { name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
@@ -252,7 +237,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Qualitätssicherung", name: "Qualitätssicherung",
leadTitle: "Abteilungsleitung Qualitätssicherung",
teams: [ teams: [
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 }, { name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 }, { name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
@@ -260,7 +244,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Lebensmittelsicherheit", name: "Lebensmittelsicherheit",
leadTitle: "Abteilungsleitung Lebensmittelsicherheit",
teams: [ teams: [
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 }, { name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 }, { name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
@@ -274,7 +257,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Business Applications", name: "Business Applications",
leadTitle: "Abteilungsleitung Business Applications",
teams: [ teams: [
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 }, { name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 }, { name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
@@ -282,7 +264,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Infrastruktur", name: "Infrastruktur",
leadTitle: "Abteilungsleitung Infrastruktur",
teams: [ teams: [
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 }, { name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 }, { name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
@@ -296,7 +277,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "Finanzen", name: "Finanzen",
leadTitle: "Abteilungsleitung Finanzen",
teams: [ teams: [
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 }, { name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 }, { name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
@@ -304,7 +284,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Controlling", name: "Controlling",
leadTitle: "Abteilungsleitung Controlling",
teams: [ teams: [
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 }, { name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 }, { name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
@@ -318,7 +297,6 @@ const DIVISIONS: DivisionDef[] = [
departments: [ departments: [
{ {
name: "HR Business Partner", name: "HR Business Partner",
leadTitle: "Abteilungsleitung HR Business Partner",
teams: [ teams: [
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 }, { name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 }, { name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
@@ -326,7 +304,6 @@ const DIVISIONS: DivisionDef[] = [
}, },
{ {
name: "Personaladministration", name: "Personaladministration",
leadTitle: "Abteilungsleitung Personaladministration",
teams: [ teams: [
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 }, { name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 }, { name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
@@ -351,11 +328,13 @@ type EmployeeRow = {
address_country: string; address_country: string;
email: string; email: string;
phone: string; phone: string;
// Die Einordnung in die Organisation steckt jetzt ausschliesslich in der team_id: string | null;
// Planstelle (position_assignments -> om_positions -> org_units). Keine division_id: string;
// division_id/team_id/manager_id mehr auf der Person.
job_title: string; job_title: string;
location_id: string; location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: "Vollzeit" | "Teilzeit"; employment_type: "Vollzeit" | "Teilzeit";
weekly_hours: number; weekly_hours: number;
contract_type: "unbefristet" | "befristet"; contract_type: "unbefristet" | "befristet";
@@ -424,7 +403,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] {
]); ]);
} }
function newHireBase(jobTitle: string) { function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
const gender: "m" | "w" = chance(0.48) ? "m" : "w"; const gender: "m" | "w" = chance(0.48) ? "m" : "w";
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES); const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
const lastName = pick(LAST_NAMES); const lastName = pick(LAST_NAMES);
@@ -444,35 +423,25 @@ function newHireBase(jobTitle: string) {
address_country: addressCountryFor(nationality), address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName), email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`, phone: `+43 664 ${randInt(1000000, 9999999)}`,
team_id: teamId,
division_id: divisionId,
job_title: jobTitle, job_title: jobTitle,
location_id: location.id, location_id: location.id,
manager_id: managerId,
org_level: orgLevel,
is_lead: isLead,
}; };
} }
const employees: EmployeeRow[] = []; const employees: EmployeeRow[] = [];
const history: HistoryRow[] = []; const history: HistoryRow[] = [];
const icPoolForStatusAssignment: EmployeeRow[] = [];
function finalizeEmployee( function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
base: ReturnType<typeof newHireBase>, const age = randInt(22, 60);
opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date }
): EmployeeRow {
// Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit
// sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene
// Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach —
// sonst umgekehrt.
let age: number;
let entryDate: Date;
if (opts.entryDate) {
entryDate = opts.entryDate;
const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5)));
const minAge = Math.min(Math.max(22, 20 + tenureYears), 55);
age = randInt(minAge, 62);
} else {
age = randInt(22, 60);
const maxTenureYears = Math.min(15, age - 20);
entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
}
const birthDate = birthDateForAge(age); const birthDate = birthDateForAge(age);
const maxTenureYears = Math.min(15, age - 20);
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit"; const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]); const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
@@ -518,45 +487,80 @@ function finalizeEmployee(
return row; return row;
} }
// ── Organisation im OM-Modell ──────────────────────────────── type TeamRef = { id: string; org_number: string; name: string; department_id: string };
// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests type DeptRef = { id: string; org_number: string; name: string; division_id: string };
// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer* type DivisionRef = { id: string; org_number: string; name: string };
// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache.
const COMPANY_NAME = "Alpenwerk Industrie GmbH";
const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({ const divisionRows: DivisionRef[] = [];
...div, const departmentRows: DeptRef[] = [];
departments: div.departments.map((dept) => ({ const teamRows: TeamRef[] = [];
...dept,
// -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und
// buildOrg sie zusätzlich zu icTitles anlegt.
teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })),
})),
}));
const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID); // Geschäftsführung: small division, no departments/teams — CEO and their
const unitById = new Map(org.units.map((u) => [u.id, u])); // assistant sit directly under it (§5).
const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title])); const gfDivisionId = randomUUID();
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
type AssignmentRow = { const ceo = finalizeEmployee(
position_id: string; newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
employee_id: string; { paygrade: "F" }
valid_from: string; );
valid_to: string | null; const gfAssistant = finalizeEmployee(
}; newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
const assignments: AssignmentRow[] = []; { paygrade: "C" }
);
employees.push(ceo, gfAssistant);
// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz let divisionCounter = 0;
// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne let deptCounter = 0;
// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und let teamCounter = 0;
// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben.
const VAKANT_IC = 8; // offene Stellen ohne Nachfolge
const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft
const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch
const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen
const LANGZEITABWESEND = 12;
const GEPLANTER_AUSTRITT = 3;
for (const div of DIVISIONS) {
divisionCounter += 1;
const divisionId = randomUUID();
const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`;
divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name });
const divisionHead = finalizeEmployee(
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
{ paygrade: "F" }
);
employees.push(divisionHead);
for (const dept of div.departments) {
deptCounter += 1;
const departmentId = randomUUID();
const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`;
departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId });
for (const team of dept.teams) {
teamCounter += 1;
const teamId = randomUUID();
const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`;
teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId });
const size = Math.max(2, Math.round(team.baseSize * SCALE));
const teamLead = finalizeEmployee(
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
{ paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const paygrade = paygradeForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ paygrade }
);
employees.push(ic);
icPoolForStatusAssignment.push(ic);
}
}
}
}
// ── Apply the target status distribution (§5) across the IC pool ────────
function shuffle<T>(arr: T[]): T[] { function shuffle<T>(arr: T[]): T[] {
const a = [...arr]; const a = [...arr];
for (let i = a.length - 1; i > 0; i--) { for (let i = a.length - 1; i > 0; i--) {
@@ -565,74 +569,24 @@ function shuffle<T>(arr: T[]): T[] {
} }
return a; return a;
} }
const shuffledIcs = shuffle(icPoolForStatusAssignment);
function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] {
if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc();
const type = unitById.get(p.org_unit_id)!.unit_type;
return type === "Gesellschaft" || type === "Bereich" ? "F" : "E";
}
/** Besetzt eine Planstelle laufend und legt die Person an. */
function occupy(p: (typeof org.positions)[number]): EmployeeRow {
const title = jobTitleById.get(p.job_id)!;
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) });
employees.push(e);
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
return e;
}
const chiefPositions = org.positions.filter((p) => p.is_chief);
const icPositions = org.positions.filter((p) => !p.is_chief);
// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die
// Hochrollen-Regel im Organigramm sichtbar wird.
const vakanteLeitungen = new Set(
shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team"))
.slice(0, VAKANT_LEITUNG)
.map((p) => p.id)
);
const leadEmployees: EmployeeRow[] = [];
for (const p of chiefPositions) {
if (vakanteLeitungen.has(p.id)) continue;
leadEmployees.push(occupy(p));
}
// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen.
const shuffledIc = shuffle(icPositions);
const offeneStellen = shuffledIc.slice(0, VAKANT_IC);
const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT);
const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT);
const icEmployees = besetzteIc.map((p) => occupy(p));
void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen
// ── Statusverteilung (§5) ────────────────────────────────────
const statusPool = shuffle(icEmployees);
let cursor = 0; let cursor = 0;
// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch // ~40 Ausgetreten
// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
// zeitabhängig sind. const e = shuffledIcs[cursor];
for (const p of geplanteStellen) { const entryDate = new Date(e.entry_date);
const futureEntry = addDays(TODAY, randInt(10, 90)); const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
const title = jobTitleById.get(p.job_id)!; e.status = "Ausgetreten";
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry }); e.exit_date = isoDate(exitDate);
e.status = "Geplant"; e.exit_reason = pick(EXIT_REASONS);
employees.push(e); history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
} }
// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst // ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine // die Auswertung nach Art ist sonst nicht zu sehen.
// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
// unbesetzter) Leitung Daten hat. const e = shuffledIcs[cursor];
const abwesende: EmployeeRow[] = [
...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2),
];
while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) {
abwesende.push(statusPool[cursor++]);
}
for (const e of abwesende) {
const entryDate = new Date(e.entry_date); const entryDate = new Date(e.entry_date);
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10)); const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
const returnDate = addDays(TODAY, randInt(10, 300)); const returnDate = addDays(TODAY, randInt(10, 300));
@@ -649,89 +603,65 @@ for (const e of abwesende) {
}); });
} }
// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der // ~3 Geplant (future entry). A person who hasn't started yet can't already
// Zukunft. // have a Beförderung or other history predating that future entry date —
for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) { // found during the consolidation review that reassigning an already-
const e = statusPool[cursor]; // finalized IC to Geplant only patched their Eintritt row's date, leaving
// any earlier-generated history (e.g. a Beförderung) still on the record
// with a date before the (now future) entry_date. Fixed by dropping every
// history row for that employee except Eintritt, then moving Eintritt to
// the new future date.
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureEntry = addDays(TODAY, randInt(10, 90));
e.status = "Geplant";
e.entry_date = isoDate(futureEntry);
for (let hi = history.length - 1; hi >= 0; hi--) {
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
}
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
if (historyEntry) historyEntry.event_date = e.entry_date;
}
// ~3 planned future exits (still Aktiv until the exit date arrives)
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureExit = addDays(TODAY, randInt(10, 90)); const futureExit = addDays(TODAY, randInt(10, 90));
e.exit_date = isoDate(futureExit); e.exit_date = isoDate(futureExit);
e.exit_reason = pick(EXIT_REASONS); e.exit_reason = pick(EXIT_REASONS);
const a = assignments.find((x) => x.employee_id === e.id)!;
a.valid_to = e.exit_date;
} }
// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ─────── // ── Open positions (§4.6 / §5) ───────────────────────────────
// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen type PositionRow = {
// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine title: string;
// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft. team_id: string;
// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen is_lead: boolean;
// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt. reports_to_employee_id: string | null;
status: "open";
created_at: string;
};
const positions: PositionRow[] = [];
{ {
const holderOf = new Map(icEmployees.map((e) => [e.id, e])); const pool = shuffle([...teamRows]);
const uebernehmbar = shuffle( for (let i = 0; i < 8; i++) {
assignments.filter((a) => { const team = pool[i % pool.length];
const holder = holderOf.get(a.employee_id); const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
// Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze const isLeadPosition = i < 2; // first two are leadership requisitions
// Beschäftigung Platz hat. const reportsTo = isLeadPosition
return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv"; ? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
}) : (leadOfTeam?.id ?? null);
).slice(0, AUSGETRETEN); positions.push({
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
for (const a of uebernehmbar) { team_id: team.id,
const nachfolgerEintritt = new Date(a.valid_from); is_lead: isLeadPosition,
const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60)); reports_to_employee_id: reportsTo,
const entryDate = addDays(exitDate, -randInt(400, 3000)); status: "open",
const p = org.positions.find((x) => x.id === a.position_id)!; created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
const title = jobTitleById.get(p.job_id)!;
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate });
e.status = "Ausgetreten";
e.exit_date = isoDate(exitDate);
e.exit_reason = pick(EXIT_REASONS);
// Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und
// finalizeEmployee kann eine Beförderung bis heute gestreut haben, die
// hier nach dem Austritt läge.
e.contract_type = "unbefristet";
e.contract_end_date = null;
for (let i = history.length - 1; i >= 0; i--) {
if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1);
}
employees.push(e);
history.push({
employee_id: e.id,
event_date: e.exit_date,
event_type: "Austritt",
description: `Austritt (${e.exit_reason})`,
});
assignments.push({
position_id: p.id,
employee_id: e.id,
valid_from: e.entry_date,
valid_to: e.exit_date,
}); });
} }
} }
// ── Insert helpers ─────────────────────────────────────────── // ── Insert helpers ───────────────────────────────────────────
/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */
function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] {
const byParent = new Map<string | null, BuiltUnit[]>();
for (const u of units) {
const list = byParent.get(u.parent_id) ?? [];
list.push(u);
byParent.set(u.parent_id, list);
}
const out: BuiltUnit[] = [];
const queue = [...(byParent.get(null) ?? [])];
while (queue.length > 0) {
const u = queue.shift()!;
out.push(u);
queue.push(...(byParent.get(u.id) ?? []));
}
if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten");
return out;
}
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) { async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
for (let i = 0; i < rows.length; i += chunkSize) { for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize); const chunk = rows.slice(i, i + chunkSize);
@@ -741,150 +671,34 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
console.log(` inserted ${rows.length} row(s) into ${table}`); console.log(` inserted ${rows.length} row(s) into ${table}`);
} }
// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen —
// sonst sperrt sich der Seed selbst aus der Anwendung aus.
//
// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein
// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die
// Fremdschlüsselprüfung erst nach dem Statement auswertet.
const WIPE_ORDER = [
"pending_org_changes",
"hire_drafts",
"employee_notes",
"employee_dependents",
"employee_history",
"position_assignments",
"audit_log",
"saved_reports",
"employees",
"om_positions",
"jobs",
"org_units",
"locations",
];
async function wipe() {
for (const table of WIPE_ORDER) {
// PostgREST verlangt einen Filter; "id ist nicht null" trifft alles.
const { error } = await supabase.from(table).delete().not("id", "is", null);
if (error) throw new Error(`Delete from ${table} failed: ${error.message}`);
const { count } = await supabase.from(table).select("*", { count: "exact", head: true });
if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`);
console.log(` geleert: ${table}`);
}
}
/**
* Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie
* löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem
* Löschen, und dann steht die Datenbank leer da.
*/
function pruefeInvarianten() {
const laufend = assignments.filter((a) => a.valid_to === null);
const jeStelle = new Map<string, number>();
for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1);
for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`);
const jePerson = new Map<string, number>();
for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1);
for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`);
// Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur
// die laufende ab, die Historie könnte sich also unbemerkt überschneiden.
const nachStelle = new Map<string, AssignmentRow[]>();
for (const a of assignments) {
const list = nachStelle.get(a.position_id) ?? [];
list.push(a);
nachStelle.set(a.position_id, list);
}
for (const [id, list] of nachStelle) {
const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from));
for (let i = 1; i < sortiert.length; i++) {
const vorher = sortiert[i - 1];
if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) {
throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}${vorher.valid_to})`);
}
}
}
for (const a of assignments) {
if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`);
}
const personen = new Set(employees.map((e) => e.id));
for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person");
for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person");
// Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge
// sie ausserhalb der Organisation.
const mitStelle = new Set(assignments.map((a) => a.employee_id));
for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`);
// Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden
// voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen,
// wenn die Datenbank bereits leergeräumt ist.
for (const e of employees) {
const fehler = validateSvnr(e.sv_nummer, e.birth_date);
if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date}${svnrErrorMessage(fehler)}`);
}
for (const e of employees) {
if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`);
}
for (const h of history) {
const e = employees.find((x) => x.id === h.employee_id)!;
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
}
}
async function main() { async function main() {
pruefeInvarianten(); console.log("Seeding locations...");
if (process.argv.includes("--dry-run")) {
console.log("Trockenlauf — es wird nichts geschrieben.");
berichte();
return;
}
console.log("Lösche alle Daten (Anmeldekonten bleiben)...");
await wipe();
console.log("\nSeeding locations...");
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l }))); await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
console.log(`Seeding ${org.units.length} org_units...`); console.log("Seeding divisions...");
// Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile await insertInChunks("divisions", divisionRows);
// geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg
// liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll
// sich der Seed nicht verlassen.
await insertInChunks("org_units", sortParentsFirst(org.units));
console.log(`Seeding ${org.jobs.length} jobs...`); console.log("Seeding departments...");
await insertInChunks("jobs", org.jobs); await insertInChunks("departments", departmentRows);
console.log(`Seeding ${org.positions.length} Planstellen...`); console.log("Seeding teams...");
await insertInChunks("om_positions", org.positions); await insertInChunks("teams", teamRows);
console.log(`Seeding ${employees.length} employees...`); console.log(`Seeding ${employees.length} employees...`);
await insertInChunks("employees", employees); await insertInChunks("employees", employees);
console.log(`Seeding ${assignments.length} Besetzungen...`);
await insertInChunks("position_assignments", assignments);
console.log(`Seeding ${history.length} employee_history rows...`); console.log(`Seeding ${history.length} employee_history rows...`);
await insertInChunks("employee_history", history); await insertInChunks("employee_history", history);
// Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den console.log(`Seeding ${positions.length} open positions...`);
// Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht await insertInChunks("positions", positions);
// anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste
// Freischaltung, jede weitere profiles-Zeile startet mit is_active = false // The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
// und muss von HR freigeschaltet werden (§2.3). // second "manager" role exists to seed a test account for. This is the
const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle(); // one deliberate, explicit bootstrap grant of HR access (not an automatic
if (existing) { // one): every other new profile row defaults to is_active = false and
console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`); // must be activated by an existing HR user (§2.3).
} else { console.log("Creating initial HR account...");
console.log("\nLege HR-Konto an...");
const hrPassword = randomUUID().slice(0, 12) + "!Aa1"; const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({ const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
email: ADMIN_EMAIL, email: ADMIN_EMAIL,
@@ -899,38 +713,10 @@ async function main() {
role: "hr", role: "hr",
is_active: true, is_active: true,
}); });
console.log("\nDone.");
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`); console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
console.log("(Password is shown once here only — store it somewhere safe.)"); console.log("(Password is shown once here only — store it somewhere safe.)");
}
// Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr
// gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet
// hat, fällt das hier auf und nicht erst im Organigramm.
const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) });
if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`);
const ohneVorgesetzte = (linien ?? []).filter(
(l: { acting_manager_id: string | null }) => l.acting_manager_id === null
);
console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`);
console.log("\nFertig.");
berichte();
}
function berichte() {
const heute = isoDate(TODAY);
const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute));
const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length;
console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`);
console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`);
console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`);
console.log(` Personen: ${employees.length}`);
for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) {
console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`);
}
console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`);
console.log(` Historie: ${history.length} Ereignisse`);
} }
main().catch((err) => { main().catch((err) => {

View File

@@ -0,0 +1,140 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
type TestUser,
} from "./helpers";
// Org-assignment history (supabase/migrations/20260724120000_employee_
// assignment_history.sql). The point of capturing this with a trigger rather
// than inside each RPC is that it holds for *every* write path — so these
// tests drive the real RPCs and assert on the timeline they leave behind.
describe("employee_assignments history", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("employee_assignments")
.select("team_id, job_title, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("opens an interval when an employee is hired", async () => {
const employeeId = await freshEmployee(teamA.id);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBeNull();
});
it("closes the old interval and opens a new one on transfer", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].team_id).toBe(teamB.id);
expect(rows[1].valid_to).toBeNull();
// Intervals must abut exactly, or an as-of query lands in a gap.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
expect(rows.at(-1)?.team_id).toBe(teamA.id);
});
it("records a promotion's new title as its own interval", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
expect(rows.at(-1)?.valid_to).toBeNull();
});
it("writes no new interval when nothing about the placement changed", async () => {
const employeeId = await freshEmployee(teamA.id);
const before = await assignmentsFor(employeeId);
const { error } = await hrClient.rpc("change_employee_data", {
payload: {
employee_id: employeeId,
effective_date: isoDateOffset(0),
person: { phone: "+43 1 2345678" },
contract: {},
role: {},
},
});
expect(error).toBeNull();
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
});
it("keeps exactly one open interval per employee", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("is not readable without an active HR session", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});

View File

@@ -36,9 +36,7 @@ describe("HR-only access (is_hr_user gate)", () => {
createdUsers.push(user); createdUsers.push(user);
const client = await signInAs(user); const client = await signInAs(user);
const { error } = await client const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
.from("org_units")
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
expect(error).not.toBeNull(); expect(error).not.toBeNull();
}); });

View File

@@ -2,13 +2,11 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
adminClient, adminClient,
createHrUser, createHrUser,
createTestPosition,
deleteTestEmployee, deleteTestEmployee,
deleteTestPosition,
deleteTestUser, deleteTestUser,
hireTestEmployee, hireTestEmployee,
isoDateOffset, isoDateOffset,
pickSeededUnit, pickSeededTeam,
signInAs, signInAs,
type TestUser, type TestUser,
} from "./helpers"; } from "./helpers";
@@ -22,32 +20,22 @@ import type { Database } from "@/lib/supabase/types";
describe("data integrity guards", () => { describe("data integrity guards", () => {
let hrUser: TestUser; let hrUser: TestUser;
let hrClient: SupabaseClient<Database>; let hrClient: SupabaseClient<Database>;
let unitA: { id: string }; let teamA: { id: string };
const employeeIds: string[] = []; const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
hrUser = await createHrUser({ active: true }); hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser); hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit(); teamA = await pickSeededTeam();
}); });
afterAll(async () => { afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id); for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser); await deleteTestUser(hrUser);
}); });
// Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine
// geteilte wäre nach der ersten besetzt.
async function freshPosition(): Promise<string> {
const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) });
positionIds.push(id);
return id;
}
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> { async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
const employeeId = await hireTestEmployee(hrClient, await freshPosition()); const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId); employeeIds.push(employeeId);
const karenzStartDate = isoDateOffset(-5); const karenzStartDate = isoDateOffset(-5);
const { error } = await hrClient.rpc("start_karenz", { const { error } = await hrClient.rpc("start_karenz", {
@@ -107,7 +95,7 @@ describe("data integrity guards", () => {
}); });
it("rejects an employee_history row dated before the employee's entry_date", async () => { it("rejects an employee_history row dated before the employee's entry_date", async () => {
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) }); const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) });
employeeIds.push(employeeId); employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({ const { error } = await adminClient.from("employee_history").insert({
@@ -121,7 +109,7 @@ describe("data integrity guards", () => {
it("accepts an employee_history row dated exactly on the entry_date", async () => { it("accepts an employee_history row dated exactly on the entry_date", async () => {
const entryDate = isoDateOffset(-10); const entryDate = isoDateOffset(-10);
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate }); const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate });
employeeIds.push(employeeId); employeeIds.push(employeeId);
const { error } = await adminClient.from("employee_history").insert({ const { error } = await adminClient.from("employee_history").insert({

View File

@@ -1,16 +1,14 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
adminClient, adminClient,
chiefOfUnit,
createHrUser, createHrUser,
createTestPosition,
deleteTestEmployee, deleteTestEmployee,
deleteTestPosition,
deleteTestUser, deleteTestUser,
hireTestEmployee, hireTestEmployee,
isoDateOffset, isoDateOffset,
pickSeededUnit, pickSeededTeam,
signInAs, signInAs,
teamLeadId,
type TestUser, type TestUser,
} from "./helpers"; } from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
@@ -18,89 +16,60 @@ import type { Database } from "@/lib/supabase/types";
// Deferred/effective-dated changes (supabase/migrations/20260714120200_ // Deferred/effective-dated changes (supabase/migrations/20260714120200_
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a // effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
// pending_org_changes row instead of writing immediately; // pending_org_changes row instead of writing to `employees` immediately;
// apply_due_pending_changes() applies it once due. // apply_due_pending_changes() applies it once due.
//
// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und
// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die
// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr
// employees.team_id/manager_id, die es nicht mehr gibt.
describe("effective-dated mutations", () => { describe("effective-dated mutations", () => {
let hrUser: TestUser; let hrUser: TestUser;
let hrClient: SupabaseClient<Database>; let hrClient: SupabaseClient<Database>;
let unitA: { id: string }; let teamA: { id: string };
let unitB: { id: string }; let teamB: { id: string };
const employeeIds: string[] = []; const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
hrUser = await createHrUser({ active: true }); hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser); hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit(); teamA = await pickSeededTeam();
unitB = await pickSeededUnit(unitA.id); teamB = await pickSeededTeam(teamA.id);
}); });
afterAll(async () => { afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id); for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser); await deleteTestUser(hrUser);
}); });
/** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */ async function freshEmployee(teamId: string): Promise<string> {
async function freshPosition(unitId: string): Promise<string> { const id = await hireTestEmployee(hrClient, teamId);
const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
return positionId;
}
async function freshEmployee(unitId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, await freshPosition(unitId));
employeeIds.push(id); employeeIds.push(id);
return id; return id;
} }
async function lineOf(employeeId: string) {
const { data } = await adminClient
.rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) })
.eq("employee_id", employeeId)
.single();
return data as unknown as { org_unit_id: string; formal_manager_id: string | null };
}
it("transfer_employee with today's date writes immediately", async () => { it("transfer_employee with today's date writes immediately", async () => {
const employeeId = await freshEmployee(unitA.id); const employeeId = await freshEmployee(teamA.id);
const target = await freshPosition(unitB.id); const newLead = await teamLeadId(teamB.id);
const { error } = await hrClient.rpc("transfer_employee", { const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target }, payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
}); });
expect(error).toBeNull(); expect(error).toBeNull();
const { data: assignment } = await adminClient const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
.from("position_assignments") expect(employee?.team_id).toBe(teamB.id);
.select("position_id") expect(employee?.manager_id).toBe(newLead);
.eq("employee_id", employeeId)
.is("valid_to", null)
.single();
expect(assignment?.position_id).toBe(target);
const line = await lineOf(employeeId);
expect(line.org_unit_id).toBe(unitB.id);
expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id));
}); });
it("transfer_employee with a future date defers the write and applies it once due", async () => { it("transfer_employee with a future date defers the write and applies it once due", async () => {
const employeeId = await freshEmployee(unitA.id); const employeeId = await freshEmployee(teamA.id);
const target = await freshPosition(unitB.id); const newLead = await teamLeadId(teamB.id);
const { error } = await hrClient.rpc("transfer_employee", { const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target }, payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id },
}); });
expect(error).toBeNull(); expect(error).toBeNull();
// Not written yet — this is the exact bug the migration fixes: a // Not written yet — this is the exact bug the migration fixes: a
// future-dated transfer must not overwrite the live record today. // future-dated transfer must not overwrite the live record today.
expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id); const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(unchanged?.team_id).toBe(teamA.id);
const { data: pending } = await adminClient const { data: pending } = await adminClient
.from("pending_org_changes") .from("pending_org_changes")
@@ -109,7 +78,7 @@ describe("effective-dated mutations", () => {
.eq("change_type", "transfer") .eq("change_type", "transfer")
.single(); .single();
expect(pending?.status).toBe("pending"); expect(pending?.status).toBe("pending");
expect(pending?.payload.target_position_id).toBe(target); expect(pending?.payload.new_team_id).toBe(teamB.id);
// Fast-forward: simulate the effective date having arrived, then run // Fast-forward: simulate the effective date having arrived, then run
// the same function the daily cron route calls. // the same function the daily cron route calls.
@@ -118,14 +87,9 @@ describe("effective-dated mutations", () => {
expect(applyError).toBeNull(); expect(applyError).toBeNull();
expect(appliedCount).toBeGreaterThanOrEqual(1); expect(appliedCount).toBeGreaterThanOrEqual(1);
const { data: assignment } = await adminClient const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
.from("position_assignments") expect(employee?.team_id).toBe(teamB.id);
.select("position_id") expect(employee?.manager_id).toBe(newLead);
.eq("employee_id", employeeId)
.is("valid_to", null)
.single();
expect(assignment?.position_id).toBe(target);
expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id);
const { data: appliedRow } = await adminClient const { data: appliedRow } = await adminClient
.from("pending_org_changes") .from("pending_org_changes")
@@ -137,7 +101,7 @@ describe("effective-dated mutations", () => {
}); });
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => { it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
const employeeId = await freshEmployee(unitA.id); const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("promote_employee", { const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" }, payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
@@ -162,7 +126,7 @@ describe("effective-dated mutations", () => {
}); });
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => { it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
const employeeId = await freshEmployee(unitA.id); const employeeId = await freshEmployee(teamA.id);
const startDate = isoDateOffset(20); const startDate = isoDateOffset(20);
const returnDate = isoDateOffset(200); const returnDate = isoDateOffset(200);

View File

@@ -55,57 +55,31 @@ export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>
return client; return client;
} }
// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie // Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead
// kommt über die laufende Besetzung — deshalb liefert das hier gleich die // employee works for read/mutation tests that don't care which one.
// Planstelle und ihre Einheit mit. export async function pickSeededEmployee(
export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{ filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {}
): Promise<{
id: string; id: string;
team_id: string | null;
division_id: string;
manager_id: string | null;
status: string; status: string;
position_id: string;
org_unit_id: string;
is_chief: boolean;
}> { }> {
let q = adminClient let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1);
.from("position_assignments") if (filter.status) q = q.eq("status", filter.status);
.select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)") if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead);
.is("valid_to", null)
.limit(1);
if (filter.status) q = q.eq("employees.status", filter.status);
if (filter.isChief !== undefined) q = q.eq("om_positions.is_chief", filter.isChief);
const { data, error } = await q.maybeSingle(); const { data, error } = await q.maybeSingle();
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`); if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
const row = data as unknown as { return data;
employee_id: string;
om_positions: { id: string; org_unit_id: string; is_chief: boolean };
employees: { status: string };
};
return {
id: row.employee_id,
status: row.employees.status,
position_id: row.om_positions.id,
org_unit_id: row.om_positions.org_unit_id,
is_chief: row.om_positions.is_chief,
};
} }
/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */ export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> {
export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> { const q = adminClient.from("teams").select("id").limit(2);
const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2); const { data, error } = await q;
if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`); if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`);
return data.find((u) => u.id !== excludeUnitId) ?? data[0]; const match = data.find((t) => t.id !== excludeTeamId) ?? data[0];
} return match;
/**
* Eine heute unbesetzte Planstelle. Einstellung und Versetzung setzen im
* OM-Modell eine freie Zielplanstelle voraus — ohne die gibt es nichts zu
* testen, deshalb legt der Aufrufer sonst selbst eine an.
*/
export async function pickVacantPosition(): Promise<{ id: string; org_unit_id: string } | null> {
const { data: positions } = await adminClient.from("om_positions").select("id, org_unit_id").is("valid_to", null);
const { data: taken } = await adminClient.from("position_assignments").select("position_id").is("valid_to", null);
const besetzt = new Set((taken ?? []).map((a) => a.position_id));
return (positions ?? []).find((p) => !besetzt.has(p.id)) ?? null;
} }
export async function pickSeededLocation(): Promise<{ id: string }> { export async function pickSeededLocation(): Promise<{ id: string }> {
@@ -114,19 +88,18 @@ export async function pickSeededLocation(): Promise<{ id: string }> {
return data; return data;
} }
/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */ // The seeded org guarantees exactly one active team lead per team (§2's
export async function chiefOfUnit(orgUnitId: string): Promise<string | null> { // "reports-to" rule) — resolve_manager_for() relies on the same query.
export async function teamLeadId(teamId: string): Promise<string | null> {
const { data, error } = await adminClient const { data, error } = await adminClient
.from("om_positions") .from("employees")
.select("position_assignments!inner(employee_id, valid_to)") .select("id")
.eq("org_unit_id", orgUnitId) .eq("team_id", teamId)
.eq("is_chief", true) .eq("is_lead", true)
.is("valid_to", null) .neq("status", "Ausgetreten")
.is("position_assignments.valid_to", null)
.maybeSingle(); .maybeSingle();
if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`); if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`);
const row = data as unknown as { position_assignments: { employee_id: string }[] } | null; return data?.id ?? null;
return row?.position_assignments[0]?.employee_id ?? null;
} }
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads // YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
@@ -137,13 +110,12 @@ export function isoDateOffset(days: number): string {
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte // Hires a throwaway employee into `teamId` via the real hire_employee RPC
// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem // (not a raw insert) so every mutation test starts from a state the app
// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit // itself can produce. Caller must clean up with deleteTestEmployee.
// deleteTestEmployee.
export async function hireTestEmployee( export async function hireTestEmployee(
hrClient: SupabaseClient<Database>, hrClient: SupabaseClient<Database>,
positionId: string, teamId: string,
overrides: Partial<Record<string, unknown>> = {} overrides: Partial<Record<string, unknown>> = {}
): Promise<string> { ): Promise<string> {
const location = await pickSeededLocation(); const location = await pickSeededLocation();
@@ -153,9 +125,8 @@ export async function hireTestEmployee(
gender: "w", gender: "w",
birth_date: "1990-01-01", birth_date: "1990-01-01",
location_id: location.id, location_id: location.id,
// Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht team_id: teamId,
// mitgegeben, sonst könnten die beiden auseinanderlaufen. job_title: "Integrationstest-Rolle",
position_id: positionId,
entry_date: isoDateOffset(-30), entry_date: isoDateOffset(-30),
source: "Extern", source: "Extern",
...overrides, ...overrides,
@@ -174,19 +145,21 @@ export async function deleteTestEmployee(employeeId: string): Promise<void> {
await adminClient.from("employees").delete().eq("id", employeeId); await adminClient.from("employees").delete().eq("id", employeeId);
} }
// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte // Creates a throwaway open position via the real create_position RPC.
// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben — // Defaults to a non-lead position reporting to `superiorEmployeeId` (its
// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und // team is derived from that employee's own team, same as the app does).
// zwar *vor* den Personen, die darauf sassen. // Caller must clean up with deleteTestPosition — and, since positions.
// reports_to_employee_id / filled_by_employee_id reference employees(id)
// with no cascade, delete positions before the employees they point to.
export async function createTestPosition( export async function createTestPosition(
hrClient: SupabaseClient<Database>, hrClient: SupabaseClient<Database>,
orgUnitId: string, superiorEmployeeId: string,
overrides: Partial<Record<string, unknown>> = {} overrides: Partial<Record<string, unknown>> = {}
): Promise<string> { ): Promise<string> {
const payload = { const payload = {
org_unit_id: orgUnitId, title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`,
job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`, superior_employee_id: superiorEmployeeId,
is_chief: false, is_lead: false,
...overrides, ...overrides,
}; };
const { data, error } = await hrClient.rpc("create_position", { payload }); const { data, error } = await hrClient.rpc("create_position", { payload });
@@ -195,7 +168,5 @@ export async function createTestPosition(
} }
export async function deleteTestPosition(positionId: string): Promise<void> { export async function deleteTestPosition(positionId: string): Promise<void> {
// Besetzungen hängen mit on delete cascade daran, der Job bleibt im await adminClient.from("positions").delete().eq("id", positionId);
// Katalog — er ist geteilt und gehört keiner einzelnen Planstelle.
await adminClient.from("om_positions").delete().eq("id", positionId);
} }

View File

@@ -1,110 +0,0 @@
import { describe, expect, it } from "vitest";
import { todayIso } from "@/lib/format";
import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting";
import { adminClient } from "./helpers";
// Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in
// der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei
// Fassungen derselben Regel driften auseinander, und die Abweichung fällt
// niemandem auf — im Organigramm stünde einfach eine andere Führungskraft
// als im Export. Also über den gesamten Bestand gegeneinanderhalten.
describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
const asOf = todayIso();
async function fromDatabase() {
const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf });
if (error) throw new Error(error.message);
return data ?? [];
}
async function fromTypeScript() {
const [{ data: units }, { data: assignments }] = await Promise.all([
adminClient.from("org_units").select("id, parent_id"),
adminClient
.from("position_assignments")
.select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)")
.lte("valid_from", asOf)
.or(`valid_to.is.null,valid_to.gt.${asOf}`),
]);
const { data: employees } = await adminClient
.from("employees")
.select("id, karenz_start_date, karenz_return_date");
const absentById = new Map(
(employees ?? []).map((e) => [
e.id,
Boolean(
e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)
),
])
);
const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id }));
const holders: OmHolder[] = (assignments ?? [])
.filter((a) => {
const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null;
return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf);
})
.map((a) => {
const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean };
return {
employeeId: a.employee_id,
positionId: a.position_id,
orgUnitId: p.org_unit_id,
isChief: p.is_chief,
absent: absentById.get(a.employee_id) ?? false,
};
});
return resolveReportingLines(omUnits, holders);
}
it("liefert für jede Person dieselbe formale und tatsächliche Führungskraft", async () => {
const [db, ts] = await Promise.all([fromDatabase(), fromTypeScript()]);
expect(db.length).toBe(ts.length);
expect(db.length).toBeGreaterThan(0);
const tsById = new Map(ts.map((l) => [l.employeeId, l]));
const abweichungen = db
.map((row) => {
const mine = tsById.get(row.employee_id);
if (!mine) return `${row.employee_id}: fehlt in der TypeScript-Fassung`;
if (mine.actingManagerId !== row.acting_manager_id)
return `${row.employee_id}: acting DB=${row.acting_manager_id} TS=${mine.actingManagerId}`;
if (mine.formalManagerId !== row.formal_manager_id)
return `${row.employee_id}: formal DB=${row.formal_manager_id} TS=${mine.formalManagerId}`;
return null;
})
.filter(Boolean);
expect(abweichungen.slice(0, 10)).toEqual([]);
});
it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => {
const db = await fromDatabase();
const wurzel = db.filter((r) => r.acting_manager_id === null);
expect(wurzel).toHaveLength(1);
});
it("erzeugt keine Berichtslinie auf sich selbst", async () => {
const db = await fromDatabase();
expect(db.filter((r) => r.acting_manager_id === r.employee_id)).toEqual([]);
});
it("lässt jede Berichtslinie an der obersten Leitung enden", async () => {
// Ein Ring in den abgeleiteten Linien wäre im Organigramm ein Teilbaum,
// der nie gerendert wird — und niemand würde es merken.
const db = await fromDatabase();
const managerOf = new Map(db.map((r) => [r.employee_id, r.acting_manager_id]));
for (const start of db) {
const gesehen = new Set<string>();
let cur: string | null = start.employee_id;
while (cur && !gesehen.has(cur)) {
gesehen.add(cur);
cur = managerOf.get(cur) ?? null;
}
expect(cur, `Ring in der Berichtslinie ab ${start.employee_id}`).toBeNull();
}
});
});

View File

@@ -1,216 +0,0 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
// Die Besetzungshistorie (A008). Im Altmodell wurde sie von einem Trigger in
// eine eigene Tabelle mitgeschrieben; jetzt *ist* position_assignments die
// Historie — dieselben Zeilen, aus denen auch der heutige Stand kommt. Damit
// gibt es nichts mehr, was auseinanderlaufen könnte, aber die Invarianten
// müssen umso mehr halten: keine Lücke, keine Überschneidung, höchstens eine
// laufende Besetzung.
//
// Die Tests treiben die echten RPCs, nicht Inserts.
describe("position_assignments als Besetzungshistorie", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let unitA: { id: string };
let unitB: { id: string };
const employeeIds: string[] = [];
const positionIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
unitA = await pickSeededUnit();
unitB = await pickSeededUnit(unitA.id);
});
afterAll(async () => {
// Planstellen vor den Personen: die Besetzungen hängen an beiden.
for (const id of positionIds) await deleteTestPosition(id);
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshPosition(orgUnitId: string): Promise<string> {
const id = await createTestPosition(hrClient, orgUnitId);
positionIds.push(id);
return id;
}
async function freshEmployee(positionId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, positionId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("position_assignments")
.select("position_id, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("öffnet eine Besetzung bei der Einstellung", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(positionId);
expect(rows[0].valid_to).toBeNull();
});
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
// Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die
// Tätigkeit auf der Person und die der Planstelle auseinanderlaufen.
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { data: position } = await adminClient
.from("om_positions")
.select("jobs!inner(title)")
.eq("id", positionId)
.single();
const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title);
});
it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => {
// Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit
// einer Meldung tun, die in der Oberfläche etwas erklärt.
const positionId = await freshPosition(unitA.id);
await freshEmployee(positionId);
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i);
});
it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].position_id).toBe(from);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].position_id).toBe(to);
expect(rows[1].valid_to).toBeNull();
// Die Intervalle müssen exakt aneinanderstossen, sonst landet eine
// Stichtagsabfrage in einer Lücke.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("hält höchstens eine laufende Besetzung je Person", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => {
const besetzt = await freshPosition(unitA.id);
await freshEmployee(besetzt);
const andere = await freshPosition(unitB.id);
const employeeId = await freshEmployee(andere);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt },
});
expect(error?.message).toMatch(/bereits besetzt/i);
});
it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => {
const from = await freshPosition(unitA.id);
const to = await freshPosition(unitB.id);
const employeeId = await freshEmployee(from);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to },
});
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].position_id).toBe(from);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("change_type, effective_date, payload, status")
.eq("employee_id", employeeId);
expect(pending).toHaveLength(1);
expect(pending?.[0].status).toBe("pending");
expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to);
});
it("gibt die Planstelle beim Austritt frei", async () => {
const positionId = await freshPosition(unitA.id);
const employeeId = await freshEmployee(positionId);
const { error } = await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0);
expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0));
});
it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => {
// Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie
// könnte sich unbemerkt überschneiden.
const positionId = await freshPosition(unitA.id);
const ersteR = await freshEmployee(positionId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
});
await freshEmployee(positionId, );
const { data } = await adminClient
.from("position_assignments")
.select("valid_from, valid_to")
.eq("position_id", positionId)
.order("valid_from");
const rows = data ?? [];
for (let i = 1; i < rows.length; i++) {
const vorher = rows[i - 1];
expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true);
}
});
it("ist ohne aktive HR-Sitzung nicht lesbar", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("position_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { import {
adminClient, adminClient,
@@ -8,202 +9,148 @@ import {
deleteTestUser, deleteTestUser,
hireTestEmployee, hireTestEmployee,
isoDateOffset, isoDateOffset,
pickSeededUnit, pickSeededLocation,
pickSeededTeam,
signInAs, signInAs,
teamLeadId,
type TestUser, type TestUser,
} from "./helpers"; } from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
// Planstellenpflege im OM-Modell // Position validity window + delete (supabase/migrations/20260716120000_position_validity_and_delete.sql):
// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql). // positions now carry a required valid_from ("gültig ab") date, an open
// // position can be deleted again, and neither internal staffing nor an
// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit // external hire may assign an employee to a position before that date.
// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der describe("position validity and delete", () => {
// Ausschreibung hing — vorgesetzte Person, Team, is_lead — ergibt sich jetzt
// aus der Einheit und wird deshalb hier nicht mehr geprüft: es kann gar nicht
// mehr abweichen.
describe("Planstellen anlegen und schliessen", () => {
let hrUser: TestUser; let hrUser: TestUser;
let hrClient: SupabaseClient<Database>; let hrClient: SupabaseClient<Database>;
let unit: { id: string }; let teamA: { id: string };
let superiorId: string;
const positionIds: string[] = []; const positionIds: string[] = [];
const employeeIds: string[] = []; const employeeIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
hrUser = await createHrUser({ active: true }); hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser); hrClient = await signInAs(hrUser);
unit = await pickSeededUnit(); teamA = await pickSeededTeam();
superiorId = (await teamLeadId(teamA.id))!;
}); });
afterAll(async () => { afterAll(async () => {
// Personen zuerst: die Besetzung hängt mit on delete cascade an der // Positions first: reports_to_employee_id / filled_by_employee_id
// Planstelle, die Person selbst nicht. // reference employees(id) with no cascade.
for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id); for (const id of positionIds) await deleteTestPosition(id);
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser); await deleteTestUser(hrUser);
}); });
it("übernimmt das angegebene Gültig-ab", async () => { it("create_position records the given valid_from", async () => {
const validFrom = isoDateOffset(10); const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom }); const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
positionIds.push(positionId); positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(validFrom); expect(data?.valid_from).toBe(validFrom);
}); });
it("setzt Gültig-ab ohne Angabe auf heute", async () => { it("create_position defaults valid_from to today when omitted", async () => {
const positionId = await createTestPosition(hrClient, unit.id); const positionId = await createTestPosition(hrClient, superiorId);
positionIds.push(positionId); positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(isoDateOffset(0)); expect(data?.valid_from).toBe(isoDateOffset(0));
}); });
it("hängt die Planstelle an die angegebene Einheit", async () => { it("delete_position removes an open position", async () => {
const positionId = await createTestPosition(hrClient, unit.id); const positionId = await createTestPosition(hrClient, superiorId);
positionIds.push(positionId);
const { data } = await adminClient.from("om_positions").select("org_unit_id, is_chief").eq("id", positionId).single();
expect(data?.org_unit_id).toBe(unit.id);
expect(data?.is_chief).toBe(false);
});
it("teilt sich denselben Job-Katalogeintrag, statt ihn zu verdoppeln", async () => {
// Sonst stünden „Schlosser:in" und „Schlosser" nebeneinander und jede
// Auswertung nach Tätigkeit wäre wertlos.
const title = `Geteilte Tätigkeit ${Date.now()}`;
const first = await createTestPosition(hrClient, unit.id, { job_title: title });
const second = await createTestPosition(hrClient, unit.id, { job_title: title });
positionIds.push(first, second);
const { data } = await adminClient.from("om_positions").select("job_id").in("id", [first, second]);
expect(new Set((data ?? []).map((p) => p.job_id)).size).toBe(1);
});
it("weist eine Planstelle ohne Tätigkeit zurück", async () => {
const { error } = await hrClient.rpc("create_position", {
payload: { org_unit_id: unit.id, job_title: " " },
});
expect(error?.message).toMatch(/Tätigkeit/);
});
it("lässt keine zweite Leitungsplanstelle für dieselbe Einheit zu", async () => {
// Der Unique-Index erzwingt das ohnehin; die RPC soll es mit einer
// Meldung abfangen, die in der Oberfläche etwas erklärt.
const { data: existing } = await adminClient
.from("om_positions")
.select("org_unit_id")
.eq("is_chief", true)
.is("valid_to", null)
.limit(1)
.single();
const { error } = await hrClient.rpc("create_position", {
payload: { org_unit_id: existing!.org_unit_id, job_title: "Zweite Leitung", is_chief: true },
});
expect(error?.message).toMatch(/Leitungsplanstelle/);
});
it("löscht eine nie besetzte Planstelle vollständig", async () => {
const positionId = await createTestPosition(hrClient, unit.id);
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error).toBeNull(); expect(error).toBeNull();
const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle(); const { data } = await adminClient.from("positions").select("id").eq("id", positionId).maybeSingle();
expect(data).toBeNull(); expect(data).toBeNull();
}); });
it("weigert sich, eine besetzte Planstelle zu entfernen", async () => { it("delete_position rejects a filled position", async () => {
const positionId = await createTestPosition(hrClient, unit.id); const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-10) });
positionIds.push(positionId); positionIds.push(positionId);
employeeIds.push(await hireTestEmployee(hrClient, positionId)); const employeeId = await hireTestEmployee(hrClient, teamA.id);
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error?.message).toMatch(/besetzt/);
});
it("schliesst eine früher besetzte Planstelle, statt die Historie zu löschen", async () => {
// Sonst verschwände mit der Planstelle die Besetzungshistorie, und in der
// Personalakte klaffte eine Lücke.
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId); employeeIds.push(employeeId);
await hrClient.rpc("terminate_employee", { const { error: staffError } = await hrClient.rpc("staff_position_internally", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, payload: { position_id: positionId, employee_id: employeeId },
}); });
expect(staffError).toBeNull();
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error?.message).toMatch(/Nur offene Positionen können gelöscht werden/);
});
it("staff_position_internally rejects assigning to a position before its valid_from", async () => {
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(10) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const { error } = await hrClient.rpc("staff_position_internally", {
payload: { position_id: positionId, employee_id: employeeId },
});
expect(error?.message).toMatch(/erst ab .* gültig/);
});
it("staff_position_internally accepts assigning to a position on/after its valid_from", async () => {
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-1) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const { error } = await hrClient.rpc("staff_position_internally", {
payload: { position_id: positionId, employee_id: employeeId },
});
expect(error).toBeNull(); expect(error).toBeNull();
});
const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle(); it("hire_employee rejects an entry_date before the position's valid_from", async () => {
expect(data?.valid_to).toBe(isoDateOffset(0)); const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
positionIds.push(positionId);
const location = await pickSeededLocation();
const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId); const { error } = await hrClient.rpc("hire_employee", {
expect(history?.length).toBeGreaterThan(0); payload: {
}); first_name: "Integrationstest",
}); last_name: `Person-${randomUUID().slice(0, 8)}`,
gender: "w",
describe("Besetzung", () => { birth_date: "1990-01-01",
let hrUser: TestUser; location_id: location.id,
let hrClient: SupabaseClient<Database>; position_id: positionId,
let unit: { id: string }; entry_date: isoDateOffset(5),
const positionIds: string[] = []; source: "Extern",
const employeeIds: string[] = []; },
});
beforeAll(async () => { expect(error?.message).toMatch(/Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn/);
hrUser = await createHrUser({ active: true }); });
hrClient = await signInAs(hrUser);
unit = await pickSeededUnit(); it("hire_employee accepts an entry_date on/after the position's valid_from", async () => {
}); const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
afterAll(async () => { positionIds.push(positionId);
for (const id of employeeIds) await deleteTestEmployee(id); const location = await pickSeededLocation();
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser); const { data, error } = await hrClient.rpc("hire_employee", {
}); payload: {
first_name: "Integrationstest",
it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => { last_name: `Person-${randomUUID().slice(0, 8)}`,
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); gender: "w",
positionIds.push(positionId); birth_date: "1990-01-01",
employeeIds.push(await hireTestEmployee(hrClient, positionId)); location_id: location.id,
position_id: positionId,
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/); entry_date: validFrom,
}); source: "Extern",
},
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { });
// Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten expect(error).toBeNull();
// Planstelle und Person unterschiedliche Tätigkeiten führen. if (data) employeeIds.push(data);
const title = `Tätigkeit aus dem Katalog ${Date.now()}`;
const positionId = await createTestPosition(hrClient, unit.id, { job_title: title, valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId);
const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
expect(data?.job_title).toBe(title);
});
it("beendet die Besetzung beim Austritt und macht die Planstelle frei", async () => {
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, positionId);
employeeIds.push(employeeId);
await hrClient.rpc("terminate_employee", {
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
});
const { data } = await adminClient
.from("position_assignments")
.select("valid_to")
.eq("position_id", positionId)
.eq("employee_id", employeeId)
.single();
expect(data?.valid_to).toBe(isoDateOffset(-1));
}); });
}); });

View File

@@ -0,0 +1,147 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
teamLeadId,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Reorg scenarios: immediate apply/undo must respect employee_history's
// append-only contract (20260714120300_reorg_undo_append_only.sql), and a
// future-dated scenario must defer every move via pending_org_changes until
// its effective date, flipping reorg_scenarios.applied only once every move
// has landed (20260714120200_effective_dating_rpcs.sql).
describe("reorg scenarios", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => {
const employeeId = await freshEmployee(teamA.id);
const teamBLead = await teamLeadId(teamB.id);
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
payload: {
name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`,
effective_date: isoDateOffset(0),
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
},
});
expect(applyError).toBeNull();
expect(scenarioId).toBeTruthy();
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(movedEmployee?.team_id).toBe(teamB.id);
expect(movedEmployee?.manager_id).toBe(teamBLead);
const { data: scenario } = await adminClient
.from("reorg_scenarios")
.select("applied, applied_at")
.eq("id", scenarioId as string)
.single();
expect(scenario?.applied).toBe(true);
expect(scenario?.applied_at).not.toBeNull();
const { count: historyBeforeUndo } = await adminClient
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("employee_id", employeeId)
.eq("event_type", "Reorganisation");
expect(historyBeforeUndo).toBe(1);
const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } });
expect(undoError).toBeNull();
const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(revertedEmployee?.team_id).toBe(teamA.id);
const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single();
expect(undoneScenario?.applied).toBe(false);
// The original "Reorganisation" row must still be there — undo appends
// a compensating entry, it never deletes (the bug the migration fixed).
const { count: historyAfterUndo } = await adminClient
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("employee_id", employeeId)
.eq("event_type", "Reorganisation");
expect(historyAfterUndo).toBe(2);
});
it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => {
const employeeId = await freshEmployee(teamA.id);
const teamBLead = await teamLeadId(teamB.id);
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
payload: {
name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`,
effective_date: isoDateOffset(30),
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
},
});
expect(applyError).toBeNull();
const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
expect(unchangedEmployee?.team_id).toBe(teamA.id);
const { data: scenarioBefore } = await adminClient
.from("reorg_scenarios")
.select("applied")
.eq("id", scenarioId as string)
.single();
expect(scenarioBefore?.applied).toBe(false);
const { data: pending } = await adminClient
.from("pending_org_changes")
.select("id")
.eq("employee_id", employeeId)
.eq("reorg_scenario_id", scenarioId as string)
.eq("status", "pending")
.single();
expect(pending).not.toBeNull();
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
const { error: cronError } = await adminClient.rpc("apply_due_pending_changes");
expect(cronError).toBeNull();
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
expect(movedEmployee?.team_id).toBe(teamB.id);
expect(movedEmployee?.manager_id).toBe(teamBLead);
const { data: scenarioAfter } = await adminClient
.from("reorg_scenarios")
.select("applied, applied_at")
.eq("id", scenarioId as string)
.single();
expect(scenarioAfter?.applied).toBe(true);
expect(scenarioAfter?.applied_at).not.toBeNull();
});
});

View File

@@ -1,19 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
import { import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTestEmployee, pickSeededTeam, signInAs, type TestUser } from "./helpers";
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededUnit,
signInAs,
type TestUser,
} from "./helpers";
// SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql). // SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql).
// Enforced by a trigger, so these drive it through the real write paths and // Enforced by a trigger, so these drive it through the real write paths and
@@ -21,9 +9,8 @@ import {
describe("SVNR validation", () => { describe("SVNR validation", () => {
let hrUser: TestUser; let hrUser: TestUser;
let hrClient: SupabaseClient<Database>; let hrClient: SupabaseClient<Database>;
let unit: { id: string }; let team: { id: string };
const employeeIds: string[] = []; const employeeIds: string[] = [];
const positionIds: string[] = [];
// 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7 // 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7
const VALID = "1237 010180"; const VALID = "1237 010180";
@@ -38,21 +25,16 @@ describe("SVNR validation", () => {
beforeAll(async () => { beforeAll(async () => {
hrUser = await createHrUser({ active: true }); hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser); hrClient = await signInAs(hrUser);
unit = await pickSeededUnit(); team = await pickSeededTeam();
}); });
afterAll(async () => { afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id); for (const id of employeeIds) await deleteTestEmployee(id);
for (const id of positionIds) await deleteTestPosition(id);
await deleteTestUser(hrUser); await deleteTestUser(hrUser);
}); });
async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> { async function hireAt(country: string, overrides: Record<string, unknown> = {}): Promise<string> {
// Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der const id = await hireTestEmployee(hrClient, team.id, {
// ersten besetzt.
const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) });
positionIds.push(positionId);
const id = await hireTestEmployee(hrClient, positionId, {
location_id: await locationIn(country), location_id: await locationIn(country),
birth_date: BIRTH_DATE, birth_date: BIRTH_DATE,
...overrides, ...overrides,

View File

@@ -1,162 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildOrg, type DivisionDef } from "@/supabase/build-org";
// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge
// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt:
// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus.
const DIVISIONS: DivisionDef[] = [
{
name: "Produktion",
headTitle: "Bereichsleitung Produktion",
departments: [
{
name: "Fertigung",
leadTitle: "Abteilungsleitung Fertigung",
teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 },
{ name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 },
],
},
{
name: "Instandhaltung",
leadTitle: "Abteilungsleitung Instandhaltung",
teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }],
},
],
},
{
name: "IT",
headTitle: "Bereichsleitung IT",
departments: [
{
name: "Infrastruktur",
leadTitle: "Abteilungsleitung Infrastruktur",
teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }],
},
],
},
];
function build() {
let n = 0;
return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`);
}
describe("Struktur", () => {
it("hat genau eine Wurzel, und das ist die Gesellschaft", () => {
const { units } = build();
const roots = units.filter((u) => u.parent_id === null);
expect(roots).toHaveLength(1);
expect(roots[0].unit_type).toBe("Gesellschaft");
});
it("hängt jede Einheit unter den richtigen Typ", () => {
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const;
for (const u of units) {
if (u.unit_type === "Gesellschaft") continue;
const parent = byId.get(u.parent_id!);
expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]);
}
});
it("baut die Ebenen vollständig auf", () => {
const { units } = build();
const zahl = (t: string) => units.filter((u) => u.unit_type === t).length;
expect(zahl("Gesellschaft")).toBe(1);
expect(zahl("Bereich")).toBe(2);
expect(zahl("Abteilung")).toBe(3);
expect(zahl("Team")).toBe(4);
});
it("führt jede Einheit über parent_id auf die Wurzel zurück", () => {
// Ein abgehängter Teilbaum würde im Organigramm nie gerendert.
const { units } = build();
const byId = new Map(units.map((u) => [u.id, u]));
for (const start of units) {
const gesehen = new Set<string>();
let cur = start;
while (cur.parent_id && !gesehen.has(cur.id)) {
gesehen.add(cur.id);
cur = byId.get(cur.parent_id)!;
}
expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull();
}
});
it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => {
const { units } = build();
expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length);
const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const;
for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true);
});
});
describe("Planstellen", () => {
it("gibt jeder Einheit genau eine Leitungsplanstelle", () => {
// Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll
// der Seed gar nicht erst dagegenlaufen.
const { units, positions } = build();
for (const u of units) {
const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief);
expect(chiefs, `${u.name}`).toHaveLength(1);
}
});
it("legt auch für die Abteilung eine Leitung an", () => {
// Die Ebene, die im Altmodell gefehlt hat.
const { units, positions } = build();
const abteilungen = units.filter((u) => u.unit_type === "Abteilung");
expect(abteilungen.length).toBeGreaterThan(0);
for (const a of abteilungen) {
expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true);
}
});
it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => {
const { units, icPositionsByTeam } = build();
const montage = units.find((u) => u.name === "Montage")!;
expect(icPositionsByTeam.get(montage.id)).toHaveLength(3);
expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true);
});
it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => {
const { positions } = build();
expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length);
for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/);
});
it("hängt jede Planstelle an eine existierende Einheit", () => {
const { units, positions } = build();
const ids = new Set(units.map((u) => u.id));
for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true);
});
});
describe("Job-Katalog", () => {
it("führt jede Tätigkeit genau einmal", () => {
// "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine
// Auswertung nach Tätigkeit wertlos.
const { jobs } = build();
expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length);
expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length);
});
it("teilt denselben Job über mehrere Planstellen", () => {
const { positions, jobs } = build();
const jobById = new Map(jobs.map((j) => [j.id, j]));
const monteur = jobs.find((j) => j.title === "Monteur:in")!;
const stellen = positions.filter((p) => p.job_id === monteur.id);
expect(stellen.length).toBeGreaterThan(1);
expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in");
});
it("verweist jede Planstelle auf einen existierenden Job", () => {
const { positions, jobs } = build();
const ids = new Set(jobs.map((j) => j.id));
for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true);
});
});

View File

@@ -1,154 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting";
// Vier Ebenen wie in der Zielstruktur: Gesellschaft -> Bereich -> Abteilung
// -> Team. Die Hierarchie steckt allein in parent_id; unit_type ist ein
// Etikett und für die Ableitung ohne Bedeutung.
const UNITS: OmUnit[] = [
{ id: "gf", parentId: null },
{ id: "bereich", parentId: "gf" },
{ id: "abteilung", parentId: "bereich" },
{ id: "team", parentId: "abteilung" },
];
function holder(employeeId: string, orgUnitId: string, isChief: boolean, absent = false): OmHolder {
return { employeeId, positionId: `pos-${employeeId}`, orgUnitId, isChief, absent };
}
function lineFor(employeeId: string, holders: OmHolder[], units: OmUnit[] = UNITS) {
return resolveReportingLines(units, holders).find((l) => l.employeeId === employeeId)!;
}
describe("Berichtslinie aus dem Organisationsbaum", () => {
const full = [
holder("gf-person", "gf", true),
holder("bl", "bereich", true),
holder("al", "abteilung", true),
holder("tl", "team", true),
holder("ma", "team", false),
];
it("lässt Mitarbeitende an die Leitung der eigenen Einheit berichten", () => {
expect(lineFor("ma", full).actingManagerId).toBe("tl");
});
it("lässt eine Leitung an die Leitung der übergeordneten Einheit berichten", () => {
expect(lineFor("tl", full).actingManagerId).toBe("al");
expect(lineFor("al", full).actingManagerId).toBe("bl");
expect(lineFor("bl", full).actingManagerId).toBe("gf-person");
});
it("gibt der obersten Leitung keine Führungskraft", () => {
const gf = lineFor("gf-person", full);
expect(gf.actingManagerId).toBeNull();
expect(gf.formalManagerId).toBeNull();
});
});
describe("unbesetzte Leitung", () => {
// Der eigentliche Grund für die Aufwärtsregel: eine Abteilung ohne
// Leitung soll die Kette nicht abreißen lassen und braucht keine
// Sonderbehandlung im Code.
const ohneAbteilungsleitung = [
holder("gf-person", "gf", true),
holder("bl", "bereich", true),
holder("tl", "team", true),
holder("ma", "team", false),
];
it("überspringt eine unbesetzte Abteilungsleitung", () => {
expect(lineFor("tl", ohneAbteilungsleitung).actingManagerId).toBe("bl");
});
it("nennt die unbesetzte Ebene auch nicht als formale Leitung", () => {
expect(lineFor("tl", ohneAbteilungsleitung).formalManagerId).toBeNull();
});
it("überspringt mehrere unbesetzte Ebenen hintereinander", () => {
const nurGf = [holder("gf-person", "gf", true), holder("ma", "team", false)];
expect(lineFor("ma", nurGf).actingManagerId).toBe("gf-person");
});
it("lässt die Führungskraft leer, wenn oberhalb niemand besetzt ist", () => {
const allein = [holder("ma", "team", false)];
expect(lineFor("ma", allein).actingManagerId).toBeNull();
});
});
describe("abwesende Leitung", () => {
const teamleitungAbwesend = [
holder("gf-person", "gf", true),
holder("bl", "bereich", true),
holder("al", "abteilung", true),
holder("tl", "team", true, true),
holder("ma", "team", false),
];
it("hebt die Berichtslinie auf die nächste anwesende Ebene", () => {
expect(lineFor("ma", teamleitungAbwesend).actingManagerId).toBe("al");
});
it("nennt weiterhin die formal zuständige Leitung, damit die Vertretung erkennbar bleibt", () => {
// Ohne das würde die Oberfläche die Vertretung als die echte
// Führungskraft ausgeben.
expect(lineFor("ma", teamleitungAbwesend).formalManagerId).toBe("tl");
});
it("steigt über mehrere abwesende Ebenen hinweg", () => {
const zweiAbwesend = [
holder("gf-person", "gf", true),
holder("bl", "bereich", true),
holder("al", "abteilung", true, true),
holder("tl", "team", true, true),
holder("ma", "team", false),
];
expect(lineFor("ma", zweiAbwesend).actingManagerId).toBe("bl");
expect(lineFor("ma", zweiAbwesend).formalManagerId).toBe("tl");
});
it("gibt die abwesende Leitung selbst an ihre eigene übergeordnete Ebene", () => {
expect(lineFor("tl", teamleitungAbwesend).actingManagerId).toBe("al");
});
});
describe("Randfälle", () => {
it("lässt niemanden an sich selbst berichten", () => {
// Eine Leitung, deren übergeordnete Einheit sie ebenfalls führt.
const doppelrolle = [holder("chef", "bereich", true), holder("chef2", "abteilung", true)];
const units: OmUnit[] = [
{ id: "bereich", parentId: null },
{ id: "abteilung", parentId: "bereich" },
];
expect(lineFor("chef", doppelrolle, units).actingManagerId).toBeNull();
});
it("bricht bei einem Ring in parent_id ab, statt ewig zu laufen", () => {
// parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann
// einen Ring erzeugen.
const ring: OmUnit[] = [
{ id: "a", parentId: "b" },
{ id: "b", parentId: "a" },
];
const holders = [holder("ma", "a", false)];
expect(() => resolveReportingLines(ring, holders)).not.toThrow();
expect(lineFor("ma", holders, ring).actingManagerId).toBeNull();
});
it("kommt mit mehreren Teams unter derselben Abteilung zurecht", () => {
const units: OmUnit[] = [
{ id: "abteilung", parentId: null },
{ id: "team-a", parentId: "abteilung" },
{ id: "team-b", parentId: "abteilung" },
];
const holders = [
holder("al", "abteilung", true),
holder("tl-a", "team-a", true),
holder("tl-b", "team-b", true),
holder("ma-a", "team-a", false),
holder("ma-b", "team-b", false),
];
expect(lineFor("ma-a", holders, units).actingManagerId).toBe("tl-a");
expect(lineFor("ma-b", holders, units).actingManagerId).toBe("tl-b");
expect(lineFor("tl-b", holders, units).actingManagerId).toBe("al");
});
});

View File

@@ -1,102 +1,52 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ancestorsOf, breadcrumbLabel, buildOrgMaps, divisionOf, subtreeOf, type OrgUnit } from "@/lib/org"; import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org";
import type { Database } from "@/lib/supabase/types";
// Der Baum ersetzt die drei festen Ebenen des Altmodells. Damit hängt an type Division = Database["public"]["Tables"]["divisions"]["Row"];
// dieser Datei mehr als vorher: eine Einheit falsch verkettet, und ein Filter type Department = Database["public"]["Tables"]["departments"]["Row"];
// „Bereich Produktion" liefert stillschweigend zu wenig — nicht gar nichts, type Team = Database["public"]["Tables"]["teams"]["Row"];
// was auffallen würde. type Location = Database["public"]["Tables"]["locations"]["Row"];
const units: OrgUnit[] = [ const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" };
{ id: "gmbh", org_number: "10000000", name: "Alpenwerk", parent_id: null, unit_type: "Gesellschaft" }, const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" };
{ id: "prod", org_number: "20100000", name: "Produktion", parent_id: "gmbh", unit_type: "Bereich" }, const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" };
{ id: "fert", org_number: "21100000", name: "Fertigung", parent_id: "prod", unit_type: "Abteilung" }, const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" };
{ id: "mont", org_number: "22001000", name: "Montage", parent_id: "fert", unit_type: "Team" },
{ id: "cnc", org_number: "22002000", name: "CNC", parent_id: "fert", unit_type: "Team" },
{ id: "it", org_number: "20200000", name: "IT", parent_id: "gmbh", unit_type: "Bereich" },
];
const locations = [{ id: "loc-1", name: "Wien-Hernals", country: "Österreich" }];
const maps = buildOrgMaps(units, locations);
describe("buildOrgMaps", () => { const orgMaps: OrgMaps = {
it("listet Eltern vor ihren Kindern", () => { divisions: new Map([[division.id, division]]),
// Die Reihenfolge ist eine Zusage: der Filter im Mitarbeiterlisten-Select departments: new Map([[department.id, department]]),
// rückt danach ein, und ein Einfügen in die Datenbank verlässt sich darauf. teams: new Map([[team.id, team]]),
const position = new Map(maps.unitList.map((u, i) => [u.id, i])); locations: new Map([[location.id, location]]),
for (const u of maps.unitList) { divisionList: [division],
if (!u.parent_id) continue; locationList: [location],
expect(position.get(u.parent_id)!, `${u.name} steht vor ${u.parent_id}`).toBeLessThan(position.get(u.id)!); };
}
describe("breadcrumbFor", () => {
it("resolves division, department (via team), and team from ids", () => {
const result = breadcrumbFor(orgMaps, division.id, team.id);
expect(result.division?.name).toBe("Produktion");
expect(result.department?.name).toBe("Fertigung");
expect(result.team?.name).toBe("Montage");
}); });
it("misst die Tiefe ab der Wurzel", () => { it("has no team/department for a division head with no team (team_id null)", () => {
expect(maps.depthOf.get("gmbh")).toBe(0); const result = breadcrumbFor(orgMaps, division.id, null);
expect(maps.depthOf.get("prod")).toBe(1); expect(result.division?.name).toBe("Produktion");
expect(maps.depthOf.get("mont")).toBe(3); expect(result.team).toBeUndefined();
}); expect(result.department).toBeUndefined();
});
describe("ancestorsOf", () => {
it("gibt die Kette von der Wurzel bis zur Einheit selbst", () => {
expect(ancestorsOf(maps, "mont").map((u) => u.name)).toEqual(["Alpenwerk", "Produktion", "Fertigung", "Montage"]);
});
it("bricht bei einem Ring ab, statt endlos zu laufen", () => {
// parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann
// einen Ring erzeugen, und jede Auswertung hier ist rekursiv.
const ringMaps = buildOrgMaps(
[
{ id: "a", org_number: "1", name: "A", parent_id: "b", unit_type: "Bereich" },
{ id: "b", org_number: "2", name: "B", parent_id: "a", unit_type: "Bereich" },
],
[]
);
expect(ancestorsOf(ringMaps, "a")).toHaveLength(2);
});
it("liefert nichts für eine unbekannte Einheit", () => {
expect(ancestorsOf(maps, "gibtesnicht")).toEqual([]);
expect(ancestorsOf(maps, null)).toEqual([]);
});
});
describe("subtreeOf", () => {
it("schliesst die Einheit selbst und alles darunter ein", () => {
// Genau das meint der Filter „Bereich Produktion": im Bereich selbst
// sitzt nur die Bereichsleitung, alle anderen hängen tiefer.
expect(new Set(subtreeOf(maps, "prod"))).toEqual(new Set(["prod", "fert", "mont", "cnc"]));
});
it("ist für ein Blatt die Einheit allein", () => {
expect(subtreeOf(maps, "mont")).toEqual(["mont"]);
});
});
describe("divisionOf", () => {
it("findet den Bereich einer tief hängenden Einheit", () => {
expect(divisionOf(maps, "mont")?.name).toBe("Produktion");
});
it("gibt für eine Bereichsleitung ihren eigenen Bereich", () => {
expect(divisionOf(maps, "prod")?.name).toBe("Produktion");
});
it("hat für die Gesellschaft selbst keinen Bereich", () => {
// Die Geschäftsführung sitzt über allen Bereichen, nicht in einem.
expect(divisionOf(maps, "gmbh")).toBeUndefined();
}); });
}); });
describe("breadcrumbLabel", () => { describe("breadcrumbLabel", () => {
it("lässt die Gesellschaft weg, die in jeder Zeile gleich wäre", () => { it("joins division department team with the SAP-OM breadcrumb separator", () => {
expect(breadcrumbLabel(maps, "mont")).toBe("Produktion Fertigung Montage"); expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion Fertigung Montage");
}); });
it("endet bei der Einheit, an der die Person tatsächlich hängt", () => { it("omits missing segments instead of producing empty separators", () => {
expect(breadcrumbLabel(maps, "prod")).toBe("Produktion"); expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion");
}); });
it("fällt auf einen Strich zurück, wenn nichts auflösbar ist", () => { it("falls back to a dash when nothing resolves", () => {
expect(breadcrumbLabel(maps, null)).toBe(""); expect(breadcrumbLabel(orgMaps, null, null)).toBe("");
expect(breadcrumbLabel(maps, "gmbh")).toBe("");
}); });
}); });

View File

@@ -1,38 +1,27 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveOrgSnapshot } from "@/lib/orgchart-data"; import { resolveOrgSnapshot } from "@/lib/orgchart-data";
// Der Stand zu einem Stichtag. Im Altmodell mussten dafür drei Quellen const DIV = "div-1";
// versöhnt werden; im OM-Modell beantwortet die zeitabhängige Besetzung fast const TEAM_A = "team-a";
// alles allein — was diese Datei deutlich kürzer macht, aber nicht const TEAM_B = "team-b";
// überflüssig: die Projektion vorgemerkter Versetzungen und die Frage, wer am
// Stichtag überhaupt dazuzählte, entscheiden sich weiterhin hier.
type Args = Parameters<typeof resolveOrgSnapshot>[0]; type EmployeeInput = Parameters<typeof resolveOrgSnapshot>[0]["employees"][number];
type AssignmentInput = Parameters<typeof resolveOrgSnapshot>[0]["assignments"][number];
const UNITS: Args["units"] = [ function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput {
{ id: "gmbh", parentId: null },
{ id: "prod", parentId: "gmbh" },
{ id: "team-a", parentId: "prod" },
{ id: "team-b", parentId: "prod" },
];
// Planstellen: je Einheit eine Leitung, dazu Mitarbeiterstellen.
const POSITIONS: Args["positions"] = [
{ id: "p-gf", position_number: "60000001", org_unit_id: "gmbh", is_chief: true, jobs: { title: "Geschäftsführung" } },
{ id: "p-bl", position_number: "60000002", org_unit_id: "prod", is_chief: true, jobs: { title: "Bereichsleitung" } },
{ id: "p-tl-a", position_number: "60000003", org_unit_id: "team-a", is_chief: true, jobs: { title: "Teamleitung A" } },
{ id: "p-tl-b", position_number: "60000004", org_unit_id: "team-b", is_chief: true, jobs: { title: "Teamleitung B" } },
{ id: "p-a1", position_number: "60000005", org_unit_id: "team-a", is_chief: false, jobs: { title: "Monteur:in" } },
{ id: "p-b1", position_number: "60000006", org_unit_id: "team-b", is_chief: false, jobs: { title: "Fräser:in" } },
];
function emp(id: string, overrides: Partial<Args["employees"][number]> = {}): Args["employees"][number] {
return { return {
id, id,
personnel_number: 1000, personnel_number: 1000,
first_name: "Test", first_name: "Test",
last_name: id, last_name: id,
job_title: "Freitext auf der Person", job_title: "Mitarbeiter:in",
manager_id: null,
team_id: TEAM_A,
division_id: DIV,
is_lead: false,
org_level: 3,
entry_date: "2020-01-01",
exit_date: null,
karenz_start_date: null, karenz_start_date: null,
karenz_return_date: null, karenz_return_date: null,
absence_type: null, absence_type: null,
@@ -40,158 +29,141 @@ function emp(id: string, overrides: Partial<Args["employees"][number]> = {}): Ar
}; };
} }
function snapshot(args: Partial<Args> & { asOf: string }) { function assignment(employeeId: string, overrides: Partial<AssignmentInput> = {}): AssignmentInput {
return resolveOrgSnapshot({ return {
units: UNITS, employee_id: employeeId,
positions: POSITIONS, manager_id: null,
assignments: [], team_id: TEAM_A,
employees: [], division_id: DIV,
pending: [], job_title: "Mitarbeiter:in",
historyStartsAt: null, is_lead: false,
...args, org_level: 3,
}); valid_from: "2020-01-01",
...overrides,
};
} }
describe("Zugehörigkeit zum Stichtag", () => { const TEAMS = [
it("zeigt nur, wer am Stichtag eine Planstelle innehatte", () => { { id: TEAM_A, department_id: "dept-1" },
// Die Zugehörigkeit ist keine eigene Regel mehr: wer keine Planstelle { id: TEAM_B, department_id: "dept-2" },
// hat, steht nicht in der Organisation. Ein-, Austritt und geplanter ];
// Eintritt stecken alle in der Gültigkeit der Besetzung. const DEPARTMENTS = [
const employees = [emp("a"), emp("b")]; { id: "dept-1", division_id: DIV },
const result = snapshot({ { id: "dept-2", division_id: "div-2" },
asOf: "2026-06-01", ];
employees,
assignments: [{ employee_id: "a", position_id: "p-a1" }], function snapshot(args: Partial<Parameters<typeof resolveOrgSnapshot>[0]> & { asOf: string }) {
}); return resolveOrgSnapshot({ employees: [], assignments: [], teams: TEAMS, departments: DEPARTMENTS, pending: [], ...args });
expect(result.employees.map((e) => e.id)).toEqual(["a"]); }
describe("membership as of a date", () => {
it("excludes someone who had not started yet and includes them once they have", () => {
const employees = [emp("a", { entry_date: "2026-06-01" })];
expect(snapshot({ asOf: "2026-05-31", employees }).employees).toHaveLength(0);
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
}); });
it("übergeht eine Besetzung auf einer am Stichtag ungültigen Planstelle", () => { it("excludes someone from their exit date onwards", () => {
// loadOrgAsOf filtert Planstellen bereits nach Gültigkeit; kommt eine const employees = [emp("a", { exit_date: "2026-06-30" })];
// Besetzung ohne passende Planstelle an, wäre sie im Baum nicht expect(snapshot({ asOf: "2026-06-29", employees }).employees).toHaveLength(1);
// verortbar. expect(snapshot({ asOf: "2026-06-30", employees }).employees).toHaveLength(0);
const result = snapshot({
asOf: "2026-06-01",
employees: [emp("a")],
assignments: [{ employee_id: "a", position_id: "gibtesnicht" }],
}); });
expect(result.employees).toHaveLength(0);
it("keeps someone on Karenz in the chart", () => {
const employees = [emp("a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" })];
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
}); });
}); });
describe("Berichtslinie", () => { describe("placement as of a date", () => {
const employees = [emp("gf"), emp("bl"), emp("tl-a"), emp("a1")]; it("uses the assignment interval covering the date, not today's row on employees", () => {
const assignments = [ const employees = [emp("a", { team_id: TEAM_B, job_title: "Heutiger Titel" })];
{ employee_id: "gf", position_id: "p-gf" }, const assignments = [assignment("a", { team_id: TEAM_A, job_title: "Damaliger Titel" })];
{ employee_id: "bl", position_id: "p-bl" }, const [result] = snapshot({ asOf: "2024-03-01", employees, assignments }).employees;
{ employee_id: "tl-a", position_id: "p-tl-a" }, expect(result.team_id).toBe(TEAM_A);
{ employee_id: "a1", position_id: "p-a1" }, expect(result.job_title).toBe("Damaliger Titel");
});
it("falls back to the employee row when no assignment covers the date", () => {
const employees = [emp("a", { team_id: TEAM_B })];
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments: [] }).employees;
expect(result.team_id).toBe(TEAM_B);
});
});
describe("orphan re-rooting", () => {
// Without this the whole reporting line below an absent manager silently
// disappears from the chart instead of moving up a level.
it("drops a manager reference to somebody not employed on that date", () => {
const employees = [
emp("boss", { exit_date: "2026-01-01", org_level: 2, is_lead: true }),
emp("report", { manager_id: "boss" }),
]; ];
const assignments = [assignment("report", { manager_id: "boss" })];
it("führt eine Mitarbeiterin an die Leitung ihrer Einheit", () => { const result = snapshot({ asOf: "2026-06-01", employees, assignments }).employees;
const result = snapshot({ asOf: "2026-06-01", employees, assignments }); expect(result).toHaveLength(1);
expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("tl-a"); expect(result[0].id).toBe("report");
}); expect(result[0].manager_id).toBeNull();
it("führt eine Leitung an die Leitung darüber, nicht an sich selbst", () => {
const result = snapshot({ asOf: "2026-06-01", employees, assignments });
expect(result.employees.find((e) => e.id === "tl-a")!.manager_id).toBe("bl");
expect(result.employees.find((e) => e.id === "gf")!.manager_id).toBeNull();
});
it("rollt bei unbesetzter Leitung eine Ebene hoch", () => {
const ohneTeamleitung = assignments.filter((a) => a.employee_id !== "tl-a");
const result = snapshot({ asOf: "2026-06-01", employees, assignments: ohneTeamleitung });
expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("bl");
});
it("nennt bei Abwesenheit beide: die zuständige und die tatsächliche Leitung", () => {
// Sonst gäbe die Oberfläche die Vertretung stillschweigend als die echte
// Führungskraft aus.
const abwesend = employees.map((e) =>
e.id === "tl-a" ? emp("tl-a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" }) : e
);
const [a1] = snapshot({ asOf: "2026-06-01", employees: abwesend, assignments }).employees.filter((e) => e.id === "a1");
expect(a1.manager_id).toBe("bl");
expect(a1.formal_manager_id).toBe("tl-a");
});
it("lässt formal_manager_id leer, solange niemand vertritt", () => {
const [a1] = snapshot({ asOf: "2026-06-01", employees, assignments }).employees.filter((e) => e.id === "a1");
expect(a1.formal_manager_id).toBeNull();
}); });
}); });
describe("Tätigkeit", () => { describe("future projection from pending changes", () => {
it("nimmt die Tätigkeit der Planstelle, nicht das Freitextfeld der Person", () => { const leadB = emp("lead-b", { id: "lead-b", team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 });
// Bei einer projizierten Versetzung ist nur die erste schon richtig.
const [result] = snapshot({
asOf: "2026-06-01",
employees: [emp("a", { job_title: "Veraltet" })],
assignments: [{ employee_id: "a", position_id: "p-a1" }],
}).employees;
expect(result.job_title).toBe("Monteur:in");
expect(result.position_number).toBe("60000005");
});
});
describe("Projektion vorgemerkter Versetzungen", () => { it("moves an employee into the target team and under that team's lead", () => {
const employees = [emp("bl"), emp("tl-b"), emp("a")]; const employees = [emp("a", { manager_id: "lead-a" }), leadB];
const assignments = [ const assignments = [assignment("a", { manager_id: "lead-a" }), assignment("lead-b", { team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 })];
{ employee_id: "bl", position_id: "p-bl" }, const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }];
{ employee_id: "tl-b", position_id: "p-tl-b" },
{ employee_id: "a", position_id: "p-a1" },
];
it("setzt die Person auf die Zielplanstelle und damit unter deren Leitung", () => { const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending });
const result = snapshot({
asOf: "2026-09-01",
employees,
assignments,
pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } }],
});
const moved = result.employees.find((e) => e.id === "a")!; const moved = result.employees.find((e) => e.id === "a")!;
expect(moved.org_unit_id).toBe("team-b"); expect(moved.team_id).toBe(TEAM_B);
expect(moved.job_title).toBe("Fräser:in"); expect(moved.division_id).toBe("div-2");
expect(moved.manager_id).toBe("tl-b"); expect(moved.manager_id).toBe("lead-b");
expect(result.projectedCount).toBe(1); expect(result.projectedCount).toBe(1);
}); });
it("lässt eine spätere Versetzung über eine frühere gewinnen", () => { it("lets a later change win over an earlier one", () => {
const [result] = snapshot({ const employees = [emp("a")];
asOf: "2026-10-01", const assignments = [assignment("a")];
employees, const pending = [
assignments, { employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B, new_title: "Zwischenstand" } },
pending: [ { employee_id: "a", effective_date: "2026-09-01", payload: { new_team_id: TEAM_A, new_title: "Endstand" } },
{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "p-b1" } }, ];
{ employee_id: "a", effective_date: "2026-09-01", payload: { target_position_id: "p-a1" } }, const [result] = snapshot({ asOf: "2026-10-01", employees, assignments, pending }).employees;
], expect(result.team_id).toBe(TEAM_A);
}).employees.filter((e) => e.id === "a"); expect(result.job_title).toBe("Endstand");
expect(result.org_unit_id).toBe("team-a");
}); });
it("übergeht eine Versetzung auf eine unbekannte Planstelle, statt die Person zu verlieren", () => { it("leaves an untouched employee's manager exactly as recorded", () => {
const result = snapshot({ // Deliberately deviating from the resolve rule: real data drifts, and a
asOf: "2026-09-01", // snapshot must not silently "repair" reporting lines it was not asked
employees, // to change.
assignments, const employees = [emp("a", { manager_id: "someone-else" }), emp("someone-else", { id: "someone-else" }), leadB];
pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "weg" } }], const assignments = [assignment("a", { manager_id: "someone-else" })];
const [result] = snapshot({ asOf: "2026-09-01", employees, assignments }).employees;
expect(result.manager_id).toBe("someone-else");
}); });
expect(result.employees.find((e) => e.id === "a")!.org_unit_id).toBe("team-a");
it("ignores pending changes for a date the caller did not ask about", () => {
// loadOrgAsOf only fetches pending rows for a future date, so an empty
// list here must simply mean "no projection", not "drop the employee".
const employees = [emp("a")];
const assignments = [assignment("a")];
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending: [] });
expect(result.projectedCount).toBe(0); expect(result.projectedCount).toBe(0);
expect(result.employees).toHaveLength(1);
}); });
}); });
describe("Vakanzen", () => { describe("historyStartsAt", () => {
it("meldet jede am Stichtag unbesetzte Planstelle", () => { it("reports the earliest recorded assignment so the UI can flag older dates", () => {
// Vakanz ist im OM-Modell kein eigenes Objekt, sondern das Komplement der const employees = [emp("a"), emp("b", { id: "b" })];
// Besetzungen — sie kann deshalb gar nicht mehr aus dem Tritt geraten. const assignments = [assignment("a", { valid_from: "2023-05-01" }), assignment("b", { valid_from: "2021-02-01" })];
const result = snapshot({ expect(snapshot({ asOf: "2026-01-01", employees, assignments }).historyStartsAt).toBe("2021-02-01");
asOf: "2026-06-01",
employees: [emp("a")],
assignments: [{ employee_id: "a", position_id: "p-a1" }],
}); });
expect(result.vacancies.map((v) => v.position_id).sort()).toEqual(["p-b1", "p-bl", "p-gf", "p-tl-a", "p-tl-b"]);
expect(result.vacancies.find((v) => v.position_id === "p-tl-a")!.is_chief).toBe(true); it("is null when nothing is recorded yet", () => {
expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull();
}); });
}); });

View File

@@ -18,7 +18,8 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
first_name: "Maria", first_name: "Maria",
last_name: "Gruber", last_name: "Gruber",
job_title: "Maschinenbediener:in", job_title: "Maschinenbediener:in",
org_unit_id: "team-1", division_id: "div-1",
team_id: "team-1",
location_id: "loc-1", location_id: "loc-1",
status: "Aktiv", status: "Aktiv",
employment_type: "Vollzeit", employment_type: "Vollzeit",
@@ -42,16 +43,9 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
}; };
} }
// Alle drei sind über die Einheit geschlüsselt, in der die Person sitzt:
// "team-1" hängt unter der Abteilung Fertigung im Bereich Produktion.
// "div-1" ist der Bereich selbst — dort sitzt nur die Bereichsleitung, die
// weder Abteilung noch Team hat.
const lookups: OrgLookups = { const lookups: OrgLookups = {
divisionName: new Map([ divisionName: new Map([["div-1", "Produktion"]]),
["team-1", "Produktion"], departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
["div-1", "Produktion"],
]),
departmentName: new Map([["team-1", "Fertigung"]]),
teamName: new Map([["team-1", "Montage"]]), teamName: new Map([["team-1", "Montage"]]),
locationName: new Map([["loc-1", "Wien-Hernals"]]), locationName: new Map([["loc-1", "Wien-Hernals"]]),
}; };
@@ -71,23 +65,14 @@ describe("groupKeyFor", () => {
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals"); expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
}); });
it("falls back to a dash for department/team for somebody sitting at the division itself", () => { it("falls back to a dash for department/team when the employee has no team", () => {
// Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher const e = emp({ team_id: null });
// liess sich das nur über team_id = null ausdrücken.
const e = emp({ org_unit_id: "div-1" });
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
expect(groupKeyFor(e, "department", lookups)).toBe(""); expect(groupKeyFor(e, "department", lookups)).toBe("");
expect(groupKeyFor(e, "team", lookups)).toBe(""); expect(groupKeyFor(e, "team", lookups)).toBe("");
}); });
it("falls back to a dash when somebody held no position at all", () => {
const e = emp({ org_unit_id: null });
expect(groupKeyFor(e, "division", lookups)).toBe("");
expect(groupKeyFor(e, "team", lookups)).toBe("");
});
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => { it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
const e = emp({ org_unit_id: "ghost", location_id: "ghost" }); const e = emp({ division_id: "ghost", location_id: "ghost" });
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt"); expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt"); expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
}); });
@@ -181,17 +166,15 @@ describe("measureValue", () => {
describe("aggregateReport", () => { describe("aggregateReport", () => {
it("groups rows, sorts groups descending by value, and never includes a salary field", () => { it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
const employees = [ const employees = [
emp({ id: "1", org_unit_id: "team-1" }), emp({ id: "1", division_id: "div-1" }),
emp({ id: "2", org_unit_id: "team-1" }), emp({ id: "2", division_id: "div-1" }),
emp({ id: "3", org_unit_id: "team-2" }), emp({ id: "3", division_id: "div-2" }),
]; ];
// Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt
// unter Produktion, team-2 unter IT.
const multiDivisionLookups: OrgLookups = { const multiDivisionLookups: OrgLookups = {
...lookups, ...lookups,
divisionName: new Map([ divisionName: new Map([
["team-1", "Produktion"], ["div-1", "Produktion"],
["team-2", "IT"], ["div-2", "IT"],
]), ]),
}; };
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups); const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
@@ -246,7 +229,7 @@ describe("aggregateReport", () => {
}); });
it("sorts a weekday split chronologically within each group", () => { it("sorts a weekday split chronologically within each group", () => {
const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })]; const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })];
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups); const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]); expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
}); });
@@ -276,8 +259,8 @@ describe("aggregateEvents", () => {
const eventLookups: OrgLookups = { const eventLookups: OrgLookups = {
...lookups, ...lookups,
divisionName: new Map([ divisionName: new Map([
["team-1", "Produktion"], ["div-1", "Produktion"],
["team-2", "IT"], ["div-2", "IT"],
]), ]),
}; };
@@ -287,7 +270,8 @@ describe("aggregateEvents", () => {
first_name: "Maria", first_name: "Maria",
last_name: "Gruber", last_name: "Gruber",
job_title: "Maschinenbediener:in", job_title: "Maschinenbediener:in",
org_unit_id: "team-1", division_id: "div-1",
team_id: "team-1",
location_id: "loc-1", location_id: "loc-1",
event_date: "2026-03-01", event_date: "2026-03-01",
event_type: "Eintritt", event_type: "Eintritt",
@@ -297,14 +281,14 @@ describe("aggregateEvents", () => {
} }
it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => { it("counts every event, unlike a Bestand headcount which only sees the current entry_date", () => {
const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", org_unit_id: "team-2" })]; const events = [ev({ employee_id: "1" }), ev({ employee_id: "1", event_date: "2026-05-01", event_type: "Beförderung" }), ev({ employee_id: "2", division_id: "div-2" })];
const rows = aggregateEvents(events, "event_type", null, eventLookups); const rows = aggregateEvents(events, "event_type", null, eventLookups);
expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 }); expect(rows.find((r) => r.key === "Eintritt")).toMatchObject({ value: 2, count: 2 });
expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 }); expect(rows.find((r) => r.key === "Beförderung")).toMatchObject({ value: 1, count: 1 });
}); });
it("groups by the affected employee's org unit and preserves the event description on drill-down", () => { it("groups by the affected employee's org unit and preserves the event description on drill-down", () => {
const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })]; const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })];
const rows = aggregateEvents(events, "division", null, eventLookups); const rows = aggregateEvents(events, "division", null, eventLookups);
const produktion = rows.find((r) => r.key === "Produktion")!; const produktion = rows.find((r) => r.key === "Produktion")!;
expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" }); expect(produktion.people[0]).toMatchObject({ id: "1", title: "Eintritt als Maschinenbediener:in", entry_date: "2026-03-01" });