Files
alpenwerk-hr/actions/employees.ts
Maximilian Stubhan f85731dde5
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s
Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:25:58 +02:00

320 lines
12 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/supabase/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;
}
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;
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;
has_kuendigungsschutz?: boolean;
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
kuendigungsschutz_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;
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";
}): 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: 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;
rehire_date: string;
position_id: string;
}): 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}`]);
}
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}`, "/"]);
}