Drei Befunde aus dem Test: Logo, Geplant-Filter, Anstehend-Farben
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m27s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m2s

**Das Logo auf der Anmeldeseite.** Statt des Schriftzugs stand dort der
Ersatztext "Manner". Die SVG-Fassung war gueltiges XML, lag im Repository und
war committet — warum sie im Betrieb nicht geladen wurde, laesst sich von hier
aus nicht feststellen; dafuer braucht es die Antwort des Servers auf die URL.

Statt das weiter zu raten, faellt die Angriffsflaeche weg: es gibt jetzt eine
Datei, manner-logo.png, der Schriftzug mit durchsichtigem Grund. Kein XML, kein
Beschnitt, kein eingebackenes Feld. Das Blau darin ist #164194, also der Wert
aus §1.1.

Damit verschwinden auch die zwei Fassungen. §1.2 laesst den Schriftzug nur zur
Gaenze auf Rosa zu, und das Manual kennt dafuer zwei Lagen: auf Weiss gehoert
er nach §4.1 in ein rechteckiges rosa Feld, in einem grossflaechigen rosa
Umfeld nach §3.1 nur mit Freiraum. Beides entsteht jetzt aus derselben Datei —
das Feld zeichnet das Bauteil, aus derselben Polsterung wie den Freiraum.

**Der Filter "Geplant" fand Ausgetretene.** Die Ableitung pruefte den Eintritt
vor dem Austritt, und wer einen Eintritt in der Zukunft hatte, galt als
geplant — auch wenn der Austritt laengst verbucht war. Das trifft genau den
No-Show (Migration 20260814100000): eingestellt, nie erschienen, Austritt vor
dem Eintrittstag. Im Bestand sind das Zeilen mit Eintritt 01.10.2026, die der
Filter mitzaehlte, waehrend die Liste daneben "Ausgetreten" anzeigte.
employees.status, das die SQL-Funktion beim Austritt setzt, sagte von Anfang
an das Richtige; falsch war die Ableitung in der Anwendung.

Ein abgeschlossener Austritt wird jetzt zuerst geprueft: er beendet das
Verhaeltnis, gleichgueltig ob der Eintritt schon war oder noch kommt. Ein
Austritt, der selbst noch bevorsteht, nimmt den Eintritt nicht zurueck — wer
am 01.10. anfaengt und am 31.12. aufhoert, ist heute geplant. Die SQL-Fassung
in lib/employee-status-filter.ts bildet dieselbe Reihenfolge ab.

Keine Migration: beide Fassungen der Regel liegen in TypeScript. In SQL wird
nur der Karenz-Teil wiederholt, fuer die Fuehrungslinie, und der ist nicht
betroffen.

**Die vier Anstehend-Chips.** Zwei davon standen in der Markenfarbe, weil die
Farbe ueber den Beschriftungstext aus der Tabelle der Protokoll-Aktionen
geholt wurde — und die kennt eine andere Sprache: "Neueinstellung", nicht
"Eintritt". Wer dort nicht steht, bekam den neutralen Chip. Ein Nachschlagen,
das bei einem Fehlschlag still etwas Plausibles liefert, faellt eben nicht auf.

Die vier haben jetzt eine eigene Zuordnung, nach dem Wert verschluesselt und
nicht nach der Beschriftung: Eintritt gruen, Austritt rot, Wiedervorlage gelb,
Rueckkehr violett. Tuerkis waere fuer die Rueckkehr die naheliegendere Lesart
gewesen, kam gegen das Gruen des Eintritts aber nur auf dE 13.0; Violett steht
mit 30.8 eindeutig daneben. Schwaechstes Paar der vier: 14.2, schwaechster
Kontrast 5.49:1.

Zehn Tests dazu, darunter die drei Faelle, an denen der Filter gescheitert war.

Lint, Typen, Schemaabgleich, 562 Tests und der Build sind sauber. Im Browser
nicht gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 20:42:42 +02:00
parent 8016d317be
commit d2d5e1dabb
12 changed files with 275 additions and 97 deletions

View File

@@ -1,3 +1,4 @@
import type { AnstehendArt } from "./dashboard-filter";
import type { EmploymentStatus, NoteCategory } from "./types";
// Die Initialen stehen in Weiss darauf, jeder Ton braucht also 4.5:1 gegen
@@ -72,6 +73,42 @@ export function actionBadgeStyle(action: string): string {
return CATEGORY_STYLES[ACTION_CATEGORY[action] ?? "brand"];
}
/**
* Die vier Arten auf der Karte „Anstehend".
*
* Eigene Zuordnung statt actionBadgeStyle(): das war der Fehler, den sie
* ersetzt. Dort wird ueber den **Beschriftungstext** in die Tabelle der
* Protokoll-Aktionen nachgeschlagen, und die kennt eine andere Sprache —
* „Neueinstellung", nicht „Eintritt". Zwei der vier Beschriftungen standen
* gar nicht darin und fielen auf den neutralen Rosa-Chip zurueck. Ein
* Nachschlagen, das bei einem Fehlschlag still etwas Plausibles liefert,
* faellt eben nicht auf.
*
* Deshalb hier: nach dem **Wert** verschluesselt, nicht nach der Beschriftung
* — eine umbenannte Beschriftung kann die Farben so nicht mehr stillschweigend
* verstellen. Und vollstaendig ueber `Record<AnstehendArt, …>`: eine fuenfte
* Art muesste sich hier eintragen, der Typpruefer laesst sie sonst nicht durch.
*
* Die Farben selbst, nebeneinander gemessen (dE im CIELAB, Kontrast nach
* WCAG — schwaechstes Paar 14.2, schwaechster Kontrast 5.49:1):
*
* Eintritt gruen jemand kommt
* Austritt rot jemand geht
* Rueckkehr violett weder das eine noch das andere — eine eigene Klasse
* Wiedervorlage gelb ein Termin, an dem etwas zu tun ist
*
* Tuerkis fuer die Rueckkehr waere die naheliegendere Lesart gewesen, kam
* gegen das Gruen des Eintritts aber nur auf dE 13.0. Violett steht mit 30.8
* eindeutig daneben, und die vier sind das, was sie sein sollen: vier Dinge,
* die man im Vorbeigehen auseinanderhaelt.
*/
export const ANSTEHEND_STYLES: Record<AnstehendArt, string> = {
hire: CATEGORY_STYLES.success,
exit: CATEGORY_STYLES.danger,
return: CATEGORY_STYLES.purple,
note: CATEGORY_STYLES.warning,
};
export const NOTE_CATEGORY_STYLES: Record<NoteCategory, string> = {
Vertraulich: CATEGORY_STYLES.purple,
"Personalgespräch": CATEGORY_STYLES.info,

View File

@@ -13,11 +13,15 @@ import type { EmploymentStatus } from "./types";
//
// Kept deliberately close to deriveStatusAsOf, clause for clause:
//
// entry_date > asOf -> Geplant
// exit_date <= asOf -> Ausgetreten
// entry_date > asOf -> Geplant
// karenz window covers asOf -> Karenz
// otherwise -> Aktiv
//
// Die Reihenfolge der ersten beiden ist nicht beliebig: ein abgeschlossener
// Austritt schlaegt einen Eintritt, der noch bevorsteht. Warum das der Fall
// ist, steht bei deriveStatusAsOf.
//
// tests/integration/employee-status-filter.test.ts asserts the two agree
// against a real database, which is the only place that can prove it.
@@ -39,8 +43,17 @@ export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf:
const wanted = new Set(statuses);
if (wanted.size === 0) return null;
// A single non-employed status is a straight date comparison.
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
// „Geplant" ist ein Eintritt, der noch bevorsteht **und** nicht
// zurueckgenommen wurde. Ohne die zweite Haelfte zaehlte der Filter die
// No-Shows mit: eingestellt, nie erschienen, Austritt vor dem Eintrittstag
// verbucht — in der Liste als „Ausgetreten" ausgewiesen und trotzdem unter
// „Geplant" gefunden.
if (wanted.size === 1 && wanted.has("Geplant")) {
return eb.and([
eb("entry_date", ">", asOf),
eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)]),
]);
}
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
}

View File

@@ -133,12 +133,31 @@ export type OrgLookups = {
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
// ── Warum der Austritt zuerst geprüft wird ──────────────────────────
//
// Umgekehrt herum stand hier „Geplant" vor „Ausgetreten", und wer einen
// Eintritt in der Zukunft hatte, galt als geplant — auch dann noch, wenn der
// Austritt längst erfasst war. Genau das trifft den No-Show (Migration
// 20260814100000): jemand wird eingestellt, erscheint nie, und der Austritt
// wird noch vor dem Eintrittstag verbucht. Im Bestand sind das Zeilen mit
// Eintritt 01.10.2026 und einem Austritt, die der Filter „Geplant" mitzählte,
// während die Liste daneben „Ausgetreten" anzeigte — employees.status, das
// die SQL-Funktion beim Austritt gesetzt hat, sagte von Anfang an das
// Richtige.
//
// Ein abgeschlossener Austritt ist der stärkere Befund: er beendet das
// Verhältnis, gleichgültig ob der Eintritt schon war oder noch kommt.
// „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der
// noch bevorsteht und nicht zurückgenommen wurde.
//
// lib/employee-status-filter.ts bildet dieselbe Reihenfolge in SQL ab; die
// beiden müssen Klausel für Klausel zusammenpassen.
export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string
): EmploymentStatus {
if (e.entry_date > asOf) return "Geplant";
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten";
if (e.entry_date > asOf) return "Geplant";
if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz";
return "Aktiv";
}