Files
alpenwerk-hr/actions/employees.ts
Maximilian Stubhan f5ace8af2e Make the part-time arrangement a state you can report on
Last step moved the four part-time arrangements out of the absence list
and recorded the reason in the history text. That answered "what
happened" but not "who is in one right now", and the profile showed
nothing at all. So it becomes a real field: teilzeit_art, with an
optional end date.

The objection I raised then still holds — a state goes stale, because
nobody goes back to note when a Bildungsteilzeit ended. teilzeit_bis is
the answer to it: with an end date a report decides for itself what is
still running instead of trusting that someone maintained the row. Left
empty it means "open end", which is an honest thing to say.

It runs through the ordinary change machinery rather than beside it. It
sits in app_feld_karte, so it shows up in the history as a field with
before and after, and can be corrected there like any other. The
description suffix from last step is gone — writing the same thing twice
is how two versions start disagreeing.

Reporting: filter by variant, by "in one at all", and by when it ends;
group headcount by variant, where the absence of one reads "Keine"
rather than a dash, because in a report that is an answer and not a gap.
Plus columns in the export and the import.

One gap found while rehearsing, and only because the probe happened to
pick a return date in the future: a scheduled return carries its payload
through pending_org_changes, and that payload did not include the
variant. Someone would have come back on reduced hours in April with the
reason gone. The daily run now carries it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:07:41 +02:00

233 lines
8.2 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 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(), (tx) =>
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
);
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> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
}
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}`, "/"]);
}