In "Letzte Aktivitaeten" stand "Eintritt" weiter auf Rosa, waehrend die Karte daneben ihn laengst gruen zeigte. Dieselbe Ursache wie bei den Anstehend-Chips, nur eine Ecke weiter: die Uebersicht zeigt Ereignisse aus employee_history, holte ihre Farbe aber aus ACTION_CATEGORY — und das ist die Sprache des Protokolls. Dort heisst es "Neueinstellung" und "Wiedereinstellung", in der Historie "Eintritt" und "Wiedereintritt". Genau diese zwei von elf standen nicht darin und fielen auf den neutralen Chip zurueck; die uebrigen neun trafen zufaellig. EVENT_CATEGORY ist jetzt die Zuordnung fuer die Historie, als Record<HistoryEventType, …> und damit vollzaehlig: ein zwoelftes Ereignis laesst der Typpruefer nicht durch, ohne dass jemand eine Farbe dafuer bestimmt. Ein Nachschlagen mit Rueckfall haette auch dann wieder still etwas Plausibles geliefert. Betroffen war nicht nur die Uebersicht — der Historie-Reiter in der Personalakte faerbte seine Chips und seine Filterknoepfe aus derselben falschen Tabelle. Auch die sind umgestellt. Die Punkte vor den Zeilen lagen in einer zweiten Tabelle in page.tsx und sagten fuer "Eintritt" bereits gruen — Punkt und Chip derselben Zeile kamen also aus zwei Verzeichnissen, von denen eines das falsche war. Beide leiten jetzt aus EVENT_CATEGORY ab. Die Rueckkehr ist dabei violett geworden, auch in der Historie: auf der Uebersicht steht sie neben dem Eintritt, und zwei Gruentoene nebeneinander sind keine zwei Dinge. ANSTEHEND_STYLES leitet fuer Eintritt, Austritt und Rueckkehr aus derselben Tabelle ab — die beiden Karten koennen nicht mehr auseinanderlaufen. ACTION_CATEGORY behaelt seinen Rueckfall, und das bleibt richtig: die Aktionen schreiben die SQL-Funktionen als freien Text, eine neue kann jederzeit dazukommen, und ihr neutraler Chip ist dann eine ehrliche Aussage. Fuer eine geschlossene Aufzaehlung war derselbe Rueckfall ein Fehler. Acht Tests, aus EVENT_TYPE_LABELS abgeleitet statt abgeschrieben: dass jedes Ereignis eine Farbe hat, dass keines den neutralen Chip bekommt, dass Punkt und Chip derselben Zeile zusammenpassen und dass die beiden Karten der Uebersicht dasselbe meinen. Lint, Typen, Schemaabgleich, 575 Tests und der Build sind sauber. 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 { eventBadgeStyle } 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) ? eventBadgeStyle(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 ${eventBadgeStyle(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>
|
||
);
|
||
}
|