Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.
Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.
Was sich dadurch von selbst erledigt hat:
- Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
- Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
- Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
- Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
- Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
beantwortet werden.
Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.
Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.
Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.
Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
This commit is contained in:
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,58 +75,73 @@ 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) => ({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: e.job_title,
|
||||
division_id: e.division_id,
|
||||
team_id: e.team_id,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
contract_type: e.contract_type,
|
||||
entry_date: e.entry_date,
|
||||
exit_date: e.exit_date,
|
||||
weekly_hours: e.weekly_hours,
|
||||
source: e.source,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
worker_type: e.worker_type,
|
||||
collective_agreement: e.collective_agreement,
|
||||
work_days: e.work_days,
|
||||
is_betriebsrat: e.is_betriebsrat,
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
}));
|
||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
||||
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
|
||||
// Bereich selbst nur die Bereichsleitung sitzt.
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = [];
|
||||
for (const e of data) {
|
||||
const placement = placements.get(e.id);
|
||||
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
|
||||
// sitzt aber auf keiner Planstelle mehr.
|
||||
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
||||
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
|
||||
|
||||
withDerivedStatus.push({
|
||||
id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: placement?.jobTitle ?? e.job_title,
|
||||
org_unit_id: orgUnitId,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
contract_type: e.contract_type,
|
||||
entry_date: e.entry_date,
|
||||
exit_date: e.exit_date,
|
||||
weekly_hours: e.weekly_hours,
|
||||
source: e.source,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
gender: e.gender,
|
||||
worker_type: e.worker_type,
|
||||
collective_agreement: e.collective_agreement,
|
||||
work_days: e.work_days,
|
||||
is_betriebsrat: e.is_betriebsrat,
|
||||
has_dienstwagen: e.has_dienstwagen,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
||||
}
|
||||
|
||||
// Ereignisse: employee_history has no division_id/team_id of its own, so
|
||||
// this joins in the affected employee's *current* org placement (two plain
|
||||
// queries, merged in JS — the hand-written Database type has no relational
|
||||
// embedding metadata for a single nested-select query).
|
||||
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
|
||||
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
|
||||
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
|
||||
// Team stand, in das die Person nie versetzt worden war.
|
||||
//
|
||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||
@@ -125,25 +159,49 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
|
||||
return query;
|
||||
}
|
||||
|
||||
const [history, employees] = await Promise.all([
|
||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||
fetchAllRows(historyQuery),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")),
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("position_assignments")
|
||||
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
|
||||
.order("employee_id")
|
||||
),
|
||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
||||
for (const a of assignments as unknown as {
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
om_positions: { org_unit_id: string };
|
||||
}[]) {
|
||||
const list = spans.get(a.employee_id) ?? [];
|
||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
|
||||
spans.set(a.employee_id, list);
|
||||
}
|
||||
|
||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
|
||||
const events: ReportEvent[] = [];
|
||||
for (const h of history) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) continue;
|
||||
if (filters.location && emp.location_id !== filters.location) continue;
|
||||
|
||||
const unitId =
|
||||
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
|
||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
||||
|
||||
events.push({
|
||||
employee_id: emp.id,
|
||||
first_name: emp.first_name,
|
||||
last_name: emp.last_name,
|
||||
job_title: emp.job_title,
|
||||
division_id: emp.division_id,
|
||||
team_id: emp.team_id,
|
||||
org_unit_id: unitId,
|
||||
location_id: emp.location_id,
|
||||
event_date: h.event_date,
|
||||
event_type: h.event_type,
|
||||
|
||||
@@ -81,8 +81,8 @@ export type ReportEmployee = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
division_id: string;
|
||||
team_id: string | null;
|
||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
||||
org_unit_id: string | null;
|
||||
location_id: string;
|
||||
status: string;
|
||||
employment_type: string;
|
||||
@@ -104,20 +104,26 @@ export type ReportEmployee = {
|
||||
dependents_count: number;
|
||||
};
|
||||
|
||||
// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über
|
||||
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
|
||||
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
|
||||
// und wird einmal vorberechnet.
|
||||
export type OrgLookups = {
|
||||
divisionName: Map<string, string>;
|
||||
departmentNameByTeam: Map<string, string>;
|
||||
departmentName: Map<string, string>;
|
||||
teamName: Map<string, string>;
|
||||
locationName: Map<string, string>;
|
||||
};
|
||||
|
||||
// Reconstructs status as of any date from the columns that actually carry a
|
||||
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
||||
// which only ever reflects *today*. Division/team/location still reflect the
|
||||
// employee's *current* assignment — the schema has no history of org-unit
|
||||
// changes over time, only free-text employee_history descriptions — so a
|
||||
// stichtag report groups by today's org placement, not the placement as of
|
||||
// that date. Documented in the UI rather than silently wrong.
|
||||
// which only ever reflects *today*.
|
||||
//
|
||||
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit
|
||||
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung
|
||||
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
|
||||
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
|
||||
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
|
||||
export function deriveStatusAsOf(
|
||||
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
|
||||
asOf: string
|
||||
@@ -137,11 +143,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin
|
||||
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
|
||||
switch (dim) {
|
||||
case "division":
|
||||
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
||||
case "department":
|
||||
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "team":
|
||||
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "status":
|
||||
@@ -256,7 +262,7 @@ export function aggregateReport(
|
||||
id: e.id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
title: e.job_title,
|
||||
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.entry_date,
|
||||
}));
|
||||
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
|
||||
@@ -345,8 +351,8 @@ export type ReportEvent = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
division_id: string;
|
||||
team_id: string | null;
|
||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
||||
org_unit_id: string | null;
|
||||
location_id: string;
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
@@ -358,11 +364,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
|
||||
case "event_type":
|
||||
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
|
||||
case "division":
|
||||
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
||||
case "department":
|
||||
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "team":
|
||||
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "event_year":
|
||||
@@ -394,7 +400,7 @@ export function aggregateEvents(
|
||||
id: e.employee_id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
title: e.description,
|
||||
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.event_date,
|
||||
}));
|
||||
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
|
||||
|
||||
@@ -32,8 +32,6 @@ export type HistoryEventType =
|
||||
| "Reorganisation"
|
||||
| "Gehaltsanpassung"
|
||||
| "Rückkehr";
|
||||
export type PositionStatus = "open" | "filled";
|
||||
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
|
||||
export type PendingChangeType =
|
||||
| "transfer"
|
||||
| "promotion"
|
||||
@@ -51,21 +49,6 @@ type NoRelationships = { Relationships: [] };
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
divisions: NoRelationships & {
|
||||
Row: { id: string; org_number: string; name: string };
|
||||
Insert: { id?: string; org_number: string; name: string };
|
||||
Update: Partial<{ id: string; org_number: string; name: string }>;
|
||||
};
|
||||
departments: NoRelationships & {
|
||||
Row: { id: string; org_number: string; name: string; division_id: string };
|
||||
Insert: { id?: string; org_number: string; name: string; division_id: string };
|
||||
Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>;
|
||||
};
|
||||
teams: NoRelationships & {
|
||||
Row: { id: string; org_number: string; name: string; department_id: string };
|
||||
Insert: { id?: string; org_number: string; name: string; department_id: string };
|
||||
Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>;
|
||||
};
|
||||
locations: NoRelationships & {
|
||||
Row: { id: string; name: string; country: string };
|
||||
Insert: { id?: string; name: string; country: string };
|
||||
@@ -119,13 +102,8 @@ export type Database = {
|
||||
address_country: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id: string | null;
|
||||
org_level: number;
|
||||
is_lead: boolean;
|
||||
employment_type: EmploymentType;
|
||||
weekly_hours: number;
|
||||
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
|
||||
@@ -168,13 +146,8 @@ export type Database = {
|
||||
address_country?: string | null;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
team_id?: string | null;
|
||||
division_id?: string;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id?: string | null;
|
||||
org_level?: number;
|
||||
is_lead?: boolean;
|
||||
employment_type?: EmploymentType;
|
||||
weekly_hours?: number;
|
||||
contract_type?: ContractType;
|
||||
@@ -210,7 +183,6 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -219,7 +191,6 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -276,37 +247,6 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
||||
};
|
||||
positions: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
position_number: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: PositionStatus;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
filled_at: string | null;
|
||||
filled_by_employee_id: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
position_number?: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id?: string;
|
||||
is_lead?: boolean;
|
||||
reports_to_employee_id?: string | null;
|
||||
status?: PositionStatus;
|
||||
valid_from?: string;
|
||||
created_at?: string;
|
||||
filled_at?: string | null;
|
||||
filled_by_employee_id?: string | null;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
|
||||
};
|
||||
hire_drafts: NoRelationships & {
|
||||
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
||||
@@ -340,34 +280,6 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
||||
};
|
||||
reorg_scenarios: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
name: string;
|
||||
effective_date: string;
|
||||
created_by: string | null;
|
||||
applied: boolean;
|
||||
applied_at: string | null;
|
||||
undo_snapshot: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
name: string;
|
||||
effective_date: string;
|
||||
created_by?: string | null;
|
||||
applied?: boolean;
|
||||
applied_at?: string | null;
|
||||
undo_snapshot?: Record<string, unknown> | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
|
||||
};
|
||||
reorg_moves: NoRelationships & {
|
||||
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
|
||||
};
|
||||
pending_org_changes: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -375,7 +287,6 @@ export type Database = {
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id: string | null;
|
||||
status: PendingChangeStatus;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
@@ -387,7 +298,6 @@ export type Database = {
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id?: string | null;
|
||||
status?: PendingChangeStatus;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
@@ -395,11 +305,17 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||
// read access only, hence no Insert/Update shapes worth modelling.
|
||||
// ── SAP-OM-Modell ──────────────────────────────────────────
|
||||
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
|
||||
org_units: NoRelationships & {
|
||||
org_units: {
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "org_units_parent_id_fkey";
|
||||
columns: ["parent_id"];
|
||||
referencedRelation: "org_units";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
Row: {
|
||||
id: string;
|
||||
org_number: string;
|
||||
@@ -428,9 +344,23 @@ export type Database = {
|
||||
Insert: { id?: string; code: string; title: string; created_at?: string };
|
||||
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
|
||||
};
|
||||
// S: Planstelle. Heisst om_positions, weil `positions` noch die alte
|
||||
// Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist.
|
||||
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
|
||||
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
|
||||
om_positions: {
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "om_positions_org_unit_id_fkey";
|
||||
columns: ["org_unit_id"];
|
||||
referencedRelation: "org_units";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "om_positions_job_id_fkey";
|
||||
columns: ["job_id"];
|
||||
referencedRelation: "jobs";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
Row: {
|
||||
id: string;
|
||||
position_number: string;
|
||||
@@ -452,7 +382,6 @@ export type Database = {
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
||||
Relationships: [];
|
||||
};
|
||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
||||
position_assignments: {
|
||||
@@ -473,26 +402,23 @@ export type Database = {
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
|
||||
// Einbettung auf die Planstelle, damit die Berichtslinie in einer
|
||||
// Abfrage geladen werden kann.
|
||||
Relationships: [{ foreignKeyName: "position_assignments_position_id_fkey"; columns: ["position_id"]; referencedRelation: "om_positions"; referencedColumns: ["id"] }];
|
||||
};
|
||||
employee_assignments: NoRelationships & {
|
||||
Row: {
|
||||
id: string;
|
||||
employee_id: string;
|
||||
manager_id: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
job_title: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: never;
|
||||
Update: never;
|
||||
// Beide Richtungen: über die Planstelle hängt die Verortung in der
|
||||
// Organisation, über die Person die Verortung in der Akte. Die
|
||||
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "position_assignments_position_id_fkey";
|
||||
columns: ["position_id"];
|
||||
referencedRelation: "om_positions";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "position_assignments_employee_id_fkey";
|
||||
columns: ["employee_id"];
|
||||
referencedRelation: "employees";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
Views: Record<string, never>;
|
||||
@@ -510,12 +436,11 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
om_reporting_lines: {
|
||||
Args: { p_as_of?: string };
|
||||
|
||||
Reference in New Issue
Block a user