Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
187
components/employees/HistorieBearbeiten.tsx
Normal file
187
components/employees/HistorieBearbeiten.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"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,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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.
|
||||
if (istZukunft && neuesDatum <= heute) {
|
||||
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
|
||||
return;
|
||||
}
|
||||
if (!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>
|
||||
{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="Wirksam ab"
|
||||
type="date"
|
||||
min={istZukunft ? morgen : undefined}
|
||||
max={istZukunft ? undefined : heute}
|
||||
value={neuesDatum}
|
||||
onChange={setNeuesDatum}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<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">
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,15 @@ export function HistorieLoeschen({
|
||||
bezeichnung,
|
||||
datum,
|
||||
vorschau,
|
||||
istZukunft,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
vorschau: Vorschau[];
|
||||
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
|
||||
istZukunft: boolean;
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
@@ -80,10 +83,30 @@ export function HistorieLoeschen({
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong> vom {fmtDate(datum)} wird aus der Historie entfernt.
|
||||
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
|
||||
Historie entfernt.
|
||||
</p>
|
||||
|
||||
{zurueck.length > 0 && (
|
||||
{istZukunft && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{vorschau.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
|
||||
<span className="text-ink-muted">gesetzt werden</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
|
||||
noch weitere Felder, läuft er mit diesen weiter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && zurueck.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
@@ -98,7 +121,7 @@ export function HistorieLoeschen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bleibt.length > 0 && (
|
||||
{!istZukunft && bleibt.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
|
||||
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
|
||||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history";
|
||||
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
@@ -37,7 +38,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
{history.map((h) => {
|
||||
const isFuture = h.event_date > today;
|
||||
const changes = h.changes ?? [];
|
||||
const urteil = darfGeloeschtWerden(h, today);
|
||||
const urteil = darfKorrigiertWerden(h, today);
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -49,13 +50,23 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
</span>
|
||||
)}
|
||||
{urteil.erlaubt && (
|
||||
<span className="ml-auto">
|
||||
<span className="ml-auto flex items-center gap-0.5">
|
||||
<HistorieBearbeiten
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
changes={changes}
|
||||
istZukunft={isFuture}
|
||||
heute={today}
|
||||
/>
|
||||
<HistorieLoeschen
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
vorschau={loeschVorschau(h, history)}
|
||||
istZukunft={isFuture}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user