Compare commits
10 Commits
2776c33d08
...
730521ee79
| Author | SHA1 | Date | |
|---|---|---|---|
| 730521ee79 | |||
| acbb03c4f5 | |||
| ee1a38492c | |||
| 2cce101c4b | |||
| 27669e0359 | |||
| 4929252f45 | |||
| c2366e3408 | |||
| cce5c6b0ce | |||
| 35f17858d8 | |||
| 4b9c23472c |
@@ -1,20 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
// Anmeldung ausschliesslich über Entra ID (in Supabase heisst der Anbieter
|
||||
// „Azure"). Es gibt bewusst keinen Passwort-Pfad mehr: ein zweiter Anmeldeweg
|
||||
// neben dem Firmenkonto hebelt jede Vorgabe des Mandanten aus — Mehrfaktor,
|
||||
// bedingten Zugriff, Sperrung beim Austritt.
|
||||
//
|
||||
// Für die Datenbank ändert sich dadurch nichts. auth.uid() liefert weiterhin
|
||||
// eine UUID, profiles.id trägt weiterhin role und is_active, und damit bleiben
|
||||
// is_hr_user() und alle darauf gebauten RLS-Policies unverändert gültig.
|
||||
|
||||
export async function signInWithEntra() {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
// Die Herkunft kommt aus dem Request statt aus einer Umgebungsvariablen,
|
||||
// damit lokal, Vorschau und Produktion denselben Code benutzen. Supabase
|
||||
// nimmt das Ziel nur an, wenn es in der Redirect-Allowlist des Projekts
|
||||
// steht — ein untergeschobener Host läuft also ins Leere.
|
||||
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
|
||||
|
||||
redirect("/");
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "azure",
|
||||
options: {
|
||||
// openid/profile/email sind das Minimum für Anmeldung und Anzeigename.
|
||||
// Weitere Berechtigungen holt sich die Anwendung bewusst nicht.
|
||||
scopes: "openid profile email",
|
||||
redirectTo: `${origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error || !data.url) redirect("/login?error=sso_failed");
|
||||
redirect(data.url);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
|
||||
@@ -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,14 +1,20 @@
|
||||
import { login, logout } from "@/actions/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS } from "@/components/ui/Field";
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen.
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
|
||||
// für die Fehlertexte, die Entra im Rückweg mitschickt.
|
||||
const ERROR_MESSAGES = {
|
||||
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
||||
no_hr_access: {
|
||||
title: "Kein HR-Zugriff",
|
||||
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
},
|
||||
sso_failed: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -23,56 +29,105 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const error = code ? ERROR_MESSAGES[code] : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
||||
// im ersten Moment unter dem Faltenrand sitzt.
|
||||
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
||||
<BrandPanel />
|
||||
|
||||
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="lg:hidden">
|
||||
<Wordmark className="text-ink" />
|
||||
</div>
|
||||
|
||||
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
||||
<p className="text-sm font-bold text-danger-text">{error.title}</p>
|
||||
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
<form action={logout} className="mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
|
||||
>
|
||||
Abmelden und mit einem anderen Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" fullWidth className="mt-2">
|
||||
Anmelden
|
||||
</Button>
|
||||
<form action={signInWithEntra} className="mt-7">
|
||||
<EntraSignInButton />
|
||||
</form>
|
||||
|
||||
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
|
||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
||||
Personaldaten verschlossen.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
||||
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
|
||||
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
|
||||
ohne von der einen Schaltfläche gegenüber abzulenken. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
|
||||
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
|
||||
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
|
||||
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
|
||||
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Wordmark className="text-white" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
Stichtag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
|
||||
drei Einheiten. */}
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
|
||||
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
|
||||
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
|
||||
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
||||
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
|
||||
</svg>
|
||||
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
30
app/auth/callback/route.ts
Normal file
30
app/auth/callback/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
// Rückweg aus Entra ID. @supabase/ssr benutzt PKCE, das heisst der Anbieter
|
||||
// liefert einen einmaligen Code, der hier gegen eine Sitzung getauscht wird.
|
||||
// Ohne diese Route landet die Anmeldung in einer Schleife: der Code steht in
|
||||
// der URL, aber es entsteht nie ein Sitzungscookie, und der Proxy schickt
|
||||
// zurück auf /login.
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams, origin } = request.nextUrl;
|
||||
|
||||
// Entra meldet abgelehnte Zustimmung oder gesperrte Konten als Fehler
|
||||
// zurück. Der Text daraus wird nicht angezeigt — er ist fremdbestimmt und
|
||||
// stünde sonst auf der echten, korrekt gebrandeten Anmeldeseite.
|
||||
if (searchParams.get("error")) {
|
||||
return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
if (!code) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||
if (error) return NextResponse.redirect(`${origin}/login?error=sso_failed`);
|
||||
|
||||
// Ob die Person HR-Zugriff hat, entscheidet nicht diese Route, sondern
|
||||
// proxy.ts anhand von profiles.role/is_active — und darunter, unabhängig
|
||||
// davon, die RLS-Policies. Hier wird nur die Sitzung hergestellt.
|
||||
return NextResponse.redirect(`${origin}/`);
|
||||
}
|
||||
32
components/auth/EntraSignInButton.tsx
Normal file
32
components/auth/EntraSignInButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
|
||||
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
|
||||
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
|
||||
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
|
||||
// fremde Anmeldeseite springt.
|
||||
export function EntraSignInButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
|
||||
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||
</svg>
|
||||
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
148
docs/azure-migration.md
Normal file
148
docs/azure-migration.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Umstieg auf Azure — Sicherheitsarchitektur
|
||||
|
||||
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
|
||||
|
||||
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
|
||||
Supabase als Abhängigkeit entfernt.
|
||||
|
||||
## Ausgangslage, gemessen
|
||||
|
||||
| | Anzahl |
|
||||
|---|---|
|
||||
| RLS-Policies | 58 |
|
||||
| `auth.uid()` / `auth.users` in Migrationen | 79 |
|
||||
| Fremdschlüssel auf `auth.users` | 9 |
|
||||
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
|
||||
| Dateien mit Supabase-Import | 10 |
|
||||
|
||||
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
|
||||
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
|
||||
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
|
||||
Proxy und nicht der Anwendungscode.
|
||||
|
||||
## Der entscheidende Befund
|
||||
|
||||
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
|
||||
Stelle:
|
||||
|
||||
```sql
|
||||
create or replace function is_hr_user() returns boolean
|
||||
language sql security definer stable as $$
|
||||
select exists (
|
||||
select 1 from profiles p
|
||||
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
|
||||
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
|
||||
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
|
||||
war meine Sorge bei Variante B, und sie ist ausgeräumt.
|
||||
|
||||
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
|
||||
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
|
||||
|
||||
## Zielarchitektur
|
||||
|
||||
### 1. Benutzertabelle statt `auth.users`
|
||||
|
||||
```sql
|
||||
create table app_users (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
|
||||
email text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
```
|
||||
|
||||
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
|
||||
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
|
||||
funktioniert unverändert.
|
||||
|
||||
### 2. Sitzungskontext statt `auth.uid()`
|
||||
|
||||
```sql
|
||||
create or replace function current_app_user() returns uuid
|
||||
language sql stable as $$
|
||||
select nullif(current_setting('app.user_id', true), '')::uuid;
|
||||
$$;
|
||||
```
|
||||
|
||||
`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst
|
||||
Wort für Wort gleich.
|
||||
|
||||
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
|
||||
|
||||
**Hier entscheidet sich, ob die Migration sicher ist.**
|
||||
|
||||
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
|
||||
`set local app.user_id` ausführt:
|
||||
|
||||
```ts
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
|
||||
return tx.select()…;
|
||||
});
|
||||
```
|
||||
|
||||
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
|
||||
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
|
||||
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
|
||||
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
|
||||
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
|
||||
|
||||
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
|
||||
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
|
||||
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
|
||||
Das muss strukturell unmöglich sein, nicht per Konvention.
|
||||
|
||||
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
||||
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
|
||||
zurück — statt alles.
|
||||
|
||||
### 4. Anmeldung
|
||||
|
||||
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
|
||||
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
|
||||
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
|
||||
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
|
||||
(`is_active` ist per Vorgabe `false`).
|
||||
|
||||
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
|
||||
|
||||
### 5. Datenzugriff
|
||||
|
||||
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
|
||||
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
|
||||
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
|
||||
|
||||
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
|
||||
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
|
||||
|
||||
## Was bewusst gleich bleibt
|
||||
|
||||
- **Alle 58 RLS-Policies**, unverändert
|
||||
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
|
||||
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
|
||||
- Die Geschäftslogik in den RPCs
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
|
||||
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
|
||||
3. Entra-ID-Anmeldung
|
||||
4. Die 50 Datenzugriffe umstellen
|
||||
5. Supabase-Pakete entfernen
|
||||
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
|
||||
|
||||
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
|
||||
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
|
||||
prüft, dass die zweite die erste nicht sieht.
|
||||
|
||||
## Offene Fragen an die Kunden-IT
|
||||
|
||||
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
|
||||
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
|
||||
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
|
||||
- Wer betreibt und patcht?
|
||||
121
docs/entra-sso.md
Normal file
121
docs/entra-sso.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Anmeldung über Entra ID
|
||||
|
||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Supabase Auth
|
||||
bleibt dabei die Sitzungsverwaltung — Entra ist der Anbieter, nicht der Ersatz.
|
||||
|
||||
**Das ist der Grund, warum der Umstieg klein ist:** `auth.uid()` liefert
|
||||
weiterhin eine UUID, `profiles.id` trägt weiterhin `role` und `is_active`, und
|
||||
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
||||
|
||||
## Einrichtung im Entra-Mandanten
|
||||
|
||||
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Name | Alpenwerk HR |
|
||||
| Kontotypen | Nur ein Mandant |
|
||||
| Umleitungs-URI (Web) | `https://<projekt-ref>.supabase.co/auth/v1/callback` |
|
||||
| Anwendungs-ID (Client) | `<client-id>` |
|
||||
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
||||
|
||||
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Schlüssel gibt
|
||||
eine Projekt-URL nichts her, und RLS greift ohnehin —, aber sie zeigen auf die
|
||||
echte Umgebung, und dieses Repository wandert weiter als sie.
|
||||
|
||||
Der *Wert* des Client-Geheimnisses gehört ausschliesslich ins Supabase-Feld
|
||||
„Secret Value" und in keine Datei im Projekt.
|
||||
|
||||
Die Umleitungs-URI ist **Supabases** Callback, nicht der der Anwendung. Der
|
||||
eigene Callback (`/auth/callback`) steht nur in der Redirect-Allowlist des
|
||||
Supabase-Projekts.
|
||||
|
||||
Danach:
|
||||
|
||||
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
||||
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
||||
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
||||
delegiert), Administratorzustimmung erteilen.
|
||||
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
||||
|
||||
## Einrichtung in Supabase
|
||||
|
||||
Authentication → Providers → Azure:
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Application (Client) ID | `<client-id>` |
|
||||
| Secret Value | der Wert aus „Zertifikate & Geheimnisse" |
|
||||
| Azure Tenant URL | `https://login.microsoftonline.com/<tenant-id>` |
|
||||
|
||||
Die Tenant URL ist bei „Nur ein Mandant" nicht optional. Bleibt sie leer,
|
||||
benutzt Supabase `common`, und Entra weist die Anmeldung ab, weil die
|
||||
Registrierung nur den eigenen Mandanten akzeptiert.
|
||||
|
||||
Authentication → URL Configuration:
|
||||
|
||||
- Site URL: die Produktions-URL
|
||||
- Redirect URLs: `http://localhost:3000/auth/callback` und
|
||||
`https://<produktion>/auth/callback`
|
||||
|
||||
## Freischaltung über die Entra-Gruppe
|
||||
|
||||
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt. Diese Zeile
|
||||
entsteht aus der Mitgliedschaft in einer Entra-Gruppe.
|
||||
|
||||
### Woher der Gruppen-Anspruch kommt
|
||||
|
||||
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
|
||||
ist. Zwei Varianten:
|
||||
|
||||
| Variante | Lizenz | Haken |
|
||||
|---|---|---|
|
||||
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
||||
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
||||
|
||||
### Warum die Auswertung aus `auth.identities` liest, nicht aus `auth.users`
|
||||
|
||||
Das ist kein Detail, sondern der Kern der Absicherung.
|
||||
|
||||
`auth.users.raw_user_meta_data` ist **von der angemeldeten Person selbst
|
||||
beschreibbar** — `supabase.auth.updateUser({ data: … })` schreibt genau dorthin.
|
||||
Läse die Freischaltung von dort, könnte sich jede:r Angemeldete den HR-Anspruch
|
||||
selbst eintragen und hätte damit Zugriff auf sämtliche Personaldaten.
|
||||
|
||||
`auth.identities.identity_data` schreibt ausschliesslich GoTrue aus der Antwort
|
||||
des Anbieters. Nur das ist eine belastbare Quelle.
|
||||
|
||||
### Reihenfolge
|
||||
|
||||
Der Trigger wird erst gebaut, wenn feststeht, wie der Anspruch tatsächlich
|
||||
ankommt — das hängt an der gewählten Variante und an der Konfiguration des
|
||||
Mandanten. Ablauf:
|
||||
|
||||
1. SSO in Betrieb nehmen, einmal anmelden.
|
||||
2. `node --env-file=.env.local supabase/entra-claims.ts <e-mail>` zeigt, was in
|
||||
`identity_data` gelandet ist.
|
||||
3. Erst dann die Migration mit der konkreten Gruppen-ID schreiben.
|
||||
|
||||
Ohne Schritt 2 wäre die Migration geraten.
|
||||
|
||||
### Was die Gruppe nicht kann
|
||||
|
||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert
|
||||
den Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den
|
||||
sofortigen Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt
|
||||
beim nächsten Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
||||
|
||||
## Bestehende Zugänge
|
||||
|
||||
Ein bestehendes Konto mit Passwort-Anmeldung und ein Entra-Konto derselben
|
||||
Person sind für Supabase **zwei verschiedene Benutzer** mit verschiedenen IDs.
|
||||
Die `profiles`-Zeile hängt an der alten ID; nach der ersten Entra-Anmeldung
|
||||
zeigt sie ins Leere und die Person ist ausgesperrt.
|
||||
|
||||
`supabase/relink-profile.ts` hängt sie um. Es überträgt auch die
|
||||
Fremdschlüssel, die auf die alte Benutzer-ID zeigen (`audit_log.actor_user_id`,
|
||||
`employee_notes.author_user_id`, …), sonst stünde in der Historie eine Kennung,
|
||||
zu der es kein Konto mehr gibt.
|
||||
85
lib/om-reporting.ts
Normal file
85
lib/om-reporting.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum.
|
||||
//
|
||||
// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei
|
||||
// Fassungen derselben Regel driften auseinander, deshalb prüft
|
||||
// tests/integration/om-reporting.test.ts beide gegen denselben Bestand.
|
||||
// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin
|
||||
// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je
|
||||
// Stichtagswechsel nichts brächte.
|
||||
//
|
||||
// Regel:
|
||||
// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
|
||||
// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der
|
||||
// übergeordneten Einheit.
|
||||
//
|
||||
// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es
|
||||
// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine
|
||||
// Sonderbehandlung — sie wird übersprungen.
|
||||
|
||||
export type OmUnit = { id: string; parentId: string | null };
|
||||
|
||||
export type OmHolder = {
|
||||
employeeId: string;
|
||||
positionId: string;
|
||||
orgUnitId: string;
|
||||
isChief: boolean;
|
||||
/** Langzeitabwesend am Stichtag. */
|
||||
absent: boolean;
|
||||
};
|
||||
|
||||
export type OmReportingLine = {
|
||||
employeeId: string;
|
||||
positionId: string;
|
||||
orgUnitId: string;
|
||||
isChief: boolean;
|
||||
/** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */
|
||||
formalManagerId: string | null;
|
||||
/** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */
|
||||
actingManagerId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `units` und `holders` beschreiben den Stand zu genau einem Stichtag —
|
||||
* gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst
|
||||
* draußen, damit diese Funktion nur eine Sache tut.
|
||||
*/
|
||||
export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] {
|
||||
const parentOf = new Map(units.map((u) => [u.id, u.parentId]));
|
||||
const chiefOfUnit = new Map<string, OmHolder>();
|
||||
for (const h of holders) {
|
||||
if (h.isChief) chiefOfUnit.set(h.orgUnitId, h);
|
||||
}
|
||||
|
||||
return holders.map((h) => {
|
||||
// Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der
|
||||
// eigenen — sonst berichtete eine Leitung an sich selbst.
|
||||
const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId;
|
||||
|
||||
const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null;
|
||||
|
||||
// Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der
|
||||
// Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche
|
||||
// Spalte, und ein fehlerhafter Import kann einen Ring erzeugen.
|
||||
let actingManagerId: string | null = null;
|
||||
const seen = new Set<string>();
|
||||
let unitId: string | null = baseUnitId;
|
||||
while (unitId && !seen.has(unitId)) {
|
||||
seen.add(unitId);
|
||||
const chief = chiefOfUnit.get(unitId);
|
||||
if (chief && !chief.absent && chief.employeeId !== h.employeeId) {
|
||||
actingManagerId = chief.employeeId;
|
||||
break;
|
||||
}
|
||||
unitId = parentOf.get(unitId) ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
employeeId: h.employeeId,
|
||||
positionId: h.positionId,
|
||||
orgUnitId: h.orgUnitId,
|
||||
isChief: h.isChief,
|
||||
formalManagerId: formal?.employeeId ?? null,
|
||||
actingManagerId,
|
||||
};
|
||||
});
|
||||
}
|
||||
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 };
|
||||
|
||||
@@ -18,6 +18,8 @@ export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "
|
||||
// union (not a string literal) so a future hr_admin/hr_user split, if ever
|
||||
// technically required, is a type-level addition, not a rewrite.
|
||||
export type ProfileRole = "hr";
|
||||
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
|
||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||
export type HistoryEventType =
|
||||
| "Eintritt"
|
||||
| "Beförderung"
|
||||
@@ -30,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"
|
||||
@@ -49,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 };
|
||||
@@ -117,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. */
|
||||
@@ -166,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;
|
||||
@@ -208,7 +183,6 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -217,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"]>;
|
||||
@@ -274,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 };
|
||||
@@ -338,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;
|
||||
@@ -373,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;
|
||||
@@ -385,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;
|
||||
@@ -393,24 +305,120 @@ 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.
|
||||
employee_assignments: NoRelationships & {
|
||||
// ── SAP-OM-Modell ──────────────────────────────────────────
|
||||
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
|
||||
org_units: {
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "org_units_parent_id_fkey";
|
||||
columns: ["parent_id"];
|
||||
referencedRelation: "org_units";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
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;
|
||||
org_number: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
unit_type: OrgUnitType;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: never;
|
||||
Update: never;
|
||||
Insert: {
|
||||
id?: string;
|
||||
org_number: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
unit_type: OrgUnitType;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["org_units"]["Insert"]>;
|
||||
};
|
||||
// C: Katalog der Tätigkeiten.
|
||||
jobs: NoRelationships & {
|
||||
Row: { id: string; code: string; title: string; created_at: string };
|
||||
Insert: { id?: string; code: string; title: string; created_at?: string };
|
||||
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
|
||||
};
|
||||
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
|
||||
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
|
||||
om_positions: {
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "om_positions_org_unit_id_fkey";
|
||||
columns: ["org_unit_id"];
|
||||
referencedRelation: "org_units";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "om_positions_job_id_fkey";
|
||||
columns: ["job_id"];
|
||||
referencedRelation: "jobs";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
Row: {
|
||||
id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
job_id: string;
|
||||
is_chief: boolean;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
job_id: string;
|
||||
is_chief?: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||
};
|
||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
||||
position_assignments: {
|
||||
Row: {
|
||||
id: string;
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
|
||||
// Beide Richtungen: über die Planstelle hängt die Verortung in der
|
||||
// Organisation, über die Person die Verortung in der Akte. Die
|
||||
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "position_assignments_position_id_fkey";
|
||||
columns: ["position_id"];
|
||||
referencedRelation: "om_positions";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "position_assignments_employee_id_fkey";
|
||||
columns: ["employee_id"];
|
||||
referencedRelation: "employees";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
Views: Record<string, never>;
|
||||
@@ -428,13 +436,23 @@ 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 };
|
||||
Returns: {
|
||||
employee_id: string;
|
||||
position_id: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
formal_manager_id: string | null;
|
||||
acting_manager_id: string | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -36,6 +36,9 @@
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22 <25"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
||||
5
proxy.ts
5
proxy.ts
@@ -38,6 +38,11 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||
|
||||
// Der Rückweg aus Entra muss durch, bevor es eine Sitzung gibt — dort wird
|
||||
// sie ja erst hergestellt. Ohne diese Ausnahme leitet der Gate den Code
|
||||
// nach /login um und die Anmeldung kommt nie zustande.
|
||||
if (request.nextUrl.pathname.startsWith("/auth/callback")) return response;
|
||||
|
||||
if (!user) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
|
||||
149
supabase/build-org.ts
Normal file
149
supabase/build-org.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen
|
||||
// Bereichsdefinition des Seeds.
|
||||
//
|
||||
// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die
|
||||
// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen
|
||||
// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem
|
||||
// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm
|
||||
// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests.
|
||||
|
||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||
|
||||
export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
|
||||
export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] };
|
||||
export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
|
||||
|
||||
export type BuiltUnit = {
|
||||
id: string;
|
||||
org_number: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
unit_type: OrgUnitType;
|
||||
};
|
||||
|
||||
export type BuiltJob = { id: string; code: string; title: string };
|
||||
|
||||
export type BuiltPosition = {
|
||||
id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
job_id: string;
|
||||
is_chief: boolean;
|
||||
};
|
||||
|
||||
export type BuiltOrg = {
|
||||
units: BuiltUnit[];
|
||||
jobs: BuiltJob[];
|
||||
positions: BuiltPosition[];
|
||||
/** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */
|
||||
icPositionsByTeam: Map<string, BuiltPosition[]>;
|
||||
};
|
||||
|
||||
// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den
|
||||
// Umstieg hinweg wiedererkennbar bleiben.
|
||||
const PREFIX: Record<OrgUnitType, string> = {
|
||||
Gesellschaft: "10",
|
||||
Bereich: "20",
|
||||
Abteilung: "21",
|
||||
Team: "22",
|
||||
};
|
||||
|
||||
function orgNumber(type: OrgUnitType, counter: number): string {
|
||||
return `${PREFIX[type]}${String(counter).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */
|
||||
function positionNumber(counter: number): string {
|
||||
return `6${String(counter).padStart(7, "0")}`;
|
||||
}
|
||||
|
||||
function jobCode(counter: number): string {
|
||||
return `J${String(counter).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `newId` wird hereingereicht, damit die Funktion in Tests deterministisch
|
||||
* bleibt und im Seed randomUUID benutzt.
|
||||
*/
|
||||
export function buildOrg(
|
||||
companyName: string,
|
||||
divisions: DivisionDef[],
|
||||
newId: () => string
|
||||
): BuiltOrg {
|
||||
const units: BuiltUnit[] = [];
|
||||
const positions: BuiltPosition[] = [];
|
||||
const icPositionsByTeam = new Map<string, BuiltPosition[]>();
|
||||
|
||||
// Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title
|
||||
// Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen.
|
||||
const jobIdByTitle = new Map<string, string>();
|
||||
const jobs: BuiltJob[] = [];
|
||||
function jobFor(title: string): string {
|
||||
const existing = jobIdByTitle.get(title);
|
||||
if (existing) return existing;
|
||||
const job = { id: newId(), code: jobCode(jobs.length + 1), title };
|
||||
jobs.push(job);
|
||||
jobIdByTitle.set(title, job.id);
|
||||
return job.id;
|
||||
}
|
||||
|
||||
let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 };
|
||||
let positionCounter = 0;
|
||||
|
||||
function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit {
|
||||
unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 };
|
||||
const unit: BuiltUnit = {
|
||||
id: newId(),
|
||||
org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)),
|
||||
name,
|
||||
parent_id: parentId,
|
||||
unit_type: type,
|
||||
};
|
||||
units.push(unit);
|
||||
return unit;
|
||||
}
|
||||
|
||||
function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition {
|
||||
positionCounter += 1;
|
||||
const position: BuiltPosition = {
|
||||
id: newId(),
|
||||
position_number: positionNumber(positionCounter),
|
||||
org_unit_id: unitId,
|
||||
job_id: jobFor(title),
|
||||
is_chief: isChief,
|
||||
};
|
||||
positions.push(position);
|
||||
return position;
|
||||
}
|
||||
|
||||
const company = addUnit(companyName, "Gesellschaft", null);
|
||||
addPosition(company.id, "Geschäftsführer:in", true);
|
||||
// Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau
|
||||
// die Unterscheidung, die es im Altmodell nicht gab.
|
||||
addPosition(company.id, "Assistenz der Geschäftsführung", false);
|
||||
|
||||
for (const div of divisions) {
|
||||
const bereich = addUnit(div.name, "Bereich", company.id);
|
||||
addPosition(bereich.id, div.headTitle, true);
|
||||
|
||||
for (const dept of div.departments) {
|
||||
const abteilung = addUnit(dept.name, "Abteilung", bereich.id);
|
||||
// Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt
|
||||
// eine eigene Leitungsplanstelle.
|
||||
addPosition(abteilung.id, dept.leadTitle, true);
|
||||
|
||||
for (const team of dept.teams) {
|
||||
const teamUnit = addUnit(team.name, "Team", abteilung.id);
|
||||
addPosition(teamUnit.id, team.leadTitle, true);
|
||||
|
||||
const size = Math.max(1, team.baseSize);
|
||||
const icPositions = Array.from({ length: size }, (_, i) =>
|
||||
addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false)
|
||||
);
|
||||
icPositionsByTeam.set(teamUnit.id, icPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { units, jobs, positions, icPositionsByTeam };
|
||||
}
|
||||
70
supabase/entra-claims.ts
Normal file
70
supabase/entra-claims.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Zeigt, was Entra ID beim Anmelden tatsächlich mitgeschickt hat.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/entra-claims.ts <e-mail>
|
||||
//
|
||||
// Die Freischaltung über eine Entra-Gruppe hängt daran, wie der Anspruch im
|
||||
// Token heisst und wie er aussieht — das unterscheidet sich je nachdem, ob im
|
||||
// Mandanten „Sicherheitsgruppen" oder „der Anwendung zugewiesene Gruppen"
|
||||
// eingestellt ist. Diese Ausgabe ist die Grundlage für den Trigger; ohne sie
|
||||
// wäre er geraten.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/entra-claims.ts <e-mail>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const matches = data.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
if (matches.length === 0) {
|
||||
console.error(`Kein Konto zu ${email}. Vorhanden:`);
|
||||
for (const u of data.users) console.error(` ${u.email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Mehrere Treffer sind der Normalfall in der Umstellungsphase: das alte Konto
|
||||
// mit Passwort und das neue über Entra sind für Supabase zwei Benutzer.
|
||||
for (const user of matches) {
|
||||
console.log(`\n── ${user.email} ──`);
|
||||
console.log(` id: ${user.id}`);
|
||||
console.log(` erstellt: ${user.created_at}`);
|
||||
console.log(` Anbieter: ${user.identities?.map((i) => i.provider).join(", ") || "keiner"}`);
|
||||
|
||||
for (const identity of user.identities ?? []) {
|
||||
console.log(`\n identity_data (${identity.provider}) — von GoTrue aus der Antwort des Anbieters:`);
|
||||
console.log(
|
||||
Object.entries(identity.identity_data ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
// Zum Vergleich, und als Warnung: hierher schreibt auch updateUser(), also
|
||||
// die angemeldete Person selbst. Als Grundlage für eine Freischaltung ist
|
||||
// das unbrauchbar.
|
||||
console.log("\n raw_user_meta_data — auch von der Person selbst beschreibbar, NICHT als Quelle verwenden:");
|
||||
console.log(
|
||||
Object.entries(user.user_metadata ?? {})
|
||||
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
||||
.join("\n") || " (leer)"
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profiles } = await supabase.from("profiles").select("id, email, role, is_active").eq("email", email);
|
||||
console.log(`\n── profiles zu ${email} ──`);
|
||||
for (const p of profiles ?? []) console.log(` ${p.id} role=${p.role} is_active=${p.is_active}`);
|
||||
if (!profiles?.length) console.log(" (keine Zeile — damit besteht kein Zugriff)");
|
||||
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Organisationsmanagement nach SAP-OM-Vorbild.
|
||||
--
|
||||
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
|
||||
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
|
||||
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
|
||||
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
|
||||
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
|
||||
--
|
||||
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
|
||||
--
|
||||
-- O Organisationseinheit org_units (rekursiv über parent_id)
|
||||
-- C Stelle / Job jobs (Katalog)
|
||||
-- S Planstelle positions (gehört zu genau einer O)
|
||||
-- P Person employees (besetzt eine S)
|
||||
--
|
||||
-- A003 "gehört zu" positions.org_unit_id
|
||||
-- A012 "ist Leiter von" positions.is_chief
|
||||
-- A008 "Inhaber ist" position_assignments
|
||||
--
|
||||
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
|
||||
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
|
||||
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
|
||||
|
||||
-- ── O: Organisationseinheit ────────────────────────────────────────
|
||||
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
|
||||
|
||||
create table org_units (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
org_number text not null unique,
|
||||
name text not null,
|
||||
-- Die Hierarchie selbst. Null nur für die Wurzel.
|
||||
parent_id uuid references org_units(id),
|
||||
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
|
||||
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
|
||||
-- und ist bewusst nicht verboten.
|
||||
unit_type org_unit_type not null,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
|
||||
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
|
||||
);
|
||||
|
||||
create index on org_units (parent_id);
|
||||
create index on org_units (unit_type);
|
||||
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
|
||||
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
|
||||
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
|
||||
|
||||
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
|
||||
|
||||
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
|
||||
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
|
||||
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
|
||||
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
|
||||
-- konnten und keine Auswertung über Tätigkeiten möglich war.
|
||||
create table jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
code text not null unique,
|
||||
title text not null unique,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
|
||||
|
||||
-- ── S: Planstelle ──────────────────────────────────────────────────
|
||||
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
|
||||
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
|
||||
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
|
||||
-- Eigenschaft der Planstelle, kein eigenes Objekt.
|
||||
create table om_positions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_number text not null unique,
|
||||
org_unit_id uuid not null references org_units(id),
|
||||
job_id uuid not null references jobs(id),
|
||||
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
|
||||
is_chief boolean not null default false,
|
||||
valid_from date not null default current_date,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
|
||||
);
|
||||
|
||||
create index on om_positions (org_unit_id);
|
||||
create index on om_positions (job_id);
|
||||
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
|
||||
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
|
||||
|
||||
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
|
||||
|
||||
-- ── A008: Person besetzt Planstelle ────────────────────────────────
|
||||
create table position_assignments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
position_id uuid not null references om_positions(id) on delete cascade,
|
||||
employee_id uuid not null references employees(id) on delete cascade,
|
||||
valid_from date not null,
|
||||
valid_to date,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
|
||||
);
|
||||
|
||||
create index on position_assignments (position_id);
|
||||
create index on position_assignments (employee_id);
|
||||
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
|
||||
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
|
||||
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
|
||||
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
|
||||
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
|
||||
|
||||
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
|
||||
|
||||
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
|
||||
alter table org_units enable row level security;
|
||||
alter table jobs enable row level security;
|
||||
alter table om_positions enable row level security;
|
||||
alter table position_assignments enable row level security;
|
||||
|
||||
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
|
||||
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;
|
||||
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
|
||||
--
|
||||
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
|
||||
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
|
||||
-- der Organisationsstruktur abweichen, und tat es auch.
|
||||
--
|
||||
-- SAP-OM-Regel, hier eins zu eins:
|
||||
--
|
||||
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
|
||||
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
|
||||
-- an die Leitung der übergeordneten Einheit.
|
||||
--
|
||||
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
|
||||
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
|
||||
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
|
||||
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
|
||||
-- übersprungen.
|
||||
--
|
||||
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
|
||||
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
|
||||
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
|
||||
-- Führungskraft auszugeben.
|
||||
|
||||
create or replace function om_reporting_lines(p_as_of date default current_date)
|
||||
returns table (
|
||||
employee_id uuid,
|
||||
position_id uuid,
|
||||
org_unit_id uuid,
|
||||
is_chief boolean,
|
||||
formal_manager_id uuid,
|
||||
acting_manager_id uuid
|
||||
)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
with recursive
|
||||
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
|
||||
-- gültig sein.
|
||||
holder as (
|
||||
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
|
||||
from position_assignments pa
|
||||
join om_positions p on p.id = pa.position_id
|
||||
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
|
||||
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
|
||||
),
|
||||
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
|
||||
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
|
||||
chief as (
|
||||
select h.org_unit_id, h.employee_id,
|
||||
(e.karenz_start_date is not null
|
||||
and e.karenz_start_date <= p_as_of
|
||||
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
|
||||
from holder h
|
||||
join employees e on e.id = h.employee_id
|
||||
where h.is_chief
|
||||
),
|
||||
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
|
||||
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
|
||||
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
|
||||
ancestry as (
|
||||
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
|
||||
from org_units u
|
||||
union all
|
||||
select a.unit_id, p.id, p.parent_id, a.depth + 1
|
||||
from ancestry a
|
||||
join org_units p on p.id = a.parent_id
|
||||
),
|
||||
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
|
||||
-- sonst die eigene.
|
||||
base as (
|
||||
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
|
||||
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
|
||||
from holder h
|
||||
join org_units u on u.id = h.org_unit_id
|
||||
)
|
||||
select
|
||||
b.employee_id,
|
||||
b.position_id,
|
||||
b.org_unit_id,
|
||||
b.is_chief,
|
||||
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
|
||||
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
|
||||
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
|
||||
-- die Ausgangseinheit eingeschlossen.
|
||||
(
|
||||
select c.employee_id
|
||||
from ancestry a
|
||||
join chief c on c.org_unit_id = a.ancestor_id
|
||||
where a.unit_id = b.base_unit_id
|
||||
and not c.absent
|
||||
and c.employee_id <> b.employee_id
|
||||
order by a.depth
|
||||
limit 1
|
||||
) as acting_manager_id
|
||||
from base b;
|
||||
$$;
|
||||
|
||||
comment on function om_reporting_lines(date) is
|
||||
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
|
||||
|
||||
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;
|
||||
539
supabase/migrations/20260727120200_om_cutover.sql
Normal file
539
supabase/migrations/20260727120200_om_cutover.sql
Normal file
@@ -0,0 +1,539 @@
|
||||
-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
|
||||
--
|
||||
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
|
||||
--
|
||||
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
|
||||
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
|
||||
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
|
||||
-- "division_id = X and team_id is null and org_level = 1" — eine
|
||||
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
|
||||
-- willkürlich eine von beiden.
|
||||
--
|
||||
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
|
||||
-- das Organigramm gefüllt.
|
||||
--
|
||||
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
|
||||
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
|
||||
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
|
||||
-- sind — die Berichtslinie bleibt durchgängig.
|
||||
|
||||
begin;
|
||||
|
||||
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
|
||||
|
||||
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
|
||||
-- die Personen, die daran hingen, ihre Einheit behalten.
|
||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
||||
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
|
||||
from divisions where name = 'Geschäftsführung';
|
||||
|
||||
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
|
||||
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
|
||||
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
|
||||
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
|
||||
|
||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
||||
select d.id, d.org_number, d.name,
|
||||
(select id from org_units where unit_type = 'Gesellschaft'),
|
||||
'Bereich', '2000-01-01'
|
||||
from divisions d
|
||||
where d.name <> 'Geschäftsführung';
|
||||
|
||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
||||
select dep.id, dep.org_number, dep.name,
|
||||
coalesce((select u.id from org_units u where u.id = dep.division_id),
|
||||
(select id from org_units where unit_type = 'Gesellschaft')),
|
||||
'Abteilung', '2000-01-01'
|
||||
from departments dep;
|
||||
|
||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
||||
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
|
||||
from teams t;
|
||||
|
||||
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
|
||||
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
|
||||
-- Titel der neuen Abteilungsleitungen.
|
||||
insert into jobs (code, title)
|
||||
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
|
||||
from (
|
||||
select distinct job_title as title from employees where job_title is not null
|
||||
union
|
||||
select distinct title from positions where title is not null
|
||||
union
|
||||
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
|
||||
) t
|
||||
on conflict (title) do nothing;
|
||||
|
||||
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
|
||||
|
||||
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
|
||||
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
|
||||
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
|
||||
-- doch verschieden.
|
||||
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
|
||||
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
|
||||
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
|
||||
create temporary table om_pos_map (
|
||||
employee_id uuid primary key,
|
||||
position_id uuid not null default gen_random_uuid(),
|
||||
seq bigint
|
||||
);
|
||||
|
||||
insert into om_pos_map (employee_id, seq)
|
||||
select id, row_number() over (order by org_level, personnel_number) from employees;
|
||||
|
||||
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
|
||||
select
|
||||
m.position_id,
|
||||
'6' || lpad(m.seq::text, 7, '0'),
|
||||
case
|
||||
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
|
||||
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
|
||||
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
|
||||
end,
|
||||
(select j.id from jobs j where j.title = e.job_title),
|
||||
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
|
||||
e.entry_date
|
||||
from employees e
|
||||
join om_pos_map m on m.employee_id = e.id;
|
||||
|
||||
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
|
||||
-- wieder frei, die Historie bleibt.
|
||||
--
|
||||
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
|
||||
-- position_assignments verlangt aber valid_to > valid_from. Ein
|
||||
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
|
||||
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
|
||||
select m.position_id, e.id, e.entry_date,
|
||||
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
|
||||
from employees e
|
||||
join om_pos_map m on m.employee_id = e.id;
|
||||
|
||||
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
|
||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
||||
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
|
||||
u.id,
|
||||
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
|
||||
true,
|
||||
current_date
|
||||
from org_units u
|
||||
where u.unit_type = 'Abteilung'
|
||||
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
|
||||
|
||||
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
|
||||
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
|
||||
-- ohnehin nicht zu.
|
||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
||||
select p.position_number, p.team_id,
|
||||
(select id from jobs j where j.title = p.title),
|
||||
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
|
||||
p.valid_from
|
||||
from positions p
|
||||
where p.status = 'open'
|
||||
and exists (select 1 from org_units u where u.id = p.team_id);
|
||||
|
||||
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
|
||||
-- als ein halb überführter Bestand ohne Rückweg.
|
||||
do $$
|
||||
declare v_fehlend int;
|
||||
begin
|
||||
select count(*) into v_fehlend
|
||||
from employees e
|
||||
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
|
||||
if v_fehlend > 0 then
|
||||
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
|
||||
end if;
|
||||
|
||||
select count(*) into v_fehlend from om_positions where job_id is null;
|
||||
if v_fehlend > 0 then
|
||||
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
|
||||
end if;
|
||||
|
||||
select count(*) into v_fehlend
|
||||
from org_units u
|
||||
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
|
||||
if v_fehlend > 0 then
|
||||
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
drop table om_pos_map;
|
||||
|
||||
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
|
||||
|
||||
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
|
||||
-- transient; halb übersetzt wären sie schlimmer als verworfen.
|
||||
delete from pending_org_changes where status = 'pending';
|
||||
|
||||
drop trigger if exists trg_track_employee_assignment on employees;
|
||||
drop function if exists fn_track_employee_assignment();
|
||||
drop table if exists employee_assignments;
|
||||
|
||||
-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die
|
||||
-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle
|
||||
-- diese Rolle, der Trigger wird ersatzlos entfernt.
|
||||
drop trigger if exists trg_employees_set_org_unit on employees;
|
||||
drop function if exists fn_set_employee_org_unit();
|
||||
|
||||
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
|
||||
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
|
||||
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
|
||||
-- om_reporting_lines() tritt an ihre Stelle.
|
||||
drop view if exists employees_directory;
|
||||
|
||||
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
|
||||
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
|
||||
-- Umstellung der Oberfläche.
|
||||
drop function if exists resolve_manager_for(uuid, boolean, uuid);
|
||||
drop function if exists staff_position_internally(jsonb);
|
||||
drop function if exists create_position(jsonb);
|
||||
drop function if exists delete_position(uuid);
|
||||
drop function if exists apply_reorg(jsonb);
|
||||
drop function if exists undo_reorg(uuid);
|
||||
drop function if exists hire_employee(jsonb);
|
||||
drop function if exists terminate_employee(jsonb);
|
||||
drop function if exists transfer_employee(jsonb);
|
||||
drop function if exists rehire_employee(jsonb);
|
||||
drop function if exists record_karenz_return(jsonb);
|
||||
drop function if exists apply_due_pending_changes();
|
||||
|
||||
alter table employees
|
||||
drop column if exists division_id,
|
||||
drop column if exists team_id,
|
||||
drop column if exists manager_id,
|
||||
drop column if exists org_level,
|
||||
drop column if exists is_lead;
|
||||
|
||||
drop table if exists positions;
|
||||
drop table if exists teams;
|
||||
drop table if exists departments;
|
||||
drop table if exists divisions;
|
||||
|
||||
-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen
|
||||
-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt
|
||||
-- wurde. om_positions vergibt seine Nummern selbst.
|
||||
drop function if exists fn_set_position_org_unit();
|
||||
drop function if exists generate_position_number();
|
||||
|
||||
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
|
||||
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
|
||||
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
|
||||
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
|
||||
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
|
||||
|
||||
create or replace function hire_employee(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_entry date := (payload->>'entry_date')::date;
|
||||
v_besetzt uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if v_position_id is null then
|
||||
raise exception 'Es muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_position_id and pa.valid_to is null;
|
||||
if v_besetzt is not null then
|
||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||
end if;
|
||||
|
||||
insert into employees (
|
||||
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
|
||||
address, postal_code, city, address_country, location_id, job_title,
|
||||
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
|
||||
source, status, entry_date, title_prefix, title_suffix,
|
||||
worker_type, collective_agreement, work_days,
|
||||
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
|
||||
)
|
||||
values (
|
||||
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer',
|
||||
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
|
||||
payload->>'address', payload->>'postal_code', payload->>'city',
|
||||
coalesce(payload->>'address_country', 'Österreich'),
|
||||
(payload->>'location_id')::uuid,
|
||||
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
|
||||
v_entry,
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
|
||||
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
|
||||
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
|
||||
coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{Mo,Di,Mi,Do,Fr}'),
|
||||
coalesce((payload->>'is_betriebsrat')::boolean, false),
|
||||
coalesce((payload->>'has_dienstwagen')::boolean, false),
|
||||
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
|
||||
coalesce((payload->>'is_c_level')::boolean, false)
|
||||
)
|
||||
returning id into v_id;
|
||||
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_position_id, v_id, v_entry);
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
|
||||
(select position_number from om_positions where id = v_position_id));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Neueinstellung',
|
||||
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function terminate_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_exit date := (payload->>'exit_date')::date;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
update employees set
|
||||
status = case when v_exit <= current_date then 'Ausgetreten' else status end,
|
||||
exit_date = v_exit,
|
||||
exit_reason = payload->>'exit_reason'
|
||||
where id = v_employee_id;
|
||||
|
||||
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
|
||||
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
|
||||
-- die nächste besetzte Ebene.
|
||||
update position_assignments set valid_to = v_exit
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
|
||||
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
|
||||
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
|
||||
create or replace function transfer_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_target_position uuid := (payload->>'target_position_id')::uuid;
|
||||
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
|
||||
v_name text;
|
||||
v_besetzt uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
select pa.employee_id into v_besetzt
|
||||
from position_assignments pa
|
||||
where pa.position_id = v_target_position and pa.valid_to is null;
|
||||
if v_besetzt is not null and v_besetzt <> v_employee_id then
|
||||
raise exception 'Die Zielplanstelle ist bereits besetzt.';
|
||||
end if;
|
||||
|
||||
if v_effective <= current_date then
|
||||
update position_assignments set valid_to = v_effective
|
||||
where employee_id = v_employee_id and valid_to is null;
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_target_position, v_employee_id, v_effective);
|
||||
update employees set job_title =
|
||||
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_target_position)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'transfer', v_effective,
|
||||
jsonb_build_object('target_position_id', v_target_position));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
|
||||
(select position_number from om_positions where id = v_target_position));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function rehire_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_date date := (payload->>'rehire_date')::date;
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
if v_position_id is null then
|
||||
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
|
||||
end if;
|
||||
|
||||
update employees set
|
||||
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
|
||||
entry_date = v_date,
|
||||
exit_date = null,
|
||||
exit_reason = null
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values (v_position_id, v_employee_id, v_date);
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function record_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
v_karenz_start date;
|
||||
v_absence_type text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, karenz_start_date, absence_type
|
||||
into v_name, v_karenz_start, v_absence_type
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if v_karenz_start is not null and v_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
|
||||
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
|
||||
-- fällt automatisch von der Vertretung auf ihn zurück.
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr',
|
||||
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function apply_due_pending_changes()
|
||||
returns int language plpgsql security definer set search_path = public as $$
|
||||
declare
|
||||
v_rec record;
|
||||
v_count int := 0;
|
||||
begin
|
||||
for v_rec in
|
||||
select * from pending_org_changes
|
||||
where status = 'pending' and effective_date <= current_date
|
||||
order by effective_date, created_at
|
||||
loop
|
||||
if v_rec.change_type = 'transfer' then
|
||||
update position_assignments set valid_to = v_rec.effective_date
|
||||
where employee_id = v_rec.employee_id and valid_to is null;
|
||||
insert into position_assignments (position_id, employee_id, valid_from)
|
||||
values ((v_rec.payload->>'target_position_id')::uuid, v_rec.employee_id, v_rec.effective_date);
|
||||
update employees set job_title = (
|
||||
select j.title from om_positions p join jobs j on j.id = p.job_id
|
||||
where p.id = (v_rec.payload->>'target_position_id')::uuid
|
||||
) where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'promotion' then
|
||||
update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_start' then
|
||||
update employees set
|
||||
status = 'Karenz',
|
||||
karenz_return_date = (v_rec.payload->>'planned_return_date')::date,
|
||||
absence_type = coalesce(nullif(v_rec.payload->>'absence_type', ''), absence_type)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_return' then
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
absence_type = null,
|
||||
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'contract_change' then
|
||||
update employees set
|
||||
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
|
||||
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
|
||||
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
|
||||
address = coalesce(v_rec.payload->'person'->>'address', address),
|
||||
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
|
||||
city = coalesce(v_rec.payload->'person'->>'city', city),
|
||||
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
|
||||
email = coalesce(v_rec.payload->'person'->>'email', email),
|
||||
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
|
||||
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'dependent_add' then
|
||||
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
|
||||
values (v_rec.employee_id, v_rec.payload->>'first_name', v_rec.payload->>'last_name',
|
||||
(v_rec.payload->>'relationship')::relationship_type,
|
||||
nullif(v_rec.payload->>'sv_nummer', ''), (v_rec.payload->>'birth_date')::date);
|
||||
|
||||
elsif v_rec.change_type = 'dependent_remove' then
|
||||
delete from employee_dependents where id = (v_rec.payload->>'dependent_id')::uuid;
|
||||
end if;
|
||||
|
||||
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
|
||||
v_count := v_count + 1;
|
||||
end loop;
|
||||
|
||||
return v_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
commit;
|
||||
150
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
150
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
|
||||
-- nachziehen.
|
||||
--
|
||||
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
|
||||
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
|
||||
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
|
||||
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
|
||||
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
|
||||
-- Aufruf würde erst zur Laufzeit scheitern.
|
||||
|
||||
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
|
||||
drop function if exists create_position(jsonb);
|
||||
drop function if exists delete_position(jsonb);
|
||||
drop function if exists delete_position(uuid);
|
||||
drop function if exists staff_position_internally(jsonb);
|
||||
drop function if exists apply_reorg(jsonb);
|
||||
drop function if exists undo_reorg(jsonb);
|
||||
drop function if exists undo_reorg(uuid);
|
||||
|
||||
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
|
||||
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
|
||||
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
|
||||
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
|
||||
alter table employee_history drop column if exists reorg_scenario_id;
|
||||
alter table pending_org_changes drop column if exists reorg_scenario_id;
|
||||
drop table if exists reorg_moves;
|
||||
drop table if exists reorg_scenarios;
|
||||
|
||||
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
|
||||
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
|
||||
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
|
||||
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
|
||||
-- und Schliessen sind deshalb Operationen auf om_positions.
|
||||
|
||||
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
|
||||
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
|
||||
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
|
||||
-- und daran denkt dann niemand.
|
||||
create or replace function next_position_number()
|
||||
returns text language sql stable
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
|
||||
from om_positions
|
||||
where position_number ~ '^6[0-9]{7}$';
|
||||
$$;
|
||||
|
||||
comment on function next_position_number() is
|
||||
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
|
||||
|
||||
create or replace function create_position(payload jsonb)
|
||||
returns uuid language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
|
||||
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
||||
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
|
||||
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
||||
v_job_id uuid;
|
||||
v_position_id uuid;
|
||||
v_unit_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select name into v_unit_name from org_units where id = v_org_unit_id;
|
||||
if v_unit_name is null then
|
||||
raise exception 'Die Organisationseinheit existiert nicht.';
|
||||
end if;
|
||||
if v_job_title is null then
|
||||
raise exception 'Es muss eine Tätigkeit angegeben werden.';
|
||||
end if;
|
||||
|
||||
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
|
||||
-- die in der Oberfläche nichts erklärt.
|
||||
if v_is_chief and exists (
|
||||
select 1 from om_positions
|
||||
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
|
||||
) then
|
||||
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
||||
end if;
|
||||
|
||||
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
|
||||
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
|
||||
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
|
||||
if v_job_id is null then
|
||||
insert into jobs (code, title)
|
||||
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
|
||||
returning id into v_job_id;
|
||||
end if;
|
||||
|
||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
||||
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
|
||||
returning id into v_position_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
|
||||
v_job_title || ' (' || v_unit_name || ')',
|
||||
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
|
||||
|
||||
return v_position_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function delete_position(payload jsonb)
|
||||
returns void language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $$
|
||||
declare
|
||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||
v_label text;
|
||||
v_hat_historie boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select j.title || ' (' || u.name || ')' into v_label
|
||||
from om_positions p
|
||||
join jobs j on j.id = p.job_id
|
||||
join org_units u on u.id = p.org_unit_id
|
||||
where p.id = v_position_id;
|
||||
if v_label is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
|
||||
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
|
||||
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
|
||||
end if;
|
||||
|
||||
select exists (select 1 from position_assignments where position_id = v_position_id)
|
||||
into v_hat_historie;
|
||||
|
||||
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
|
||||
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
|
||||
-- Personalakte klafft eine Lücke.
|
||||
if v_hat_historie then
|
||||
update om_positions set valid_to = current_date where id = v_position_id;
|
||||
else
|
||||
delete from om_positions where id = v_position_id;
|
||||
end if;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(),
|
||||
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
|
||||
v_label, null);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function next_position_number() to anon, authenticated, service_role;
|
||||
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
|
||||
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- search_path für alle eigenen Funktionen festnageln.
|
||||
--
|
||||
-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable".
|
||||
-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier
|
||||
-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id,
|
||||
-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad
|
||||
-- längst. Deshalb steht im Advisor auch 0 errors.
|
||||
--
|
||||
-- Warum das trotzdem behoben wird:
|
||||
--
|
||||
-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im
|
||||
-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt
|
||||
-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene
|
||||
-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER
|
||||
-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu
|
||||
-- gewinnen, und die RLS-Policies greifen unverändert.
|
||||
--
|
||||
-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf
|
||||
-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei
|
||||
-- schreiben muss. In dem Moment denkt niemand mehr an den search_path.
|
||||
-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten.
|
||||
--
|
||||
-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres
|
||||
-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie
|
||||
-- will.
|
||||
|
||||
do $$
|
||||
declare
|
||||
v_func record;
|
||||
v_count int := 0;
|
||||
begin
|
||||
for v_func in
|
||||
select p.oid::regprocedure as signature
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
-- Nur Funktionen, keine Prozeduren oder Aggregate.
|
||||
and p.prokind = 'f'
|
||||
-- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit
|
||||
-- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung
|
||||
-- der Erweiterung oder wird stillschweigend zurückgesetzt.
|
||||
and not exists (
|
||||
select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e'
|
||||
)
|
||||
-- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen
|
||||
-- auf `search_path = public` und sollen so bleiben.
|
||||
and not exists (
|
||||
select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'
|
||||
)
|
||||
loop
|
||||
execute format('alter function %s set search_path = public, pg_temp', v_func.signature);
|
||||
v_count := v_count + 1;
|
||||
end loop;
|
||||
|
||||
raise notice 'search_path festgenagelt für % Funktion(en)', v_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path
|
||||
-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser
|
||||
-- hier, als es im Advisor stehen zu lassen.
|
||||
do $$
|
||||
declare v_offen int;
|
||||
begin
|
||||
select count(*) into v_offen
|
||||
from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public'
|
||||
and p.prokind = 'f'
|
||||
and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e')
|
||||
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%');
|
||||
|
||||
if v_offen > 0 then
|
||||
raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
|
||||
--
|
||||
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
|
||||
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
|
||||
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
|
||||
--
|
||||
-- anon.rpc(is_hr_user) -> false
|
||||
-- anon.rpc(current_hr_user_id) -> null
|
||||
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
|
||||
--
|
||||
-- Nur der dritte ist einer.
|
||||
|
||||
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
|
||||
--
|
||||
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
|
||||
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
|
||||
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
|
||||
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
|
||||
-- würde die Anwendung vollständig lahmlegen.
|
||||
--
|
||||
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
|
||||
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
|
||||
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
|
||||
|
||||
-- ── Wird entzogen ────────────────────────────────────────────────
|
||||
--
|
||||
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
|
||||
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
|
||||
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
|
||||
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
|
||||
--
|
||||
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
|
||||
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
|
||||
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
|
||||
-- wirkungslos.
|
||||
--
|
||||
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
|
||||
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
|
||||
revoke execute on function apply_due_pending_changes() from anon, authenticated;
|
||||
|
||||
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
|
||||
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
|
||||
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
|
||||
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
|
||||
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
|
||||
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1 from pg_proc p
|
||||
join pg_namespace n on n.oid = p.pronamespace
|
||||
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
|
||||
) then
|
||||
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
|
||||
--
|
||||
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
|
||||
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
|
||||
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
|
||||
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
|
||||
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
|
||||
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
|
||||
-- sondern eine Konvention.
|
||||
--
|
||||
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
|
||||
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
|
||||
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
|
||||
-- Passwort, dessen Kompromittierung geprüft werden könnte.
|
||||
110
supabase/relink-profile.ts
Normal file
110
supabase/relink-profile.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// Hängt eine bestehende profiles-Zeile auf die Entra-Identität derselben
|
||||
// Person um.
|
||||
//
|
||||
// Run with: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]
|
||||
//
|
||||
// Ein Konto mit Passwort-Anmeldung und das Entra-Konto derselben Person sind
|
||||
// für Supabase zwei Benutzer mit verschiedenen IDs. Die profiles-Zeile hängt an
|
||||
// der alten; nach der ersten Anmeldung über Entra zeigt sie ins Leere und die
|
||||
// Person ist ausgesperrt — mit „Kein HR-Zugriff", obwohl sie HR ist.
|
||||
//
|
||||
// Ohne --apply wird nur angezeigt, was passieren würde.
|
||||
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
|
||||
}
|
||||
|
||||
const email = process.argv[2];
|
||||
const apply = process.argv.includes("--apply");
|
||||
if (!email) {
|
||||
console.error("Aufruf: node --env-file=.env.local supabase/relink-profile.ts <e-mail> [--apply]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
// Die Fremdschlüssel auf auth.users(id). Sie zeigen sonst weiter auf das alte
|
||||
// Konto, und in der Historie stünde eine Kennung ohne Konto dahinter.
|
||||
const REFERENCES: { table: string; column: string }[] = [
|
||||
{ table: "audit_log", column: "actor_user_id" },
|
||||
{ table: "employee_notes", column: "author_user_id" },
|
||||
{ table: "employee_notes", column: "done_by" },
|
||||
{ table: "hire_drafts", column: "created_by" },
|
||||
{ table: "saved_reports", column: "created_by" },
|
||||
{ table: "pending_org_changes", column: "created_by" },
|
||||
{ table: "profiles", column: "created_by" },
|
||||
];
|
||||
|
||||
const { data: userList, error } = await supabase.auth.admin.listUsers({ perPage: 1000 });
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
const accounts = userList.users.filter((u) => u.email?.toLowerCase() === email.toLowerCase());
|
||||
const entra = accounts.find((u) => u.identities?.some((i) => i.provider === "azure"));
|
||||
const alt = accounts.find((u) => u.id !== entra?.id);
|
||||
|
||||
if (!entra) {
|
||||
console.error(`Kein Entra-Konto zu ${email}. Bitte zuerst einmal über „Mit Firmenkonto anmelden" anmelden.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!alt) {
|
||||
console.log(`Zu ${email} gibt es nur das Entra-Konto (${entra.id}) — nichts umzuhängen.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("*").eq("id", alt.id).maybeSingle();
|
||||
if (!profile) {
|
||||
console.error(`Das alte Konto ${alt.id} hat keine profiles-Zeile. Nichts umzuhängen.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`alt: ${alt.id} (${alt.identities?.map((i) => i.provider).join(", ")})`);
|
||||
console.log(`neu: ${entra.id} (azure)`);
|
||||
console.log(`Rolle: ${profile.role}, aktiv: ${profile.is_active}`);
|
||||
|
||||
if (!apply) {
|
||||
console.log("\nTrockenlauf. Mit --apply ausführen.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Neue Zeile zuerst: profiles.id verweist auf auth.users(id), und die alte
|
||||
// Zeile fällt erst, wenn die neue steht — sonst gibt es einen Moment ohne
|
||||
// HR-Konto, und niemand könnte eines mehr freischalten.
|
||||
const { error: insertError } = await supabase.from("profiles").insert({
|
||||
...profile,
|
||||
id: entra.id,
|
||||
email: entra.email ?? profile.email,
|
||||
});
|
||||
if (insertError) throw new Error(`profiles-Zeile anlegen fehlgeschlagen: ${insertError.message}`);
|
||||
console.log("profiles-Zeile für das Entra-Konto angelegt.");
|
||||
|
||||
for (const ref of REFERENCES) {
|
||||
const { error: updateError, count } = await supabase
|
||||
.from(ref.table)
|
||||
.update({ [ref.column]: entra.id }, { count: "exact" })
|
||||
.eq(ref.column, alt.id);
|
||||
if (updateError) {
|
||||
console.warn(` ${ref.table}.${ref.column}: ${updateError.message}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${ref.table}.${ref.column}: ${count ?? 0} Zeile(n) umgehängt`);
|
||||
}
|
||||
|
||||
const { error: deleteProfileError } = await supabase.from("profiles").delete().eq("id", alt.id);
|
||||
if (deleteProfileError) throw new Error(`alte profiles-Zeile löschen fehlgeschlagen: ${deleteProfileError.message}`);
|
||||
|
||||
const { error: deleteUserError } = await supabase.auth.admin.deleteUser(alt.id);
|
||||
if (deleteUserError) {
|
||||
// Kein Abbruch: der Zugriff hängt an profiles, und die ist bereits
|
||||
// umgehängt. Das alte Konto ist damit wirkungslos, nur nicht aufgeräumt.
|
||||
console.warn(`Altes Konto konnte nicht gelöscht werden: ${deleteUserError.message}`);
|
||||
} else {
|
||||
console.log("Altes Konto gelöscht.");
|
||||
}
|
||||
|
||||
console.log("\nFertig. Die Anmeldung läuft jetzt über das Firmenkonto.");
|
||||
556
supabase/seed.ts
556
supabase/seed.ts
@@ -10,8 +10,9 @@ import { createClient } from "@supabase/supabase-js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
// Explicit .ts extension: this file is run directly by Node (type-stripping,
|
||||
// ESM), where an extensionless relative import does not resolve.
|
||||
import { svnrCheckDigit } from "../lib/svnr.ts";
|
||||
import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts";
|
||||
import { ABSENCE_TYPES } from "../lib/absence.ts";
|
||||
import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts";
|
||||
|
||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
@@ -49,8 +50,15 @@ function addDays(d: Date, days: number): Date {
|
||||
r.setDate(r.getDate() + days);
|
||||
return r;
|
||||
}
|
||||
// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen
|
||||
// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet
|
||||
// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach
|
||||
// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das
|
||||
// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt.
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
function randomDateBetween(start: Date, end: Date): Date {
|
||||
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
|
||||
@@ -141,9 +149,8 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])
|
||||
];
|
||||
|
||||
// ── Org structure ────────────────────────────────────────────
|
||||
type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
|
||||
type DeptDef = { name: string; teams: TeamDef[] };
|
||||
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
|
||||
// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was
|
||||
// daraus gebaut wird.
|
||||
|
||||
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
|
||||
|
||||
@@ -154,6 +161,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Fertigung",
|
||||
leadTitle: "Abteilungsleitung Fertigung",
|
||||
teams: [
|
||||
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
|
||||
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
|
||||
@@ -162,6 +170,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Instandhaltung",
|
||||
leadTitle: "Abteilungsleitung Instandhaltung",
|
||||
teams: [
|
||||
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
|
||||
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
|
||||
@@ -175,6 +184,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Logistik",
|
||||
leadTitle: "Abteilungsleitung Logistik",
|
||||
teams: [
|
||||
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
|
||||
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
|
||||
@@ -183,6 +193,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Einkauf",
|
||||
leadTitle: "Abteilungsleitung Einkauf",
|
||||
teams: [
|
||||
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
|
||||
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
|
||||
@@ -196,6 +207,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Vertrieb",
|
||||
leadTitle: "Abteilungsleitung Vertrieb",
|
||||
teams: [
|
||||
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
|
||||
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
|
||||
@@ -204,6 +216,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Marketing",
|
||||
leadTitle: "Abteilungsleitung Marketing",
|
||||
teams: [
|
||||
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
|
||||
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
|
||||
@@ -217,6 +230,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Produktentwicklung",
|
||||
leadTitle: "Abteilungsleitung Produktentwicklung",
|
||||
teams: [
|
||||
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
|
||||
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
|
||||
@@ -224,6 +238,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Verfahrenstechnik",
|
||||
leadTitle: "Abteilungsleitung Verfahrenstechnik",
|
||||
teams: [
|
||||
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
|
||||
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
|
||||
@@ -237,6 +252,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Qualitätssicherung",
|
||||
leadTitle: "Abteilungsleitung Qualitätssicherung",
|
||||
teams: [
|
||||
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
|
||||
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
|
||||
@@ -244,6 +260,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Lebensmittelsicherheit",
|
||||
leadTitle: "Abteilungsleitung Lebensmittelsicherheit",
|
||||
teams: [
|
||||
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
|
||||
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
|
||||
@@ -257,6 +274,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Business Applications",
|
||||
leadTitle: "Abteilungsleitung Business Applications",
|
||||
teams: [
|
||||
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
|
||||
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
|
||||
@@ -264,6 +282,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Infrastruktur",
|
||||
leadTitle: "Abteilungsleitung Infrastruktur",
|
||||
teams: [
|
||||
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
|
||||
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
|
||||
@@ -277,6 +296,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Finanzen",
|
||||
leadTitle: "Abteilungsleitung Finanzen",
|
||||
teams: [
|
||||
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
|
||||
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
|
||||
@@ -284,6 +304,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Controlling",
|
||||
leadTitle: "Abteilungsleitung Controlling",
|
||||
teams: [
|
||||
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
|
||||
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
|
||||
@@ -297,6 +318,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "HR Business Partner",
|
||||
leadTitle: "Abteilungsleitung HR Business Partner",
|
||||
teams: [
|
||||
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
|
||||
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
|
||||
@@ -304,6 +326,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Personaladministration",
|
||||
leadTitle: "Abteilungsleitung Personaladministration",
|
||||
teams: [
|
||||
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
|
||||
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
|
||||
@@ -328,13 +351,11 @@ type EmployeeRow = {
|
||||
address_country: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
// Die Einordnung in die Organisation steckt jetzt ausschliesslich in der
|
||||
// Planstelle (position_assignments -> om_positions -> org_units). Keine
|
||||
// division_id/team_id/manager_id mehr auf der Person.
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id: string | null;
|
||||
org_level: number;
|
||||
is_lead: boolean;
|
||||
employment_type: "Vollzeit" | "Teilzeit";
|
||||
weekly_hours: number;
|
||||
contract_type: "unbefristet" | "befristet";
|
||||
@@ -403,7 +424,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] {
|
||||
]);
|
||||
}
|
||||
|
||||
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
|
||||
function newHireBase(jobTitle: string) {
|
||||
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
|
||||
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
|
||||
const lastName = pick(LAST_NAMES);
|
||||
@@ -423,25 +444,35 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
|
||||
address_country: addressCountryFor(nationality),
|
||||
email: makeEmail(firstName, lastName),
|
||||
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
||||
team_id: teamId,
|
||||
division_id: divisionId,
|
||||
job_title: jobTitle,
|
||||
location_id: location.id,
|
||||
manager_id: managerId,
|
||||
org_level: orgLevel,
|
||||
is_lead: isLead,
|
||||
};
|
||||
}
|
||||
|
||||
const employees: EmployeeRow[] = [];
|
||||
const history: HistoryRow[] = [];
|
||||
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||
|
||||
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
|
||||
const age = randInt(22, 60);
|
||||
const birthDate = birthDateForAge(age);
|
||||
function finalizeEmployee(
|
||||
base: ReturnType<typeof newHireBase>,
|
||||
opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date }
|
||||
): EmployeeRow {
|
||||
// Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit
|
||||
// sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene
|
||||
// Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach —
|
||||
// sonst umgekehrt.
|
||||
let age: number;
|
||||
let entryDate: Date;
|
||||
if (opts.entryDate) {
|
||||
entryDate = opts.entryDate;
|
||||
const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5)));
|
||||
const minAge = Math.min(Math.max(22, 20 + tenureYears), 55);
|
||||
age = randInt(minAge, 62);
|
||||
} else {
|
||||
age = randInt(22, 60);
|
||||
const maxTenureYears = Math.min(15, age - 20);
|
||||
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
||||
entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
||||
}
|
||||
const birthDate = birthDateForAge(age);
|
||||
|
||||
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
|
||||
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
|
||||
@@ -487,80 +518,45 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade
|
||||
return row;
|
||||
}
|
||||
|
||||
type TeamRef = { id: string; org_number: string; name: string; department_id: string };
|
||||
type DeptRef = { id: string; org_number: string; name: string; division_id: string };
|
||||
type DivisionRef = { id: string; org_number: string; name: string };
|
||||
// ── Organisation im OM-Modell ────────────────────────────────
|
||||
// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests
|
||||
// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer*
|
||||
// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache.
|
||||
const COMPANY_NAME = "Alpenwerk Industrie GmbH";
|
||||
|
||||
const divisionRows: DivisionRef[] = [];
|
||||
const departmentRows: DeptRef[] = [];
|
||||
const teamRows: TeamRef[] = [];
|
||||
const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({
|
||||
...div,
|
||||
departments: div.departments.map((dept) => ({
|
||||
...dept,
|
||||
// -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und
|
||||
// buildOrg sie zusätzlich zu icTitles anlegt.
|
||||
teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Geschäftsführung: small division, no departments/teams — CEO and their
|
||||
// assistant sit directly under it (§5).
|
||||
const gfDivisionId = randomUUID();
|
||||
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
|
||||
const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID);
|
||||
const unitById = new Map(org.units.map((u) => [u.id, u]));
|
||||
const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title]));
|
||||
|
||||
const ceo = finalizeEmployee(
|
||||
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
|
||||
{ paygrade: "F" }
|
||||
);
|
||||
const gfAssistant = finalizeEmployee(
|
||||
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
|
||||
{ paygrade: "C" }
|
||||
);
|
||||
employees.push(ceo, gfAssistant);
|
||||
type AssignmentRow = {
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
};
|
||||
const assignments: AssignmentRow[] = [];
|
||||
|
||||
let divisionCounter = 0;
|
||||
let deptCounter = 0;
|
||||
let teamCounter = 0;
|
||||
// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz
|
||||
// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne
|
||||
// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und
|
||||
// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben.
|
||||
const VAKANT_IC = 8; // offene Stellen ohne Nachfolge
|
||||
const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft
|
||||
const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch
|
||||
const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen
|
||||
const LANGZEITABWESEND = 12;
|
||||
const GEPLANTER_AUSTRITT = 3;
|
||||
|
||||
for (const div of DIVISIONS) {
|
||||
divisionCounter += 1;
|
||||
const divisionId = randomUUID();
|
||||
const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`;
|
||||
divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name });
|
||||
|
||||
const divisionHead = finalizeEmployee(
|
||||
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
|
||||
{ paygrade: "F" }
|
||||
);
|
||||
employees.push(divisionHead);
|
||||
|
||||
for (const dept of div.departments) {
|
||||
deptCounter += 1;
|
||||
const departmentId = randomUUID();
|
||||
const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`;
|
||||
departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId });
|
||||
|
||||
for (const team of dept.teams) {
|
||||
teamCounter += 1;
|
||||
const teamId = randomUUID();
|
||||
const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`;
|
||||
teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId });
|
||||
|
||||
const size = Math.max(2, Math.round(team.baseSize * SCALE));
|
||||
|
||||
const teamLead = finalizeEmployee(
|
||||
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
|
||||
{ paygrade: "E" }
|
||||
);
|
||||
employees.push(teamLead);
|
||||
|
||||
for (let i = 0; i < size - 1; i++) {
|
||||
const jobTitle = pick(team.icTitles);
|
||||
const paygrade = paygradeForIc();
|
||||
const ic = finalizeEmployee(
|
||||
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
|
||||
{ paygrade }
|
||||
);
|
||||
employees.push(ic);
|
||||
icPoolForStatusAssignment.push(ic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply the target status distribution (§5) across the IC pool ────────
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
@@ -569,24 +565,74 @@ function shuffle<T>(arr: T[]): T[] {
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const shuffledIcs = shuffle(icPoolForStatusAssignment);
|
||||
let cursor = 0;
|
||||
|
||||
// ~40 Ausgetreten
|
||||
for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
const entryDate = new Date(e.entry_date);
|
||||
const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
|
||||
e.status = "Ausgetreten";
|
||||
e.exit_date = isoDate(exitDate);
|
||||
e.exit_reason = pick(EXIT_REASONS);
|
||||
history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
|
||||
function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] {
|
||||
if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc();
|
||||
const type = unitById.get(p.org_unit_id)!.unit_type;
|
||||
return type === "Gesellschaft" || type === "Bereich" ? "F" : "E";
|
||||
}
|
||||
|
||||
// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
|
||||
// die Auswertung nach Art ist sonst nicht zu sehen.
|
||||
for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
/** Besetzt eine Planstelle laufend und legt die Person an. */
|
||||
function occupy(p: (typeof org.positions)[number]): EmployeeRow {
|
||||
const title = jobTitleById.get(p.job_id)!;
|
||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) });
|
||||
employees.push(e);
|
||||
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
|
||||
return e;
|
||||
}
|
||||
|
||||
const chiefPositions = org.positions.filter((p) => p.is_chief);
|
||||
const icPositions = org.positions.filter((p) => !p.is_chief);
|
||||
|
||||
// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die
|
||||
// Hochrollen-Regel im Organigramm sichtbar wird.
|
||||
const vakanteLeitungen = new Set(
|
||||
shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team"))
|
||||
.slice(0, VAKANT_LEITUNG)
|
||||
.map((p) => p.id)
|
||||
);
|
||||
const leadEmployees: EmployeeRow[] = [];
|
||||
for (const p of chiefPositions) {
|
||||
if (vakanteLeitungen.has(p.id)) continue;
|
||||
leadEmployees.push(occupy(p));
|
||||
}
|
||||
|
||||
// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen.
|
||||
const shuffledIc = shuffle(icPositions);
|
||||
const offeneStellen = shuffledIc.slice(0, VAKANT_IC);
|
||||
const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT);
|
||||
const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT);
|
||||
|
||||
const icEmployees = besetzteIc.map((p) => occupy(p));
|
||||
void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen
|
||||
|
||||
// ── Statusverteilung (§5) ────────────────────────────────────
|
||||
const statusPool = shuffle(icEmployees);
|
||||
let cursor = 0;
|
||||
|
||||
// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch
|
||||
// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen
|
||||
// zeitabhängig sind.
|
||||
for (const p of geplanteStellen) {
|
||||
const futureEntry = addDays(TODAY, randInt(10, 90));
|
||||
const title = jobTitleById.get(p.job_id)!;
|
||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry });
|
||||
e.status = "Geplant";
|
||||
employees.push(e);
|
||||
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
|
||||
}
|
||||
|
||||
// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst
|
||||
// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine
|
||||
// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur
|
||||
// unbesetzter) Leitung Daten hat.
|
||||
const abwesende: EmployeeRow[] = [
|
||||
...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2),
|
||||
];
|
||||
while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) {
|
||||
abwesende.push(statusPool[cursor++]);
|
||||
}
|
||||
for (const e of abwesende) {
|
||||
const entryDate = new Date(e.entry_date);
|
||||
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
|
||||
const returnDate = addDays(TODAY, randInt(10, 300));
|
||||
@@ -603,65 +649,89 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
});
|
||||
}
|
||||
|
||||
// ~3 Geplant (future entry). A person who hasn't started yet can't already
|
||||
// have a Beförderung or other history predating that future entry date —
|
||||
// found during the consolidation review that reassigning an already-
|
||||
// finalized IC to Geplant only patched their Eintritt row's date, leaving
|
||||
// any earlier-generated history (e.g. a Beförderung) still on the record
|
||||
// with a date before the (now future) entry_date. Fixed by dropping every
|
||||
// history row for that employee except Eintritt, then moving Eintritt to
|
||||
// the new future date.
|
||||
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
const futureEntry = addDays(TODAY, randInt(10, 90));
|
||||
e.status = "Geplant";
|
||||
e.entry_date = isoDate(futureEntry);
|
||||
for (let hi = history.length - 1; hi >= 0; hi--) {
|
||||
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
|
||||
}
|
||||
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
|
||||
if (historyEntry) historyEntry.event_date = e.entry_date;
|
||||
}
|
||||
|
||||
// ~3 planned future exits (still Aktiv until the exit date arrives)
|
||||
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der
|
||||
// Zukunft.
|
||||
for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) {
|
||||
const e = statusPool[cursor];
|
||||
const futureExit = addDays(TODAY, randInt(10, 90));
|
||||
e.exit_date = isoDate(futureExit);
|
||||
e.exit_reason = pick(EXIT_REASONS);
|
||||
const a = assignments.find((x) => x.employee_id === e.id)!;
|
||||
a.valid_to = e.exit_date;
|
||||
}
|
||||
|
||||
// ── Open positions (§4.6 / §5) ───────────────────────────────
|
||||
type PositionRow = {
|
||||
title: string;
|
||||
team_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: "open";
|
||||
created_at: string;
|
||||
};
|
||||
const positions: PositionRow[] = [];
|
||||
// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ───────
|
||||
// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen
|
||||
// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine
|
||||
// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft.
|
||||
// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen
|
||||
// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt.
|
||||
{
|
||||
const pool = shuffle([...teamRows]);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const team = pool[i % pool.length];
|
||||
const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
|
||||
const isLeadPosition = i < 2; // first two are leadership requisitions
|
||||
const reportsTo = isLeadPosition
|
||||
? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
|
||||
: (leadOfTeam?.id ?? null);
|
||||
positions.push({
|
||||
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
|
||||
team_id: team.id,
|
||||
is_lead: isLeadPosition,
|
||||
reports_to_employee_id: reportsTo,
|
||||
status: "open",
|
||||
created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
|
||||
const holderOf = new Map(icEmployees.map((e) => [e.id, e]));
|
||||
const uebernehmbar = shuffle(
|
||||
assignments.filter((a) => {
|
||||
const holder = holderOf.get(a.employee_id);
|
||||
// Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze
|
||||
// Beschäftigung Platz hat.
|
||||
return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv";
|
||||
})
|
||||
).slice(0, AUSGETRETEN);
|
||||
|
||||
for (const a of uebernehmbar) {
|
||||
const nachfolgerEintritt = new Date(a.valid_from);
|
||||
const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60));
|
||||
const entryDate = addDays(exitDate, -randInt(400, 3000));
|
||||
const p = org.positions.find((x) => x.id === a.position_id)!;
|
||||
const title = jobTitleById.get(p.job_id)!;
|
||||
|
||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate });
|
||||
e.status = "Ausgetreten";
|
||||
e.exit_date = isoDate(exitDate);
|
||||
e.exit_reason = pick(EXIT_REASONS);
|
||||
// Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und
|
||||
// finalizeEmployee kann eine Beförderung bis heute gestreut haben, die
|
||||
// hier nach dem Austritt läge.
|
||||
e.contract_type = "unbefristet";
|
||||
e.contract_end_date = null;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1);
|
||||
}
|
||||
employees.push(e);
|
||||
history.push({
|
||||
employee_id: e.id,
|
||||
event_date: e.exit_date,
|
||||
event_type: "Austritt",
|
||||
description: `Austritt (${e.exit_reason})`,
|
||||
});
|
||||
assignments.push({
|
||||
position_id: p.id,
|
||||
employee_id: e.id,
|
||||
valid_from: e.entry_date,
|
||||
valid_to: e.exit_date,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insert helpers ───────────────────────────────────────────
|
||||
/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */
|
||||
function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] {
|
||||
const byParent = new Map<string | null, BuiltUnit[]>();
|
||||
for (const u of units) {
|
||||
const list = byParent.get(u.parent_id) ?? [];
|
||||
list.push(u);
|
||||
byParent.set(u.parent_id, list);
|
||||
}
|
||||
const out: BuiltUnit[] = [];
|
||||
const queue = [...(byParent.get(null) ?? [])];
|
||||
while (queue.length > 0) {
|
||||
const u = queue.shift()!;
|
||||
out.push(u);
|
||||
queue.push(...(byParent.get(u.id) ?? []));
|
||||
}
|
||||
if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten");
|
||||
return out;
|
||||
}
|
||||
|
||||
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
|
||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||
const chunk = rows.slice(i, i + chunkSize);
|
||||
@@ -671,34 +741,150 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
|
||||
console.log(` inserted ${rows.length} row(s) into ${table}`);
|
||||
}
|
||||
|
||||
// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen —
|
||||
// sonst sperrt sich der Seed selbst aus der Anwendung aus.
|
||||
//
|
||||
// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein
|
||||
// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die
|
||||
// Fremdschlüsselprüfung erst nach dem Statement auswertet.
|
||||
const WIPE_ORDER = [
|
||||
"pending_org_changes",
|
||||
"hire_drafts",
|
||||
"employee_notes",
|
||||
"employee_dependents",
|
||||
"employee_history",
|
||||
"position_assignments",
|
||||
"audit_log",
|
||||
"saved_reports",
|
||||
"employees",
|
||||
"om_positions",
|
||||
"jobs",
|
||||
"org_units",
|
||||
"locations",
|
||||
];
|
||||
|
||||
async function wipe() {
|
||||
for (const table of WIPE_ORDER) {
|
||||
// PostgREST verlangt einen Filter; "id ist nicht null" trifft alles.
|
||||
const { error } = await supabase.from(table).delete().not("id", "is", null);
|
||||
if (error) throw new Error(`Delete from ${table} failed: ${error.message}`);
|
||||
const { count } = await supabase.from(table).select("*", { count: "exact", head: true });
|
||||
if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`);
|
||||
console.log(` geleert: ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie
|
||||
* löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem
|
||||
* Löschen, und dann steht die Datenbank leer da.
|
||||
*/
|
||||
function pruefeInvarianten() {
|
||||
const laufend = assignments.filter((a) => a.valid_to === null);
|
||||
|
||||
const jeStelle = new Map<string, number>();
|
||||
for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1);
|
||||
for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`);
|
||||
|
||||
const jePerson = new Map<string, number>();
|
||||
for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1);
|
||||
for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`);
|
||||
|
||||
// Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur
|
||||
// die laufende ab, die Historie könnte sich also unbemerkt überschneiden.
|
||||
const nachStelle = new Map<string, AssignmentRow[]>();
|
||||
for (const a of assignments) {
|
||||
const list = nachStelle.get(a.position_id) ?? [];
|
||||
list.push(a);
|
||||
nachStelle.set(a.position_id, list);
|
||||
}
|
||||
for (const [id, list] of nachStelle) {
|
||||
const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from));
|
||||
for (let i = 1; i < sortiert.length; i++) {
|
||||
const vorher = sortiert[i - 1];
|
||||
if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) {
|
||||
throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of assignments) {
|
||||
if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`);
|
||||
}
|
||||
|
||||
const personen = new Set(employees.map((e) => e.id));
|
||||
for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person");
|
||||
for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person");
|
||||
|
||||
// Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge
|
||||
// sie ausserhalb der Organisation.
|
||||
const mitStelle = new Set(assignments.map((a) => a.employee_id));
|
||||
for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`);
|
||||
|
||||
// Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden
|
||||
// voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen,
|
||||
// wenn die Datenbank bereits leergeräumt ist.
|
||||
for (const e of employees) {
|
||||
const fehler = validateSvnr(e.sv_nummer, e.birth_date);
|
||||
if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`);
|
||||
}
|
||||
|
||||
for (const e of employees) {
|
||||
if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`);
|
||||
}
|
||||
for (const h of history) {
|
||||
const e = employees.find((x) => x.id === h.employee_id)!;
|
||||
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding locations...");
|
||||
pruefeInvarianten();
|
||||
|
||||
if (process.argv.includes("--dry-run")) {
|
||||
console.log("Trockenlauf — es wird nichts geschrieben.");
|
||||
berichte();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Lösche alle Daten (Anmeldekonten bleiben)...");
|
||||
await wipe();
|
||||
|
||||
console.log("\nSeeding locations...");
|
||||
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
|
||||
|
||||
console.log("Seeding divisions...");
|
||||
await insertInChunks("divisions", divisionRows);
|
||||
console.log(`Seeding ${org.units.length} org_units...`);
|
||||
// Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile
|
||||
// geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg
|
||||
// liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll
|
||||
// sich der Seed nicht verlassen.
|
||||
await insertInChunks("org_units", sortParentsFirst(org.units));
|
||||
|
||||
console.log("Seeding departments...");
|
||||
await insertInChunks("departments", departmentRows);
|
||||
console.log(`Seeding ${org.jobs.length} jobs...`);
|
||||
await insertInChunks("jobs", org.jobs);
|
||||
|
||||
console.log("Seeding teams...");
|
||||
await insertInChunks("teams", teamRows);
|
||||
console.log(`Seeding ${org.positions.length} Planstellen...`);
|
||||
await insertInChunks("om_positions", org.positions);
|
||||
|
||||
console.log(`Seeding ${employees.length} employees...`);
|
||||
await insertInChunks("employees", employees);
|
||||
|
||||
console.log(`Seeding ${assignments.length} Besetzungen...`);
|
||||
await insertInChunks("position_assignments", assignments);
|
||||
|
||||
console.log(`Seeding ${history.length} employee_history rows...`);
|
||||
await insertInChunks("employee_history", history);
|
||||
|
||||
console.log(`Seeding ${positions.length} open positions...`);
|
||||
await insertInChunks("positions", positions);
|
||||
|
||||
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
|
||||
// second "manager" role exists to seed a test account for. This is the
|
||||
// one deliberate, explicit bootstrap grant of HR access (not an automatic
|
||||
// one): every other new profile row defaults to is_active = false and
|
||||
// must be activated by an existing HR user (§2.3).
|
||||
console.log("Creating initial HR account...");
|
||||
// Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den
|
||||
// Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht
|
||||
// anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste
|
||||
// Freischaltung, jede weitere profiles-Zeile startet mit is_active = false
|
||||
// und muss von HR freigeschaltet werden (§2.3).
|
||||
const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle();
|
||||
if (existing) {
|
||||
console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`);
|
||||
} else {
|
||||
console.log("\nLege HR-Konto an...");
|
||||
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
|
||||
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
|
||||
email: ADMIN_EMAIL,
|
||||
@@ -713,12 +899,40 @@ async function main() {
|
||||
role: "hr",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
console.log("\nDone.");
|
||||
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
|
||||
console.log("(Password is shown once here only — store it somewhere safe.)");
|
||||
}
|
||||
|
||||
// Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr
|
||||
// gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet
|
||||
// hat, fällt das hier auf und nicht erst im Organigramm.
|
||||
const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) });
|
||||
if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`);
|
||||
const ohneVorgesetzte = (linien ?? []).filter(
|
||||
(l: { acting_manager_id: string | null }) => l.acting_manager_id === null
|
||||
);
|
||||
console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`);
|
||||
|
||||
console.log("\nFertig.");
|
||||
berichte();
|
||||
}
|
||||
|
||||
function berichte() {
|
||||
const heute = isoDate(TODAY);
|
||||
const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute));
|
||||
const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length;
|
||||
|
||||
console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`);
|
||||
console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`);
|
||||
console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`);
|
||||
console.log(` Personen: ${employees.length}`);
|
||||
for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) {
|
||||
console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`);
|
||||
}
|
||||
console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`);
|
||||
console.log(` Historie: ${history.length} Ereignisse`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
110
tests/integration/om-reporting.test.ts
Normal file
110
tests/integration/om-reporting.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting";
|
||||
import { adminClient } from "./helpers";
|
||||
|
||||
// Die Berichtslinien-Regel existiert zweimal: als om_reporting_lines() in
|
||||
// der Datenbank und als resolveReportingLines() im Anwendungscode. Zwei
|
||||
// Fassungen derselben Regel driften auseinander, und die Abweichung fällt
|
||||
// niemandem auf — im Organigramm stünde einfach eine andere Führungskraft
|
||||
// als im Export. Also über den gesamten Bestand gegeneinanderhalten.
|
||||
describe("om_reporting_lines stimmt mit resolveReportingLines überein", () => {
|
||||
const asOf = todayIso();
|
||||
|
||||
async function fromDatabase() {
|
||||
const { data, error } = await adminClient.rpc("om_reporting_lines", { p_as_of: asOf });
|
||||
if (error) throw new Error(error.message);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async function fromTypeScript() {
|
||||
const [{ data: units }, { data: assignments }] = await Promise.all([
|
||||
adminClient.from("org_units").select("id, parent_id"),
|
||||
adminClient
|
||||
.from("position_assignments")
|
||||
.select("employee_id, position_id, valid_from, valid_to, om_positions(org_unit_id, is_chief, valid_from, valid_to)")
|
||||
.lte("valid_from", asOf)
|
||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`),
|
||||
]);
|
||||
|
||||
const { data: employees } = await adminClient
|
||||
.from("employees")
|
||||
.select("id, karenz_start_date, karenz_return_date");
|
||||
const absentById = new Map(
|
||||
(employees ?? []).map((e) => [
|
||||
e.id,
|
||||
Boolean(
|
||||
e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
const omUnits: OmUnit[] = (units ?? []).map((u) => ({ id: u.id, parentId: u.parent_id }));
|
||||
const holders: OmHolder[] = (assignments ?? [])
|
||||
.filter((a) => {
|
||||
const p = a.om_positions as unknown as { valid_from: string; valid_to: string | null } | null;
|
||||
return p && p.valid_from <= asOf && (p.valid_to === null || p.valid_to > asOf);
|
||||
})
|
||||
.map((a) => {
|
||||
const p = a.om_positions as unknown as { org_unit_id: string; is_chief: boolean };
|
||||
return {
|
||||
employeeId: a.employee_id,
|
||||
positionId: a.position_id,
|
||||
orgUnitId: p.org_unit_id,
|
||||
isChief: p.is_chief,
|
||||
absent: absentById.get(a.employee_id) ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return resolveReportingLines(omUnits, holders);
|
||||
}
|
||||
|
||||
it("liefert für jede Person dieselbe formale und tatsächliche Führungskraft", async () => {
|
||||
const [db, ts] = await Promise.all([fromDatabase(), fromTypeScript()]);
|
||||
|
||||
expect(db.length).toBe(ts.length);
|
||||
expect(db.length).toBeGreaterThan(0);
|
||||
|
||||
const tsById = new Map(ts.map((l) => [l.employeeId, l]));
|
||||
const abweichungen = db
|
||||
.map((row) => {
|
||||
const mine = tsById.get(row.employee_id);
|
||||
if (!mine) return `${row.employee_id}: fehlt in der TypeScript-Fassung`;
|
||||
if (mine.actingManagerId !== row.acting_manager_id)
|
||||
return `${row.employee_id}: acting DB=${row.acting_manager_id} TS=${mine.actingManagerId}`;
|
||||
if (mine.formalManagerId !== row.formal_manager_id)
|
||||
return `${row.employee_id}: formal DB=${row.formal_manager_id} TS=${mine.formalManagerId}`;
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
expect(abweichungen.slice(0, 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it("gibt genau einer Person keine Führungskraft — der obersten Leitung", async () => {
|
||||
const db = await fromDatabase();
|
||||
const wurzel = db.filter((r) => r.acting_manager_id === null);
|
||||
expect(wurzel).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("erzeugt keine Berichtslinie auf sich selbst", async () => {
|
||||
const db = await fromDatabase();
|
||||
expect(db.filter((r) => r.acting_manager_id === r.employee_id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("lässt jede Berichtslinie an der obersten Leitung enden", async () => {
|
||||
// Ein Ring in den abgeleiteten Linien wäre im Organigramm ein Teilbaum,
|
||||
// der nie gerendert wird — und niemand würde es merken.
|
||||
const db = await fromDatabase();
|
||||
const managerOf = new Map(db.map((r) => [r.employee_id, r.acting_manager_id]));
|
||||
for (const start of db) {
|
||||
const gesehen = new Set<string>();
|
||||
let cur: string | null = start.employee_id;
|
||||
while (cur && !gesehen.has(cur)) {
|
||||
gesehen.add(cur);
|
||||
cur = managerOf.get(cur) ?? null;
|
||||
}
|
||||
expect(cur, `Ring in der Berichtslinie ab ${start.employee_id}`).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
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) });
|
||||
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data } = await adminClient.from("om_positions").select("valid_to").eq("id", positionId).maybeSingle();
|
||||
expect(data?.valid_to).toBe(isoDateOffset(0));
|
||||
|
||||
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);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
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 { error } = await hrClient.rpc("staff_position_internally", {
|
||||
payload: { position_id: positionId, employee_id: employeeId },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
const { data } = await adminClient.from("employees").select("job_title").eq("id", employeeId).single();
|
||||
expect(data?.job_title).toBe(title);
|
||||
});
|
||||
|
||||
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 });
|
||||
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 location = await pickSeededLocation();
|
||||
const employeeId = await hireTestEmployee(hrClient, positionId);
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
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/);
|
||||
await hrClient.rpc("terminate_employee", {
|
||||
payload: { employee_id: employeeId, exit_date: isoDateOffset(-1), exit_reason: "Integrationstest" },
|
||||
});
|
||||
|
||||
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 } = 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,
|
||||
|
||||
162
tests/unit/build-org.test.ts
Normal file
162
tests/unit/build-org.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildOrg, type DivisionDef } from "@/supabase/build-org";
|
||||
|
||||
// Die Konstruktion des Org-Baums ist die Stelle, an der sich Elternbezüge
|
||||
// und Leitungsplanstellen falsch verdrahten lassen, ohne dass es auffällt:
|
||||
// ein Team unter dem falschen Bereich sieht im Organigramm plausibel aus.
|
||||
|
||||
const DIVISIONS: DivisionDef[] = [
|
||||
{
|
||||
name: "Produktion",
|
||||
headTitle: "Bereichsleitung Produktion",
|
||||
departments: [
|
||||
{
|
||||
name: "Fertigung",
|
||||
leadTitle: "Abteilungsleitung Fertigung",
|
||||
teams: [
|
||||
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Monteur:in", "Anlagenführer:in"], baseSize: 3 },
|
||||
{ name: "CNC", leadTitle: "Teamleitung CNC", icTitles: ["CNC-Fräser:in"], baseSize: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Instandhaltung",
|
||||
leadTitle: "Abteilungsleitung Instandhaltung",
|
||||
teams: [{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Schlosser:in"], baseSize: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "IT",
|
||||
headTitle: "Bereichsleitung IT",
|
||||
departments: [
|
||||
{
|
||||
name: "Infrastruktur",
|
||||
leadTitle: "Abteilungsleitung Infrastruktur",
|
||||
teams: [{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["Systemadministrator:in"], baseSize: 2 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function build() {
|
||||
let n = 0;
|
||||
return buildOrg("Alpenwerk Industrie GmbH", DIVISIONS, () => `id-${++n}`);
|
||||
}
|
||||
|
||||
describe("Struktur", () => {
|
||||
it("hat genau eine Wurzel, und das ist die Gesellschaft", () => {
|
||||
const { units } = build();
|
||||
const roots = units.filter((u) => u.parent_id === null);
|
||||
expect(roots).toHaveLength(1);
|
||||
expect(roots[0].unit_type).toBe("Gesellschaft");
|
||||
});
|
||||
|
||||
it("hängt jede Einheit unter den richtigen Typ", () => {
|
||||
const { units } = build();
|
||||
const byId = new Map(units.map((u) => [u.id, u]));
|
||||
const erwartetesElternteil = { Bereich: "Gesellschaft", Abteilung: "Bereich", Team: "Abteilung" } as const;
|
||||
|
||||
for (const u of units) {
|
||||
if (u.unit_type === "Gesellschaft") continue;
|
||||
const parent = byId.get(u.parent_id!);
|
||||
expect(parent?.unit_type, `${u.name} hängt falsch`).toBe(erwartetesElternteil[u.unit_type]);
|
||||
}
|
||||
});
|
||||
|
||||
it("baut die Ebenen vollständig auf", () => {
|
||||
const { units } = build();
|
||||
const zahl = (t: string) => units.filter((u) => u.unit_type === t).length;
|
||||
expect(zahl("Gesellschaft")).toBe(1);
|
||||
expect(zahl("Bereich")).toBe(2);
|
||||
expect(zahl("Abteilung")).toBe(3);
|
||||
expect(zahl("Team")).toBe(4);
|
||||
});
|
||||
|
||||
it("führt jede Einheit über parent_id auf die Wurzel zurück", () => {
|
||||
// Ein abgehängter Teilbaum würde im Organigramm nie gerendert.
|
||||
const { units } = build();
|
||||
const byId = new Map(units.map((u) => [u.id, u]));
|
||||
for (const start of units) {
|
||||
const gesehen = new Set<string>();
|
||||
let cur = start;
|
||||
while (cur.parent_id && !gesehen.has(cur.id)) {
|
||||
gesehen.add(cur.id);
|
||||
cur = byId.get(cur.parent_id)!;
|
||||
}
|
||||
expect(cur.parent_id, `${start.name} erreicht die Wurzel nicht`).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("vergibt eindeutige Org-Nummern im richtigen Nummernkreis", () => {
|
||||
const { units } = build();
|
||||
expect(new Set(units.map((u) => u.org_number)).size).toBe(units.length);
|
||||
const prefix = { Gesellschaft: "10", Bereich: "20", Abteilung: "21", Team: "22" } as const;
|
||||
for (const u of units) expect(u.org_number.startsWith(prefix[u.unit_type]), u.name).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Planstellen", () => {
|
||||
it("gibt jeder Einheit genau eine Leitungsplanstelle", () => {
|
||||
// Der Unique-Index in der Datenbank erzwingt das ebenfalls; hier soll
|
||||
// der Seed gar nicht erst dagegenlaufen.
|
||||
const { units, positions } = build();
|
||||
for (const u of units) {
|
||||
const chiefs = positions.filter((p) => p.org_unit_id === u.id && p.is_chief);
|
||||
expect(chiefs, `${u.name}`).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("legt auch für die Abteilung eine Leitung an", () => {
|
||||
// Die Ebene, die im Altmodell gefehlt hat.
|
||||
const { units, positions } = build();
|
||||
const abteilungen = units.filter((u) => u.unit_type === "Abteilung");
|
||||
expect(abteilungen.length).toBeGreaterThan(0);
|
||||
for (const a of abteilungen) {
|
||||
expect(positions.some((p) => p.org_unit_id === a.id && p.is_chief)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("erzeugt für jedes Team so viele Mitarbeiter-Planstellen wie vorgesehen", () => {
|
||||
const { units, icPositionsByTeam } = build();
|
||||
const montage = units.find((u) => u.name === "Montage")!;
|
||||
expect(icPositionsByTeam.get(montage.id)).toHaveLength(3);
|
||||
expect(icPositionsByTeam.get(montage.id)!.every((p) => !p.is_chief)).toBe(true);
|
||||
});
|
||||
|
||||
it("vergibt eindeutige Planstellennummern nach dem bestehenden Muster", () => {
|
||||
const { positions } = build();
|
||||
expect(new Set(positions.map((p) => p.position_number)).size).toBe(positions.length);
|
||||
for (const p of positions) expect(p.position_number).toMatch(/^6\d{7}$/);
|
||||
});
|
||||
|
||||
it("hängt jede Planstelle an eine existierende Einheit", () => {
|
||||
const { units, positions } = build();
|
||||
const ids = new Set(units.map((u) => u.id));
|
||||
for (const p of positions) expect(ids.has(p.org_unit_id), p.position_number).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Job-Katalog", () => {
|
||||
it("führt jede Tätigkeit genau einmal", () => {
|
||||
// "Schlosser:in" darf nicht als zwei Einträge existieren, sonst ist eine
|
||||
// Auswertung nach Tätigkeit wertlos.
|
||||
const { jobs } = build();
|
||||
expect(new Set(jobs.map((j) => j.title)).size).toBe(jobs.length);
|
||||
expect(new Set(jobs.map((j) => j.code)).size).toBe(jobs.length);
|
||||
});
|
||||
|
||||
it("teilt denselben Job über mehrere Planstellen", () => {
|
||||
const { positions, jobs } = build();
|
||||
const jobById = new Map(jobs.map((j) => [j.id, j]));
|
||||
const monteur = jobs.find((j) => j.title === "Monteur:in")!;
|
||||
const stellen = positions.filter((p) => p.job_id === monteur.id);
|
||||
expect(stellen.length).toBeGreaterThan(1);
|
||||
expect(jobById.get(stellen[0].job_id)!.title).toBe("Monteur:in");
|
||||
});
|
||||
|
||||
it("verweist jede Planstelle auf einen existierenden Job", () => {
|
||||
const { positions, jobs } = build();
|
||||
const ids = new Set(jobs.map((j) => j.id));
|
||||
for (const p of positions) expect(ids.has(p.job_id), p.position_number).toBe(true);
|
||||
});
|
||||
});
|
||||
154
tests/unit/om-reporting.test.ts
Normal file
154
tests/unit/om-reporting.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "@/lib/om-reporting";
|
||||
|
||||
// Vier Ebenen wie in der Zielstruktur: Gesellschaft -> Bereich -> Abteilung
|
||||
// -> Team. Die Hierarchie steckt allein in parent_id; unit_type ist ein
|
||||
// Etikett und für die Ableitung ohne Bedeutung.
|
||||
const UNITS: OmUnit[] = [
|
||||
{ id: "gf", parentId: null },
|
||||
{ id: "bereich", parentId: "gf" },
|
||||
{ id: "abteilung", parentId: "bereich" },
|
||||
{ id: "team", parentId: "abteilung" },
|
||||
];
|
||||
|
||||
function holder(employeeId: string, orgUnitId: string, isChief: boolean, absent = false): OmHolder {
|
||||
return { employeeId, positionId: `pos-${employeeId}`, orgUnitId, isChief, absent };
|
||||
}
|
||||
|
||||
function lineFor(employeeId: string, holders: OmHolder[], units: OmUnit[] = UNITS) {
|
||||
return resolveReportingLines(units, holders).find((l) => l.employeeId === employeeId)!;
|
||||
}
|
||||
|
||||
describe("Berichtslinie aus dem Organisationsbaum", () => {
|
||||
const full = [
|
||||
holder("gf-person", "gf", true),
|
||||
holder("bl", "bereich", true),
|
||||
holder("al", "abteilung", true),
|
||||
holder("tl", "team", true),
|
||||
holder("ma", "team", false),
|
||||
];
|
||||
|
||||
it("lässt Mitarbeitende an die Leitung der eigenen Einheit berichten", () => {
|
||||
expect(lineFor("ma", full).actingManagerId).toBe("tl");
|
||||
});
|
||||
|
||||
it("lässt eine Leitung an die Leitung der übergeordneten Einheit berichten", () => {
|
||||
expect(lineFor("tl", full).actingManagerId).toBe("al");
|
||||
expect(lineFor("al", full).actingManagerId).toBe("bl");
|
||||
expect(lineFor("bl", full).actingManagerId).toBe("gf-person");
|
||||
});
|
||||
|
||||
it("gibt der obersten Leitung keine Führungskraft", () => {
|
||||
const gf = lineFor("gf-person", full);
|
||||
expect(gf.actingManagerId).toBeNull();
|
||||
expect(gf.formalManagerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unbesetzte Leitung", () => {
|
||||
// Der eigentliche Grund für die Aufwärtsregel: eine Abteilung ohne
|
||||
// Leitung soll die Kette nicht abreißen lassen und braucht keine
|
||||
// Sonderbehandlung im Code.
|
||||
const ohneAbteilungsleitung = [
|
||||
holder("gf-person", "gf", true),
|
||||
holder("bl", "bereich", true),
|
||||
holder("tl", "team", true),
|
||||
holder("ma", "team", false),
|
||||
];
|
||||
|
||||
it("überspringt eine unbesetzte Abteilungsleitung", () => {
|
||||
expect(lineFor("tl", ohneAbteilungsleitung).actingManagerId).toBe("bl");
|
||||
});
|
||||
|
||||
it("nennt die unbesetzte Ebene auch nicht als formale Leitung", () => {
|
||||
expect(lineFor("tl", ohneAbteilungsleitung).formalManagerId).toBeNull();
|
||||
});
|
||||
|
||||
it("überspringt mehrere unbesetzte Ebenen hintereinander", () => {
|
||||
const nurGf = [holder("gf-person", "gf", true), holder("ma", "team", false)];
|
||||
expect(lineFor("ma", nurGf).actingManagerId).toBe("gf-person");
|
||||
});
|
||||
|
||||
it("lässt die Führungskraft leer, wenn oberhalb niemand besetzt ist", () => {
|
||||
const allein = [holder("ma", "team", false)];
|
||||
expect(lineFor("ma", allein).actingManagerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("abwesende Leitung", () => {
|
||||
const teamleitungAbwesend = [
|
||||
holder("gf-person", "gf", true),
|
||||
holder("bl", "bereich", true),
|
||||
holder("al", "abteilung", true),
|
||||
holder("tl", "team", true, true),
|
||||
holder("ma", "team", false),
|
||||
];
|
||||
|
||||
it("hebt die Berichtslinie auf die nächste anwesende Ebene", () => {
|
||||
expect(lineFor("ma", teamleitungAbwesend).actingManagerId).toBe("al");
|
||||
});
|
||||
|
||||
it("nennt weiterhin die formal zuständige Leitung, damit die Vertretung erkennbar bleibt", () => {
|
||||
// Ohne das würde die Oberfläche die Vertretung als die echte
|
||||
// Führungskraft ausgeben.
|
||||
expect(lineFor("ma", teamleitungAbwesend).formalManagerId).toBe("tl");
|
||||
});
|
||||
|
||||
it("steigt über mehrere abwesende Ebenen hinweg", () => {
|
||||
const zweiAbwesend = [
|
||||
holder("gf-person", "gf", true),
|
||||
holder("bl", "bereich", true),
|
||||
holder("al", "abteilung", true, true),
|
||||
holder("tl", "team", true, true),
|
||||
holder("ma", "team", false),
|
||||
];
|
||||
expect(lineFor("ma", zweiAbwesend).actingManagerId).toBe("bl");
|
||||
expect(lineFor("ma", zweiAbwesend).formalManagerId).toBe("tl");
|
||||
});
|
||||
|
||||
it("gibt die abwesende Leitung selbst an ihre eigene übergeordnete Ebene", () => {
|
||||
expect(lineFor("tl", teamleitungAbwesend).actingManagerId).toBe("al");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Randfälle", () => {
|
||||
it("lässt niemanden an sich selbst berichten", () => {
|
||||
// Eine Leitung, deren übergeordnete Einheit sie ebenfalls führt.
|
||||
const doppelrolle = [holder("chef", "bereich", true), holder("chef2", "abteilung", true)];
|
||||
const units: OmUnit[] = [
|
||||
{ id: "bereich", parentId: null },
|
||||
{ id: "abteilung", parentId: "bereich" },
|
||||
];
|
||||
expect(lineFor("chef", doppelrolle, units).actingManagerId).toBeNull();
|
||||
});
|
||||
|
||||
it("bricht bei einem Ring in parent_id ab, statt ewig zu laufen", () => {
|
||||
// parent_id ist eine gewöhnliche Spalte; ein fehlerhafter Import kann
|
||||
// einen Ring erzeugen.
|
||||
const ring: OmUnit[] = [
|
||||
{ id: "a", parentId: "b" },
|
||||
{ id: "b", parentId: "a" },
|
||||
];
|
||||
const holders = [holder("ma", "a", false)];
|
||||
expect(() => resolveReportingLines(ring, holders)).not.toThrow();
|
||||
expect(lineFor("ma", holders, ring).actingManagerId).toBeNull();
|
||||
});
|
||||
|
||||
it("kommt mit mehreren Teams unter derselben Abteilung zurecht", () => {
|
||||
const units: OmUnit[] = [
|
||||
{ id: "abteilung", parentId: null },
|
||||
{ id: "team-a", parentId: "abteilung" },
|
||||
{ id: "team-b", parentId: "abteilung" },
|
||||
];
|
||||
const holders = [
|
||||
holder("al", "abteilung", true),
|
||||
holder("tl-a", "team-a", true),
|
||||
holder("tl-b", "team-b", true),
|
||||
holder("ma-a", "team-a", false),
|
||||
holder("ma-b", "team-b", false),
|
||||
];
|
||||
expect(lineFor("ma-a", holders, units).actingManagerId).toBe("tl-a");
|
||||
expect(lineFor("ma-b", holders, units).actingManagerId).toBe("tl-b");
|
||||
expect(lineFor("tl-b", holders, units).actingManagerId).toBe("al");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
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("übergeht eine Besetzung auf einer am Stichtag ungültigen Planstelle", () => {
|
||||
// loadOrgAsOf filtert Planstellen bereits nach Gültigkeit; kommt eine
|
||||
// Besetzung ohne passende Planstelle an, wäre sie im Baum nicht
|
||||
// verortbar.
|
||||
const result = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees: [emp("a")],
|
||||
assignments: [{ employee_id: "a", position_id: "gibtesnicht" }],
|
||||
});
|
||||
expect(result.employees).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("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" },
|
||||
];
|
||||
|
||||
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);
|
||||
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("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("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("keeps someone on Karenz in the chart", () => {
|
||||
const employees = [emp("a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" })];
|
||||
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
|
||||
it("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("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("Tätigkeit", () => {
|
||||
it("nimmt die Tätigkeit der Planstelle, nicht das Freitextfeld der Person", () => {
|
||||
// Bei einer projizierten Versetzung ist nur die erste schon richtig.
|
||||
const [result] = snapshot({
|
||||
asOf: "2026-06-01",
|
||||
employees: [emp("a", { job_title: "Veraltet" })],
|
||||
assignments: [{ employee_id: "a", position_id: "p-a1" }],
|
||||
}).employees;
|
||||
expect(result.job_title).toBe("Monteur:in");
|
||||
expect(result.position_number).toBe("60000005");
|
||||
});
|
||||
});
|
||||
|
||||
describe("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("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 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("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" } }],
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
it("moves an employee into the target team and under that team's lead", () => {
|
||||
const employees = [emp("a", { manager_id: "lead-a" }), leadB];
|
||||
const assignments = [assignment("a", { manager_id: "lead-a" }), assignment("lead-b", { team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 })];
|
||||
const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }];
|
||||
|
||||
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending });
|
||||
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