diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 467d28f..5557c80 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -4,7 +4,7 @@ import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; import { todayIso } from "@/lib/format"; import { breadcrumbLabel, loadOrgMaps } from "@/lib/org"; -import { loadPlacements, loadReportingLines } from "@/lib/placement"; +import { loadPlacements, loadReportingLines, orgAsOf } from "@/lib/placement"; import { loadOpenPositions } from "@/lib/positions"; type PageProps = { params: Promise<{ id: string }> }; @@ -14,30 +14,39 @@ export default async function EmployeeDetailPage({ params }: PageProps) { const today = todayIso(); const data = await withUser(await currentUserId(), async (tx) => { + // Die Person zuerst, allein: erst aus Eintritt und Austritt ergibt sich + // der Stichtag, zu dem ihre Organisation überhaupt eine Antwort hat. + // Eine Person, die am 01.09. anfängt, hat heute keine laufende Besetzung + // — zu heute gefragt lieferte om_reporting_lines() nichts, und die Akte + // behauptete „Keine Führungskraft", obwohl das Team eine hat. + const employee = await tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(); + if (!employee) return null; + + const asOf = orgAsOf(employee, today); + // Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie // kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der // Funktion ein, es wandern also neun Zeilen über die Leitung und nicht // achthundert. - const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = - await Promise.all([ - tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(), - loadReportingLines(tx, today, { employeeId: id }), - loadReportingLines(tx, today, { actingManagerId: id }), - tx - .selectFrom("employee_history") - .selectAll() - .where("employee_id", "=", id) - .orderBy("event_date", "desc") - .orderBy("created_at", "desc") - .execute(), - tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(), - tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(), - loadOrgMaps(tx), - loadPlacements(tx, { asOf: today, employeeIds: [id] }), - loadOpenPositions(tx), - ]); + const [ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] = await Promise.all([ + loadReportingLines(tx, asOf, { employeeId: id }), + loadReportingLines(tx, asOf, { actingManagerId: id }), + tx + .selectFrom("employee_history") + .selectAll() + .where("employee_id", "=", id) + .orderBy("event_date", "desc") + .orderBy("created_at", "desc") + .execute(), + tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(), + tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(), + loadOrgMaps(tx), + loadPlacements(tx, { asOf, employeeIds: [id] }), + // Die offenen Planstellen bleiben bei heute: sie gehören zur + // Organisation, nicht zu dieser Person. + loadOpenPositions(tx), + ]); - if (!employee) return null; const line = ownLines[0] ?? null; // Namen für die beteiligten Personen in einem Zug: die Vertretung, die diff --git a/lib/placement.ts b/lib/placement.ts index 4534e6f..58df265 100644 --- a/lib/placement.ts +++ b/lib/placement.ts @@ -1,4 +1,33 @@ import { sql, type Tx } from "./db"; +import { addDaysIso } from "./format"; + +/** + * Der Stichtag, zu dem die Organisation *dieser* Person betrachtet wird. + * + * „Heute" ist für zwei Gruppen die falsche Frage. Wer am 01.09. anfängt, hat + * heute keine laufende Besetzung — om_reporting_lines() liefert dann gar + * keine Zeile, und die Akte behauptet „Keine Führungskraft", obwohl das Team + * eine hat. Wer ausgetreten ist, ebenso. + * + * Die Stammdaten zeigten die Planstelle trotzdem an, weil pickPlacements() + * ersatzweise auf die nächstbeste Zuordnung zurückfällt. Zwei Vorstellungen + * davon, wo jemand sitzt — eine nachsichtige und eine strenge — und in der + * Akte standen sie nebeneinander. + * + * Deshalb: der Stichtag wird in das Beschäftigungsverhältnis hineingezogen. + * Für künftige Eintritte auf den ersten Tag, für Ausgetretene auf den + * letzten. Sonst bleibt es heute. + */ +export function orgAsOf( + employee: { entry_date: string; exit_date: string | null }, + today: string +): string { + if (employee.entry_date > today) return employee.entry_date; + // exit_date ist ausschliessend wie überall im Modell: der letzte Arbeitstag + // ist der Tag davor. + if (employee.exit_date && employee.exit_date <= today) return addDaysIso(employee.exit_date, -1); + return today; +} // Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es // ergibt sich aus der Planstelle, die sie zum Stichtag innehat: diff --git a/tests/unit/org-as-of.test.ts b/tests/unit/org-as-of.test.ts new file mode 100644 index 0000000..0062176 --- /dev/null +++ b/tests/unit/org-as-of.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { orgAsOf } from "@/lib/placement"; + +// orgAsOf() ist reine Rechnung, liegt aber neben den Ladefunktionen — und die +// ziehen über lib/db das Paket `server-only` herein, das ausserhalb von +// Next.js beim Import wirft. +vi.mock("server-only", () => ({})); + +// Der Stichtag, zu dem die Organisation einer Person betrachtet wird. +// +// „Heute" ist die naheliegende und für zwei Gruppen falsche Antwort. Wer +// später anfängt oder schon gegangen ist, hat heute keine laufende +// Besetzung — om_reporting_lines() liefert dann keine Zeile, und die Akte +// behauptet „Keine Führungskraft", obwohl das Team eine hat. Genau so ist es +// aufgefallen. + +const HEUTE = "2026-08-10"; + +describe("Stichtag der Organisation", () => { + it("bleibt heute für eine laufende Beschäftigung", () => { + expect(orgAsOf({ entry_date: "2015-03-01", exit_date: null }, HEUTE)).toBe(HEUTE); + }); + + it("nimmt bei künftigem Eintritt den ersten Tag", () => { + // Sonst steht in der Akte einer Person, die am 01.09. anfängt, heute + // keine Führungskraft — obwohl die Planstelle einer Einheit mit Leitung + // zugeordnet ist. + expect(orgAsOf({ entry_date: "2026-09-01", exit_date: null }, HEUTE)).toBe("2026-09-01"); + }); + + it("nimmt bei Austritt den letzten Arbeitstag", () => { + // exit_date ist ausschliessend wie überall im Modell; am Austrittstag + // selbst besteht die Besetzung nicht mehr. Zu diesem Tag gefragt käme + // wieder nichts heraus. + expect(orgAsOf({ entry_date: "2015-03-01", exit_date: "2026-07-31" }, HEUTE)).toBe("2026-07-30"); + }); + + it("bleibt heute, solange ein künftiger Austritt noch nicht wirksam ist", () => { + expect(orgAsOf({ entry_date: "2015-03-01", exit_date: "2026-12-31" }, HEUTE)).toBe(HEUTE); + }); + + it("nimmt am Eintrittstag selbst diesen Tag, nicht den Vortag", () => { + // Grenzfall: heute ist der erste Arbeitstag. Die Zuordnung gilt ab + // heute, also ist heute richtig. + expect(orgAsOf({ entry_date: HEUTE, exit_date: null }, HEUTE)).toBe(HEUTE); + }); + + it("behandelt Ein- und Austritt am selben Tag ohne Sprung ins Nichts", () => { + // Wer den Dienst nicht antritt: die Datenbank erlaubt exit = entry. Der + // Stichtag liegt dann vor dem Eintritt und liefert nichts — das ist + // richtig so, denn eine Organisation hatte diese Person nie. + expect(orgAsOf({ entry_date: "2026-07-31", exit_date: "2026-07-31" }, HEUTE)).toBe("2026-07-30"); + }); +});