Files
alpenwerk-hr/lib/supabase/types.ts
Maximilian Stubhan 4b9c23472c SAP OM: org units, jobs, positions, and a derived reporting line
The org structure was three fixed tables — divisions -> departments ->
teams — with people hanging directly off them and a hand-maintained
manager_id. The depth was therefore wired into the schema: an Abteilungsleitung
could not exist without a migration, and a team directly under a Bereich not
at all. That is what this replaces.

The SAP OM object types, one table each:

  O  org_units             recursive over parent_id
  C  jobs                  catalogue, so many positions can share a job
  S  om_positions          belongs to exactly one org unit
  P  employees             existing table
  A012 om_positions.is_chief          "ist Leiter von"
  A008 position_assignments           "Inhaber ist", time-dependent

Two consequences worth stating, because they are the point of the exercise:

- GF/Bereich/Abteilung/Team are now a label (unit_type), not a structure.
  Adding a fifth level, or hanging a team straight off a Bereich, becomes a
  data question rather than a migration.
- Nobody hangs off an org unit any more: person -> position -> unit. A
  vacancy stops being its own concept — it is a position with no current
  assignment.

The reporting line is derived rather than stored: an ordinary position
reports to the chief of its own unit, a chief to the chief of the parent
unit, and if that chief is vacant or on a long-term absence it keeps
climbing. An unfilled Abteilungsleitung therefore needs no special case —
it is simply skipped. Both ids come back, formal and acting, so the UI can
show a stand-in as a stand-in instead of passing it off as the real manager.

The rule exists twice, as om_reporting_lines() in SQL and
resolveReportingLines() in TypeScript, because the as-of chart computes it
per date in the app and a round trip per date change would buy nothing. Two
copies drift silently — the org chart would just show a different manager
than the export — so an integration test runs both over the whole roster and
requires identical answers, plus that every line terminates at the top.

Unit tests cover the rule itself: unfilled levels, several absent levels in
a row, nobody above, a chief who also leads the parent unit, and a cycle in
parent_id, which is an ordinary column an import could get wrong.

Additive so far. The old tables still stand and the app still reads them;
the cut-over follows.
2026-07-27 12:47:14 +02:00

534 lines
20 KiB
TypeScript

// Hand-written to match supabase/schema.sql + supabase/migrations/*.sql (no DB
// connection string available to run `supabase gen types typescript` in this
// environment — regenerate from the live project once you have the Supabase
// CLI linked).
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
export type EmploymentType = "Vollzeit" | "Teilzeit";
export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w";
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
export type CollectiveAgreement = "Handel" | "Süßwaren";
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
// 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"
| "Versetzung"
| "Karenz"
| "Vertragsänderung"
| "Stammdatenänderung"
| "Austritt"
| "Wiedereintritt"
| "Reorganisation"
| "Gehaltsanpassung"
| "Rückkehr";
export type PositionStatus = "open" | "filled";
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
export type PendingChangeType =
| "transfer"
| "promotion"
| "karenz_start"
| "karenz_return"
| "contract_change"
| "reorg";
export type PendingChangeStatus = "pending" | "applied" | "cancelled";
// @supabase/postgrest-js requires every table/view to carry a Relationships
// array (used for typed embedded selects) — left empty since no code in this
// app relies on nested/embedded resource selects.
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 };
Update: Partial<{ id: string; name: string; country: string }>;
};
profiles: NoRelationships & {
Row: {
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
email: string;
full_name?: string | null;
role?: ProfileRole;
is_active?: boolean;
created_by?: string | null;
created_at?: string;
updated_at?: string;
};
Update: Partial<{
id: string;
email: string;
full_name: string | null;
role: ProfileRole;
is_active: boolean;
created_by: string | null;
created_at: string;
updated_at: string;
}>;
};
employees: NoRelationships & {
Row: {
id: string;
personnel_number: number;
first_name: string;
last_name: string;
gender: GenderType;
birth_date: string;
sv_nummer: string | null;
nationality: string;
address: string | null;
postal_code: string | null;
city: string | null;
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. */
monthly_salary_gross: number | null;
contract_type: ContractType;
contract_end_date: string | null;
paygrade: PaygradeType;
source: SourceType;
status: EmploymentStatus;
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
avatar_color: string | null;
worker_type: WorkerType;
collective_agreement: CollectiveAgreement;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
title_prefix: string[];
title_suffix: string[];
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
first_name: string;
last_name: string;
gender: GenderType;
birth_date: string;
sv_nummer?: string | null;
nationality?: string;
address?: string | null;
postal_code?: string | null;
city?: string | null;
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;
contract_end_date?: string | null;
paygrade?: PaygradeType;
source?: SourceType;
status?: EmploymentStatus;
entry_date: string;
exit_date?: string | null;
exit_reason?: string | null;
karenz_start_date?: string | null;
karenz_return_date?: string | null;
absence_type?: string | null;
avatar_color?: string | null;
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
title_prefix?: string[];
title_suffix?: string[];
created_at?: string;
updated_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employees"]["Insert"]>;
};
employee_history: NoRelationships & {
Row: {
id: string;
employee_id: string;
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id: string | null;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
event_date: string;
event_type: HistoryEventType;
description: string;
reorg_scenario_id?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
};
employee_dependents: NoRelationships & {
Row: {
id: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer: string | null;
birth_date: string;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string | null;
birth_date: string;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_dependents"]["Insert"]>;
};
employee_notes: NoRelationships & {
Row: {
id: string;
employee_id: string;
author_user_id: string | null;
author_name: string;
category: NoteCategory;
note_text: string;
due_date: string | null;
done: boolean;
done_at: string | null;
done_by: string | null;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
author_user_id?: string | null;
author_name: string;
category?: NoteCategory;
note_text: string;
due_date?: string | null;
done?: boolean;
done_at?: string | null;
done_by?: string | null;
created_at?: string;
};
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 };
Update: Partial<Database["public"]["Tables"]["hire_drafts"]["Insert"]>;
};
saved_reports: NoRelationships & {
Row: { id: string; created_by: string | null; name: string; config: Record<string, unknown>; created_at: string };
Insert: { id?: string; created_by?: string | null; name: string; config: Record<string, unknown>; created_at?: string };
Update: Partial<Database["public"]["Tables"]["saved_reports"]["Insert"]>;
};
audit_log: NoRelationships & {
Row: {
id: string;
occurred_at: string;
actor_user_id: string | null;
actor_name: string;
action: string;
target_label: string;
target_employee_id: string | null;
details: string | null;
};
Insert: {
id?: string;
occurred_at?: string;
actor_user_id?: string | null;
actor_name: string;
action: string;
target_label: string;
target_employee_id?: string | null;
details?: string | null;
};
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;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id: string | null;
status: PendingChangeStatus;
created_by: string | null;
created_at: string;
applied_at: string | null;
};
Insert: {
id?: string;
employee_id: string;
change_type: PendingChangeType;
effective_date: string;
payload: Record<string, unknown>;
reorg_scenario_id?: string | null;
status?: PendingChangeStatus;
created_by?: string | null;
created_at?: string;
applied_at?: string | null;
};
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 & {
Row: {
id: string;
org_number: string;
name: string;
parent_id: string | null;
unit_type: OrgUnitType;
valid_from: string;
valid_to: string | null;
created_at: string;
};
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. Heisst om_positions, weil `positions` noch die alte
// Tabelle für offene Stellen ist, bis der Umstieg abgeschlossen ist.
om_positions: {
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"]>;
Relationships: [];
};
// 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"]>;
// 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;
};
};
Views: Record<string, never>;
Functions: {
hire_employee: { Args: { payload: Record<string, unknown> }; Returns: string };
terminate_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
transfer_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
promote_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
start_karenz: { Args: { payload: Record<string, unknown> }; Returns: void };
adjust_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
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 };
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;
}[];
};
};
};
};