HR can now delete a history entry, but only where deleting one is an honest thing to do — and deleting it also undoes it. The rule they asked for is the interesting part: the last valid change wins. Deleting an entry walks its fields one at a time. If a later entry touched the same field, the current value stays — that later change is the one in force. Otherwise the field goes back to what the deleted entry recorded as its "before". So the middle of three entries can be removed without an old value overwriting a newer one. Four kinds of entry refuse to be deleted, each saying why in the place the button would have been. Eintritt anchors the timeline. Transfers, promotions, absences and exits moved positions and status — they have proper operations for that, and guessing backwards is how you corrupt an org chart. Anything not yet effective hangs off a planned change, and that link is not trustworthy: there is no key between a history row and its pending row, only a person and a date, and the data already has an Eintritt and a Vertragsänderung sharing one. Matching on the date would eventually cancel a change nobody meant. And entries from before the history carried values have nothing to fall back to. Confirmation is not "are you sure" — that question gets a reflex yes by the third time. The dialog says what will be different afterwards: which field goes back to which value, and which one stays because something later claimed it. employee_history keeps its append-only policies; delete_history_entry is SECURITY DEFINER and checks the permission itself in its first line. The audit log keeps the deletion with the values that were removed, and the audit log genuinely cannot be edited. The rule lives twice — in SQL and in lib/history.ts. The database is the authority; the copy exists so the UI can hide a button that would fail and print the reason instead. Rehearsed against real data in a rolled-back transaction first: the later change held, the untouched field reverted, all four refusals fired. Also corrected in the data catalogue: I had written that require_hr_admin was called by nothing. It guards all sixteen mutating functions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.1 KiB
TypeScript
206 lines
7.1 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;
|
|
}): 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;
|
|
}): 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"]);
|
|
}
|
|
|
|
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}`, "/"]);
|
|
}
|