Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
534 lines
20 KiB
TypeScript
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";
|
|
|
|
/**
|
|
* Antriebsart des Dienstwagens.
|
|
*
|
|
* Bewusst keine dritte Möglichkeit „unbekannt": die Angabe hängt per CHECK
|
|
* an has_dienstwagen, und wer einen Dienstwagen hat, weiss auch, ob er lädt
|
|
* oder tankt.
|
|
*/
|
|
export type DienstwagenArt = "Verbrenner" | "Elektro";
|
|
|
|
/**
|
|
* Verhältnis zum Notfallkontakt.
|
|
*
|
|
* Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht
|
|
* „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der
|
|
* Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung.
|
|
*
|
|
* In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede
|
|
* Ergänzung zu einer Migration machen, und diese Liste wird sich ändern.
|
|
*/
|
|
export const EMERGENCY_RELATIONS = [
|
|
"Gattin/Gatte",
|
|
"Lebensgefährtin/Lebensgefährte",
|
|
"Mutter",
|
|
"Vater",
|
|
"Tochter",
|
|
"Sohn",
|
|
"Schwester",
|
|
"Bruder",
|
|
"Freundin/Freund",
|
|
"Sonstige",
|
|
] as const;
|
|
|
|
export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number];
|
|
|
|
/**
|
|
* Eine einzelne Feldänderung im Protokoll.
|
|
*
|
|
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
|
|
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
|
|
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
|
|
* das; gerechnet wird damit nicht.
|
|
*/
|
|
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
|
|
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 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: {
|
|
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;
|
|
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
|
|
email: string | null;
|
|
phone: string | null;
|
|
job_title: string;
|
|
location_id: string;
|
|
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;
|
|
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
|
|
dienstwagen_art: DienstwagenArt | null;
|
|
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
|
|
emergency_contact_name: string | null;
|
|
emergency_contact_phone: string | null;
|
|
emergency_contact_relation: string | null;
|
|
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 | null;
|
|
phone?: string | null;
|
|
job_title: string;
|
|
location_id: string;
|
|
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;
|
|
dienstwagen_art?: DienstwagenArt | null;
|
|
emergency_contact_name?: string | null;
|
|
emergency_contact_phone?: string | null;
|
|
emergency_contact_relation?: string | null;
|
|
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;
|
|
/** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */
|
|
changes: AuditChange[] | null;
|
|
/** Der geplante Vorgang, solange die Änderung noch nicht wirksam ist. */
|
|
pending_id: string | null;
|
|
created_at: string;
|
|
};
|
|
Insert: {
|
|
id?: string;
|
|
employee_id: string;
|
|
event_date: string;
|
|
event_type: HistoryEventType;
|
|
description: string;
|
|
changes?: AuditChange[] | null;
|
|
pending_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"]>;
|
|
};
|
|
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;
|
|
/**
|
|
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
|
|
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
|
|
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
|
|
* nachliefern.
|
|
*/
|
|
changes: AuditChange[] | 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;
|
|
changes?: AuditChange[] | null;
|
|
};
|
|
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
|
};
|
|
pending_org_changes: NoRelationships & {
|
|
Row: {
|
|
id: string;
|
|
employee_id: string;
|
|
change_type: PendingChangeType;
|
|
effective_date: string;
|
|
payload: Record<string, unknown>;
|
|
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>;
|
|
status?: PendingChangeStatus;
|
|
created_by?: string | null;
|
|
created_at?: string;
|
|
applied_at?: string | null;
|
|
};
|
|
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
|
};
|
|
// ── 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;
|
|
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. 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>;
|
|
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 };
|
|
// Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige
|
|
// Weg an der fehlenden delete-Policy auf employee_history vorbei.
|
|
delete_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
|
|
// Berichtigt Wert und/oder Datum eines Historieneintrags.
|
|
update_history_entry: { 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 };
|
|
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
|
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
|
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
|
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;
|
|
}[];
|
|
};
|
|
};
|
|
};
|
|
};
|