The database moved to a container of our own; the platform is gone. This takes out what was left of it — and, where the leftovers were load bearing, moves rather than deletes. Moved, not deleted: supabase/migrations/ -> db/migrations/ the schema's source of truth supabase/build-org.ts -> scripts/build-org.ts lib/supabase/types.ts -> lib/types.ts 52 import sites repointed The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`, and simply renaming the schema would have left the runner facing an empty table: it would have called all 67 migrations pending and replayed them against a database that is long since current. So the runner now creates `migrationen.schema_migrations` and, once, copies the old rows across — guarded so a second run does nothing and a fresh database skips it entirely. Only then does migration 20260907100000 drop the old schema. Deleted: the CLI config, the seed, the historical schema/function dumps (nothing read them), scripts/umzug-von-supabase.sh (the move is done), and both Supabase packages plus the CLI. Nothing in the application imported them — the build now succeeds with no environment variables at all, which is the proof. Integration tests: six of them signed in through Supabase Auth and asserted against the anon key and the service role. That model is gone, so the tests were not portable — they are deleted. session-context and employee-status-filter already ran on pg and are untouched; om-reporting is ported to a direct connection because it guards a real risk (the reporting line rule exists twice, once in SQL and once in TypeScript). CI: the integration job started a Supabase stack. It now runs a postgres service, applies deploy/db-init and every migration to an empty database — that was the valuable part, and it still holds — then checks that a second run is a no-op, which is what proves the bookkeeping works. Docs: security-review.md audited a service-role key, a cookie adapter and auth.users, none of which exist. Restating findings about removed components would suggest today's system had been reviewed; it has not. It now records what was removed and says a fresh review is due. data-model.md was already marked obsolete and described the pre-OM schema; azure-migration.md was a plan for a route not taken. Both deleted. Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the packages. Integration tests skip cleanly with no database. Migration SQL and the runner are reviewed but NOT executed: no Docker here, and the old instance no longer resolves. 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/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>
|
|
</>
|
|
);
|
|
}
|