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.
235 lines
9.8 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|