Three things, all from the same screenshot. The entry date can now be corrected. The Eintritt entry gets an edit button — date only, no delete, because it is the start of the timeline and a person without one has no beginning. Unlike every other entry it needs no recorded before-values: the old date is on the employee row, so this works on rows written long before any of this existed, which is exactly the case that matters. What hangs off that date is checked: no other event may precede it, exit and absence start may not fall before it, and the first position assignment moves with it — left behind it would leave days of employment with no post, or a post with nobody in it. Someone already working cannot be given a future entry date either; without that check a person who has been here for years could be turned into a planned entry, and the status derivation would agree. That last rule came out of the rehearsal finding a hole: my first probe picked a person with no other history rows, so the "nothing may precede it" check had nothing to compare against and a date in 2099 sailed through. Second, the screenshot showed two returns from one absence, and the data confirmed it: one person with two Rückkehr entries and a third still scheduled, recorded while they were long since active. record_karenz_ return never checked that there was an absence to return from. Now it does, and it refuses a second scheduled return — which would have silently overwritten the first on its effective date. Third, the history is filterable: upcoming versus done, a date range, and the event types that actually occur in that file. The count of upcoming items shows without filtering, because "what is coming" is the usual reason to open the tab at all. Still not deletable: Versetzung, Beförderung, Austritt, Wiedereintritt, Reorganisation. Undoing those means restoring position assignments, and that deserves its own step rather than being tacked onto this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.5 KiB
TypeScript
211 lines
8.5 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 type { AuditChange } from "@/lib/supabase/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">
|
|
<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>
|
|
</>
|
|
);
|
|
}
|