diff --git a/actions/employees.ts b/actions/employees.ts index 6bb9933..987e98d 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; @@ -65,8 +64,8 @@ export async function terminateEmployee(payload: { export async function transferEmployee(payload: { employee_id: string; effective_date: string; - new_team_id: string; - new_title?: string; + /** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */ + target_position_id: string; }): Promise { return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); } @@ -153,23 +152,3 @@ export async function addEmployeeNote(payload: { export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise { 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 { - 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 ?? []; -} diff --git a/actions/positions.ts b/actions/positions.ts index d06b6ff..2e43393 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -1,7 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; -import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; type ActionResult = { success: boolean; error?: string }; @@ -21,10 +20,9 @@ async function callRpc( } export async function createPosition(payload: { - title: string; - superior_employee_id: string; - is_lead: boolean; - team_id?: string; + org_unit_id: string; + job_title: string; + is_chief: boolean; valid_from: string; }): Promise { return callRpc("create_position", payload, POSITION_PATHS); @@ -34,23 +32,3 @@ export async function deletePosition(positionId: string): Promise 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 { - 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 ?? []; -} diff --git a/actions/reorg.ts b/actions/reorg.ts deleted file mode 100644 index cfc6b84..0000000 --- a/actions/reorg.ts +++ /dev/null @@ -1,37 +0,0 @@ -"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 { - 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 { - 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 }; -} diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 711f14a..7003c7a 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -1,40 +1,35 @@ import { notFound } from "next/navigation"; 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 type { Database } from "@/lib/supabase/types"; 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) { const { id } = await params; const supabase = await createClient(); + const today = todayIso(); - // Everything here keys off the id already in the URL, and the manager - // comes back as an embedded resource on the employee row rather than as a - // 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. + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen + // aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es + // wandern also neun Zeilen über die Leitung und nicht achthundert. const [ - { data: employeeRow }, - { data: directReports }, + { data: employee }, + { data: ownLine }, + { data: reportLines }, { data: history }, { data: dependents }, { data: notes }, - { data: divisions }, - { data: departments }, - { data: teams }, - { data: locations }, - { data: openPositions }, + orgMaps, + placements, + openPositions, ] = await Promise.all([ - supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(), - supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"), + supabase.from("employees").select("*").eq("id", id).single(), + supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(), + supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id), supabase .from("employee_history") .select("*") @@ -43,32 +38,61 @@ export default async function EmployeeDetailPage({ params }: PageProps) { .order("created_at", { ascending: false }), 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("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - 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"), + loadOrgMaps(supabase), + loadPlacements(supabase, { asOf: today, employeeIds: [id] }), + loadOpenPositions(supabase), ]); - if (!employeeRow) notFound(); + if (!employee) notFound(); - // Split the embedded manager back off so EmployeeDetail keeps receiving a - // plain employees row plus a separate manager, unchanged. - const { manager, ...employee } = employeeRow as EmployeeWithManager; + const line = ownLine as ReportingLine | null; + const reports = (reportLines ?? []) as ReportingLine[]; + + // 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 ( { + const e = byId.get(r.employee_id); + return e ? [e] : []; + })} history={history ?? []} dependents={dependents ?? []} notes={notes ?? []} - divisions={divisions ?? []} - departments={departments ?? []} - teams={teams ?? []} - locations={locations ?? []} - openPositions={openPositions ?? []} + locations={orgMaps.locationList} + openPositions={openPositions} /> ); } diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 7051d7e..eeae0ed 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -7,13 +7,28 @@ import { Pagination } from "@/components/ui/Pagination"; import { StatusChip } from "@/components/ui/StatusChip"; import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, todayIso } from "@/lib/format"; -import { breadcrumbFor, loadOrgMaps } from "@/lib/org"; +import { loadPlacements } from "@/lib/placement"; +import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org"; import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { EmploymentStatus } from "@/lib/supabase/types"; 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 EmployeesPageProps = { @@ -37,48 +52,73 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps const page = Math.max(1, Number(params.page ?? "1") || 1); const from = (page - 1) * PAGE_SIZE; const to = from + PAGE_SIZE - 1; + const today = todayIso(); - let query = supabase - .from("employees") - .select( - "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" } - ) - .order("last_name", { ascending: true }) - .range(from, to); + // Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum + // braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die + // Einheit selbst — dort sitzt nur die Bereichsleitung. + const orgMaps = await loadOrgMaps(supabase); + + // Nach Organisationseinheit gefiltert wird über die laufende Besetzung. + // `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung + // die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu + // 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 // status set it counted rather than a narrower one. const statuses = (params.status ?? "") .split(",") .map((s) => s.trim()) .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); - // Derived from the dates, not read off employees.status — see - // lib/employee-status-filter.ts for why the two can disagree. - query = applyDerivedStatusFilter(query, statuses, todayIso()); - if (params.location) query = query.eq("location_id", params.location); - // The org lookup tables are needed only to label the rows, so they load - // alongside the page of employees instead of before it — one round trip - // saved on a page that is otherwise two fast queries. - const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]); + // 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(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 + // lib/employee-status-filter.ts for why the two can disagree. + q = applyDerivedStatusFilter(q, statuses, today); + if (params.location) q = q.eq("location_id", params.location) as Q; + return q; + } + + const { data: employeesData, count } = unitFilter + ? await applyFilters( + supabase + .from("employees") + .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 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 (
- +

{count ?? 0} Mitarbeiter:innen gefunden

@@ -97,7 +137,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps {employees.map((e) => { - const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id); + const placement = placements.get(e.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; return ( // border-subtle between rows: the full-strength border made @@ -113,7 +155,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
{e.first_name} {e.last_name}
-
{e.job_title}
+
{placement?.jobTitle ?? e.job_title}
@@ -122,7 +164,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps {e.personnel_number}
{division?.name ?? "–"}
-
{team?.name ?? "–"}
+ {/* Die eigene Einheit, egal auf welcher Ebene sie hängt — + eine Bereichsleitung sitzt am Bereich, nicht an einem + Team, und stand vorher deshalb ohne Zuordnung da. */} +
+ {unit && unit.id !== division?.id ? unit.name : "–"} +
{location?.name ?? "–"} {fmtDate(e.entry_date)} diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index cccb90a..6f288de 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -1,8 +1,8 @@ import { Suspense } from "react"; import { OrgChartClient } from "@/components/orgchart/OrgChartClient"; +import type { OrgUnitNode } from "@/components/orgchart/types"; import { todayIso } from "@/lib/format"; import { loadOrgAsOf } from "@/lib/orgchart-data"; -import { loadOpenPositions } from "@/lib/positions"; import { parseIsoDateParam } from "@/lib/reports"; import { createClient } from "@/lib/supabase/server"; @@ -20,30 +20,17 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro const supabase = await createClient(); - const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] = - await Promise.all([ - loadOrgAsOf(supabase, asOf), - 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), - ]); + const [org, { data: units }] = await Promise.all([ + loadOrgAsOf(supabase, asOf), + supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), + ]); return ( supabase .from("employees") - .select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date") + .select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date") .order("id") ), // Entries/exits count history events, which is what the linked report @@ -100,8 +104,9 @@ export default async function DashboardPage() { .eq("event_type", "Austritt") .gte("event_date", yearStart) .lte("event_date", yearEnd), - supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"), - supabase.from("divisions").select("id, name"), + loadOpenPositions(supabase), + loadOrgMaps(supabase), + loadPlacements(supabase, { asOf: today }), supabase .from("employees") .select("id, first_name, last_name, entry_date") @@ -143,12 +148,18 @@ export default async function DashboardPage() { const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length; 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(); for (const row of activeStaff) { - if (!row.division_id) continue; - headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1); + const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId); + if (!division) continue; + headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1); } - const divisionBars = (divisionsRes.data ?? []) + const divisionBars = orgMaps.unitList + .filter((u) => u.unit_type === "Bereich") .map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 })) .sort((a, b) => b.count - a.count); const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count)); @@ -214,7 +225,7 @@ export default async function DashboardPage() { href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, - { label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" }, + { label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, ]; return ( diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index 4404a58..c942a4a 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -1,21 +1,32 @@ +import type { UnitOption } from "@/components/positions/CreatePositionModal"; import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; -import { daysBetweenIso, toIsoDate } from "@/lib/format"; +import { daysBetweenIso } from "@/lib/format"; +import { loadOrgMaps } from "@/lib/org"; import { loadOpenPositions } from "@/lib/positions"; import { createClient } from "@/lib/supabase/server"; export default async function PositionsPage() { const supabase = await createClient(); - // 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([ + const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([ loadOpenPositions(supabase), - supabase.from("teams").select("*").order("name"), + loadOrgMaps(supabase), + // 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 openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) })); + const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id)); + 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), + })); - return ; + const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) })); + + return ; } diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index ad17ecb..283f7eb 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -1,6 +1,9 @@ import { NextResponse, type NextRequest } from "next/server"; import { statusLabel } from "@/lib/absence"; 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 { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/supabase/auth"; @@ -8,7 +11,14 @@ import { fetchAllRows } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types"; -type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; +// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt +// ü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 // pivot report groups by. Respects the same division/location/status/ @@ -32,24 +42,43 @@ export async function GET(request: NextRequest) { const statuses = parseStatuses(filters.status); + const stichtag = asOf ?? todayIso(); + function employeeQuery() { 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.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (!asOf) query = query.in("status", statuses); return query; } - const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([ + const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([ fetchAllRows(employeeQuery), loadOrgLookups(supabase), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")), 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 rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees; + // Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein + // 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 filename = exportFilename("mitarbeiter-export", format); @@ -81,14 +110,14 @@ function employeeExportColumns( { header: "Wohnsitzland", get: (e) => e.address_country }, { header: "E-Mail", get: (e) => e.email }, { header: "Telefon", get: (e) => e.phone }, - { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, - { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, - { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, + { header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") }, { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, { header: "Position", get: (e) => e.job_title }, { header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") }, - { header: "Führungskraft", get: (e) => e.is_lead }, - { header: "Org-Level", get: (e) => e.org_level }, + { header: "Planstelle", get: (e) => e.position_number }, + { header: "Leitungsplanstelle", get: (e) => e.is_chief }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Wochenstunden", get: (e) => e.weekly_hours }, // work_days is stored in click order (see RoleEmploymentFields), not diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 3e24266..8a03382 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -44,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn[] { { header: "Vorname", get: (e) => e.first_name }, { header: "Nachname", get: (e) => e.last_name }, { header: "Position", get: (e) => e.job_title }, - { header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" }, - { header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") }, - { header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") }, + { header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") }, + { header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") }, + { header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") }, { header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" }, { header: "Beschreibung", get: (e) => e.description }, ]; diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index 3101577..a846a3f 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar"; import { Button } from "@/components/ui/Button"; import { StatusChip } from "@/components/ui/StatusChip"; import { fmtFullName, tenure } from "@/lib/format"; +import type { OpenPositionResolved } from "@/lib/positions"; import type { Database } from "@/lib/supabase/types"; import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { KarenzPanel } from "./panels/KarenzPanel"; @@ -21,42 +22,37 @@ import { StammdatenTab } from "./tabs/StammdatenTab"; import { VertragTab } from "./tabs/VertragTab"; 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 HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; type Dependent = Database["public"]["Tables"]["employee_dependents"]["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 OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean }; +/** Die Planstelle, die die Person heute innehat. */ +type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean }; type EmployeeDetailProps = { employee: EmployeeRow; + placement: PlacementInfo | null; + breadcrumb: string; manager: MiniEmployee | null; + /** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */ + formalManager: MiniEmployee | null; directReports: MiniEmployee[]; history: HistoryRow[]; dependents: Dependent[]; notes: NoteRow[]; - divisions: Division[]; - departments: Department[]; - teams: Team[]; locations: Location[]; - openPositions: OpenPosition[]; + openPositions: OpenPositionResolved[]; }; type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const; export function EmployeeDetail(props: EmployeeDetailProps) { - const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props; + const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten"); const [panel, setPanel] = useState(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 isActive = employee.status === "Aktiv" || employee.status === "Karenz"; @@ -79,8 +75,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) { -

{employee.job_title}

-

{breadcrumb}

+

{placement?.jobTitle ?? employee.job_title}

+

+ {breadcrumb} + {placement && ( + <> + {" · Planstelle "} + {placement.positionNumber} + {placement.isChief && " (Leitung)"} + + )} +

Pers.-Nr. {employee.personnel_number} {employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}} @@ -142,7 +147,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) { {tab === "Stammdaten" && } {tab === "Vertrag" && } {tab === "Organisation" && ( - + )} {tab === "Historie" && } {tab === "HR-Notizen" && } @@ -152,10 +163,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { open={panel === "transfer"} onClose={() => setPanel(null)} employee={employee} - divisions={divisions} - departments={departments} - teams={teams} - currentTeamId={employee.team_id} + openPositions={openPositions} /> setPanel(null)} employee={employee} /> setPanel(null)} employee={employee} /> diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 11d22bc..d29fbc5 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; import { SearchInput } from "@/components/ui/SearchInput"; type EmployeeFiltersProps = { - divisions: { id: string; name: string }[]; + /** Der ganze Baum, in Tiefensuche-Reihenfolge. */ + units: { id: string; name: string; unit_type: string }[]; + depthOf: Map; locations: { id: string; name: string }[]; }; @@ -22,7 +24,7 @@ const STATUS_OPTIONS = [ { value: "Ausgetreten", label: "Ausgetreten" }, ] as const; -export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) { +export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -55,18 +57,26 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {/* 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 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. */} { - setIsLead(e.target.checked); - setSuperior(null); - }} - /> - Führungsposition (Teamleitung) + setIsChief(e.target.checked)} /> + Leitungsplanstelle für diese Einheit - {!superior ? ( - - {(p) => ( - - {...p} - placeholder="Name oder Titel…" - onSearch={(q) => searchSuperiors(q, isLead)} - onSelect={setSuperior} - renderResult={(r) => ( -

-
- {r.first_name} {r.last_name} -
-
{r.job_title}
-
- )} - /> - )} - - ) : ( -
-

- {isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} -

-
-
-
- {superior.first_name} {superior.last_name} -
-
{superior.job_title}
-
- -
-
- )} - {isLead && ( - ({ value: t.id, label: t.name }))} - /> + {chiefTaken && ( +

Für {unit?.name} besteht bereits eine Leitungsplanstelle.

)} diff --git a/components/positions/PositionsPageClient.tsx b/components/positions/PositionsPageClient.tsx index 71dac4c..aa59535 100644 --- a/components/positions/PositionsPageClient.tsx +++ b/components/positions/PositionsPageClient.tsx @@ -8,16 +8,16 @@ import { Button } from "@/components/ui/Button"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, todayIso } from "@/lib/format"; import type { OpenPositionResolved } from "@/lib/positions"; -import { CreatePositionModal } from "./CreatePositionModal"; +import { CreatePositionModal, type UnitOption } from "./CreatePositionModal"; type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number }; type PositionsPageClientProps = { openPositions: OpenPositionWithDays[]; - teams: { id: string; org_number: string; name: string; department_id: string }[]; + units: UnitOption[]; }; -export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) { +export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) { const { showToast } = useToast(); const router = useRouter(); const [createOpen, setCreateOpen] = useState(false); @@ -29,7 +29,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien const result = await deletePosition(id); setDeletingId(null); if (result.success) { - showToast("Position gelöscht."); + showToast("Planstelle entfernt."); router.refresh(); } else { showToast(result.error ?? "Fehler beim Löschen.", "error"); @@ -40,14 +40,14 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
-

Offene Positionen ({openPositions.length})

+

Unbesetzte Planstellen ({openPositions.length})

{openPositions.length === 0 ? ( -

Derzeit keine offenen Positionen.

+

Derzeit ist jede Planstelle besetzt.

) : (
{openPositions.map((p) => { @@ -60,7 +60,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien variant="icon" onClick={() => handleDelete(p.id)} pending={deletingId === p.id} - aria-label={`Position ${p.title} löschen`} + aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`} className="-mr-1 -mt-1 hover:!text-danger-solid" > @@ -69,7 +69,11 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
{p.position_number} · {p.orgLabel}
-
seit {p.daysOpen} Tagen offen
+
+ {p.is_chief ? "Leitungsplanstelle · " : ""} + seit {p.daysOpen} Tagen unbesetzt +
+ {p.managerName &&
berichtet an {p.managerName}
} {notYetValid &&
Gültig ab {fmtDate(p.valid_from)}
}
); @@ -78,7 +82,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien )}
- setCreateOpen(false)} teams={teams} /> + setCreateOpen(false)} units={units} />
); } diff --git a/lib/org.ts b/lib/org.ts index 3c16e70..f5675a8 100644 --- a/lib/org.ts +++ b/lib/org.ts @@ -1,48 +1,119 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "./supabase/types"; -type Division = Database["public"]["Tables"]["divisions"]["Row"]; -type Department = Database["public"]["Tables"]["departments"]["Row"]; -type Team = Database["public"]["Tables"]["teams"]["Row"]; +// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher +// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt +// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte +// 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"]; export type OrgMaps = { - divisions: Map; - departments: Map; - teams: Map; + units: Map; + /** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */ + unitList: OrgUnit[]; + /** Abstand zur Wurzel; die Wurzel selbst hat 0. */ + depthOf: Map; + childrenOf: Map; locations: Map; - divisionList: Division[]; locationList: Location[]; }; -// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5 -// locations) — fetched whole and joined client-side rather than per-row. +// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden +// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen. export async function loadOrgMaps(supabase: SupabaseClient): Promise { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - supabase.from("divisions").select("*").order("name"), - supabase.from("departments").select("*"), - supabase.from("teams").select("*"), + const [{ data: units }, { data: locations }] = await Promise.all([ + supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"), 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(); + 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(); + 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 { - divisions: new Map((divisions ?? []).map((d) => [d.id, d])), - departments: new Map((departments ?? []).map((d) => [d.id, d])), - teams: new Map((teams ?? []).map((t) => [t.id, t])), - locations: new Map((locations ?? []).map((l) => [l.id, l])), - divisionList: divisions ?? [], - locationList: locations ?? [], + units: new Map(units.map((u) => [u.id, u])), + unitList, + depthOf, + childrenOf, + locations: new Map(locations.map((l) => [l.id, l])), + locationList: locations, }; } -export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) { - const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined; - const team = teamId ? orgMaps.teams.get(teamId) : undefined; - const department = team ? orgMaps.departments.get(team.department_id) : undefined; - return { division, department, team }; +/** Wurzel zuerst, die Einheit selbst zuletzt. */ +export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] { + const chain: OrgUnit[] = []; + const seen = new Set(); + let current = unitId ? maps.units.get(unitId) : undefined; + 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; } -export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string { - const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId); - return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–"; +/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */ +export function subtreeOf(maps: OrgMaps, unitId: string): string[] { + const out: string[] = []; + const queue = [unitId]; + const seen = new Set(); + 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; } diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index 3436f2c..480efde 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -1,32 +1,35 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import type { OrgEmployee } from "@/components/orgchart/types"; -import { resolveActingManagers } from "./acting-manager"; +import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; import { todayIso } from "./format"; -import { deriveStatusAsOf } from "./reports"; +import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; import { fetchAllRows } from "./supabase/query"; import type { Database } from "./supabase/types"; -// 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: +// Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // -// past/today employee_assignments — the interval covering `asOf` -// future pending_org_changes — effective-dated moves not yet applied -// membership entry/exit/karenz — who counted as staff on that date +// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den +// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die +// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die +// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte. // -// See supabase/migrations/*_employee_assignment_history.sql for why the -// placement timeline is captured by a trigger rather than per-RPC. +// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also +// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche +// 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. -const ORG_COLUMNS = - "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; +/** Änderungsarten, die jemanden in der Organisation verschieben. */ +const PLACEMENT_CHANGES = ["transfer"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; - /** How many placements were projected from not-yet-applied changes. */ + vacancies: OrgVacancy[]; + /** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */ projectedCount: number; - /** Earliest date the assignment history actually covers. */ + /** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ historyStartsAt: string | null; }; @@ -36,196 +39,172 @@ type EmployeeRow = { first_name: string; last_name: 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_return_date: string | null; absence_type: string | null; }; -type AssignmentRow = { - 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; +type PositionRow = { + id: string; + position_number: string; + org_unit_id: string; + is_chief: boolean; + jobs: { title: string }; }; +type AssignmentRow = { employee_id: string; position_id: string }; + type PendingRow = { employee_id: string; effective_date: string; payload: Record }; -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, asOf: string): Promise { const today = todayIso(); - const [allEmployees, assignments, teams, departments, pending] = await Promise.all([ - fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")), + const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ + fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), fetchAllRows(() => supabase - .from("employee_assignments") - .select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from") + .from("om_positions") + .select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") + .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) .or(`valid_to.is.null,valid_to.gt.${asOf}`) .order("employee_id") ), - fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")), - fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")), + fetchAllRows(() => + supabase + .from("employees") + .select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") + .order("id") + ), asOf > today ? fetchAllRows(() => supabase .from("pending_org_changes") - .select("employee_id, change_type, effective_date, payload") + .select("employee_id, effective_date, payload") .eq("status", "pending") .lte("effective_date", asOf) .in("change_type", [...PLACEMENT_CHANGES]) .order("effective_date") ) : Promise.resolve([]), + supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(), ]); - return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending }); + return resolveOrgSnapshot({ + 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 -// snapshot, with no Supabase client in sight, so the reconciliation rules can -// be tested directly. +/** + * Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die + * Regeln direkt prüfbar sind. + */ export function resolveOrgSnapshot({ asOf, - employees: allEmployees, + units, + positions, assignments, - teams, - departments, + employees: allEmployees, pending, + historyStartsAt, }: { asOf: string; - employees: EmployeeRow[]; + units: OmUnit[]; + positions: PositionRow[]; assignments: AssignmentRow[]; - teams: { id: string; department_id: string }[]; - departments: { id: string; division_id: string }[]; + employees: EmployeeRow[]; pending: PendingRow[]; + historyStartsAt: string | null; }): OrgAsOfResult { - const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a])); - // 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] : []; - }) - ); + const positionById = new Map(positions.map((p) => [p.id, p])); + const employeeById = new Map(allEmployees.map((e) => [e.id, e])); - // Employed (or on leave) on that date — the same derivation the Berichte - // page uses, so the two can never disagree on who counted when. - const staff = allEmployees.filter((e) => { - const status = deriveStatusAsOf(e, asOf); - return status === "Aktiv" || status === "Karenz"; - }); + // Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), + // damit die drei nie auseinanderlaufen können. + const isAbsent = (e: EmployeeRow) => + e.karenz_start_date !== null && + e.karenz_start_date <= asOf && + (e.karenz_return_date === null || asOf < e.karenz_return_date); - const resolved = staff.map((e) => { - const a = assignmentByEmployee.get(e.id); - return { - employee: e, - managerId: a ? a.manager_id : e.manager_id, - placement: { - team_id: a ? a.team_id : e.team_id, - division_id: a ? a.division_id : e.division_id, - job_title: a ? a.job_title : e.job_title, - is_lead: a ? a.is_lead : e.is_lead, - org_level: a ? a.org_level : e.org_level, - } satisfies Placement, - }; - }); + const positionOf = new Map(); + for (const a of assignments) { + if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id); + } - // Project the future. Ordered by effective_date, so a later move wins. - const byId = new Map(resolved.map((r) => [r.employee.id, r])); + // Die Zukunft projizieren: nach effective_date sortiert, eine spätere + // Versetzung gewinnt. const moved = new Set(); for (const change of pending) { - const target = byId.get(change.employee_id); - if (!target) continue; - const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string }; - const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null; - if (newTeamId) { - target.placement.team_id = newTeamId; - 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; + 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); } - // 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 }))); + 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)!), + }); } - // 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; + const lines = resolveReportingLines(units, holders); - // 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 }; + const employees: OrgEmployee[] = lines.map((l) => { + const e = employeeById.get(l.employeeId)!; + const position = positionById.get(l.positionId)!; 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, + id: e.id, + personnel_number: e.personnel_number, + first_name: e.first_name, + last_name: e.last_name, + // Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: + // bei einer projizierten Versetzung ist nur die erste schon richtig. + job_title: position.jobs.title, + manager_id: l.actingManagerId, + // Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die + // 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, }; }); - const historyStartsAt = assignments.reduce( - (min, a) => (min === null || a.valid_from < min ? a.valid_from : min), - null - ); + // Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene + // Tabelle neben der Organisation; hier sind sie schlicht das Komplement. + const besetzt = new Set(positionOf.values()); + const vacancies: OrgVacancy[] = positions + .filter((p) => !besetzt.has(p.id)) + .map((p) => ({ + position_id: p.id, + position_number: p.position_number, + job_title: p.jobs.title, + org_unit_id: p.org_unit_id, + is_chief: p.is_chief, + })); - return { employees, projectedCount: moved.size, historyStartsAt }; + return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; } diff --git a/lib/placement.ts b/lib/placement.ts new file mode 100644 index 0000000..4dd7672 --- /dev/null +++ b/lib/placement.ts @@ -0,0 +1,115 @@ +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 { + const byEmployee = new Map(); + 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, + { asOf, employeeIds }: { asOf: string; employeeIds?: string[] } +): Promise> { + 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, + asOf: string +): Promise> { + 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])); +} diff --git a/lib/positions.ts b/lib/positions.ts index c2ea943..064097a 100644 --- a/lib/positions.ts +++ b/lib/positions.ts @@ -1,41 +1,112 @@ import type { SupabaseClient } from "@supabase/supabase-js"; -import { breadcrumbLabel, loadOrgMaps } from "./org"; +import { todayIso } from "./format"; +import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org"; +import { fetchAllRows } from "./supabase/query"; 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 = { id: string; position_number: string; title: string; - team_id: string; - division_id: string; - is_lead: boolean; - reports_to_employee_id: string | null; + org_unit_id: string; + is_chief: boolean; valid_from: string; - created_at: string; + /** Wer die Stelle nach der Berichtslinie führen wird. */ managerName: string | null; orgLabel: string; + /** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */ + vacantSince: string; }; -// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page. -export async function loadOpenPositions(supabase: SupabaseClient): Promise { - 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 }); +type PositionRow = { + 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 }[]; +}; - const managerIds = Array.from( - new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id))) - ); - 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}`])); - - return (positions ?? []).map((p) => ({ - ...p, - managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null, - orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id), - })); +/** + * 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): Promise { + const asOf = todayIso(); + + const [orgMaps, positions] = await Promise.all([ + loadOrgMaps(supabase), + 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 []; + + // Die Leitung der zuständigen Einheit — genau die Planstellen, die als + // Leitung markiert und laufend besetzt sind. + const chiefUnitIds = Array.from( + new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(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, + }; + }); } diff --git a/lib/reports-data.ts b/lib/reports-data.ts index e73ad8f..dbbf432 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -1,4 +1,6 @@ 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 { fetchAllRows } from "./supabase/query"; import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; @@ -7,6 +9,7 @@ import type { Database, EmploymentType, HistoryEventType } from "./supabase/type // what "the current view" means — same filters, same stichtag/event-window // rules. export type ReportFilters = { + /** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */ division?: string; location?: string; status?: string; @@ -16,33 +19,49 @@ export type ReportFilters = { export type SnapshotFilters = ReportFilters & { asOf?: 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(); + const departmentName = new Map(); + const teamName = new Map(); + + 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): Promise<{ lookups: OrgLookups; + orgMaps: OrgMaps; divisions: { id: string; name: string }[]; locations: { id: string; name: string }[]; }> { - const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ - 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 orgMaps = await loadOrgMaps(supabase); + const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); - const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name])); return { - lookups: { - divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])), - departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])), - teamName: new Map((teams ?? []).map((t) => [t.id, t.name])), - locationName: new Map((locations ?? []).map((l) => [l.id, l.name])), - }, - divisions: divisions ?? [], - locations: locations ?? [], + lookups: lookupsFromOrgMaps(orgMaps, locations), + orgMaps, + // Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — + // das, was im Altmodell „Bereich" hiess. Der Filter greift auf den + // ganzen Teilbaum. + divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), + locations, }; } const SNAPSHOT_EMPLOYEE_COLUMNS = - "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"; + "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"; // employee_id -> number of employee_dependents rows. Selects only the FK // column (no dependent PII needed) since only per-employee counts feed the @@ -56,58 +75,73 @@ export async function loadDependentsCounts(supabase: SupabaseClient): return counts; } -// 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. +// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { 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.employment) query = query.eq("employment_type", filters.employment as EmploymentType); return query; } - const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]); + const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ + fetchAllRows(snapshotQuery), + loadDependentsCounts(supabase), + loadPlacements(supabase, { asOf }), + filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), + ]); - const withDerivedStatus: ReportEmployee[] = data.map((e) => ({ - id: e.id, - first_name: e.first_name, - last_name: e.last_name, - job_title: e.job_title, - division_id: e.division_id, - team_id: e.team_id, - location_id: e.location_id, - status: deriveStatusAsOf(e, asOf), - employment_type: e.employment_type, - contract_type: e.contract_type, - entry_date: e.entry_date, - exit_date: e.exit_date, - weekly_hours: e.weekly_hours, - source: e.source, - paygrade: e.paygrade, - birth_date: e.birth_date, - gender: e.gender, - worker_type: e.worker_type, - collective_agreement: e.collective_agreement, - work_days: e.work_days, - is_betriebsrat: e.is_betriebsrat, - has_dienstwagen: e.has_dienstwagen, - is_laterale_fuehrung: e.is_laterale_fuehrung, - is_c_level: e.is_c_level, - dependents_count: dependentsCounts.get(e.id) ?? 0, - })); + // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst + // 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, + first_name: e.first_name, + last_name: e.last_name, + job_title: placement?.jobTitle ?? e.job_title, + org_unit_id: orgUnitId, + location_id: e.location_id, + status: deriveStatusAsOf(e, asOf), + employment_type: e.employment_type, + contract_type: e.contract_type, + entry_date: e.entry_date, + exit_date: e.exit_date, + weekly_hours: e.weekly_hours, + source: e.source, + paygrade: e.paygrade, + birth_date: e.birth_date, + gender: e.gender, + worker_type: e.worker_type, + collective_agreement: e.collective_agreement, + work_days: e.work_days, + is_betriebsrat: e.is_betriebsrat, + has_dienstwagen: e.has_dienstwagen, + is_laterale_fuehrung: e.is_laterale_fuehrung, + is_c_level: e.is_c_level, + dependents_count: dependentsCounts.get(e.id) ?? 0, + }); + } const statuses = parseStatuses(filters.status); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); } -// Ereignisse: employee_history has no division_id/team_id of its own, so -// this joins in the affected employee's *current* org placement (two plain -// queries, merged in JS — the hand-written Database type has no relational -// embedding metadata for a single nested-select query). +// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie +// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. +// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem +// Team stand, in das die Person nie versetzt worden war. // // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally @@ -125,25 +159,49 @@ export async function loadEventHistory(supabase: SupabaseClient, filte return query; } - const [history, employees] = await Promise.all([ + const [history, employees, assignments, orgMaps] = await Promise.all([ fetchAllRows(historyQuery), - fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")), + fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, 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(); + 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 events: ReportEvent[] = []; for (const h of history) { const emp = employeeById.get(h.employee_id); if (!emp) continue; - if (filters.division && emp.division_id !== filters.division) 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({ employee_id: emp.id, first_name: emp.first_name, last_name: emp.last_name, job_title: emp.job_title, - division_id: emp.division_id, - team_id: emp.team_id, + org_unit_id: unitId, location_id: emp.location_id, event_date: h.event_date, event_type: h.event_type, diff --git a/lib/reports.ts b/lib/reports.ts index 954f25e..24d70a3 100644 --- a/lib/reports.ts +++ b/lib/reports.ts @@ -81,8 +81,8 @@ export type ReportEmployee = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; status: string; employment_type: string; @@ -104,20 +104,26 @@ export type ReportEmployee = { 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 = { divisionName: Map; - departmentNameByTeam: Map; + departmentName: Map; teamName: Map; locationName: Map; }; // Reconstructs status as of any date from the columns that actually carry a // timeline (entry/exit/Karenz), rather than trusting `employees.status`, -// which only ever reflects *today*. Division/team/location still reflect the -// employee's *current* assignment — the schema has no history of org-unit -// changes over time, only free-text employee_history descriptions — so a -// stichtag report groups by today's org placement, not the placement as of -// that date. Documented in the UI rather than silently wrong. +// which only ever reflects *today*. +// +// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit +// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung +// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie +// 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( e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null }, asOf: string @@ -137,11 +143,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { switch (dim) { case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "status": @@ -256,7 +262,7 @@ export function aggregateReport( id: e.id, name: `${e.first_name} ${e.last_name}`, title: e.job_title, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.entry_date, })); const row: ReportRow = { key, value, count: rowsForGroup.length, people }; @@ -345,8 +351,8 @@ export type ReportEvent = { first_name: string; last_name: string; job_title: string; - division_id: string; - team_id: string | null; + /** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */ + org_unit_id: string | null; location_id: string; event_date: string; event_type: HistoryEventType; @@ -358,11 +364,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org case "event_type": return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type; case "division": - return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–"; case "department": - return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–"; case "team": - return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–"; case "location": return lookups.locationName.get(e.location_id) ?? "Unbekannt"; case "event_year": @@ -394,7 +400,7 @@ export function aggregateEvents( id: e.employee_id, name: `${e.first_name} ${e.last_name}`, title: e.description, - team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–", entry_date: e.event_date, })); const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people }; diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 724f655..2c80a9c 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -32,8 +32,6 @@ export type HistoryEventType = | "Reorganisation" | "Gehaltsanpassung" | "Rückkehr"; -export type PositionStatus = "open" | "filled"; -export type ReorgMoveKind = "emp" | "team" | "abt" | "dept"; export type PendingChangeType = | "transfer" | "promotion" @@ -51,21 +49,6 @@ type NoRelationships = { Relationships: [] }; export type Database = { public: { 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 & { Row: { id: string; name: string; country: string }; Insert: { id?: string; name: string; country: string }; @@ -119,13 +102,8 @@ export type Database = { address_country: string | null; email: string; phone: string | null; - team_id: string | null; - division_id: string; job_title: string; location_id: string; - manager_id: string | null; - org_level: number; - is_lead: boolean; employment_type: EmploymentType; weekly_hours: number; /** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */ @@ -168,13 +146,8 @@ export type Database = { address_country?: string | null; email: string; phone?: string | null; - team_id?: string | null; - division_id?: string; job_title: string; location_id: string; - manager_id?: string | null; - org_level?: number; - is_lead?: boolean; employment_type?: EmploymentType; weekly_hours?: number; contract_type?: ContractType; @@ -210,7 +183,6 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id: string | null; created_at: string; }; Insert: { @@ -219,7 +191,6 @@ export type Database = { event_date: string; event_type: HistoryEventType; description: string; - reorg_scenario_id?: string | null; created_at?: string; }; Update: Partial; @@ -276,37 +247,6 @@ export type Database = { }; Update: Partial; }; - 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; - }; hire_drafts: NoRelationships & { Row: { id: string; created_by: string | null; step: number; payload: Record; updated_at: string }; Insert: { id?: string; created_by?: string | null; step?: number; payload: Record; updated_at?: string }; @@ -340,34 +280,6 @@ export type Database = { }; Update: Partial; }; - reorg_scenarios: NoRelationships & { - Row: { - id: string; - name: string; - effective_date: string; - created_by: string | null; - applied: boolean; - applied_at: string | null; - undo_snapshot: Record | 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 | null; - created_at?: string; - }; - Update: Partial; - }; - reorg_moves: NoRelationships & { - Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record }; - Update: Partial; - }; pending_org_changes: NoRelationships & { Row: { id: string; @@ -375,7 +287,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id: string | null; status: PendingChangeStatus; created_by: string | null; created_at: string; @@ -387,7 +298,6 @@ export type Database = { change_type: PendingChangeType; effective_date: string; payload: Record; - reorg_scenario_id?: string | null; status?: PendingChangeStatus; created_by?: string | null; created_at?: string; @@ -395,11 +305,17 @@ export type Database = { }; Update: Partial; }; - // Written exclusively by trg_track_employee_assignment; RLS grants HR - // read access only, hence no Insert/Update shapes worth modelling. // ── SAP-OM-Modell ────────────────────────────────────────── // O: rekursiv über parent_id, unit_type ist nur ein Etikett. - org_units: NoRelationships & { + org_units: { + Relationships: [ + { + foreignKeyName: "org_units_parent_id_fkey"; + columns: ["parent_id"]; + referencedRelation: "org_units"; + referencedColumns: ["id"]; + }, + ]; Row: { id: string; org_number: string; @@ -428,9 +344,23 @@ export type Database = { Insert: { id?: string; code: string; title: string; created_at?: string }; Update: Partial; }; - // S: Planstelle. Heisst om_positions, weil `positions` noch die alte - // Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist. + // 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; @@ -452,7 +382,6 @@ export type Database = { created_at?: string; }; Update: Partial; - Relationships: []; }; // A008: Person besetzt Planstelle, zeitabhängig. position_assignments: { @@ -473,26 +402,23 @@ export type Database = { created_at?: string; }; Update: Partial; - // Einbettung auf die Planstelle, damit die Berichtslinie in einer - // Abfrage geladen werden kann. - Relationships: [{ foreignKeyName: "position_assignments_position_id_fkey"; columns: ["position_id"]; referencedRelation: "om_positions"; referencedColumns: ["id"] }]; - }; - employee_assignments: NoRelationships & { - Row: { - 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_to: string | null; - created_at: string; - }; - Insert: never; - Update: never; + // 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; @@ -510,12 +436,11 @@ export type Database = { delete_employee_dependent: { Args: { payload: Record }; Returns: void }; add_employee_note: { Args: { payload: Record }; Returns: string }; complete_employee_note: { Args: { payload: Record }; Returns: void }; + // Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf + // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. create_position: { Args: { payload: Record }; Returns: string }; delete_position: { Args: { payload: Record }; Returns: void }; - staff_position_internally: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; - apply_reorg: { Args: { payload: Record }; Returns: string }; - undo_reorg: { Args: { payload: Record }; Returns: void }; apply_due_pending_changes: { Args: Record; Returns: number }; om_reporting_lines: { Args: { p_as_of?: string }; diff --git a/supabase/migrations/20260727130000_om_cleanup_and_positions.sql b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql new file mode 100644 index 0000000..27398bf --- /dev/null +++ b/supabase/migrations/20260727130000_om_cleanup_and_positions.sql @@ -0,0 +1,140 @@ +-- 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. + +create or replace function next_position_number() +returns text language sql stable 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 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 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; diff --git a/supabase/seed.ts b/supabase/seed.ts index 80f0ee7..8588172 100644 --- a/supabase/seed.ts +++ b/supabase/seed.ts @@ -451,7 +451,6 @@ function newHireBase(jobTitle: string) { const employees: EmployeeRow[] = []; const history: HistoryRow[] = []; -const icPoolForStatusAssignment: EmployeeRow[] = []; function finalizeEmployee( base: ReturnType, @@ -749,8 +748,6 @@ async function insertInChunks(table: string, rows: Record[], ch // einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die // Fremdschlüsselprüfung erst nach dem Statement auswertet. const WIPE_ORDER = [ - "reorg_moves", - "reorg_scenarios", "pending_org_changes", "hire_drafts", "employee_notes", diff --git a/tests/integration/assignment-history.test.ts b/tests/integration/assignment-history.test.ts deleted file mode 100644 index c344eb1..0000000 --- a/tests/integration/assignment-history.test.ts +++ /dev/null @@ -1,140 +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, - 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; - 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 { - 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); - }); -}); diff --git a/tests/integration/authorization.test.ts b/tests/integration/authorization.test.ts index 8fca2dc..7dcc038 100644 --- a/tests/integration/authorization.test.ts +++ b/tests/integration/authorization.test.ts @@ -36,7 +36,9 @@ describe("HR-only access (is_hr_user gate)", () => { createdUsers.push(user); const client = await signInAs(user); - const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` }); + const { error } = await client + .from("org_units") + .insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" }); expect(error).not.toBeNull(); }); diff --git a/tests/integration/data-integrity.test.ts b/tests/integration/data-integrity.test.ts index 88fecc6..e2e44bd 100644 --- a/tests/integration/data-integrity.test.ts +++ b/tests/integration/data-integrity.test.ts @@ -2,11 +2,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, createHrUser, + createTestPosition, deleteTestEmployee, + deleteTestPosition, deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededTeam, + pickSeededUnit, signInAs, type TestUser, } from "./helpers"; @@ -20,22 +22,32 @@ import type { Database } from "@/lib/supabase/types"; describe("data integrity guards", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let teamA: { id: string }; + let unitA: { id: string }; const employeeIds: string[] = []; + const positionIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); + unitA = await pickSeededUnit(); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); + // Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine + // geteilte wäre nach der ersten besetzt. + async function freshPosition(): Promise { + const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(id); + return id; + } + async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> { - const employeeId = await hireTestEmployee(hrClient, teamA.id); + const employeeId = await hireTestEmployee(hrClient, await freshPosition()); employeeIds.push(employeeId); const karenzStartDate = isoDateOffset(-5); const { error } = await hrClient.rpc("start_karenz", { @@ -95,7 +107,7 @@ describe("data integrity guards", () => { }); it("rejects an employee_history row dated before the employee's entry_date", async () => { - const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) }); + const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) }); employeeIds.push(employeeId); const { error } = await adminClient.from("employee_history").insert({ @@ -109,7 +121,7 @@ describe("data integrity guards", () => { it("accepts an employee_history row dated exactly on the entry_date", async () => { const entryDate = isoDateOffset(-10); - const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate }); + const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate }); employeeIds.push(employeeId); const { error } = await adminClient.from("employee_history").insert({ diff --git a/tests/integration/effective-dating.test.ts b/tests/integration/effective-dating.test.ts index 0558b95..9263240 100644 --- a/tests/integration/effective-dating.test.ts +++ b/tests/integration/effective-dating.test.ts @@ -1,14 +1,16 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, + chiefOfUnit, createHrUser, + createTestPosition, deleteTestEmployee, + deleteTestPosition, deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededTeam, + pickSeededUnit, signInAs, - teamLeadId, type TestUser, } from "./helpers"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -16,60 +18,89 @@ import type { Database } from "@/lib/supabase/types"; // Deferred/effective-dated changes (supabase/migrations/20260714120200_ // effective_dating_rpcs.sql): a future "wirksam ab" date must queue a -// pending_org_changes row instead of writing to `employees` immediately; +// pending_org_changes row instead of writing immediately; // 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", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let teamA: { id: string }; - let teamB: { id: string }; + 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); - teamA = await pickSeededTeam(); - teamB = await pickSeededTeam(teamA.id); + unitA = await pickSeededUnit(); + unitB = await pickSeededUnit(unitA.id); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); - async function freshEmployee(teamId: string): Promise { - const id = await hireTestEmployee(hrClient, teamId); + /** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */ + async function freshPosition(unitId: string): Promise { + const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + return positionId; + } + + async function freshEmployee(unitId: string): Promise { + const id = await hireTestEmployee(hrClient, await freshPosition(unitId)); employeeIds.push(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 () => { - const employeeId = await freshEmployee(teamA.id); - const newLead = await teamLeadId(teamB.id); + const employeeId = await freshEmployee(unitA.id); + const target = await freshPosition(unitB.id); const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id }, + payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target }, }); expect(error).toBeNull(); - const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(employee?.team_id).toBe(teamB.id); - expect(employee?.manager_id).toBe(newLead); + const { data: assignment } = await adminClient + .from("position_assignments") + .select("position_id") + .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 () => { - const employeeId = await freshEmployee(teamA.id); - const newLead = await teamLeadId(teamB.id); + const employeeId = await freshEmployee(unitA.id); + const target = await freshPosition(unitB.id); const { error } = await hrClient.rpc("transfer_employee", { - payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id }, + payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target }, }); expect(error).toBeNull(); // Not written yet — this is the exact bug the migration fixes: a // future-dated transfer must not overwrite the live record today. - const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single(); - expect(unchanged?.team_id).toBe(teamA.id); + expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id); const { data: pending } = await adminClient .from("pending_org_changes") @@ -78,7 +109,7 @@ describe("effective-dated mutations", () => { .eq("change_type", "transfer") .single(); expect(pending?.status).toBe("pending"); - expect(pending?.payload.new_team_id).toBe(teamB.id); + expect(pending?.payload.target_position_id).toBe(target); // Fast-forward: simulate the effective date having arrived, then run // the same function the daily cron route calls. @@ -87,9 +118,14 @@ describe("effective-dated mutations", () => { expect(applyError).toBeNull(); expect(appliedCount).toBeGreaterThanOrEqual(1); - const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single(); - expect(employee?.team_id).toBe(teamB.id); - expect(employee?.manager_id).toBe(newLead); + const { data: assignment } = await adminClient + .from("position_assignments") + .select("position_id") + .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 .from("pending_org_changes") @@ -101,7 +137,7 @@ describe("effective-dated mutations", () => { }); it("promote_employee with a future date does not change job_title/paygrade until applied", async () => { - const employeeId = await freshEmployee(teamA.id); + const employeeId = await freshEmployee(unitA.id); const { error } = await hrClient.rpc("promote_employee", { payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" }, @@ -126,7 +162,7 @@ describe("effective-dated mutations", () => { }); it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => { - const employeeId = await freshEmployee(teamA.id); + const employeeId = await freshEmployee(unitA.id); const startDate = isoDateOffset(20); const returnDate = isoDateOffset(200); diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts index 3b5accb..4c5f0eb 100644 --- a/tests/integration/helpers.ts +++ b/tests/integration/helpers.ts @@ -55,31 +55,57 @@ export async function signInAs(user: TestUser): Promise return client; } -// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead -// employee works for read/mutation tests that don't care which one. -export async function pickSeededEmployee( - filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {} -): Promise<{ +// Aus dem Seed gezogen. Die Einordnung steht nicht mehr auf der Person, sie +// kommt über die laufende Besetzung — deshalb liefert das hier gleich die +// Planstelle und ihre Einheit mit. +export async function pickSeededEmployee(filter: Partial<{ status: EmploymentStatus; isChief: boolean }> = {}): Promise<{ id: string; - team_id: string | null; - division_id: string; - manager_id: string | null; status: string; + position_id: string; + org_unit_id: string; + is_chief: boolean; }> { - let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1); - if (filter.status) q = q.eq("status", filter.status); - if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead); + let q = adminClient + .from("position_assignments") + .select("employee_id, om_positions!inner(id, org_unit_id, is_chief), employees!inner(id, status)") + .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(); if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`); - return data; + const row = data as unknown as { + 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, + }; } -export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> { - const q = adminClient.from("teams").select("id").limit(2); - const { data, error } = await q; - if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`); - const match = data.find((t) => t.id !== excludeTeamId) ?? data[0]; - return match; +/** Eine Organisationseinheit vom Typ Team, nach Möglichkeit eine andere als die gegebene. */ +export async function pickSeededUnit(excludeUnitId?: string): Promise<{ id: string }> { + const { data, error } = await adminClient.from("org_units").select("id").eq("unit_type", "Team").limit(2); + if (error || !data?.length) throw new Error(`pickSeededUnit failed: ${error?.message}`); + return data.find((u) => u.id !== excludeUnitId) ?? data[0]; +} + +/** + * 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 }> { @@ -88,18 +114,19 @@ export async function pickSeededLocation(): Promise<{ id: string }> { return data; } -// The seeded org guarantees exactly one active team lead per team (§2's -// "reports-to" rule) — resolve_manager_for() relies on the same query. -export async function teamLeadId(teamId: string): Promise { +/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */ +export async function chiefOfUnit(orgUnitId: string): Promise { const { data, error } = await adminClient - .from("employees") - .select("id") - .eq("team_id", teamId) - .eq("is_lead", true) - .neq("status", "Ausgetreten") + .from("om_positions") + .select("position_assignments!inner(employee_id, valid_to)") + .eq("org_unit_id", orgUnitId) + .eq("is_chief", true) + .is("valid_to", null) + .is("position_assignments.valid_to", null) .maybeSingle(); - if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`); - return data?.id ?? null; + if (error) throw new Error(`chiefOfUnit(${orgUnitId}) failed: ${error.message}`); + const row = data as unknown as { position_assignments: { employee_id: string }[] } | null; + return row?.position_assignments[0]?.employee_id ?? null; } // YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads @@ -110,12 +137,13 @@ export function isoDateOffset(days: number): string { return d.toISOString().slice(0, 10); } -// Hires a throwaway employee into `teamId` via the real hire_employee RPC -// (not a raw insert) so every mutation test starts from a state the app -// itself can produce. Caller must clean up with deleteTestEmployee. +// Stellt eine Wegwerf-Person auf `positionId` ein — über die echte +// hire_employee-RPC, nicht per Insert, damit jeder Mutationstest von einem +// Zustand ausgeht, den die Anwendung selbst herstellen kann. Aufräumen mit +// deleteTestEmployee. export async function hireTestEmployee( hrClient: SupabaseClient, - teamId: string, + positionId: string, overrides: Partial> = {} ): Promise { const location = await pickSeededLocation(); @@ -125,8 +153,9 @@ export async function hireTestEmployee( gender: "w", birth_date: "1990-01-01", location_id: location.id, - team_id: teamId, - job_title: "Integrationstest-Rolle", + // Die Tätigkeit kommt aus dem Job der Planstelle; sie wird nicht + // mitgegeben, sonst könnten die beiden auseinanderlaufen. + position_id: positionId, entry_date: isoDateOffset(-30), source: "Extern", ...overrides, @@ -145,21 +174,19 @@ export async function deleteTestEmployee(employeeId: string): Promise { await adminClient.from("employees").delete().eq("id", employeeId); } -// Creates a throwaway open position via the real create_position RPC. -// Defaults to a non-lead position reporting to `superiorEmployeeId` (its -// team is derived from that employee's own team, same as the app does). -// 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. +// Legt eine Wegwerf-Planstelle in `orgUnitId` an, über die echte +// create_position-RPC. Die vorgesetzte Person wird nicht mehr angegeben — +// sie ergibt sich aus der Einheit. Aufräumen mit deleteTestPosition, und +// zwar *vor* den Personen, die darauf sassen. export async function createTestPosition( hrClient: SupabaseClient, - superiorEmployeeId: string, + orgUnitId: string, overrides: Partial> = {} ): Promise { const payload = { - title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`, - superior_employee_id: superiorEmployeeId, - is_lead: false, + org_unit_id: orgUnitId, + job_title: `Integrationstest-Tätigkeit-${randomUUID().slice(0, 8)}`, + is_chief: false, ...overrides, }; const { data, error } = await hrClient.rpc("create_position", { payload }); @@ -168,5 +195,7 @@ export async function createTestPosition( } export async function deleteTestPosition(positionId: string): Promise { - await adminClient.from("positions").delete().eq("id", positionId); + // Besetzungen hängen mit on delete cascade daran, der Job bleibt im + // Katalog — er ist geteilt und gehört keiner einzelnen Planstelle. + await adminClient.from("om_positions").delete().eq("id", positionId); } diff --git a/tests/integration/position-assignments.test.ts b/tests/integration/position-assignments.test.ts new file mode 100644 index 0000000..c7e4142 --- /dev/null +++ b/tests/integration/position-assignments.test.ts @@ -0,0 +1,216 @@ +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; + 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 { + const id = await createTestPosition(hrClient, orgUnitId); + positionIds.push(id); + return id; + } + + async function freshEmployee(positionId: string): Promise { + 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); + }); +}); diff --git a/tests/integration/positions.test.ts b/tests/integration/positions.test.ts index 2c50543..4cdd49c 100644 --- a/tests/integration/positions.test.ts +++ b/tests/integration/positions.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { adminClient, @@ -9,148 +8,202 @@ import { deleteTestUser, hireTestEmployee, isoDateOffset, - pickSeededLocation, - pickSeededTeam, + pickSeededUnit, signInAs, - teamLeadId, type TestUser, } from "./helpers"; import type { SupabaseClient } from "@supabase/supabase-js"; import type { Database } from "@/lib/supabase/types"; -// Position validity window + delete (supabase/migrations/20260716120000_position_validity_and_delete.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 -// external hire may assign an employee to a position before that date. -describe("position validity and delete", () => { +// Planstellenpflege im OM-Modell +// (supabase/migrations/20260727130000_om_cleanup_and_positions.sql). +// +// Eine Planstelle gehört zu einer Organisationseinheit, trägt eine Tätigkeit +// aus dem Job-Katalog und ist entweder Leitung oder nicht. Was früher an der +// 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 hrClient: SupabaseClient; - let teamA: { id: string }; - let superiorId: string; + let unit: { id: string }; const positionIds: string[] = []; const employeeIds: string[] = []; beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - teamA = await pickSeededTeam(); - superiorId = (await teamLeadId(teamA.id))!; + unit = await pickSeededUnit(); }); afterAll(async () => { - // Positions first: reports_to_employee_id / filled_by_employee_id - // reference employees(id) with no cascade. - for (const id of positionIds) await deleteTestPosition(id); + // Personen zuerst: die Besetzung hängt mit on delete cascade an der + // Planstelle, die Person selbst nicht. for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); - it("create_position records the given valid_from", async () => { + it("übernimmt das angegebene Gültig-ab", async () => { const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom }); + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: validFrom }); positionIds.push(positionId); - const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single(); + const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); expect(data?.valid_from).toBe(validFrom); }); - it("create_position defaults valid_from to today when omitted", async () => { - const positionId = await createTestPosition(hrClient, superiorId); + it("setzt Gültig-ab ohne Angabe auf heute", async () => { + const positionId = await createTestPosition(hrClient, unit.id); positionIds.push(positionId); - const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single(); + const { data } = await adminClient.from("om_positions").select("valid_from").eq("id", positionId).single(); expect(data?.valid_from).toBe(isoDateOffset(0)); }); - it("delete_position removes an open position", async () => { - const positionId = await createTestPosition(hrClient, superiorId); + it("hängt die Planstelle an die angegebene Einheit", async () => { + const positionId = await createTestPosition(hrClient, unit.id); + 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 } }); expect(error).toBeNull(); - const { data } = await adminClient.from("positions").select("id").eq("id", positionId).maybeSingle(); + const { data } = await adminClient.from("om_positions").select("id").eq("id", positionId).maybeSingle(); expect(data).toBeNull(); }); - it("delete_position rejects a filled position", async () => { - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-10) }); + it("weigert sich, eine besetzte Planstelle zu entfernen", async () => { + const positionId = await createTestPosition(hrClient, unit.id); positionIds.push(positionId); - const employeeId = await hireTestEmployee(hrClient, teamA.id); - employeeIds.push(employeeId); - - const { error: staffError } = await hrClient.rpc("staff_position_internally", { - payload: { position_id: positionId, employee_id: employeeId }, - }); - expect(staffError).toBeNull(); + employeeIds.push(await hireTestEmployee(hrClient, positionId)); const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); - expect(error?.message).toMatch(/Nur offene Positionen können gelöscht werden/); + expect(error?.message).toMatch(/besetzt/); }); - it("staff_position_internally rejects assigning to a position before its valid_from", async () => { - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(10) }); + 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, teamA.id); + const employeeId = await hireTestEmployee(hrClient, positionId); employeeIds.push(employeeId); - const { error } = await hrClient.rpc("staff_position_internally", { - payload: { position_id: positionId, employee_id: employeeId }, + await hrClient.rpc("terminate_employee", { + payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" }, }); - 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 }, - }); + const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } }); expect(error).toBeNull(); - }); - it("hire_employee rejects an entry_date before the position's valid_from", async () => { - const validFrom = isoDateOffset(10); - const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom }); - positionIds.push(positionId); - const location = await pickSeededLocation(); + const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle(); + expect(data?.valid_to).toBe(isoDateOffset(0)); - const { error } = await hrClient.rpc("hire_employee", { - payload: { - first_name: "Integrationstest", - last_name: `Person-${randomUUID().slice(0, 8)}`, - gender: "w", - birth_date: "1990-01-01", - location_id: location.id, - position_id: positionId, - entry_date: isoDateOffset(5), - source: "Extern", - }, - }); - expect(error?.message).toMatch(/Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn/); - }); - - 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 }); - positionIds.push(positionId); - const location = await pickSeededLocation(); - - const { data, error } = await hrClient.rpc("hire_employee", { - payload: { - first_name: "Integrationstest", - last_name: `Person-${randomUUID().slice(0, 8)}`, - gender: "w", - birth_date: "1990-01-01", - location_id: location.id, - position_id: positionId, - entry_date: validFrom, - source: "Extern", - }, - }); - expect(error).toBeNull(); - if (data) employeeIds.push(data); + const { data: history } = await adminClient.from("position_assignments").select("id").eq("position_id", positionId); + expect(history?.length).toBeGreaterThan(0); + }); +}); + +describe("Besetzung", () => { + let hrUser: TestUser; + let hrClient: SupabaseClient; + let unit: { id: string }; + const positionIds: string[] = []; + const employeeIds: string[] = []; + + beforeAll(async () => { + hrUser = await createHrUser({ active: true }); + hrClient = await signInAs(hrUser); + unit = await pickSeededUnit(); + }); + + afterAll(async () => { + for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); + await deleteTestUser(hrUser); + }); + + it("lässt eine Planstelle nicht zweimal laufend besetzen", async () => { + const positionId = await createTestPosition(hrClient, unit.id, { valid_from: isoDateOffset(-40) }); + positionIds.push(positionId); + employeeIds.push(await hireTestEmployee(hrClient, positionId)); + + await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/); + }); + + it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => { + // Der Titel wird bei der Einstellung nicht mitgegeben; sonst könnten + // Planstelle und Person unterschiedliche Tätigkeiten führen. + 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)); }); }); diff --git a/tests/integration/reorg.test.ts b/tests/integration/reorg.test.ts deleted file mode 100644 index c0ee192..0000000 --- a/tests/integration/reorg.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -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; - 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 { - 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(); - }); -}); diff --git a/tests/integration/svnr-validation.test.ts b/tests/integration/svnr-validation.test.ts index 6be5e0d..1a4189e 100644 --- a/tests/integration/svnr-validation.test.ts +++ b/tests/integration/svnr-validation.test.ts @@ -1,7 +1,19 @@ 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, pickSeededTeam, signInAs, type TestUser } from "./helpers"; +import { + adminClient, + createHrUser, + createTestPosition, + deleteTestEmployee, + deleteTestPosition, + deleteTestUser, + hireTestEmployee, + isoDateOffset, + pickSeededUnit, + signInAs, + type TestUser, +} from "./helpers"; // SVNR validation (supabase/migrations/20260725120000_svnr_validation.sql). // Enforced by a trigger, so these drive it through the real write paths and @@ -9,8 +21,9 @@ import { adminClient, createHrUser, deleteTestEmployee, deleteTestUser, hireTest describe("SVNR validation", () => { let hrUser: TestUser; let hrClient: SupabaseClient; - let team: { id: string }; + let unit: { id: string }; const employeeIds: string[] = []; + const positionIds: string[] = []; // 3·1 + 7·2 + 9·3 = 44; 010180 contributes 18; 62 mod 11 = 7 const VALID = "1237 010180"; @@ -25,16 +38,21 @@ describe("SVNR validation", () => { beforeAll(async () => { hrUser = await createHrUser({ active: true }); hrClient = await signInAs(hrUser); - team = await pickSeededTeam(); + unit = await pickSeededUnit(); }); afterAll(async () => { for (const id of employeeIds) await deleteTestEmployee(id); + for (const id of positionIds) await deleteTestPosition(id); await deleteTestUser(hrUser); }); async function hireAt(country: string, overrides: Record = {}): Promise { - const id = await hireTestEmployee(hrClient, team.id, { + // Eine eigene Planstelle je Einstellung: eine geteilte wäre nach der + // 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), birth_date: BIRTH_DATE, ...overrides, diff --git a/tests/unit/org.test.ts b/tests/unit/org.test.ts index 343ed69..a62d250 100644 --- a/tests/unit/org.test.ts +++ b/tests/unit/org.test.ts @@ -1,52 +1,102 @@ import { describe, expect, it } from "vitest"; -import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org"; -import type { Database } from "@/lib/supabase/types"; +import { ancestorsOf, breadcrumbLabel, buildOrgMaps, divisionOf, subtreeOf, type OrgUnit } from "@/lib/org"; -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"]; +// Der Baum ersetzt die drei festen Ebenen des Altmodells. Damit hängt an +// dieser Datei mehr als vorher: eine Einheit falsch verkettet, und ein Filter +// „Bereich Produktion" liefert stillschweigend zu wenig — nicht gar nichts, +// was auffallen würde. -const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" }; -const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" }; -const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" }; -const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" }; +const units: OrgUnit[] = [ + { id: "gmbh", org_number: "10000000", name: "Alpenwerk", parent_id: null, unit_type: "Gesellschaft" }, + { id: "prod", org_number: "20100000", name: "Produktion", parent_id: "gmbh", unit_type: "Bereich" }, + { id: "fert", org_number: "21100000", name: "Fertigung", parent_id: "prod", unit_type: "Abteilung" }, + { 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); -const orgMaps: OrgMaps = { - divisions: new Map([[division.id, division]]), - departments: new Map([[department.id, department]]), - teams: new Map([[team.id, team]]), - locations: new Map([[location.id, location]]), - divisionList: [division], - locationList: [location], -}; - -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"); +describe("buildOrgMaps", () => { + it("listet Eltern vor ihren Kindern", () => { + // Die Reihenfolge ist eine Zusage: der Filter im Mitarbeiterlisten-Select + // rückt danach ein, und ein Einfügen in die Datenbank verlässt sich darauf. + const position = new Map(maps.unitList.map((u, i) => [u.id, i])); + for (const u of maps.unitList) { + if (!u.parent_id) continue; + expect(position.get(u.parent_id)!, `${u.name} steht vor ${u.parent_id}`).toBeLessThan(position.get(u.id)!); + } }); - it("has no team/department for a division head with no team (team_id null)", () => { - const result = breadcrumbFor(orgMaps, division.id, null); - expect(result.division?.name).toBe("Produktion"); - expect(result.team).toBeUndefined(); - expect(result.department).toBeUndefined(); + it("misst die Tiefe ab der Wurzel", () => { + expect(maps.depthOf.get("gmbh")).toBe(0); + expect(maps.depthOf.get("prod")).toBe(1); + expect(maps.depthOf.get("mont")).toBe(3); + }); +}); + +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", () => { - it("joins division › department › team with the SAP-OM breadcrumb separator", () => { - expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion › Fertigung › Montage"); + it("lässt die Gesellschaft weg, die in jeder Zeile gleich wäre", () => { + expect(breadcrumbLabel(maps, "mont")).toBe("Produktion › Fertigung › Montage"); }); - it("omits missing segments instead of producing empty separators", () => { - expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion"); + it("endet bei der Einheit, an der die Person tatsächlich hängt", () => { + expect(breadcrumbLabel(maps, "prod")).toBe("Produktion"); }); - it("falls back to a dash when nothing resolves", () => { - expect(breadcrumbLabel(orgMaps, null, null)).toBe("–"); + it("fällt auf einen Strich zurück, wenn nichts auflösbar ist", () => { + expect(breadcrumbLabel(maps, null)).toBe("–"); + expect(breadcrumbLabel(maps, "gmbh")).toBe("–"); }); }); diff --git a/tests/unit/orgchart-data.test.ts b/tests/unit/orgchart-data.test.ts index d019b3e..610505e 100644 --- a/tests/unit/orgchart-data.test.ts +++ b/tests/unit/orgchart-data.test.ts @@ -1,27 +1,38 @@ import { describe, expect, it } from "vitest"; import { resolveOrgSnapshot } from "@/lib/orgchart-data"; -const DIV = "div-1"; -const TEAM_A = "team-a"; -const TEAM_B = "team-b"; +// Der Stand zu einem Stichtag. Im Altmodell mussten dafür drei Quellen +// versöhnt werden; im OM-Modell beantwortet die zeitabhängige Besetzung fast +// alles allein — was diese Datei deutlich kürzer macht, aber nicht +// überflüssig: die Projektion vorgemerkter Versetzungen und die Frage, wer am +// Stichtag überhaupt dazuzählte, entscheiden sich weiterhin hier. -type EmployeeInput = Parameters[0]["employees"][number]; -type AssignmentInput = Parameters[0]["assignments"][number]; +type Args = Parameters[0]; -function emp(id: string, overrides: Partial = {}): EmployeeInput { +const UNITS: Args["units"] = [ + { 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] { return { id, personnel_number: 1000, first_name: "Test", last_name: id, - 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, + job_title: "Freitext auf der Person", karenz_start_date: null, karenz_return_date: null, absence_type: null, @@ -29,141 +40,158 @@ function emp(id: string, overrides: Partial = {}): EmployeeInput }; } -function assignment(employeeId: string, overrides: Partial = {}): AssignmentInput { - return { - employee_id: employeeId, - manager_id: null, - team_id: TEAM_A, - division_id: DIV, - job_title: "Mitarbeiter:in", - is_lead: false, - org_level: 3, - valid_from: "2020-01-01", - ...overrides, - }; +function snapshot(args: Partial & { asOf: string }) { + return resolveOrgSnapshot({ + units: UNITS, + positions: POSITIONS, + assignments: [], + employees: [], + pending: [], + historyStartsAt: null, + ...args, + }); } -const TEAMS = [ - { id: TEAM_A, department_id: "dept-1" }, - { id: TEAM_B, department_id: "dept-2" }, -]; -const DEPARTMENTS = [ - { id: "dept-1", division_id: DIV }, - { id: "dept-2", division_id: "div-2" }, -]; - -function snapshot(args: Partial[0]> & { asOf: string }) { - return resolveOrgSnapshot({ employees: [], assignments: [], teams: TEAMS, departments: DEPARTMENTS, pending: [], ...args }); -} - -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); +describe("Zugehörigkeit zum Stichtag", () => { + it("zeigt nur, wer am Stichtag eine Planstelle innehatte", () => { + // Die Zugehörigkeit ist keine eigene Regel mehr: wer keine Planstelle + // hat, steht nicht in der Organisation. Ein-, Austritt und geplanter + // Eintritt stecken alle in der Gültigkeit der Besetzung. + const employees = [emp("a"), emp("b")]; + const result = snapshot({ + asOf: "2026-06-01", + employees, + assignments: [{ employee_id: "a", position_id: "p-a1" }], + }); + expect(result.employees.map((e) => e.id)).toEqual(["a"]); }); - it("excludes someone from their exit date onwards", () => { - const employees = [emp("a", { exit_date: "2026-06-30" })]; - expect(snapshot({ asOf: "2026-06-29", employees }).employees).toHaveLength(1); - expect(snapshot({ asOf: "2026-06-30", employees }).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); + it("übergeht eine Besetzung auf einer am Stichtag ungültigen Planstelle", () => { + // loadOrgAsOf filtert Planstellen bereits nach Gültigkeit; kommt eine + // Besetzung ohne passende Planstelle an, wäre sie im Baum nicht + // verortbar. + const result = snapshot({ + asOf: "2026-06-01", + employees: [emp("a")], + assignments: [{ employee_id: "a", position_id: "gibtesnicht" }], + }); + expect(result.employees).toHaveLength(0); }); }); -describe("placement as of a date", () => { - it("uses the assignment interval covering the date, not today's row on employees", () => { - const employees = [emp("a", { team_id: TEAM_B, job_title: "Heutiger Titel" })]; - const assignments = [assignment("a", { team_id: TEAM_A, job_title: "Damaliger Titel" })]; - const [result] = snapshot({ asOf: "2024-03-01", employees, assignments }).employees; - expect(result.team_id).toBe(TEAM_A); - expect(result.job_title).toBe("Damaliger Titel"); +describe("Berichtslinie", () => { + const employees = [emp("gf"), emp("bl"), emp("tl-a"), emp("a1")]; + const assignments = [ + { employee_id: "gf", position_id: "p-gf" }, + { employee_id: "bl", position_id: "p-bl" }, + { employee_id: "tl-a", position_id: "p-tl-a" }, + { employee_id: "a1", position_id: "p-a1" }, + ]; + + it("führt eine Mitarbeiterin an die Leitung ihrer Einheit", () => { + const result = snapshot({ asOf: "2026-06-01", employees, assignments }); + expect(result.employees.find((e) => e.id === "a1")!.manager_id).toBe("tl-a"); }); - 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); + 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("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" })]; - const result = snapshot({ asOf: "2026-06-01", employees, assignments }).employees; - expect(result).toHaveLength(1); - expect(result[0].id).toBe("report"); - expect(result[0].manager_id).toBeNull(); +describe("Tätigkeit", () => { + it("nimmt die Tätigkeit der Planstelle, nicht das Freitextfeld der Person", () => { + // 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("future projection from pending changes", () => { - const leadB = emp("lead-b", { id: "lead-b", team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 }); +describe("Projektion vorgemerkter Versetzungen", () => { + const employees = [emp("bl"), emp("tl-b"), emp("a")]; + const assignments = [ + { employee_id: "bl", position_id: "p-bl" }, + { employee_id: "tl-b", position_id: "p-tl-b" }, + { employee_id: "a", position_id: "p-a1" }, + ]; - it("moves an employee into the target team and under that team's lead", () => { - const employees = [emp("a", { manager_id: "lead-a" }), leadB]; - 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 })]; - const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }]; - - const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending }); + it("setzt die Person auf die Zielplanstelle und damit unter deren Leitung", () => { + 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")!; - expect(moved.team_id).toBe(TEAM_B); - expect(moved.division_id).toBe("div-2"); - expect(moved.manager_id).toBe("lead-b"); + expect(moved.org_unit_id).toBe("team-b"); + expect(moved.job_title).toBe("Fräser:in"); + expect(moved.manager_id).toBe("tl-b"); expect(result.projectedCount).toBe(1); }); - it("lets a later change win over an earlier one", () => { - const employees = [emp("a")]; - const assignments = [assignment("a")]; - const pending = [ - { employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B, new_title: "Zwischenstand" } }, - { employee_id: "a", effective_date: "2026-09-01", payload: { new_team_id: TEAM_A, new_title: "Endstand" } }, - ]; - const [result] = snapshot({ asOf: "2026-10-01", employees, assignments, pending }).employees; - expect(result.team_id).toBe(TEAM_A); - expect(result.job_title).toBe("Endstand"); + it("lässt eine spätere Versetzung über eine frühere gewinnen", () => { + const [result] = snapshot({ + asOf: "2026-10-01", + employees, + assignments, + pending: [ + { 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" } }, + ], + }).employees.filter((e) => e.id === "a"); + expect(result.org_unit_id).toBe("team-a"); }); - it("leaves an untouched employee's manager exactly as recorded", () => { - // Deliberately deviating from the resolve rule: real data drifts, and a - // snapshot must not silently "repair" reporting lines it was not asked - // to change. - const employees = [emp("a", { manager_id: "someone-else" }), emp("someone-else", { id: "someone-else" }), leadB]; - 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"); - }); - - 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: [] }); + it("übergeht eine Versetzung auf eine unbekannte Planstelle, statt die Person zu verlieren", () => { + const result = snapshot({ + asOf: "2026-09-01", + employees, + assignments, + pending: [{ employee_id: "a", effective_date: "2026-08-01", payload: { target_position_id: "weg" } }], + }); + expect(result.employees.find((e) => e.id === "a")!.org_unit_id).toBe("team-a"); expect(result.projectedCount).toBe(0); - expect(result.employees).toHaveLength(1); }); }); -describe("historyStartsAt", () => { - it("reports the earliest recorded assignment so the UI can flag older dates", () => { - const employees = [emp("a"), emp("b", { id: "b" })]; - const assignments = [assignment("a", { valid_from: "2023-05-01" }), assignment("b", { valid_from: "2021-02-01" })]; - expect(snapshot({ asOf: "2026-01-01", employees, assignments }).historyStartsAt).toBe("2021-02-01"); - }); - - it("is null when nothing is recorded yet", () => { - expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull(); +describe("Vakanzen", () => { + it("meldet jede am Stichtag unbesetzte Planstelle", () => { + // Vakanz ist im OM-Modell kein eigenes Objekt, sondern das Komplement der + // Besetzungen — sie kann deshalb gar nicht mehr aus dem Tritt geraten. + const result = snapshot({ + 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); }); }); diff --git a/tests/unit/reports.test.ts b/tests/unit/reports.test.ts index f7c4793..04f7342 100644 --- a/tests/unit/reports.test.ts +++ b/tests/unit/reports.test.ts @@ -18,8 +18,7 @@ function emp(overrides: Partial = {}): ReportEmployee { first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", - division_id: "div-1", - team_id: "team-1", + org_unit_id: "team-1", location_id: "loc-1", status: "Aktiv", employment_type: "Vollzeit", @@ -43,9 +42,16 @@ function emp(overrides: Partial = {}): 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 = { - divisionName: new Map([["div-1", "Produktion"]]), - departmentNameByTeam: new Map([["team-1", "Fertigung"]]), + divisionName: new Map([ + ["team-1", "Produktion"], + ["div-1", "Produktion"], + ]), + departmentName: new Map([["team-1", "Fertigung"]]), teamName: new Map([["team-1", "Montage"]]), locationName: new Map([["loc-1", "Wien-Hernals"]]), }; @@ -65,14 +71,23 @@ describe("groupKeyFor", () => { expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals"); }); - it("falls back to a dash for department/team when the employee has no team", () => { - const e = emp({ team_id: null }); + it("falls back to a dash for department/team for somebody sitting at the division itself", () => { + // Eine Bereichsleitung hängt am Bereich, nicht an einem Team — vorher + // 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, "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", () => { - const e = emp({ division_id: "ghost", location_id: "ghost" }); + const e = emp({ org_unit_id: "ghost", location_id: "ghost" }); expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt"); expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt"); }); @@ -166,15 +181,17 @@ describe("measureValue", () => { describe("aggregateReport", () => { it("groups rows, sorts groups descending by value, and never includes a salary field", () => { const employees = [ - emp({ id: "1", division_id: "div-1" }), - emp({ id: "2", division_id: "div-1" }), - emp({ id: "3", division_id: "div-2" }), + emp({ id: "1", org_unit_id: "team-1" }), + emp({ id: "2", org_unit_id: "team-1" }), + emp({ id: "3", org_unit_id: "team-2" }), ]; + // Geschlüsselt über die Einheit, in der die Person sitzt: team-1 hängt + // unter Produktion, team-2 unter IT. const multiDivisionLookups: OrgLookups = { ...lookups, divisionName: new Map([ - ["div-1", "Produktion"], - ["div-2", "IT"], + ["team-1", "Produktion"], + ["team-2", "IT"], ]), }; const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups); @@ -229,7 +246,7 @@ describe("aggregateReport", () => { }); it("sorts a weekday split chronologically within each group", () => { - const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })]; + const employees = [emp({ id: "1", org_unit_id: "team-1", work_days: ["Fr", "Mo"] })]; const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups); expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]); }); @@ -259,8 +276,8 @@ describe("aggregateEvents", () => { const eventLookups: OrgLookups = { ...lookups, divisionName: new Map([ - ["div-1", "Produktion"], - ["div-2", "IT"], + ["team-1", "Produktion"], + ["team-2", "IT"], ]), }; @@ -270,8 +287,7 @@ describe("aggregateEvents", () => { first_name: "Maria", last_name: "Gruber", job_title: "Maschinenbediener:in", - division_id: "div-1", - team_id: "team-1", + org_unit_id: "team-1", location_id: "loc-1", event_date: "2026-03-01", event_type: "Eintritt", @@ -281,14 +297,14 @@ describe("aggregateEvents", () => { } 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", division_id: "div-2" })]; + 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 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 === "Beförderung")).toMatchObject({ value: 1, count: 1 }); }); it("groups by the affected employee's org unit and preserves the event description on drill-down", () => { - const events = [ev({ division_id: "div-1" }), ev({ division_id: "div-2", employee_id: "2" })]; + const events = [ev({ org_unit_id: "team-1" }), ev({ org_unit_id: "team-2", employee_id: "2" })]; const rows = aggregateEvents(events, "division", null, eventLookups); 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" });