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>
209 lines
9.1 KiB
TypeScript
209 lines
9.1 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
|
||
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
|
||
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
|
||
import { TextField } from "@/components/ui/Field";
|
||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||
import { actionBadgeStyle } from "@/lib/colors";
|
||
import { fmtDate, todayIso } from "@/lib/format";
|
||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||
import type { Database } from "@/lib/types";
|
||
|
||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||
|
||
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, und
|
||
// dort, wo ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
|
||
//
|
||
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
|
||
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
|
||
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
|
||
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
|
||
// also erst wissen müssen, wonach man sucht.
|
||
//
|
||
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
|
||
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
|
||
// Ausdruck sichtbar, und es braucht kein Skript dafür.
|
||
//
|
||
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. An
|
||
// allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein Knopf,
|
||
// der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein fehlender
|
||
// Knopf ohne Erklärung wäre ein Rätsel.
|
||
|
||
type Sicht = "alle" | "anstehend" | "erledigt";
|
||
|
||
const SICHTEN: { value: Sicht; label: string }[] = [
|
||
{ value: "alle", label: "Alle" },
|
||
{ value: "anstehend", label: "Anstehend" },
|
||
{ value: "erledigt", label: "Gelaufen" },
|
||
];
|
||
|
||
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
||
const today = todayIso();
|
||
const [sicht, setSicht] = useState<Sicht>("alle");
|
||
const [von, setVon] = useState("");
|
||
const [bis, setBis] = useState("");
|
||
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
|
||
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
|
||
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
|
||
const [typen, setTypen] = useState<Set<string>>(new Set());
|
||
|
||
const gefiltert = history.filter((h) => {
|
||
if (sicht === "anstehend" && h.event_date <= today) return false;
|
||
if (sicht === "erledigt" && h.event_date > today) return false;
|
||
if (von && h.event_date < von) return false;
|
||
if (bis && h.event_date > bis) return false;
|
||
if (typen.size > 0 && !typen.has(h.event_type)) return false;
|
||
return true;
|
||
});
|
||
|
||
const anstehend = history.filter((h) => h.event_date > today).length;
|
||
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
|
||
|
||
function typUmschalten(typ: string) {
|
||
setTypen((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(typ)) next.delete(typ);
|
||
else next.add(typ);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function zuruecksetzen() {
|
||
setSicht("alle");
|
||
setVon("");
|
||
setBis("");
|
||
setTypen(new Set());
|
||
}
|
||
|
||
if (history.length === 0) {
|
||
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
|
||
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
|
||
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
|
||
{anstehend > 0 && (
|
||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||
{anstehend} anstehend
|
||
</span>
|
||
)}
|
||
<div className="ml-auto flex items-end gap-2">
|
||
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
|
||
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
|
||
</div>
|
||
</div>
|
||
|
||
{vorhandeneTypen.length > 1 && (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{vorhandeneTypen.map((typ) => (
|
||
<button
|
||
key={typ}
|
||
type="button"
|
||
onClick={() => typUmschalten(typ)}
|
||
aria-pressed={typen.has(typ)}
|
||
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||
}`}
|
||
>
|
||
{typ}
|
||
</button>
|
||
))}
|
||
{eingeschraenkt && (
|
||
<button
|
||
type="button"
|
||
onClick={zuruecksetzen}
|
||
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||
>
|
||
Filter zurücksetzen
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{gefiltert.length === 0 ? (
|
||
<p className="text-sm text-ink-muted">
|
||
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
|
||
vorhanden.
|
||
</p>
|
||
) : (
|
||
<ul className="flex flex-col divide-y divide-border">
|
||
{gefiltert.map((h) => {
|
||
const isFuture = h.event_date > today;
|
||
const changes = h.changes ?? [];
|
||
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
|
||
// Abwesenheit gelöscht werden darf, hängt an einer späteren
|
||
// Rückkehr — auch wenn die gerade ausgeblendet ist.
|
||
const loeschbar = darfKorrigiertWerden(h, today, history);
|
||
const bearbeitbar = darfBearbeitetWerden(h, today, history);
|
||
return (
|
||
<li key={h.id} className="py-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||
{h.event_type}
|
||
</span>
|
||
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||
{isFuture && (
|
||
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||
</span>
|
||
)}
|
||
<span className="ml-auto flex items-center gap-0.5">
|
||
{bearbeitbar.erlaubt && (
|
||
<HistorieBearbeiten
|
||
historyId={h.id}
|
||
employeeId={employeeId}
|
||
bezeichnung={h.event_type}
|
||
datum={h.event_date}
|
||
changes={changes}
|
||
istZukunft={isFuture}
|
||
heute={today}
|
||
nurDatum={h.event_type === "Eintritt"}
|
||
/>
|
||
)}
|
||
{loeschbar.erlaubt && (
|
||
<HistorieLoeschen
|
||
historyId={h.id}
|
||
employeeId={employeeId}
|
||
bezeichnung={h.event_type}
|
||
datum={h.event_date}
|
||
vorschau={loeschVorschau(h, history)}
|
||
istZukunft={isFuture}
|
||
/>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||
|
||
{changes.length > 0 && (
|
||
<details className="group mt-1.5">
|
||
<summary
|
||
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
|
||
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||
>
|
||
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
|
||
›
|
||
</span>
|
||
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
|
||
</summary>
|
||
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
|
||
<AenderungsTabelle changes={changes} />
|
||
</div>
|
||
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
|
||
</details>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|