Files
alpenwerk-hr/components/employees/HistorieBearbeiten.tsx
Andrei Laas db02fb4762
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m23s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m18s
Elf Punkte aus der Rueckmeldung, und die verlorene Anmerkung
Kleinigkeiten zuerst: "Gelaufen" heisst jetzt "Vergangen", die Kachel
"Langzeitabwesend" heisst "Langzeitabwesende", "Eintritte/Austritte
(Jahr)" heissen "(YTD)" -- gezaehlt wurde ohnehin seit Jahresbeginn.
"Personenkreis" heisst "Grund". Der Hinweis unter dem Grad der
Behinderung und der Erklaertext ueber dem Honestly-Report sind weg.
"Gehaltsanpassung" steht nicht mehr im Filter des Protokolls: das Gehalt
ist aus dem Funktionsumfang, keine Funktion schreibt die Art mehr, und
ein Filter, der immer leer ausgeht, sieht aus wie ein Fehler.

Im Fenster fuer den Notfallkontakt faellt "Wirksam ab" weg -- eine
Telefonnummer fuer den Ernstfall gilt ab sofort. In "Daten aendern"
wandert der Block unter die Angehoerigen, dieselbe Reihenfolge wie im
Reiter "Stammdaten". Auf der Uebersicht fuehren die Namen unter "Letzte
Aktivitaeten" in die Akte; vorher war die Karte eine Sackgasse.

"Eintrag berichtigen" bot fuer jedes Feld ein Textfeld an, auch fuer
"Notfallkontakt Verhaeltnis", wo die Erfassung sonst eine Liste fuehrt.
Wer dort "Gattin" statt "Gattin/Gatte" tippte, erzeugte einen Wert, den
keine Auswertung mehr findet -- die Berichtigung schreibt in dieselbe
Spalte wie das Formular, nur ohne dessen Pruefung. Welche Felder eine
Liste bekommen, steht in lib/historie-felder.ts; ein Test prueft jeden
Schluessel gegen app_feld_karte(), damit ein Tippfehler dort nicht still
auf ein Textfeld zurueckfaellt. Felder mit Aufzaehlungstyp bleiben
bewusst aussen vor: dort ist der gespeicherte Wert nicht die Anzeige.

Und die Antwort auf "wo sieht man die Anmerkung beim Austritt?": nirgends.
Das Formular sammelte sie ein, terminate_employee liess sie fallen. Bis
20260814100000 stand sie in der Beschreibung des Ereignisses; beim
Umschreiben fuer den Nichtantritt ging sie verloren, und die Migration
fuer die Austrittsart reichte die verkuerzte Fassung weiter. Sie steht
jetzt wieder in der Personalakte und im Protokoll, und die Selbstpruefung
faengt den naechsten Verlust ab.
2026-09-23 16:01:05 +02:00

235 lines
9.8 KiB
TypeScript

"use client";
import { Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import { auswahlFuer } from "@/lib/historie-felder";
import type { AuditChange } from "@/lib/types";
// Berichtigen, nicht neu erfassen.
//
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
// nie stattgefunden hat.
//
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
// nachträglich nicht anders beschliessen.
export function HistorieBearbeiten({
historyId,
employeeId,
bezeichnung,
datum,
changes,
istZukunft,
heute,
nurDatum = false,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
changes: AuditChange[];
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
istZukunft: boolean;
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
* verloren. */
heute: string;
/**
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
* verständlich ab.
*/
nurDatum?: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const [neuesDatum, setNeuesDatum] = useState(datum);
const [werte, setWerte] = useState<Record<string, string>>(() =>
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
);
const { showToast } = useToast();
const router = useRouter();
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
const etwasGeaendert =
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
function abbrechen() {
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
// Öffnen nicht die verworfenen Eingaben zeigt.
setNeuesDatum(datum);
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
setOffen(false);
}
async function speichern() {
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
// nur früher und freundlicher.
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
if (!nurDatum && istZukunft && neuesDatum <= heute) {
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
return;
}
if (!nurDatum && !istZukunft && neuesDatum > heute) {
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
return;
}
setLaeuft(true);
const ergebnis = await updateHistoryEntry({
history_id: historyId,
employee_id: employeeId,
event_date: neuesDatum,
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
});
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag berichtigt.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={abbrechen}
title="Eintrag berichtigen"
widthClassName="max-w-2xl"
footer={
<>
<Button variant="ghost" onClick={abbrechen}>
Abbrechen
</Button>
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
Berichtigen
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong>
{nurDatum
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
: istZukunft
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
</p>
<div className="mt-4 max-w-xs">
<TextField
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
type="date"
min={!nurDatum && istZukunft ? morgen : undefined}
max={!nurDatum && !istZukunft ? heute : undefined}
value={neuesDatum}
onChange={setNeuesDatum}
/>
</div>
{nurDatum && (
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
</p>
)}
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{changes.map((c) => (
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4 text-ink-muted">
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
</td>
<td className="py-2">
{/* Führt das Feld eine feste Liste, wird sie auch hier
angeboten: getippt entstünde sonst ein Wert, den keine
Auswertung mehr findet — die Berichtigung schreibt in
dieselbe Spalte wie das Formular, nur ohne dessen
Prüfung. Welche Felder das sind, steht in
lib/historie-felder.ts. */}
{auswahlFuer(c.feld) ? (
<select
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border bg-white px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<option value="">leer</option>
{auswahlFuer(c.feld)!.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
) : (
<input
type="text"
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
{nurDatum
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
: istZukunft
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
Die Berichtigung selbst steht im Protokoll.
</p>
</Modal>
</>
);
}