(null);
@@ -74,8 +77,15 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
const zeigtOffboarding = gehoertOffboarding(employee);
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
- const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
- const canEditData = employee.status !== "Ausgetreten";
+ // Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
+ // wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
+ // keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
+ // entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
+ // an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
+ // weiter zur Verfügung, und „Daten ändern" ebenfalls.
+ const status = deriveStatusAsOf(employee, today);
+ const isActive = status === "Aktiv" || status === "Karenz";
+ const canEditData = status !== "Ausgetreten";
return (
@@ -92,7 +102,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
-
+
{placement?.jobTitle ?? employee.job_title}
diff --git a/components/ui/StatusChip.tsx b/components/ui/StatusChip.tsx
index 6410ef6..8f96fa1 100644
--- a/components/ui/StatusChip.tsx
+++ b/components/ui/StatusChip.tsx
@@ -1,19 +1,46 @@
import { absenceLabel } from "@/lib/absence";
import { STATUS_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
-import type { EmploymentStatus } from "@/lib/types";
+import { deriveStatusAsOf } from "@/lib/reports";
-type StatusChipProps = {
- status: EmploymentStatus;
- entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant
+// ── Warum der Status hier abgeleitet und nicht übergeben wird ────────
+//
+// Vorher nahm dieses Bauteil einen fertigen Status entgegen, und beide
+// Aufrufer reichten `employees.status` durch — die gespeicherte Spalte. Die
+// Liste daneben filtert aber über die Datumsspalten (lib/employee-status-
+// filter.ts), und sobald die Spalte nachhängt, widersprechen sich Filter und
+// Beschriftung: der Filter „Ausgetreten" fand 48 Personen, von denen mehrere
+// als „Aktiv" beschriftet waren.
+//
+// Nachhängen kann sie regelmässig. terminate_employee (Migration
+// 20260814100000) setzt die Spalte nur, wenn das Austrittsdatum nicht in der
+// Zukunft liegt — „es gibt keinen Lauf, der das später nachzieht" steht dort
+// als Kommentar. Ein Austritt, der zum Zeitpunkt der Erfassung noch bevorstand,
+// lässt die Spalte also für immer auf „Aktiv" stehen.
+//
+// Deshalb nimmt das Bauteil jetzt die Zeile und den Stichtag und leitet
+// selbst ab. Die Spalte lässt sich nicht mehr hineinreichen: die zweite
+// Quelle ist nicht bloss ungenutzt, es gibt sie hier nicht mehr.
+export type StatusChipEmployee = {
+ entry_date: string;
+ exit_date: string | null;
+ karenz_start_date: string | null;
+ karenz_return_date: string | null;
/** Shown instead of the generic label when the kind of absence is known. */
- absenceType?: string | null;
+ absence_type?: string | null;
};
-export function StatusChip({ status, entryDate, absenceType }: StatusChipProps) {
- // The stored status is still 'Karenz'; absenceLabel maps it to
- // "Langzeitabwesenheit", or to the specific kind when one is recorded.
- const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : absenceLabel(status, absenceType);
+type StatusChipProps = {
+ employee: StatusChipEmployee;
+ /** Der Tag, zu dem der Status gilt — derselbe, nach dem die Seite filtert. */
+ asOf: string;
+};
+
+export function StatusChip({ employee, asOf }: StatusChipProps) {
+ const status = deriveStatusAsOf(employee, asOf);
+ // Der abgeleitete Status ist 'Karenz'; absenceLabel macht daraus
+ // "Langzeitabwesenheit", oder die konkrete Art, wenn eine erfasst ist.
+ const label = status === "Geplant" ? `Eintritt ${fmtDate(employee.entry_date)}` : absenceLabel(status, employee.absence_type ?? null);
return (
derivedStatusFilter(e, ["Geplant"], today) ?? e.val(true))
.where("entry_date", ">=", today)
.where("entry_date", "<=", bisIso)
.where((e) => e.lit(zeigt("hire")))
@@ -91,6 +96,11 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
.selectFrom("employees")
.select(["id", "first_name", "last_name", "exit_date"])
.where("exit_date", "is not", null)
+ // Ein Nichtantritt trägt als Austrittsdatum den Eintrittstag. Liegt
+ // der in der Zukunft, stand er hier als bevorstehender Austritt —
+ // vorzubereiten gibt es daran nichts, die Person fängt gar nicht an.
+ // Dieselbe Überlegung wie bei gehoertOffboarding().
+ .where((e) => e(e.ref("exit_date"), ">", e.ref("entry_date")))
.where("exit_date", ">=", today)
.where("exit_date", "<=", bisIso)
.where((e) => e.lit(zeigt("exit")))
@@ -100,7 +110,7 @@ export async function loadDashboardData(tx: Tx, p: DashboardParams) {
eb
.selectFrom("employees")
.select(["id", "first_name", "last_name", "karenz_return_date"])
- .where("status", "=", "Karenz")
+ .where((e) => derivedStatusFilter(e, ["Karenz"], today) ?? e.val(true))
.where("karenz_return_date", "is not", null)
.where("karenz_return_date", ">=", today)
.where("karenz_return_date", "<=", bisIso)
diff --git a/lib/employee-sort.ts b/lib/employee-sort.ts
index 6c55511..1af061f 100644
--- a/lib/employee-sort.ts
+++ b/lib/employee-sort.ts
@@ -94,6 +94,29 @@ const bereichAusdruck = sql`(
const standortAusdruck = sql`(select name from locations where id = employees.location_id)`;
+/**
+ * Der Status zum Stichtag als Rang — nicht die Spalte `employees.status`.
+ *
+ * Die Liste beschriftet jede Zeile mit dem **abgeleiteten** Status
+ * (components/ui/StatusChip.tsx) und filtert danach
+ * (lib/employee-status-filter.ts). Nach der gespeicherten Spalte zu sortieren
+ * hiesse, die Zeilen nach einem Wert zu ordnen, der nirgends auf der Seite
+ * steht: eine als „Ausgetreten" beschriftete Person landete mitten unter den
+ * aktiven, weil in ihrer Spalte noch „Aktiv" steht.
+ *
+ * Die Reihenfolge ist die des Aufzählungstyps — Aktiv, Karenz, Geplant,
+ * Ausgetreten. Das ist der Verlauf eines Dienstverhältnisses und sagt mehr
+ * als alphabetisch. Die Klauseln stehen in derselben Reihenfolge wie in
+ * deriveStatusAsOf; wer dort etwas ändert, ändert es auch hier.
+ */
+const statusRang = (asOf: string) => sql`case
+ when exit_date is not null and (exit_date <= ${asOf}::date or exit_date <= entry_date) then 4
+ when entry_date > ${asOf}::date then 3
+ when karenz_start_date is not null and karenz_start_date <= ${asOf}::date
+ and (karenz_return_date is null or karenz_return_date > ${asOf}::date) then 2
+ else 1
+end`;
+
/**
* Hängt die Reihenfolge an eine Abfrage über `employees`.
*
@@ -114,7 +137,8 @@ const standortAusdruck = sql`(select name from locations where id = empl
export function sortiere(
q: SelectQueryBuilder,
feld: Sortierfeld,
- richtung: Richtung
+ richtung: Richtung,
+ asOf: string
): SelectQueryBuilder {
// Zwei ausgeschriebene Zweige statt einer eingesetzten Richtung: so gerät
// nichts aus der Adresse in die Abfrage, auch nicht als geprüfter Wert.
@@ -140,10 +164,7 @@ export function sortiere(
case "beschaeftigung":
return nachName(q.orderBy(ordne(sql.ref("employment_type"))).orderBy(ordne(sql.ref("weekly_hours"))));
case "status":
- // Aufzählungstyp: Postgres ordnet nach der Reihenfolge der Werte —
- // Aktiv, Karenz, Geplant, Ausgetreten. Das ist der Verlauf eines
- // Dienstverhältnisses und sagt mehr als alphabetisch.
- return nachName(q.orderBy(ordne(sql.ref("status"))));
+ return nachName(q.orderBy(ordne(statusRang(asOf))));
case "name":
return nachName(q);
}
diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts
index 0325d40..cf1881d 100644
--- a/lib/employee-status-filter.ts
+++ b/lib/employee-status-filter.ts
@@ -13,21 +13,40 @@ import type { EmploymentStatus } from "./types";
//
// Kept deliberately close to deriveStatusAsOf, clause for clause:
//
-// exit_date <= asOf -> Ausgetreten
-// entry_date > asOf -> Geplant
-// karenz window covers asOf -> Karenz
-// otherwise -> Aktiv
+// exit_date <= asOf ODER exit_date <= entry_date -> 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.
+// ist, und warum die erste Klausel zwei Haelften hat, 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.
type Eb = ExpressionBuilder;
-/** True once the person has started and has not left yet. */
+/**
+ * Das Verhältnis ist am Stichtag beendet — entweder weil der Austritt
+ * vorbei ist, oder weil er nicht später liegt als der Eintritt und es
+ * damit nie einen Tag Beschäftigung gab (Nichtantritt).
+ */
+function ausgetreten(eb: Eb, asOf: string): Expression {
+ return eb.and([
+ eb("exit_date", "is not", null),
+ eb.or([eb("exit_date", "<=", asOf), eb("exit_date", "<=", eb.ref("entry_date"))]),
+ ]);
+}
+
+/**
+ * True once the person has started and has not left yet.
+ *
+ * Der Nichtantritt braucht hier keine eigene Haelfte: waere exit_date nicht
+ * groesser als entry_date, muesste zugleich entry_date <= asOf < exit_date <=
+ * entry_date gelten — das kann keine Zeile erfuellen.
+ */
function employed(eb: Eb, asOf: string): Expression {
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
}
@@ -45,17 +64,20 @@ export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], 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.
+ // Nichtantritte mit: eingestellt, nie erschienen — in der Liste als
+ // „Ausgetreten" ausgewiesen und trotzdem unter „Geplant" gefunden.
+ //
+ // Die zweite Haelfte muss ueber ausgetreten() laufen und nicht nur ueber
+ // `exit_date > asOf`. Ein Nichtantritt traegt als Austrittsdatum den
+ // Eintrittstag; liegt der in der Zukunft, ist auch der Austritt groesser
+ // als der Stichtag und rutschte durch eine Pruefung, die nur den Stichtag
+ // kennt, wieder hindurch. Genau das war nach der ersten Korrektur noch zu
+ // sehen: der Filter blieb wirkungslos, weil er den falschen Vergleich zog.
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)]),
- ]);
+ return eb.and([eb("entry_date", ">", asOf), eb.not(ausgetreten(eb, asOf))]);
}
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
- return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
+ return ausgetreten(eb, asOf);
}
const wantsAktiv = wanted.has("Aktiv");
diff --git a/lib/reports.ts b/lib/reports.ts
index ded4237..b93f4c5 100644
--- a/lib/reports.ts
+++ b/lib/reports.ts
@@ -150,13 +150,28 @@ export type OrgLookups = {
// „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der
// noch bevorsteht und nicht zurückgenommen wurde.
//
+// ── Warum ein Austritt am Eintrittstag *jeden* Stichtag schlägt ──────
+//
+// Die erste Fassung dieser Regel prüfte nur `exit_date <= asOf`, und das
+// reichte für den Nichtantritt nicht: Migration 20260814100000 setzt bei
+// „No Show" das Austrittsdatum **auf den Eintrittstag**. Liegt der noch in
+// der Zukunft, ist auch der Austritt in der Zukunft — die Regel fiel durch
+// auf „Geplant", und die Liste zeigte jemanden als anstehenden Eintritt, von
+// dem längst feststand, dass er nicht kommt.
+//
+// Die Migration begründet ihr Vorgehen damit, „nie aktiv" folge aus dem
+// Datum von selbst. In SQL stimmt das; hier stand der Satz nur als Absicht
+// und nicht als Klausel. Er steht jetzt da: endet das Verhältnis nicht
+// später, als es beginnt, gab es keinen einzigen Tag Beschäftigung — zu
+// keinem Stichtag, auch zu keinem vor dem Eintritt.
+//
// 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.exit_date && e.exit_date <= asOf) return "Ausgetreten";
+ if (e.exit_date && (e.exit_date <= asOf || e.exit_date <= e.entry_date)) 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";
diff --git a/tests/unit/employee-sort.test.ts b/tests/unit/employee-sort.test.ts
index ff77a2e..0f0666b 100644
--- a/tests/unit/employee-sort.test.ts
+++ b/tests/unit/employee-sort.test.ts
@@ -119,8 +119,10 @@ const db = new Kysely({
},
});
+const STICHTAG = "2026-09-15";
+
const ordnung = (feld: Sortierfeld, richtung: "asc" | "desc") =>
- sortiere(db.selectFrom("employees").select("id"), feld, richtung)
+ sortiere(db.selectFrom("employees").select("id"), feld, richtung, STICHTAG)
.compile()
.sql.replace(/^.*?order by /s, "")
.replace(/\s+/g, " ");
@@ -222,9 +224,46 @@ describe("das erzeugte SQL", () => {
it("setzt keinen Wert aus der Adresse in die Abfrage", () => {
// Die Richtung ist ausgeschrieben, nicht eingesetzt. Kämen je Werte aus
// der Adresse hierher, stünden sie im SQL statt als Parameter.
+ //
+ // Gebunden wird genau ein Wert, und er kommt nicht aus der Adresse: der
+ // Stichtag der Statusableitung. Deshalb wird er hier namentlich
+ // zugelassen und alles andere ausgeschlossen — „gar keine Parameter"
+ // wäre die schärfere Zusicherung, aber die falsche.
for (const f of SORTIERFELDER) {
- const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc").compile();
- expect(parameters, f.value).toEqual([]);
+ const { parameters } = sortiere(db.selectFrom("employees").select("id"), f.value, "desc", STICHTAG).compile();
+ expect(new Set(parameters), f.value).toEqual(new Set(f.value === "status" ? [STICHTAG] : []));
}
});
+
+ // ── Status: abgeleitet, nicht aus der Spalte gelesen ──────────────────
+ //
+ // Die Zeile zeigt den zum Stichtag abgeleiteten Status. Nach
+ // `employees.status` zu sortieren hiesse, nach einem Wert zu ordnen, der
+ // nirgends auf der Seite steht — eine als „Ausgetreten" beschriftete Person
+ // stünde mitten unter den aktiven.
+ describe("Status", () => {
+ it("ordnet nach der Ableitung und nicht nach der Spalte", () => {
+ const sql = ordnung("status", "asc");
+ expect(sql).toContain("exit_date");
+ expect(sql).toContain("entry_date");
+ expect(sql).toContain("karenz_start_date");
+ expect(sql).not.toContain('"status"');
+ });
+
+ it("hält die Reihenfolge des Dienstverhältnisses ein", () => {
+ // Aktiv (1), Karenz (2), Geplant (3), Ausgetreten (4) — dieselbe
+ // Folge wie im Aufzählungstyp, und aussagekräftiger als alphabetisch.
+ const sql = ordnung("status", "asc");
+ expect(sql.indexOf("then 4")).toBeLessThan(sql.indexOf("then 3"));
+ expect(sql.indexOf("then 3")).toBeLessThan(sql.indexOf("then 2"));
+ expect(sql).toContain("else 1");
+ });
+
+ it("erkennt den Nichtantritt wie die Ableitung", () => {
+ // exit_date <= entry_date: kein einziger Tag Beschäftigung. Fehlte der
+ // Vergleich, sortierte ein künftiger Nichtantritt unter „Geplant",
+ // während sein Chip „Ausgetreten" zeigt.
+ expect(ordnung("status", "asc")).toContain("exit_date <= entry_date");
+ });
+ });
});
diff --git a/tests/unit/employee-status-filter.test.ts b/tests/unit/employee-status-filter.test.ts
index 75c8afc..f935106 100644
--- a/tests/unit/employee-status-filter.test.ts
+++ b/tests/unit/employee-status-filter.test.ts
@@ -46,9 +46,16 @@ describe("derivedStatusFilter — Geplant", () => {
// Die eigentliche Zusicherung: ohne diese Hälfte war der Filter falsch.
it("schliesst aus, wessen Austritt schon vollzogen ist", () => {
- const sql = bedingung(["Geplant"]);
- expect(sql).toContain('"exit_date" is null');
- expect(sql).toContain('"exit_date" >');
+ expect(bedingung(["Geplant"])).toContain('"exit_date" <=');
+ });
+
+ // Und ohne *diese* Hälfte blieb er wirkungslos. Ein Nichtantritt trägt als
+ // Austrittsdatum den Eintrittstag; liegt der in der Zukunft, ist auch der
+ // Austritt grösser als der Stichtag. Ein Vergleich, der nur den Stichtag
+ // kennt, lässt ihn durch — deshalb muss exit_date auch gegen entry_date
+ // stehen.
+ it("vergleicht den Austritt auch mit dem Eintritt", () => {
+ expect(bedingung(["Geplant"])).toContain('"exit_date" <= "entry_date"');
});
});
@@ -58,6 +65,10 @@ describe("derivedStatusFilter — Ausgetreten", () => {
expect(sql).toContain('"exit_date" is not null');
expect(sql).toContain('"exit_date" <=');
});
+
+ it("zählt den Nichtantritt mit, dessen Eintritt noch bevorsteht", () => {
+ expect(bedingung(["Ausgetreten"])).toContain('"exit_date" <= "entry_date"');
+ });
});
describe("derivedStatusFilter — nicht abgedeckte Auswahl", () => {
@@ -90,6 +101,41 @@ describe("Filter und Anzeige meinen dasselbe", () => {
e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-12-31" },
erwartet: "Geplant",
},
+ // Der Fall, der nach der ersten Korrektur noch durchrutschte. So und nicht
+ // anders legt ihn terminate_employee an: bei „No Show" wird das übergebene
+ // Austrittsdatum verworfen und der Eintrittstag eingesetzt.
+ {
+ was: "Nichtantritt: Austritt am Eintrittstag, beides in der Zukunft",
+ e: { ...basis, entry_date: "2026-10-01", exit_date: "2026-10-01" },
+ erwartet: "Ausgetreten",
+ },
+ {
+ was: "Nichtantritt in der Vergangenheit",
+ e: { ...basis, entry_date: "2026-08-01", exit_date: "2026-08-01" },
+ erwartet: "Ausgetreten",
+ },
+ // Die Gegenprobe: ein einziger Tag Beschäftigung genügt, und es ist kein
+ // Nichtantritt mehr. Läge die Grenze bei „<" statt „<=", fiele genau
+ // dieser Fall auf die falsche Seite.
+ {
+ was: "ein Tag Beschäftigung, Austritt am Folgetag des Eintritts",
+ e: { ...basis, entry_date: "2026-08-01", exit_date: "2026-08-02" },
+ erwartet: "Ausgetreten",
+ },
+ {
+ was: "laufendes Verhältnis",
+ e: { ...basis, entry_date: "2020-04-30", exit_date: null },
+ erwartet: "Aktiv",
+ },
+ // Aigner, Christian aus dem Testbestand: Austritt zum 01.09.2026 erfasst,
+ // in der Spalte `status` steht bis heute „Aktiv", weil kein Lauf sie
+ // nachzieht. Abgeleitet ist er ausgetreten — und danach richtet sich jetzt
+ // auch der Chip in der Liste und in der Akte.
+ {
+ was: "Austritt liegt zurück, Spalte hängt nach",
+ e: { ...basis, entry_date: "2020-04-30", exit_date: "2026-09-01" },
+ erwartet: "Ausgetreten",
+ },
];
for (const { was, e, erwartet } of faelle) {