"use server"; import { revalidatePath } from "next/cache"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; import { OFFBOARDING_PUNKTE } from "@/lib/offboarding"; import { ONBOARDING_PUNKTE } from "@/lib/onboarding"; import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { const result = await runMutation(await currentUserId(), fn, payload); if (!result.success) return result; for (const path of revalidate) revalidatePath(path); return result; } /** * Ist diese Personalnummer noch frei? * * ── Warum das trotz der Prüfung in der Datenbank hier steht ────────── * * `hire_employee` weist eine vergebene Nummer ab, und das bleibt die * verbindliche Prüfung: zwischen dieser Frage und dem Anlegen können Sekunden * liegen, und in denen kann jemand anderes dieselbe Nummer vergeben. Der * Unique-Index ist die einzige Stelle, die das sicher ausschliesst. * * Nur kommt diese Abweisung ganz am Ende — nach Position, Vertrag, * Angehörigen, Notfallkontakt, sechs Schritten Eingabe. Die Nummer steht im * *ersten* Feld des *ersten* Schritts. Wer sie vertippt, erfährt es * frühestens nach fünf Minuten und darf dann suchen, welche der Angaben * gemeint war. * * Deshalb hier die frühe Auskunft und dort die Entscheidung. Es ist keine * doppelte Wahrheit, sondern dieselbe Frage zu zwei Zeitpunkten — und wenn * die späte Antwort einmal abweicht, gewinnt sie. * * Kein `ActionResult`: eine vergebene Nummer ist kein Fehler des Aufrufs, * sondern die Antwort auf die Frage. */ export async function personalnummerVergeben(nummer: number): Promise<{ vergeben: boolean; name?: string }> { if (!Number.isInteger(nummer) || nummer <= 0) return { vergeben: false }; return withUser(await currentUserId(), async (tx) => { const treffer = await tx .selectFrom("employees") .select(["first_name", "last_name"]) .where("personnel_number", "=", nummer) .executeTakeFirst(); // Der Name wird mitgegeben, weil „bereits vergeben" allein die Frage // aufwirft, an wen — und die Antwort ist meistens der Grund: dieselbe // Person ist schon angelegt, oder es war ein Zahlendreher. return treffer ? { vergeben: true, name: `${treffer.first_name} ${treffer.last_name}` } : { vergeben: false }; }); } export async function hireEmployee(payload: { /** * Wird eingegeben, nicht vergeben. * * Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene * Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei * Nummern. Die Datenbank weist eine bereits vergebene Nummer ab. */ personnel_number: number; first_name: string; last_name: string; title_prefix?: string[]; title_suffix?: string[]; gender: "m" | "w"; birth_date: string; sv_nummer?: string; /** * Die **private** Adresse, freiwillig. * * Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private * Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt * eindeutig, wenn angegeben. */ email?: string; /** * Die **dienstliche** Adresse, freiwillig und eindeutig. * * Eigenes Feld neben der privaten, weil nur diese das Haus verlassen darf: * der Export für die Mitarbeiterbefragung geht an einen fremden Anbieter, * der in unserem Namen Einladungen verschickt. */ company_email?: string; phone?: string; position_id?: string; team_id?: string; job_title?: string; location_id: string; entry_date: string; contract_type?: "unbefristet" | "befristet"; contract_end_date?: string; employment_type?: "Vollzeit" | "Teilzeit"; weekly_hours?: number; paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; source: "Intern" | "Extern"; worker_type?: WorkerType; collective_agreement?: CollectiveAgreement; work_days?: Weekday[]; is_betriebsrat?: boolean; has_dienstwagen?: boolean; dienstwagen_art?: DienstwagenArt | null; emergency_contact_name?: string; emergency_contact_phone?: string; emergency_contact_relation?: string; is_laterale_fuehrung?: boolean; is_c_level?: boolean; /** Form der Beschäftigung — chk_mitarbeiterart. Leer heisst „unverändert". */ mitarbeiterart?: string; has_kuendigungsschutz?: boolean; /** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */ kuendigungsschutz_bis?: string | null; /** Ebenfalls nur mit dem Kennzeichen — chk_kuendigungsschutz_grund. */ kuendigungsschutz_grund?: string | null; kuendigungsschutz_ab?: string | null; ist_beguenstigt_behindert?: boolean; /** Nur mit dem Kennzeichen — chk_behinderung. Als Text, wie er aus dem Feld kommt. */ behinderung_grad?: string | null; behinderung_ab?: string | null; behinderung_bis?: string | null; }): Promise { // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann. try { const employeeId = await withUser(await currentUserId(), async (tx) => { const id = (await callFunction(tx, "hire_employee", payload as Record)) as string; // In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine // halb erfasste Einstellung, und sie später nachzureichen hiesse, dass // jemand daran denken muss. await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) }); return id; }); revalidatePath("/employees"); revalidatePath("/"); revalidatePath("/positions"); return { success: true, employeeId: employeeId as string }; } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; } } export async function terminateEmployee(payload: { employee_id: string; exit_date: string; exit_reason: string; /** * Freiwillig oder unfreiwillig — leer heisst „nicht erfasst". * * Eigene Angabe und nicht aus `exit_reason` abgeleitet: die einvernehmliche * Auflösung kann von beiden Seiten ausgehen (siehe lib/beendigung.ts). */ austrittsart?: string; note?: string; }): Promise { // No Show ist kein Austritt im gewohnten Sinn — die Person hat nie // angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der // eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste // entstünde leer und wäre ein Etikett ohne Inhalt. if (payload.exit_reason === "No Show") { return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } try { await withUser(await currentUserId(), async (tx) => { await callFunction(tx, "terminate_employee", payload as unknown as Record); // In derselben Transaktion: ein Austritt ohne Checkliste wäre ein // halb erfasster Austritt, und sie später anzulegen hiesse, dass // jemand daran denken muss. await callFunction(tx, "start_offboarding", { employee_id: payload.employee_id, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key), }); }); } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; } for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path); return { success: true }; } /** Einen Punkt der Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */ export async function setOffboardingTask(payload: { employee_id: string; item_key: string; erledigt?: boolean; wert?: string | null; kommentar?: string | null; }): Promise { return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]); } /** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */ export async function startOffboarding(employeeId: string): Promise { return callRpc( "start_offboarding", { employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) }, [`/employees/${employeeId}`] ); } export async function transferEmployee(payload: { employee_id: string; effective_date: string; /** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */ target_position_id: string; }): Promise { return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); } export async function promoteEmployee(payload: { employee_id: string; effective_date: string; new_title: string; new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; /** * Nur, wenn die Beförderung zugleich auf eine andere Planstelle führt. * * Fehlt sie, bleibt die Person auf ihrer bisherigen — das ist der andere * der beiden Fälle, nicht ein vergessenes Feld. */ target_position_id?: string; }): Promise { return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]); } export async function startKarenz(payload: { employee_id: string; karenz_start_date: string; planned_return_date: string; absence_type: string; note?: string; }): Promise { return callRpc("start_karenz", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } export async function adjustKarenzReturn(payload: { employee_id: string; new_return_date: string; note?: string; }): Promise { return callRpc("adjust_karenz_return", payload, [`/employees/${payload.employee_id}`]); } export async function recordKarenzReturn(payload: { employee_id: string; return_date: string; employment_mode: "unverändert" | "Vollzeit" | "Teilzeit"; weekly_hours?: number; /** Wiedereingliederungs- oder Elternteilzeit, wenn reduziert zurückgekehrt wird. */ reduction_reason?: string; /** Ende der Teilzeit, falls bekannt — nur mit einem Grund zusammen. */ teilzeit_bis?: string; }): Promise { return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); } export async function changeEmployeeData(payload: { employee_id: string; effective_date: string; person: Record; contract: Record; // `role` trägt seit 20260917120000 auch `source`. Wechselt sie von // „Extern" auf „Intern", schreibt change_employee_data zusätzlich das // Ereignis „Übernahme" — der umgekehrte Weg erzeugt keines. role: Record; }): Promise { return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); } /** * `position_id` ist Pflicht — die Datenbankfunktion verlangt sie seit jeher. * * Die alte Stelle taugt nicht als stille Vorgabe: sie kann inzwischen besetzt * oder ausgelaufen sein. Sie fehlte hier nur in der Signatur, weshalb jede * Wiedereinstellung an einer Meldung scheiterte, die im Dialog nicht zu * beheben war. */ export async function rehireEmployee(payload: { employee_id: string; company_email?: string; rehire_date: string; position_id: string; /** * Die Stammdaten, wie sie der Assistent zeigt — alle freiwillig. * * Fehlt ein Schlüssel, bleibt der bestehende Wert stehen. Das hält den * schlanken Aufruf (nur Datum und Planstelle) am Leben und erlaubt dem * Assistenten trotzdem, den ganzen Satz zu schicken, ohne dass danach eine * zweite Transaktion nötig wäre — siehe Migration 20260917130000. */ [feld: string]: unknown; }): Promise { try { await withUser(await currentUserId(), async (tx) => { await callFunction(tx, "rehire_employee", payload as unknown as Record); // Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und // E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da // und bleiben stehen — start_onboarding legt nur an, was fehlt, statt // einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet // HR an der Liste; ein Programm an ihrer Stelle würde raten. await callFunction(tx, "start_onboarding", { employee_id: payload.employee_id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key), }); }); } catch (err) { return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; } for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path); return { success: true }; } /** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */ export async function setOnboardingTask(payload: { employee_id: string; item_key: string; erledigt?: boolean; wert?: string | null; kommentar?: string | null; }): Promise { return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]); } /** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */ export async function startOnboarding(employeeId: string): Promise { return callRpc( "start_onboarding", { employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) }, [`/employees/${employeeId}`] ); } export async function addEmployeeDependent(payload: { employee_id: string; first_name: string; last_name: string; relationship: RelationshipType; sv_nummer?: string; birth_date: string; effective_date: string; }): Promise { return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]); } /** * Eine Angabe berichtigen — ohne Stichtag. * * Hinzufügen und Entfernen tragen einen: dort passiert etwas zu einem * Zeitpunkt. Eine Berichtigung nicht — der Wert war schon vorher falsch, und * ein „wirksam ab" hiesse, die Person habe bis dahin anders geheissen. */ export async function updateEmployeeDependent(payload: { dependent_id: string; employee_id: string; first_name: string; last_name: string; relationship: RelationshipType; sv_nummer?: string; birth_date: string; }): Promise { return callRpc("update_employee_dependent", payload, [`/employees/${payload.employee_id}`]); } export async function deleteEmployeeDependent(payload: { dependent_id: string; employee_id: string; effective_date: string; }): Promise { return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]); } /** * Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück. * * Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank — * sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die * Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts); * kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt. */ export async function deleteHistoryEntry(payload: { history_id: string; employee_id: string; }): Promise { return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]); } /** * Berichtigt Wert und/oder Datum eines Historieneintrags. * * Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht * nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht, * leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt. */ export async function updateHistoryEntry(payload: { history_id: string; employee_id: string; event_date?: string; werte: { feld: string; nachher: string | null }[]; }): Promise { return callRpc( "update_history_entry", { history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte }, [`/employees/${payload.employee_id}`, "/audit"] ); } export async function addEmployeeNote(payload: { employee_id: string; category: NoteCategory; note_text: string; due_date?: string; }): Promise { return callRpc("add_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]); } export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise { return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]); }