The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.
Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:
Haken — the normal case. The Meldezettel is there or it is not.
Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
not decoration: "nein" is a finding, "not asked yet" is not.
One checkbox cannot say both.
Text — shoe, shirt and trouser size. The value is the point;
ticked off it would be worthless.
Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.
Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.
The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.
A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.
Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
10 KiB
TypeScript
276 lines
10 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 { 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> {
|
|
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
|
}
|
|
|
|
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}`, "/"]);
|
|
}
|