Files
alpenwerk-hr/components/employees/tabs/HistorieTab.tsx
Andrei Laas 7a33e493b5
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 10m59s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m0s
Die Historie bekommt ihre eigenen Farben
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>
2026-09-15 21:48:54 +02:00

209 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}