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>
This commit is contained in:
@@ -5,7 +5,7 @@ import { AnstehendFilter } from "@/components/dashboard/AnstehendFilter";
|
||||
import { AnstehendListe, type AnstehendEintrag } from "@/components/dashboard/AnstehendListe";
|
||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { eventBadgeStyle, eventDotStyle } from "@/lib/colors";
|
||||
import { istEingeschraenkt, parseArten, parseZeitraum } from "@/lib/dashboard-filter";
|
||||
import { loadDashboardData } from "@/lib/dashboard-data";
|
||||
import { addDaysIso, fmtName, todayIso } from "@/lib/format";
|
||||
@@ -25,19 +25,10 @@ const TONE: Record<string, { text: string; bar: string }> = {
|
||||
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
||||
};
|
||||
|
||||
const DOT_STYLES: Record<string, string> = {
|
||||
Eintritt: "bg-success-text",
|
||||
Wiedereintritt: "bg-success-text",
|
||||
Rückkehr: "bg-success-text",
|
||||
Austritt: "bg-danger-text",
|
||||
Versetzung: "bg-info-text",
|
||||
Beförderung: "bg-purple-text",
|
||||
Reorganisation: "bg-purple-text",
|
||||
Karenz: "bg-warning-text",
|
||||
Vertragsänderung: "bg-warning-text",
|
||||
Stammdatenänderung: "bg-warning-text",
|
||||
Gehaltsanpassung: "bg-warning-text",
|
||||
};
|
||||
// Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und
|
||||
// Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines
|
||||
// davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen
|
||||
// nicht mehr auseinanderlaufen.
|
||||
|
||||
export default async function DashboardPage({
|
||||
searchParams,
|
||||
@@ -254,11 +245,11 @@ export default async function DashboardPage({
|
||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||
{/* Dot aligned to the first line of text, not centred on the
|
||||
whole row, so it stays put as descriptions wrap. */}
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${eventDotStyle(h.event_type)}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${eventBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { eventBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { Database } from "@/lib/types";
|
||||
@@ -108,7 +108,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
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"
|
||||
typen.has(typ) ? eventBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{typ}
|
||||
@@ -145,7 +145,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
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)}`}>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AnstehendArt } from "./dashboard-filter";
|
||||
import type { EmploymentStatus, NoteCategory } from "./types";
|
||||
import type { EmploymentStatus, HistoryEventType, NoteCategory } from "./types";
|
||||
|
||||
// Die Initialen stehen in Weiss darauf, jeder Ton braucht also 4.5:1 gegen
|
||||
// Weiss — nachgerechnet liegt der schwaechste bei 6.11:1. Die Reihe beginnt
|
||||
@@ -50,8 +50,66 @@ export const CATEGORY_STYLES: Record<ColorCategory, string> = {
|
||||
brand: "bg-accent-200 text-brand-700",
|
||||
};
|
||||
|
||||
// Audit-log / activity-feed action -> badge color, per the action list in
|
||||
// dem Kommentar an audit_log in der ersten Migration.
|
||||
/** Der Punkt vor einer Zeile: dieselbe Kategorie, nur als Flaeche. */
|
||||
const CATEGORY_DOT: Record<ColorCategory, string> = {
|
||||
success: "bg-success-text",
|
||||
danger: "bg-danger-text",
|
||||
warning: "bg-warning-text",
|
||||
info: "bg-info-text",
|
||||
purple: "bg-purple-text",
|
||||
brand: "bg-accent-600",
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Ereignisse der Personalakte — die Sprache von employee_history.
|
||||
*
|
||||
* Getrennt von ACTION_CATEGORY darunter, und das ist der Punkt: die beiden
|
||||
* sind zwei Vokabulare fuer dasselbe Geschehen. Das Protokoll schreibt
|
||||
* „Neueinstellung", die Historie schreibt „Eintritt"; „Wiedereinstellung"
|
||||
* steht dort als „Wiedereintritt". Die Uebersicht zeigt Historienereignisse,
|
||||
* holte ihre Farbe aber aus der Protokolltabelle — und genau diese zwei
|
||||
* standen nicht darin und bekamen den neutralen Chip. In „Letzte
|
||||
* Aktivitaeten" war „Eintritt" deshalb rosa, waehrend die Karte daneben ihn
|
||||
* laengst gruen zeigte.
|
||||
*
|
||||
* `Record<HistoryEventType, …>` statt `Record<string, …>`: kaeme ein
|
||||
* zwoelftes Ereignis dazu, liesse der Typpruefer es nicht durch, ohne dass
|
||||
* jemand eine Farbe dafuer bestimmt. Ein Nachschlagen mit Rueckfall haette
|
||||
* auch dann wieder still etwas Plausibles geliefert.
|
||||
*/
|
||||
export const EVENT_CATEGORY: Record<HistoryEventType, ColorCategory> = {
|
||||
Eintritt: "success",
|
||||
Wiedereintritt: "success",
|
||||
Austritt: "danger",
|
||||
// Violett und nicht gruen: auf der Uebersicht steht die Rueckkehr neben dem
|
||||
// Eintritt, und zwei Gruentoene nebeneinander sind keine zwei Dinge. Die
|
||||
// Begruendung samt Messung steht bei ANSTEHEND_STYLES.
|
||||
Rückkehr: "purple",
|
||||
Versetzung: "info",
|
||||
Beförderung: "purple",
|
||||
Reorganisation: "purple",
|
||||
Karenz: "warning",
|
||||
Vertragsänderung: "warning",
|
||||
Stammdatenänderung: "warning",
|
||||
Gehaltsanpassung: "warning",
|
||||
};
|
||||
|
||||
export function eventBadgeStyle(event: HistoryEventType): string {
|
||||
return CATEGORY_STYLES[EVENT_CATEGORY[event]];
|
||||
}
|
||||
|
||||
export function eventDotStyle(event: HistoryEventType): string {
|
||||
return CATEGORY_DOT[EVENT_CATEGORY[event]];
|
||||
}
|
||||
|
||||
// Audit-log action -> badge color, per the action list in dem Kommentar an
|
||||
// audit_log in der ersten Migration.
|
||||
//
|
||||
// Hier ist der Rueckfall richtig und bleibt: die Aktionen schreiben die
|
||||
// SQL-Funktionen als freien Text, und eine neue kann jederzeit dazukommen.
|
||||
// Ihr neutraler Chip ist dann eine ehrliche Aussage. Fuer die Historie gilt
|
||||
// das nicht — deren Werte sind eine geschlossene Aufzaehlung, und dort war
|
||||
// derselbe Rueckfall ein Fehler.
|
||||
const ACTION_CATEGORY: Record<string, ColorCategory> = {
|
||||
Neueinstellung: "success",
|
||||
Wiedereinstellung: "success",
|
||||
@@ -103,9 +161,15 @@ export function actionBadgeStyle(action: string): string {
|
||||
* die man im Vorbeigehen auseinanderhaelt.
|
||||
*/
|
||||
export const ANSTEHEND_STYLES: Record<AnstehendArt, string> = {
|
||||
hire: CATEGORY_STYLES.success,
|
||||
exit: CATEGORY_STYLES.danger,
|
||||
return: CATEGORY_STYLES.purple,
|
||||
// Aus EVENT_CATEGORY abgeleitet, wo es dasselbe meint: auf der Uebersicht
|
||||
// stehen die beiden Karten nebeneinander, und ein Eintritt darf links nicht
|
||||
// anders aussehen als rechts. Genau das war zu sehen, bevor die Historie
|
||||
// ihre eigene Zuordnung bekam.
|
||||
hire: CATEGORY_STYLES[EVENT_CATEGORY.Eintritt],
|
||||
exit: CATEGORY_STYLES[EVENT_CATEGORY.Austritt],
|
||||
return: CATEGORY_STYLES[EVENT_CATEGORY.Rückkehr],
|
||||
// Die Wiedervorlage ist kein Ereignis der Personalakte, sondern ein Termin
|
||||
// aus den Notizen — sie hat in EVENT_CATEGORY nichts verloren.
|
||||
note: CATEGORY_STYLES.warning,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { actionBadgeStyle, avatarColorFor, CATEGORY_STYLES, STATUS_STYLES } from "@/lib/colors";
|
||||
import {
|
||||
actionBadgeStyle,
|
||||
ANSTEHEND_STYLES,
|
||||
avatarColorFor,
|
||||
CATEGORY_STYLES,
|
||||
EVENT_CATEGORY,
|
||||
eventBadgeStyle,
|
||||
eventDotStyle,
|
||||
STATUS_STYLES,
|
||||
} from "@/lib/colors";
|
||||
import { EVENT_TYPE_LABELS } from "@/lib/reports";
|
||||
import type { HistoryEventType } from "@/lib/types";
|
||||
|
||||
describe("avatarColorFor", () => {
|
||||
it("is deterministic for the same seed", () => {
|
||||
@@ -31,3 +42,76 @@ describe("actionBadgeStyle", () => {
|
||||
expect(actionBadgeStyle("Irgendwas Unbekanntes")).toBe(CATEGORY_STYLES.brand);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══ Historie: die Sprache von employee_history, nicht die des Protokolls ═══
|
||||
//
|
||||
// Die Uebersicht und der Historie-Reiter zeigen Ereignisse der Personalakte,
|
||||
// holten ihre Farbe aber aus der Tabelle der Protokoll-Aktionen. Die kennt
|
||||
// „Neueinstellung" und „Wiedereinstellung"; die Historie sagt „Eintritt" und
|
||||
// „Wiedereintritt". Genau diese zwei standen dort nicht und bekamen den
|
||||
// neutralen Chip — in „Letzte Aktivitaeten" war „Eintritt" rosa, waehrend die
|
||||
// Karte daneben ihn laengst gruen zeigte.
|
||||
describe("eventBadgeStyle", () => {
|
||||
// Aus EVENT_TYPE_LABELS abgeleitet und nicht abgeschrieben: kaeme ein
|
||||
// zwoelftes Ereignis dazu, prueft dieser Test es mit.
|
||||
const ALLE = Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[];
|
||||
|
||||
it("kennt jedes Ereignis der Personalakte", () => {
|
||||
expect(ALLE.length).toBeGreaterThan(0);
|
||||
for (const typ of ALLE) {
|
||||
expect(EVENT_CATEGORY[typ], typ).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("gibt keinem Ereignis den neutralen Chip", () => {
|
||||
// Der neutrale Chip heisst „keine eigene Bedeutung". Fuer ein Ereignis
|
||||
// der Personalakte ist das nie die Wahrheit, sondern immer ein
|
||||
// Nachschlagen, das danebengegriffen hat.
|
||||
for (const typ of ALLE) {
|
||||
expect(eventBadgeStyle(typ), typ).not.toBe(CATEGORY_STYLES.brand);
|
||||
}
|
||||
});
|
||||
|
||||
it("faerbt Eintritt und Wiedereintritt gruen", () => {
|
||||
expect(eventBadgeStyle("Eintritt")).toBe(CATEGORY_STYLES.success);
|
||||
expect(eventBadgeStyle("Wiedereintritt")).toBe(CATEGORY_STYLES.success);
|
||||
});
|
||||
|
||||
it("faerbt den Austritt rot", () => {
|
||||
expect(eventBadgeStyle("Austritt")).toBe(CATEGORY_STYLES.danger);
|
||||
});
|
||||
|
||||
it("gibt Punkt und Chip derselben Zeile dieselbe Kategorie", () => {
|
||||
// Beide leiten aus EVENT_CATEGORY ab; vorher lagen sie in zwei
|
||||
// getrennten Verzeichnissen und eines davon war das falsche.
|
||||
for (const typ of ALLE) {
|
||||
expect(eventDotStyle(typ), typ).toContain(
|
||||
eventBadgeStyle(typ).match(/text-([a-z]+)-text/)?.[1] ?? "###"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uebersicht: beide Karten meinen dasselbe", () => {
|
||||
// Anstehend und Letzte Aktivitaeten stehen nebeneinander. Ein Eintritt darf
|
||||
// links nicht anders aussehen als rechts — genau das war zu sehen.
|
||||
it("Eintritt, Austritt und Rueckkehr stimmen ueberein", () => {
|
||||
expect(ANSTEHEND_STYLES.hire).toBe(eventBadgeStyle("Eintritt"));
|
||||
expect(ANSTEHEND_STYLES.exit).toBe(eventBadgeStyle("Austritt"));
|
||||
expect(ANSTEHEND_STYLES.return).toBe(eventBadgeStyle("Rückkehr"));
|
||||
});
|
||||
|
||||
it("haelt die vier Arten voneinander getrennt", () => {
|
||||
const werte = Object.values(ANSTEHEND_STYLES);
|
||||
expect(new Set(werte).size).toBe(werte.length);
|
||||
});
|
||||
});
|
||||
|
||||
// Das Protokoll behaelt seinen Rueckfall, und das ist richtig: die Aktionen
|
||||
// schreiben die SQL-Funktionen als freien Text, eine neue kann jederzeit
|
||||
// dazukommen. Ihr neutraler Chip ist dann eine ehrliche Aussage.
|
||||
describe("actionBadgeStyle bleibt offen", () => {
|
||||
it("faengt eine unbekannte Aktion mit dem neutralen Chip auf", () => {
|
||||
expect(actionBadgeStyle("Eine neue Aktion aus einer Migration")).toBe(CATEGORY_STYLES.brand);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user