diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 46251a1..8e2425b 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -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 = { brand: { text: "text-brand-700", bar: "bg-brand-500" }, }; -const DOT_STYLES: Record = { - 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({
  • {/* Dot aligned to the first line of text, not centred on the whole row, so it stays put as descriptions wrap. */} - +
    {h.first_name && h.last_name ? fmtName(h.first_name, h.last_name) : "Unbekannt"} - + {h.event_type}
    diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index 968a988..dfbd1c7 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -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 (
  • - + {h.event_type} {fmtDate(h.event_date)} diff --git a/lib/colors.ts b/lib/colors.ts index 5f97cb2..c93e8fb 100644 --- a/lib/colors.ts +++ b/lib/colors.ts @@ -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 = { 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 = { + 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` statt `Record`: 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 = { + 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 = { Neueinstellung: "success", Wiedereinstellung: "success", @@ -103,9 +161,15 @@ export function actionBadgeStyle(action: string): string { * die man im Vorbeigehen auseinanderhaelt. */ export const ANSTEHEND_STYLES: Record = { - 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, }; diff --git a/tests/unit/colors.test.ts b/tests/unit/colors.test.ts index 134bbad..248c065 100644 --- a/tests/unit/colors.test.ts +++ b/tests/unit/colors.test.ts @@ -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); + }); +});