Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
This commit is contained in:
@@ -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<ActionResult> {
|
||||
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<ActionResult> {
|
||||
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
||||
}
|
||||
|
||||
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
|
||||
|
||||
// Shared by "Position besetzen" (staff an open position) and the reorg
|
||||
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
|
||||
// on-leave employees by name or title.
|
||||
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, team_id")
|
||||
.in("status", ["Aktiv", "Karenz"])
|
||||
.limit(20);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -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<ActionResult> {
|
||||
return callRpc("create_position", payload, POSITION_PATHS);
|
||||
@@ -34,23 +32,3 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||
|
||||
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||
// the new position is an IC role, or to division-heads/CEO when the new
|
||||
// position is itself a team lead (§2).
|
||||
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, division_id")
|
||||
.eq("status", "Aktiv")
|
||||
.limit(20);
|
||||
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -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<ActionResult & { scenarioId?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("apply_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true, scenarioId: data as string };
|
||||
}
|
||||
|
||||
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("undo_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -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 (
|
||||
<EmployeeDetail
|
||||
employee={employee}
|
||||
manager={manager ?? null}
|
||||
directReports={directReports ?? []}
|
||||
placement={
|
||||
placement && {
|
||||
positionNumber: placement.positionNumber,
|
||||
jobTitle: placement.jobTitle,
|
||||
isChief: placement.isChief,
|
||||
current: placement.current,
|
||||
}
|
||||
}
|
||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
||||
// zweimal da.
|
||||
formalManager={
|
||||
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
|
||||
? (byId.get(line.formal_manager_id) ?? null)
|
||||
: null
|
||||
}
|
||||
directReports={reports.flatMap((r) => {
|
||||
const e = byId.get(r.employee_id);
|
||||
return e ? [e] : [];
|
||||
})}
|
||||
history={history ?? []}
|
||||
dependents={dependents ?? []}
|
||||
notes={notes ?? []}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
locations={orgMaps.locationList}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
// Strukturell typisiert und generisch über den Builder, damit die beiden
|
||||
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt
|
||||
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die
|
||||
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei
|
||||
// getrennte Abfragen mit einer gemeinsamen Filterkette.
|
||||
function applyFilters<Q extends Narrowable>(query: Q): Q {
|
||||
let q = query;
|
||||
if (params.q) {
|
||||
const term = params.q.trim();
|
||||
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q;
|
||||
else {
|
||||
const safe = sanitizeIlikeTerm(term);
|
||||
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q;
|
||||
}
|
||||
}
|
||||
// Derived from the dates, not read off employees.status — see
|
||||
// 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);
|
||||
q = applyDerivedStatusFilter(q, statuses, today);
|
||||
if (params.location) q = q.eq("location_id", params.location) as Q;
|
||||
return q;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||
|
||||
@@ -97,7 +137,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
</thead>
|
||||
<tbody>
|
||||
{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
|
||||
<div className="truncate font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
|
||||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
@@ -122,7 +164,12 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">
|
||||
<div>{division?.name ?? "–"}</div>
|
||||
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||
{/* 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. */}
|
||||
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
|
||||
{unit && unit.id !== division?.id ? unit.name : "–"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||
|
||||
@@ -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([
|
||||
const [org, { data: units }] = 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),
|
||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<OrgChartClient
|
||||
employees={org.employees}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
openPositions={openPositions}
|
||||
reorgScenarios={reorgScenarios ?? []}
|
||||
units={(units ?? []) as OrgUnitNode[]}
|
||||
vacancies={org.vacancies}
|
||||
asOf={asOf}
|
||||
today={today}
|
||||
projectedCount={org.projectedCount}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
@@ -71,8 +74,9 @@ export default async function DashboardPage() {
|
||||
staffRows,
|
||||
hiresYtdRes,
|
||||
exitsYtdRes,
|
||||
openPositionsRes,
|
||||
divisionsRes,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHiresRes,
|
||||
upcomingExitsRes,
|
||||
upcomingReturnsRes,
|
||||
@@ -81,7 +85,7 @@ export default async function DashboardPage() {
|
||||
fetchAllRows(() =>
|
||||
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<string, number>();
|
||||
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 (
|
||||
|
||||
@@ -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 <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -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<PanelType>(null);
|
||||
|
||||
const division = divisions.find((d) => d.id === employee.division_id);
|
||||
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
||||
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
||||
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||
const location = locations.find((l) => l.id === employee.location_id);
|
||||
|
||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
@@ -79,8 +75,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</h2>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||
</div>
|
||||
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">{breadcrumb}</p>
|
||||
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">
|
||||
{breadcrumb}
|
||||
{placement && (
|
||||
<>
|
||||
{" · Planstelle "}
|
||||
{placement.positionNumber}
|
||||
{placement.isChief && " (Leitung)"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
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" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||
{tab === "Organisation" && (
|
||||
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
|
||||
<OrganisationTab
|
||||
employeeId={employee.id}
|
||||
manager={manager}
|
||||
formalManager={formalManager}
|
||||
directReports={directReports}
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
@@ -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}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
|
||||
@@ -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<string, number>;
|
||||
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,16 +57,24 @@ 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. */}
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
defaultValue={searchParams.get("division") ?? ""}
|
||||
onChange={(e) => updateParam("division", e.target.value)}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
<option value="">Alle Einheiten</option>
|
||||
{units
|
||||
.filter((u) => u.unit_type !== "Gesellschaft")
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -7,48 +7,51 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
|
||||
type TransferPanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
currentTeamId: string | null;
|
||||
openPositions: OpenPositionResolved[];
|
||||
};
|
||||
|
||||
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
|
||||
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die
|
||||
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
|
||||
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
|
||||
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
|
||||
// keine Stelle gibt.
|
||||
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [divisionId, setDivisionId] = useState(employee.division_id);
|
||||
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const teamsInDivision = useMemo(() => {
|
||||
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
|
||||
return teams.filter((t) => deptIds.has(t.department_id));
|
||||
}, [departments, teams, divisionId]);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !teamId) {
|
||||
showToast("Bitte Datum und Zielteam angeben.", "error");
|
||||
if (!effectiveDate || !positionId) {
|
||||
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await transferEmployee({
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_team_id: teamId,
|
||||
new_title: newTitle || undefined,
|
||||
target_position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -71,7 +74,7 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
|
||||
Versetzen
|
||||
</Button>
|
||||
</>
|
||||
@@ -79,31 +82,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie
|
||||
zuerst unter „Positionen“ eine an.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Neuer Bereich"
|
||||
label="Zielplanstelle"
|
||||
required
|
||||
value={divisionId}
|
||||
onChange={(v) => {
|
||||
setDivisionId(v);
|
||||
setTeamId("");
|
||||
}}
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Neues Team"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Neuer Titel (optional)"
|
||||
value={newTitle}
|
||||
onChange={setNewTitle}
|
||||
placeholder={employee.job_title}
|
||||
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ type MiniEmployee = { id: string; first_name: string; last_name: string; job_tit
|
||||
type OrganisationTabProps = {
|
||||
employeeId: string;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
breadcrumb: string;
|
||||
};
|
||||
|
||||
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -30,7 +32,15 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
|
||||
</h3>
|
||||
{formalManager && (
|
||||
<p className="mb-2 text-xs text-ink-muted">
|
||||
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
|
||||
besetzte Ebene.
|
||||
</p>
|
||||
)}
|
||||
{manager ? (
|
||||
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||
|
||||
@@ -2,22 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
import { PositionTree } from "./PositionTree";
|
||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
|
||||
type View = "ma" | "pos" | "reo";
|
||||
type View = "ma" | "pos";
|
||||
|
||||
type OrgChartClientProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
asOf: string;
|
||||
today: string;
|
||||
projectedCount: number;
|
||||
@@ -28,11 +23,8 @@ type OrgChartClientProps = {
|
||||
|
||||
export function OrgChartClient({
|
||||
employees,
|
||||
divisions,
|
||||
departments,
|
||||
teams,
|
||||
openPositions,
|
||||
reorgScenarios,
|
||||
units,
|
||||
vacancies,
|
||||
asOf,
|
||||
today,
|
||||
projectedCount,
|
||||
@@ -40,7 +32,6 @@ export function OrgChartClient({
|
||||
focusId,
|
||||
}: OrgChartClientProps) {
|
||||
const [view, setView] = useState<View>("ma");
|
||||
const isToday = asOf === today;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -49,35 +40,14 @@ export function OrgChartClient({
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "ma", label: "Mitarbeiter" },
|
||||
{ value: "pos", label: "Positionen" },
|
||||
{ value: "reo", label: "Reorganisation" },
|
||||
{ value: "pos", label: "Organisation" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{view !== "reo" && (
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
)}
|
||||
|
||||
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||
{view === "pos" && (
|
||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||
)}
|
||||
{view === "reo" &&
|
||||
(isToday ? (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
) : (
|
||||
// A reorg planned against a past or projected roster would be
|
||||
// applied to the *live* org anyway — better to send the user back
|
||||
// to today than to let them assemble moves from a roster that is
|
||||
// not the one the change would hit.
|
||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||
<p className="mt-1 text-ink-muted">
|
||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||
Sie zurück auf „Heute“, um eine zu planen.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,56 +2,34 @@
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
|
||||
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
|
||||
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
|
||||
// damit eine Datenfrage und keine Änderung an dieser Datei.
|
||||
//
|
||||
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
|
||||
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
|
||||
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
|
||||
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
function TreeRow({
|
||||
depth,
|
||||
expandable,
|
||||
expandedNow,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
depth: number;
|
||||
expandable: boolean;
|
||||
expandedNow: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
|
||||
{expandable ? (
|
||||
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
|
||||
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PositionTreeProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
};
|
||||
|
||||
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
|
||||
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
|
||||
// useCallback-stable: GraphOrgChart's layout memo depends on these
|
||||
// references, so unstable functions would force a Dagre re-layout on
|
||||
// every unrelated re-render.
|
||||
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
|
||||
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
|
||||
// neues Dagre-Layout.
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -60,216 +38,178 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
}
|
||||
}
|
||||
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
|
||||
}, [employees]);
|
||||
const openByTeam = useMemo(() => {
|
||||
const map = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||
map.get(p.team_id)!.push(p);
|
||||
}
|
||||
return map;
|
||||
}, [openPositions]);
|
||||
|
||||
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||
// the list already uses as `expanded` keys, so one Set drives both.
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
if (!ceo) return [];
|
||||
|
||||
function buildTeam(team: OrgTeam): ChartNode {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
|
||||
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||
id: `${teamKey}-${title}`,
|
||||
kind: "group",
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person",
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||
id: `vac-${p.id}`,
|
||||
kind: "vacancy",
|
||||
label: `${p.position_number} · ${p.title}`,
|
||||
href: "/positions",
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: teamKey,
|
||||
kind: "role",
|
||||
label: `Teamleitung ${team.name}`,
|
||||
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||
vacant: !lead,
|
||||
children: [...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDept(dept: OrgDepartment): ChartNode {
|
||||
return {
|
||||
id: `dept-${dept.id}`,
|
||||
kind: "group",
|
||||
label: `${dept.org_number} · ${dept.name}`,
|
||||
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDivision(div: OrgDivision): ChartNode {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
return {
|
||||
id: `div-${div.id}`,
|
||||
kind: "role",
|
||||
label: `Bereichsleitung ${div.name}`,
|
||||
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
|
||||
vacant: !head,
|
||||
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "root",
|
||||
kind: "role",
|
||||
label: "Geschäftsführung",
|
||||
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
|
||||
children: divisions.map(buildDivision),
|
||||
},
|
||||
];
|
||||
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
|
||||
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
<SegmentedControl<ViewMode>
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ value: "list", label: "Liste" },
|
||||
{ value: "graph", label: "Grafisch" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{mode === "graph" ? (
|
||||
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
) : (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
{ceo && (
|
||||
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
|
||||
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
besetzt: {ceo.first_name} {ceo.last_name}
|
||||
</span>
|
||||
</TreeRow>
|
||||
)}
|
||||
{expanded.has("root") &&
|
||||
divisions.map((div) => {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
const divKey = `div-${div.id}`;
|
||||
return (
|
||||
<div key={div.id}>
|
||||
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
|
||||
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
|
||||
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
|
||||
</TreeRow>
|
||||
{expanded.has(divKey) &&
|
||||
departments
|
||||
.filter((d) => d.division_id === div.id)
|
||||
.map((dept) => {
|
||||
const deptKey = `dept-${dept.id}`;
|
||||
return (
|
||||
<div key={dept.id}>
|
||||
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
|
||||
<span className="text-sm text-ink">
|
||||
{dept.org_number} · {dept.name}
|
||||
</span>
|
||||
</TreeRow>
|
||||
{expanded.has(deptKey) &&
|
||||
teams
|
||||
.filter((t) => t.department_id === dept.id)
|
||||
.map((team) => {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
return (
|
||||
<div key={team.id}>
|
||||
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
|
||||
<span className="text-sm text-ink">Teamleitung {team.name}</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
|
||||
</span>
|
||||
</TreeRow>
|
||||
{expanded.has(teamKey) && (
|
||||
<>
|
||||
{Array.from(icsByTitle.entries()).map(([title, people]) => {
|
||||
const groupKey = `${teamKey}-${title}`;
|
||||
return (
|
||||
<div key={title}>
|
||||
<TreeRow
|
||||
depth={4}
|
||||
expandable={people.length > 0}
|
||||
expandedNow={expanded.has(groupKey)}
|
||||
onToggle={() => toggle(groupKey)}
|
||||
>
|
||||
<span className="text-sm text-ink">{title}</span>
|
||||
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
|
||||
</TreeRow>
|
||||
{expanded.has(groupKey) &&
|
||||
people.map((p) => (
|
||||
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
|
||||
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
|
||||
{p.first_name} {p.last_name}
|
||||
</Link>
|
||||
</div>
|
||||
{tree.map((node) => (
|
||||
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{openForTeam.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href="/positions"
|
||||
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
|
||||
style={{ paddingLeft: 4 * 24 + 8 }}
|
||||
>
|
||||
+ {p.position_number} · {p.title}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListNode({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
node: ChartNode;
|
||||
depth: number;
|
||||
expanded: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const expandable = node.children.length > 0;
|
||||
const open = expanded.has(node.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(node.id)}
|
||||
className="shrink-0 text-ink-muted"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4 shrink-0" />
|
||||
)}
|
||||
{node.href ? (
|
||||
<Link
|
||||
href={node.href}
|
||||
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
|
||||
>
|
||||
{node.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
|
||||
)}
|
||||
{node.sublabel && (
|
||||
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
|
||||
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
|
||||
* zuletzt die unbesetzten Planstellen.
|
||||
*/
|
||||
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
|
||||
const childUnits = new Map<string | null, OrgUnitNode[]>();
|
||||
for (const u of units) {
|
||||
const list = childUnits.get(u.parent_id) ?? [];
|
||||
list.push(u);
|
||||
childUnits.set(u.parent_id, list);
|
||||
}
|
||||
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
|
||||
|
||||
const chiefOf = new Map<string, OrgEmployee>();
|
||||
const staffOf = new Map<string, OrgEmployee[]>();
|
||||
for (const e of employees) {
|
||||
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
|
||||
else {
|
||||
const list = staffOf.get(e.org_unit_id) ?? [];
|
||||
list.push(e);
|
||||
staffOf.set(e.org_unit_id, list);
|
||||
}
|
||||
}
|
||||
|
||||
const vacantOf = new Map<string, OrgVacancy[]>();
|
||||
for (const v of vacancies) {
|
||||
const list = vacantOf.get(v.org_unit_id) ?? [];
|
||||
list.push(v);
|
||||
vacantOf.set(v.org_unit_id, list);
|
||||
}
|
||||
|
||||
function build(unit: OrgUnitNode): ChartNode {
|
||||
const key = `unit-${unit.id}`;
|
||||
const chief = chiefOf.get(unit.id);
|
||||
|
||||
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
|
||||
|
||||
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
|
||||
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
|
||||
const byTitle = new Map<string, OrgEmployee[]>();
|
||||
for (const e of staffOf.get(unit.id) ?? []) {
|
||||
const list = byTitle.get(e.job_title) ?? [];
|
||||
list.push(e);
|
||||
byTitle.set(e.job_title, list);
|
||||
}
|
||||
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b, "de"))
|
||||
.map(([title, people]) => ({
|
||||
id: `${key}-job-${title}`,
|
||||
kind: "group" as const,
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people
|
||||
.slice()
|
||||
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person" as const,
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
absent: p.absent,
|
||||
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
|
||||
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
|
||||
.sort((a, b) => a.position_number.localeCompare(b.position_number))
|
||||
.map((v) => ({
|
||||
id: `vac-${v.position_id}`,
|
||||
kind: "vacancy" as const,
|
||||
label: `+ ${v.position_number} · ${v.job_title}`,
|
||||
href: "/positions",
|
||||
vacant: true,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: key,
|
||||
kind: "role",
|
||||
label: `${unit.org_number} · ${unit.name}`,
|
||||
sublabel: chief
|
||||
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
|
||||
: "Leitung vakant",
|
||||
vacant: !chief,
|
||||
children: [...subUnits, ...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
return (childUnits.get(null) ?? []).map(build);
|
||||
}
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||
|
||||
type ChangeKind = "emp" | "team" | "abt" | "dept";
|
||||
|
||||
type PendingMove = {
|
||||
id: string;
|
||||
kind: ChangeKind;
|
||||
label: string;
|
||||
employeeIds: string[];
|
||||
targetTeamId: string;
|
||||
targetTeamLabel: string;
|
||||
};
|
||||
|
||||
const KIND_LABELS: Record<ChangeKind, string> = {
|
||||
emp: "Mitarbeiter:in(nen)",
|
||||
team: "Ganzes Team",
|
||||
abt: "Ganze Abteilung",
|
||||
dept: "Ganzer Bereich",
|
||||
};
|
||||
|
||||
type ReorgWorkbenchProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
};
|
||||
|
||||
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [changeType, setChangeType] = useState<ChangeKind>("emp");
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
|
||||
const [sourceTeamId, setSourceTeamId] = useState("");
|
||||
const [sourceDeptId, setSourceDeptId] = useState("");
|
||||
const [sourceDivisionId, setSourceDivisionId] = useState("");
|
||||
const [targetDivisionId, setTargetDivisionId] = useState("");
|
||||
const [targetTeamId, setTargetTeamId] = useState("");
|
||||
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [undoingId, setUndoingId] = useState<string | null>(null);
|
||||
|
||||
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
|
||||
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
|
||||
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
|
||||
const teamDivisionId = useMemo(() => {
|
||||
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||||
const m = new Map<string, string>();
|
||||
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
|
||||
return m;
|
||||
}, [teams, departments]);
|
||||
const teamsInTargetDivision = useMemo(
|
||||
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
|
||||
[teams, teamDivisionId, targetDivisionId]
|
||||
);
|
||||
|
||||
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 2) return [];
|
||||
return employees
|
||||
.filter(
|
||||
(e) =>
|
||||
!selectedEmployees.some((s) => s.id === e.id) &&
|
||||
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
|
||||
)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function resolveEmployeeIds(): string[] {
|
||||
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
|
||||
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
|
||||
if (changeType === "abt") {
|
||||
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
|
||||
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
|
||||
}
|
||||
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
|
||||
}
|
||||
|
||||
function sourceLabel(): string {
|
||||
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
|
||||
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
|
||||
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
|
||||
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
|
||||
}
|
||||
|
||||
function handleAddMove() {
|
||||
const employeeIds = resolveEmployeeIds();
|
||||
if (employeeIds.length === 0) {
|
||||
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
|
||||
return;
|
||||
}
|
||||
if (!targetTeamId) {
|
||||
showToast("Bitte ein Ziel-Team wählen.", "error");
|
||||
return;
|
||||
}
|
||||
const targetTeam = teamById.get(targetTeamId);
|
||||
setPendingMoves((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
kind: changeType,
|
||||
label: sourceLabel(),
|
||||
employeeIds,
|
||||
targetTeamId,
|
||||
targetTeamLabel: targetTeam?.name ?? "",
|
||||
},
|
||||
]);
|
||||
setSelectedEmployees([]);
|
||||
setSourceTeamId("");
|
||||
setSourceDeptId("");
|
||||
setSourceDivisionId("");
|
||||
}
|
||||
|
||||
function removeMove(id: string) {
|
||||
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
|
||||
}
|
||||
|
||||
const divisionBefore = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
|
||||
return m;
|
||||
}, [employees]);
|
||||
|
||||
const divisionDelta = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
for (const move of pendingMoves) {
|
||||
const targetDivId = teamDivisionId.get(move.targetTeamId);
|
||||
for (const empId of move.employeeIds) {
|
||||
const emp = employeeById.get(empId);
|
||||
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
|
||||
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
|
||||
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [pendingMoves, employees, teamDivisionId]);
|
||||
|
||||
async function handleApply() {
|
||||
if (!name || !effectiveDate || pendingMoves.length === 0) {
|
||||
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setApplying(true);
|
||||
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
|
||||
kind: m.kind,
|
||||
label: m.label,
|
||||
employee_ids: m.employeeIds,
|
||||
target_team_id: m.targetTeamId,
|
||||
}));
|
||||
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
|
||||
setApplying(false);
|
||||
if (result.success) {
|
||||
showToast("Reorganisation durchgeführt.");
|
||||
setPendingMoves([]);
|
||||
setName("");
|
||||
setEffectiveDate("");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo(scenarioId: string) {
|
||||
setUndoingId(scenarioId);
|
||||
const result = await undoReorg({ scenario_id: scenarioId });
|
||||
setUndoingId(null);
|
||||
if (result.success) {
|
||||
showToast("Reorganisation rückgängig gemacht.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
</div>
|
||||
|
||||
<fieldset className="mt-4">
|
||||
<legend className="sr-only">Art der Änderung</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={changeType === kind}
|
||||
onClick={() => setChangeType(kind)}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{KIND_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
{changeType === "emp" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field label="Quelle: Mitarbeiter:innen">
|
||||
{(p) => (
|
||||
<Lookup<OrgEmployee>
|
||||
{...p}
|
||||
placeholder="Mitarbeiter:in suchen…"
|
||||
onSearch={searchLocalEmployees}
|
||||
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||||
renderResult={(e) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedEmployees.map((e) => (
|
||||
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
|
||||
{e.first_name} {e.last_name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
|
||||
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
|
||||
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{changeType === "team" && (
|
||||
<SelectField
|
||||
label="Quelle: Team"
|
||||
value={sourceTeamId}
|
||||
onChange={setSourceTeamId}
|
||||
placeholder="Team wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "abt" && (
|
||||
<SelectField
|
||||
label="Quelle: Abteilung"
|
||||
value={sourceDeptId}
|
||||
onChange={setSourceDeptId}
|
||||
placeholder="Abteilung wählen…"
|
||||
options={departments.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "dept" && (
|
||||
<SelectField
|
||||
label="Quelle: Bereich"
|
||||
value={sourceDivisionId}
|
||||
onChange={setSourceDivisionId}
|
||||
placeholder="Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SelectField
|
||||
label="Ziel-Bereich"
|
||||
required
|
||||
value={targetDivisionId}
|
||||
onChange={(v) => {
|
||||
setTargetDivisionId(v);
|
||||
setTargetTeamId("");
|
||||
}}
|
||||
placeholder="Ziel-Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Ziel-Team"
|
||||
required
|
||||
value={targetTeamId}
|
||||
onChange={setTargetTeamId}
|
||||
placeholder="Ziel-Team wählen…"
|
||||
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
|
||||
+ Zur Reorganisation hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pendingMoves.length > 0 && (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
|
||||
<ul className="mb-4 flex flex-col divide-y divide-border">
|
||||
{pendingMoves.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
|
||||
<span className="text-ink">
|
||||
{m.label} → {m.targetTeamLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => removeMove(m.id)}
|
||||
aria-label={`${m.label} aus der Reorganisation entfernen`}
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-ink-muted">
|
||||
<th className="py-1 pr-3">Bereich</th>
|
||||
<th className="py-1 pr-3">Vorher</th>
|
||||
<th className="py-1 pr-3">Nachher</th>
|
||||
<th className="py-1 pr-3">Δ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from(divisionDelta.entries())
|
||||
.filter(([, delta]) => delta !== 0)
|
||||
.map(([divId, delta]) => {
|
||||
const before = divisionBefore.get(divId) ?? 0;
|
||||
return (
|
||||
<tr key={divId} className="border-t border-border">
|
||||
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
|
||||
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
|
||||
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
|
||||
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
|
||||
{delta > 0 ? `+${delta}` : delta}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setPendingMoves([])}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
<Button onClick={handleApply} pending={applying}>
|
||||
Reorganisation durchführen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reorgScenarios.length > 0 && (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">↩ Durchgeführte Reorganisationen – rückgängig machbar</h2>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{reorgScenarios.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-ink">{s.name}</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">
|
||||
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Rückgängig machen
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,30 @@ export type OrgEmployee = {
|
||||
/** This person is on a long-term absence as of the chart's date. */
|
||||
absent: boolean;
|
||||
absence_type: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
/** Die Einheit der Planstelle — die einzige Verortung, die es noch gibt. */
|
||||
org_unit_id: string;
|
||||
/** Diese Planstelle führt ihre Einheit (SAP-OM: A012). */
|
||||
is_chief: boolean;
|
||||
position_id: string;
|
||||
position_number: string;
|
||||
};
|
||||
|
||||
export type OrgDivision = { id: string; org_number: string; name: string };
|
||||
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
|
||||
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
|
||||
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
|
||||
/** Eine Planstelle, die am Stichtag niemand innehat. */
|
||||
export type OrgVacancy = {
|
||||
position_id: string;
|
||||
position_number: string;
|
||||
job_title: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
};
|
||||
|
||||
export type OrgUnitNode = {
|
||||
id: string;
|
||||
org_number: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||
};
|
||||
|
||||
// Generic tree shape both EmployeeTree and PositionTree map their own data
|
||||
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
|
||||
|
||||
@@ -2,51 +2,64 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
|
||||
import { createPosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
|
||||
|
||||
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
|
||||
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht
|
||||
// es nicht. Vorher musste hier eine vorgesetzte Person gesucht werden; die
|
||||
// ergibt sich jetzt aus der Einheit, und die Frage kann gar nicht mehr falsch
|
||||
// beantwortet werden.
|
||||
export function CreatePositionModal({
|
||||
open,
|
||||
onClose,
|
||||
units,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
units: UnitOption[];
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [isLead, setIsLead] = useState(false);
|
||||
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [orgUnitId, setOrgUnitId] = useState("");
|
||||
const [isChief, setIsChief] = useState(false);
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const unit = units.find((u) => u.id === orgUnitId);
|
||||
// Der Unique-Index lässt nur eine gültige Leitung je Einheit zu. Das hier
|
||||
// vorwegzunehmen erspart eine Fehlermeldung, die in der Oberfläche nichts
|
||||
// erklärt.
|
||||
const chiefTaken = Boolean(unit?.hasChief);
|
||||
|
||||
function reset() {
|
||||
setTitle("");
|
||||
setIsLead(false);
|
||||
setSuperior(null);
|
||||
setTeamId("");
|
||||
setJobTitle("");
|
||||
setOrgUnitId("");
|
||||
setIsChief(false);
|
||||
setValidFrom(todayIso());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !superior || !validFrom || (isLead && !teamId)) {
|
||||
if (!jobTitle || !orgUnitId || !validFrom) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await createPosition({
|
||||
title,
|
||||
superior_employee_id: superior.id,
|
||||
is_lead: isLead,
|
||||
team_id: isLead ? teamId : undefined,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle,
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Position ausgeschrieben.");
|
||||
showToast("Planstelle angelegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
reset();
|
||||
@@ -59,77 +72,46 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Position ausschreiben"
|
||||
title="Planstelle anlegen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Ausschreiben
|
||||
Anlegen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Titel" required value={title} onChange={setTitle} />
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isLead}
|
||||
onChange={(e) => {
|
||||
setIsLead(e.target.checked);
|
||||
setSuperior(null);
|
||||
}}
|
||||
/>
|
||||
Führungsposition (Teamleitung)
|
||||
</label>
|
||||
{!superior ? (
|
||||
<Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
|
||||
{(p) => (
|
||||
<Lookup<SuperiorSearchResult>
|
||||
{...p}
|
||||
placeholder="Name oder Titel…"
|
||||
onSearch={(q) => searchSuperiors(q, isLead)}
|
||||
onSelect={setSuperior}
|
||||
renderResult={(r) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{r.first_name} {r.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<div>
|
||||
<p className="mb-1 block text-sm font-semibold text-ink">
|
||||
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
|
||||
</p>
|
||||
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">
|
||||
{superior.first_name} {superior.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{superior.job_title}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
|
||||
Ändern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLead && (
|
||||
<SelectField
|
||||
label="Zu leitendes Team"
|
||||
label="Organisationseinheit"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
value={orgUnitId}
|
||||
onChange={(v) => {
|
||||
setOrgUnitId(v);
|
||||
setIsChief(false);
|
||||
}}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
options={units.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${" ".repeat(u.depth * 3)}${u.name} (${u.unit_type})`,
|
||||
}))}
|
||||
/>
|
||||
<TextField
|
||||
label="Tätigkeit"
|
||||
required
|
||||
value={jobTitle}
|
||||
onChange={setJobTitle}
|
||||
hint="Bestehende Tätigkeiten werden wiederverwendet; ein neuer Name legt einen Katalogeintrag an."
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
|
||||
Leitungsplanstelle für diese Einheit
|
||||
</label>
|
||||
{chiefTaken && (
|
||||
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
|
||||
)}
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
|
||||
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Position ausschreiben
|
||||
Planstelle anlegen
|
||||
</Button>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
|
||||
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -69,7 +69,11 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
||||
<div className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
||||
seit {p.daysOpen} Tagen unbesetzt
|
||||
</div>
|
||||
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
|
||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -78,7 +82,7 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
125
lib/org.ts
125
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<string, Division>;
|
||||
departments: Map<string, Department>;
|
||||
teams: Map<string, Team>;
|
||||
units: Map<string, OrgUnit>;
|
||||
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
|
||||
unitList: OrgUnit[];
|
||||
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
|
||||
depthOf: Map<string, number>;
|
||||
childrenOf: Map<string | null, OrgUnit[]>;
|
||||
locations: Map<string, Location>;
|
||||
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<Database>): Promise<OrgMaps> {
|
||||
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<string | null, OrgUnit[]>();
|
||||
for (const u of units) {
|
||||
const list = childrenOf.get(u.parent_id) ?? [];
|
||||
list.push(u);
|
||||
childrenOf.set(u.parent_id, list);
|
||||
}
|
||||
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||||
|
||||
const unitList: OrgUnit[] = [];
|
||||
const depthOf = new Map<string, number>();
|
||||
const walk = (parentId: string | null, depth: number) => {
|
||||
for (const u of childrenOf.get(parentId) ?? []) {
|
||||
unitList.push(u);
|
||||
depthOf.set(u.id, depth);
|
||||
walk(u.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(null, 0);
|
||||
|
||||
return {
|
||||
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<string>();
|
||||
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<string>();
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht
|
||||
* über allem und trägt in einer Zeile nichts bei.
|
||||
*/
|
||||
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
|
||||
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
|
||||
return chain.map((u) => u.name).join(" › ") || "–";
|
||||
}
|
||||
|
||||
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
|
||||
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
||||
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
|
||||
}
|
||||
|
||||
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
|
||||
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
||||
return unitId ? maps.units.get(unitId) : undefined;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> };
|
||||
|
||||
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
|
||||
|
||||
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
|
||||
// their team's lead, a team lead to the division head, and anyone without a
|
||||
// team to the CEO. Only used for employees a pending change actually moves —
|
||||
// everyone else keeps the manager recorded on their assignment, so existing
|
||||
// data that deviates from the rule is never silently "corrected".
|
||||
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
|
||||
if (placement.team_id && !placement.is_lead) {
|
||||
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
|
||||
}
|
||||
if (placement.team_id && placement.is_lead) {
|
||||
return (
|
||||
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
||||
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<string, string>();
|
||||
for (const a of assignments) {
|
||||
if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id);
|
||||
}
|
||||
|
||||
// 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<string>();
|
||||
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<string | null>(
|
||||
(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 };
|
||||
}
|
||||
|
||||
115
lib/placement.ts
Normal file
115
lib/placement.ts
Normal file
@@ -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<string, Placement> {
|
||||
const byEmployee = new Map<string, Placement>();
|
||||
for (const row of rows) {
|
||||
const p = toPlacement(row, asOf);
|
||||
const best = byEmployee.get(p.employeeId);
|
||||
if (!best) {
|
||||
byEmployee.set(p.employeeId, p);
|
||||
continue;
|
||||
}
|
||||
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
|
||||
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
|
||||
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
|
||||
}
|
||||
return byEmployee;
|
||||
}
|
||||
|
||||
export async function loadPlacements(
|
||||
supabase: SupabaseClient<Database>,
|
||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
||||
): Promise<Map<string, Placement>> {
|
||||
if (employeeIds?.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchAllRows(() => {
|
||||
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
|
||||
return employeeIds ? q.in("employee_id", employeeIds) : q;
|
||||
});
|
||||
|
||||
return pickPlacements(rows as unknown as Row[], asOf);
|
||||
}
|
||||
|
||||
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
||||
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
|
||||
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
|
||||
// die nächste besetzte und anwesende darüber — beides, damit sich in der
|
||||
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
|
||||
// stillschweigend als die echte Führungskraft auszugeben.
|
||||
|
||||
export type ReportingLine = {
|
||||
employee_id: string;
|
||||
position_id: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
formal_manager_id: string | null;
|
||||
acting_manager_id: string | null;
|
||||
};
|
||||
|
||||
export async function loadReportingLines(
|
||||
supabase: SupabaseClient<Database>,
|
||||
asOf: string
|
||||
): Promise<Map<string, ReportingLine>> {
|
||||
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
|
||||
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
|
||||
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
|
||||
}
|
||||
125
lib/positions.ts
125
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<Database>): Promise<OpenPositionResolved[]> {
|
||||
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<Database>): Promise<OpenPositionResolved[]> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
const departmentName = new Map<string, string>();
|
||||
const teamName = new Map<string, string>();
|
||||
|
||||
for (const unit of orgMaps.unitList) {
|
||||
for (const a of ancestorsOf(orgMaps, unit.id)) {
|
||||
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
|
||||
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
|
||||
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
|
||||
}
|
||||
}
|
||||
|
||||
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
||||
}
|
||||
|
||||
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
||||
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,29 +75,43 @@ export async function loadDependentsCounts(supabase: SupabaseClient<Database>):
|
||||
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<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||
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) => ({
|
||||
// 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: e.job_title,
|
||||
division_id: e.division_id,
|
||||
team_id: e.team_id,
|
||||
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,
|
||||
@@ -98,16 +131,17 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
||||
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<Database>, 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<string, { from: string; to: string | null; unitId: string }[]>();
|
||||
for (const a of assignments as unknown as {
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
om_positions: { org_unit_id: string };
|
||||
}[]) {
|
||||
const list = spans.get(a.employee_id) ?? [];
|
||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
|
||||
spans.set(a.employee_id, list);
|
||||
}
|
||||
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
|
||||
const 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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
departmentNameByTeam: Map<string, string>;
|
||||
departmentName: Map<string, string>;
|
||||
teamName: Map<string, string>;
|
||||
locationName: Map<string, string>;
|
||||
};
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -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<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -276,37 +247,6 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
||||
};
|
||||
positions: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
position_number: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: PositionStatus;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
filled_at: string | null;
|
||||
filled_by_employee_id: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_number?: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id?: string;
|
||||
is_lead?: boolean;
|
||||
reports_to_employee_id?: string | null;
|
||||
status?: PositionStatus;
|
||||
valid_from?: string;
|
||||
created_at?: string;
|
||||
filled_at?: string | null;
|
||||
filled_by_employee_id?: string | null;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
|
||||
};
|
||||
hire_drafts: NoRelationships & {
|
||||
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
||||
@@ -340,34 +280,6 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
||||
};
|
||||
reorg_scenarios: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
name: string;
|
||||
effective_date: string;
|
||||
created_by: string | null;
|
||||
applied: boolean;
|
||||
applied_at: string | null;
|
||||
undo_snapshot: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
name: string;
|
||||
effective_date: string;
|
||||
created_by?: string | null;
|
||||
applied?: boolean;
|
||||
applied_at?: string | null;
|
||||
undo_snapshot?: Record<string, unknown> | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
|
||||
};
|
||||
reorg_moves: NoRelationships & {
|
||||
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
|
||||
};
|
||||
pending_org_changes: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -375,7 +287,6 @@ export type Database = {
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
reorg_scenario_id?: string | null;
|
||||
status?: PendingChangeStatus;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
@@ -395,11 +305,17 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
// 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<Database["public"]["Tables"]["jobs"]["Insert"]>;
|
||||
};
|
||||
// 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<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||
Relationships: [];
|
||||
};
|
||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
||||
position_assignments: {
|
||||
@@ -473,26 +402,23 @@ export type Database = {
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
|
||||
// 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<string, never>;
|
||||
@@ -510,12 +436,11 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
om_reporting_lines: {
|
||||
Args: { p_as_of?: string };
|
||||
|
||||
140
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
140
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
@@ -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;
|
||||
@@ -451,7 +451,6 @@ function newHireBase(jobTitle: string) {
|
||||
|
||||
const employees: EmployeeRow[] = [];
|
||||
const history: HistoryRow[] = [];
|
||||
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||
|
||||
function finalizeEmployee(
|
||||
base: ReturnType<typeof newHireBase>,
|
||||
@@ -749,8 +748,6 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], 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",
|
||||
|
||||
@@ -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<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function assignmentsFor(employeeId: string) {
|
||||
const { data } = await adminClient
|
||||
.from("employee_assignments")
|
||||
.select("team_id, job_title, valid_from, valid_to")
|
||||
.eq("employee_id", employeeId)
|
||||
.order("valid_from");
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
it("opens an interval when an employee is hired", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the old interval and opens a new one on transfer", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBe(isoDateOffset(0));
|
||||
expect(rows[1].team_id).toBe(teamB.id);
|
||||
expect(rows[1].valid_to).toBeNull();
|
||||
// Intervals must abut exactly, or an as-of query lands in a gap.
|
||||
expect(rows[1].valid_from).toBe(rows[0].valid_to);
|
||||
});
|
||||
|
||||
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
|
||||
});
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
expect(rows.at(-1)?.team_id).toBe(teamA.id);
|
||||
});
|
||||
|
||||
it("records a promotion's new title as its own interval", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("promote_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
|
||||
expect(rows.at(-1)?.valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("writes no new interval when nothing about the placement changed", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const before = await assignmentsFor(employeeId);
|
||||
|
||||
const { error } = await hrClient.rpc("change_employee_data", {
|
||||
payload: {
|
||||
employee_id: employeeId,
|
||||
effective_date: isoDateOffset(0),
|
||||
person: { phone: "+43 1 2345678" },
|
||||
contract: {},
|
||||
role: {},
|
||||
},
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
|
||||
});
|
||||
|
||||
it("keeps exactly one open interval per employee", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is not readable without an active HR session", async () => {
|
||||
const outsider = await createHrUser({ active: false });
|
||||
const outsiderClient = await signInAs(outsider);
|
||||
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
|
||||
expect(data ?? []).toHaveLength(0);
|
||||
await deleteTestUser(outsider);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Database>;
|
||||
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<string> {
|
||||
const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
|
||||
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({
|
||||
|
||||
@@ -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<Database>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) });
|
||||
positionIds.push(positionId);
|
||||
return positionId;
|
||||
}
|
||||
|
||||
async function freshEmployee(unitId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, await freshPosition(unitId));
|
||||
employeeIds.push(id);
|
||||
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);
|
||||
|
||||
|
||||
@@ -55,31 +55,57 @@ export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>
|
||||
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<string | null> {
|
||||
/** Wer die Leitungsplanstelle einer Einheit laufend innehat, falls jemand. */
|
||||
export async function chiefOfUnit(orgUnitId: string): Promise<string | null> {
|
||||
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<Database>,
|
||||
teamId: string,
|
||||
positionId: string,
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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<Database>,
|
||||
superiorEmployeeId: string,
|
||||
orgUnitId: string,
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
216
tests/integration/position-assignments.test.ts
Normal file
216
tests/integration/position-assignments.test.ts
Normal file
@@ -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<Database>;
|
||||
let unitA: { id: string };
|
||||
let unitB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
const positionIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
unitA = await pickSeededUnit();
|
||||
unitB = await pickSeededUnit(unitA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Planstellen vor den Personen: die Besetzungen hängen an beiden.
|
||||
for (const id of positionIds) await deleteTestPosition(id);
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshPosition(orgUnitId: string): Promise<string> {
|
||||
const id = await createTestPosition(hrClient, orgUnitId);
|
||||
positionIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function freshEmployee(positionId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, positionId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function assignmentsFor(employeeId: string) {
|
||||
const { data } = await adminClient
|
||||
.from("position_assignments")
|
||||
.select("position_id, valid_from, valid_to")
|
||||
.eq("employee_id", employeeId)
|
||||
.order("valid_from");
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
it("öffnet eine Besetzung bei der Einstellung", async () => {
|
||||
const positionId = await freshPosition(unitA.id);
|
||||
const employeeId = await freshEmployee(positionId);
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].position_id).toBe(positionId);
|
||||
expect(rows[0].valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("übernimmt die Tätigkeit aus dem Job der Planstelle", async () => {
|
||||
// Sie wird bei der Einstellung nicht mitgegeben — sonst könnten die
|
||||
// Tätigkeit auf der Person und die der Planstelle auseinanderlaufen.
|
||||
const positionId = await freshPosition(unitA.id);
|
||||
const employeeId = await freshEmployee(positionId);
|
||||
|
||||
const { data: position } = await adminClient
|
||||
.from("om_positions")
|
||||
.select("jobs!inner(title)")
|
||||
.eq("id", positionId)
|
||||
.single();
|
||||
const { data: employee } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
|
||||
|
||||
expect(employee?.job_title).toBe((position as unknown as { jobs: { title: string } }).jobs.title);
|
||||
});
|
||||
|
||||
it("weist eine Einstellung auf eine bereits besetzte Planstelle zurück", async () => {
|
||||
// Der Unique-Index fängt das ebenfalls ab; die RPC soll es vorher mit
|
||||
// einer Meldung tun, die in der Oberfläche etwas erklärt.
|
||||
const positionId = await freshPosition(unitA.id);
|
||||
await freshEmployee(positionId);
|
||||
|
||||
await expect(hireTestEmployee(hrClient, positionId)).rejects.toThrow(/bereits besetzt/i);
|
||||
});
|
||||
|
||||
it("schliesst die alte Besetzung und öffnet die neue bei einer Versetzung", async () => {
|
||||
const from = await freshPosition(unitA.id);
|
||||
const to = await freshPosition(unitB.id);
|
||||
const employeeId = await freshEmployee(from);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].position_id).toBe(from);
|
||||
expect(rows[0].valid_to).toBe(isoDateOffset(0));
|
||||
expect(rows[1].position_id).toBe(to);
|
||||
expect(rows[1].valid_to).toBeNull();
|
||||
// Die Intervalle müssen exakt aneinanderstossen, sonst landet eine
|
||||
// Stichtagsabfrage in einer Lücke.
|
||||
expect(rows[1].valid_from).toBe(rows[0].valid_to);
|
||||
});
|
||||
|
||||
it("hält höchstens eine laufende Besetzung je Person", async () => {
|
||||
const from = await freshPosition(unitA.id);
|
||||
const to = await freshPosition(unitB.id);
|
||||
const employeeId = await freshEmployee(from);
|
||||
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: to },
|
||||
});
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("weist eine Versetzung auf eine besetzte Zielplanstelle zurück", async () => {
|
||||
const besetzt = await freshPosition(unitA.id);
|
||||
await freshEmployee(besetzt);
|
||||
const andere = await freshPosition(unitB.id);
|
||||
const employeeId = await freshEmployee(andere);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: besetzt },
|
||||
});
|
||||
expect(error?.message).toMatch(/bereits besetzt/i);
|
||||
});
|
||||
|
||||
it("merkt eine Versetzung in der Zukunft vor, statt sie sofort zu schreiben", async () => {
|
||||
const from = await freshPosition(unitA.id);
|
||||
const to = await freshPosition(unitB.id);
|
||||
const employeeId = await freshEmployee(from);
|
||||
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: to },
|
||||
});
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].position_id).toBe(from);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("change_type, effective_date, payload, status")
|
||||
.eq("employee_id", employeeId);
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending?.[0].status).toBe("pending");
|
||||
expect((pending?.[0].payload as { target_position_id: string }).target_position_id).toBe(to);
|
||||
});
|
||||
|
||||
it("gibt die Planstelle beim Austritt frei", async () => {
|
||||
const positionId = await freshPosition(unitA.id);
|
||||
const employeeId = await freshEmployee(positionId);
|
||||
|
||||
const { error } = await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: employeeId, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(0);
|
||||
expect(rows.at(-1)?.valid_to).toBe(isoDateOffset(0));
|
||||
});
|
||||
|
||||
it("hinterlässt keine überschneidenden Besetzungen auf einer Planstelle", async () => {
|
||||
// Der Unique-Index deckt nur die *laufende* Besetzung ab; die Historie
|
||||
// könnte sich unbemerkt überschneiden.
|
||||
const positionId = await freshPosition(unitA.id);
|
||||
const ersteR = await freshEmployee(positionId);
|
||||
await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: ersteR, exit_date: isoDateOffset(0), exit_reason: "Kündigung AN" },
|
||||
});
|
||||
await freshEmployee(positionId, );
|
||||
|
||||
const { data } = await adminClient
|
||||
.from("position_assignments")
|
||||
.select("valid_from, valid_to")
|
||||
.eq("position_id", positionId)
|
||||
.order("valid_from");
|
||||
|
||||
const rows = data ?? [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const vorher = rows[i - 1];
|
||||
expect(vorher.valid_to === null || vorher.valid_to <= rows[i].valid_from, `Besetzung ${i} überschneidet`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ist ohne aktive HR-Sitzung nicht lesbar", async () => {
|
||||
const outsider = await createHrUser({ active: false });
|
||||
const outsiderClient = await signInAs(outsider);
|
||||
const { data } = await outsiderClient.from("position_assignments").select("id").limit(1);
|
||||
expect(data ?? []).toHaveLength(0);
|
||||
await deleteTestUser(outsider);
|
||||
});
|
||||
});
|
||||
@@ -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<Database>;
|
||||
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 },
|
||||
});
|
||||
expect(error?.message).toMatch(/erst ab .* gültig/);
|
||||
await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
|
||||
});
|
||||
|
||||
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<Database>;
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(0),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
expect(scenarioId).toBeTruthy();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenario } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenario?.applied).toBe(true);
|
||||
expect(scenario?.applied_at).not.toBeNull();
|
||||
|
||||
const { count: historyBeforeUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyBeforeUndo).toBe(1);
|
||||
|
||||
const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } });
|
||||
expect(undoError).toBeNull();
|
||||
|
||||
const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(revertedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single();
|
||||
expect(undoneScenario?.applied).toBe(false);
|
||||
|
||||
// The original "Reorganisation" row must still be there — undo appends
|
||||
// a compensating entry, it never deletes (the bug the migration fixed).
|
||||
const { count: historyAfterUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyAfterUndo).toBe(2);
|
||||
});
|
||||
|
||||
it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(30),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
|
||||
const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(unchangedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: scenarioBefore } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioBefore?.applied).toBe(false);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("reorg_scenario_id", scenarioId as string)
|
||||
.eq("status", "pending")
|
||||
.single();
|
||||
expect(pending).not.toBeNull();
|
||||
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
const { error: cronError } = await adminClient.rpc("apply_due_pending_changes");
|
||||
expect(cronError).toBeNull();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenarioAfter } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioAfter?.applied).toBe(true);
|
||||
expect(scenarioAfter?.applied_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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<Database>;
|
||||
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<string, unknown> = {}): Promise<string> {
|
||||
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,
|
||||
|
||||
@@ -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("–");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof resolveOrgSnapshot>[0]["employees"][number];
|
||||
type AssignmentInput = Parameters<typeof resolveOrgSnapshot>[0]["assignments"][number];
|
||||
type Args = Parameters<typeof resolveOrgSnapshot>[0];
|
||||
|
||||
function emp(id: string, overrides: Partial<EmployeeInput> = {}): 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]> = {}): 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> = {}): EmployeeInput
|
||||
};
|
||||
}
|
||||
|
||||
function assignment(employeeId: string, overrides: Partial<AssignmentInput> = {}): 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<Args> & { 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<Parameters<typeof resolveOrgSnapshot>[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("ü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" }],
|
||||
});
|
||||
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
it("falls back to the employee row when no assignment covers the date", () => {
|
||||
const employees = [emp("a", { team_id: TEAM_B })];
|
||||
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments: [] }).employees;
|
||||
expect(result.team_id).toBe(TEAM_B);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orphan re-rooting", () => {
|
||||
// Without this the whole reporting line below an absent manager silently
|
||||
// disappears from the chart instead of moving up a level.
|
||||
it("drops a manager reference to somebody not employed on that date", () => {
|
||||
const employees = [
|
||||
emp("boss", { exit_date: "2026-01-01", org_level: 2, is_lead: true }),
|
||||
emp("report", { manager_id: "boss" }),
|
||||
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" },
|
||||
];
|
||||
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();
|
||||
|
||||
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("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("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("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");
|
||||
});
|
||||
});
|
||||
|
||||
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 } }];
|
||||
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" },
|
||||
];
|
||||
|
||||
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("ü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" } }],
|
||||
});
|
||||
|
||||
it("ignores pending changes for a date the caller did not ask about", () => {
|
||||
// loadOrgAsOf only fetches pending rows for a future date, so an empty
|
||||
// list here must simply mean "no projection", not "drop the employee".
|
||||
const employees = [emp("a")];
|
||||
const assignments = [assignment("a")];
|
||||
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending: [] });
|
||||
expect(result.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");
|
||||
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" }],
|
||||
});
|
||||
|
||||
it("is null when nothing is recorded yet", () => {
|
||||
expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,7 @@ function emp(overrides: Partial<ReportEmployee> = {}): 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> = {}): 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" });
|
||||
|
||||
Reference in New Issue
Block a user