Files
alpenwerk-hr/actions/employees.ts
Andrei Laas c3e19606e2
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m58s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m24s
Die Cornerstone-ID als eigenes, freiwilliges Feld
Cornerstone fuehrt jede Person unter einer eigenen Kennung. Der Export
dorthin trug in "User ID" und "Username" bisher die Alpenwerk-UUID --
richtig, solange es nichts Besseres gab, aber nicht die Kennung, unter
der Cornerstone die Person kennt. Jetzt steht dort diese Spalte.

Freiwillig, weil die Zuordnungstabelle des Kunden fuer 434 der 784
Personen keine Kennung liefert. Eindeutig, weil eine Kennung genau einer
Person gehoert. Als Text, weil fuehrende Nullen in einer numerischen
Spalte verlorengingen.

Ohne hinterlegte Kennung bleiben User ID und Username **leer**. Ein
Rueckfall auf die UUID braechte zwei Kennungsarten in eine Datei, ohne
dass es auffiele, und legte in Cornerstone eine zweite Person neben der
bestehenden an. Eine fehlende Angabe soll fehlen; dafuer gibt es einen
eigenen Test.

Fuenf Funktionen mussten mit -- dieselbe Liste und derselbe Grund wie bei
der Firmen-E-Mail: hire_employee und rehire_employee teilen sich den
Schritt "Person", change_employee_data macht das Feld aenderbar,
apply_due_pending_changes sorgt dafuer, dass eine datierte Aenderung
nicht verfaellt, app_feld_karte haelt den Eintrag in der Historie
richtigstellbar. Die Migration ist wieder erzeugt, nicht abgeschrieben,
und prueft jede der fuenf einzeln.

Erfasst wird das Feld in der Akte, in "Daten aendern", bei Einstellung
und Wiedereintritt sowie ueber den Massenimport; es steht im
Mitarbeiterexport und fuellt im Cornerstone-Export User ID und Username.
2026-09-28 10:13:52 +02:00

425 lines
16 KiB
TypeScript

"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<string, unknown>, revalidate: string[]): Promise<ActionResult> {
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;
/** Kennung im Lernsystem Cornerstone. Freiwillig. */
cornerstone_id?: 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<ActionResult & { employeeId?: string }> {
// 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<string, unknown>)) 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<ActionResult> {
// 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<string, unknown>);
// 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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
export async function changeEmployeeData(payload: {
employee_id: string;
effective_date: string;
person: Record<string, unknown>;
contract: Record<string, unknown>;
// `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<string, unknown>;
}): Promise<ActionResult> {
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;
cornerstone_id?: 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<ActionResult> {
try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "rehire_employee", payload as unknown as Record<string, unknown>);
// 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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
return callRpc("add_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}