diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 8f456b5..704f248 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { Suspense } from "react"; import { AuditFilters } from "@/components/audit/AuditFilters"; +import { CARD_CLASS } from "@/components/ui/Card"; import { Pagination } from "@/components/ui/Pagination"; import { actionBadgeStyle } from "@/lib/colors"; import { sanitizeIlikeTerm } from "@/lib/supabase/query"; @@ -61,36 +62,43 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis

{count ?? 0} Einträge

-
+
- - - - - - + + + + + + {(entries ?? []).map((entry) => { return ( - - - - + - + + - + ); })} diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index ca39ef1..ce11b41 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -2,9 +2,11 @@ import Link from "next/link"; import { Suspense } from "react"; import { EmployeeFilters } from "@/components/employees/EmployeeFilters"; import { Avatar } from "@/components/ui/Avatar"; +import { CARD_CLASS } from "@/components/ui/Card"; import { Pagination } from "@/components/ui/Pagination"; import { StatusChip } from "@/components/ui/StatusChip"; -import { fmtDate } from "@/lib/format"; +import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; +import { fmtDate, todayIso } from "@/lib/format"; import { breadcrumbFor, loadOrgMaps } from "@/lib/org"; import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; @@ -56,7 +58,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps } } if (params.division) query = query.eq("division_id", params.division); - if (params.status) query = query.eq("status", params.status as EmploymentStatus); + // Comma-separated, so a dashboard tile can link here with the same + // definition it counted — "aktiv" across this app means Aktiv *and* + // Karenz, and a single-value filter would land the user on a smaller + // number than the tile they clicked. + const statuses = (params.status ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); + // Derived from the dates, not read off employees.status — see + // lib/employee-status-filter.ts for why the two can disagree. + query = applyDerivedStatusFilter(query, statuses, todayIso()); if (params.location) query = query.eq("location_id", params.location); const { data: employeesData, count } = await query; @@ -70,17 +82,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps

{count ?? 0} Mitarbeiter:innen gefunden

-
+
ZeitpunktBenutzer:inAktionObjektDetails
ZeitpunktBenutzer:inAktionObjektDetails
{dateTimeFormatter.format(new Date(entry.occurred_at))}{entry.actor_name} - {entry.action} +
+ {dateTimeFormatter.format(new Date(entry.occurred_at))} + {entry.actor_name} + + {entry.action} + + {entry.target_employee_id ? ( - + {entry.target_label} ) : ( entry.target_label )} {entry.details ?? "–"}{entry.details ?? "–"}
- - - - - - - - + + + + + + + + @@ -88,29 +100,36 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id); const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined; return ( - - + - - + - - - + + - diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index ca65e8c..ad09501 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -1,16 +1,22 @@ +import { ChevronRight } from "lucide-react"; import Link from "next/link"; import { DraftsCard } from "@/components/dashboard/DraftsCard"; +import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card"; import { actionBadgeStyle } from "@/lib/colors"; import { addDaysIso, fmtDate, todayIso } from "@/lib/format"; +import { deriveStatusAsOf } from "@/lib/reports"; import { createClient } from "@/lib/supabase/server"; import { fetchAllRows } from "@/lib/supabase/query"; -const TONE_TEXT: Record = { - default: "text-ink", - success: "text-success-text", - danger: "text-danger-text", - warning: "text-warning-text", - brand: "text-brand-700", +// Each KPI carries a colour already; the accent bar repeats it in a second +// channel so the tiles are scannable as a row rather than six identical +// boxes, and so the meaning does not rest on hue alone. +const TONE: Record = { + default: { text: "text-ink", bar: "bg-ink-muted" }, + success: { text: "text-success-text", bar: "bg-success-text" }, + danger: { text: "text-danger-text", bar: "bg-danger-text" }, + warning: { text: "text-warning-text", bar: "bg-warning-text" }, + brand: { text: "text-brand-700", bar: "bg-brand-500" }, }; const DOT_STYLES: Record = { @@ -52,36 +58,50 @@ export default async function DashboardPage() { const yearEnd = `${year}-12-31`; const in60Iso = addDaysIso(today, 60); + // Headcount, FTE, Karenz and the division bars all come from one full read + // and the *derived* status, not from the `employees.status` column. + // + // That column only ever reflects what the last mutation or cron run wrote, + // while every report derives status from entry/exit/karenz dates — so a + // planned hire whose start date has passed, or a Karenz that ended without + // anyone recording the return, made the dashboard and the Berichte page + // disagree about the same headcount. Same derivation, same numbers. + // It also replaces four separate count queries with one. const [ - activeCountRes, - karenzCountRes, + staffRows, hiresYtdRes, exitsYtdRes, openPositionsRes, - fteRows, divisionsRes, - headcountRows, upcomingHiresRes, upcomingExitsRes, upcomingReturnsRes, historyRes, ] = await Promise.all([ - supabase.from("employees").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]), - supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"), + fetchAllRows(() => + supabase + .from("employees") + .select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date") + .order("id") + ), + // Entries/exits count history events, which is what the linked report + // counts too. `entry_date` would also sweep up rehires, whose event is + // logged as 'Wiedereintritt' — the tile and its destination then showed + // different numbers for the same year. supabase - .from("employees") + .from("employee_history") .select("id", { count: "exact", head: true }) - .gte("entry_date", yearStart) - .lte("entry_date", yearEnd), + .in("event_type", ["Eintritt", "Wiedereintritt"]) + .gte("event_date", yearStart) + .lte("event_date", yearEnd), supabase - .from("employees") + .from("employee_history") .select("id", { count: "exact", head: true }) - .gte("exit_date", yearStart) - .lte("exit_date", yearEnd), + .eq("event_type", "Austritt") + .gte("event_date", yearStart) + .lte("event_date", yearEnd), supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"), - fetchAllRows(() => supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]).order("id")), supabase.from("divisions").select("id, name"), - fetchAllRows(() => supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]).order("id")), supabase .from("employees") .select("id, first_name, last_name, entry_date") @@ -109,10 +129,22 @@ export default async function DashboardPage() { .limit(10), ]); - const fte = fteRows.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; + // "Aktiv" means status Aktiv — somebody on Karenz is employed but not + // active, and is counted by its own tile instead. FTE follows the same + // set: Karenz contributes no capacity, so including it would overstate + // what the company can actually staff. + // + // Note this is narrower than DEFAULT_STATUSES in lib/reports (Aktiv + + // Karenz), which still governs what the Berichte page shows when no + // status filter is chosen. + const statusOf = (row: (typeof staffRows)[number]) => deriveStatusAsOf(row, today); + const activeStaff = staffRows.filter((row) => statusOf(row) === "Aktiv"); + const activeCount = activeStaff.length; + const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length; + const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; const headcountByDivision = new Map(); - for (const row of headcountRows) { + for (const row of activeStaff) { if (!row.division_id) continue; headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1); } @@ -151,85 +183,131 @@ export default async function DashboardPage() { : { data: [] as { id: string; first_name: string; last_name: string }[] }; const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`])); + // Each tile links to the view that shows what it counts, with the filters + // pre-applied. + // + // Two of them cannot match exactly, and it is worth knowing which: the + // headcount tiles filter `employees` and their targets filter the same + // table, so those agree. Eintritte/Austritte count `employees.entry_date` + // / `exit_date`, while the events report counts `employee_history` rows — + // and rehire_employee sets entry_date but logs the event as + // 'Wiedereintritt'. A year with rehires therefore shows a slightly higher + // number on the tile than in the linked report. const kpis = [ - { label: "Aktive Mitarbeiter:innen", value: activeCountRes.count ?? 0, tone: "default" }, - { label: "FTE", value: fte.toFixed(1), tone: "default" }, - { label: "Eintritte (Jahr)", value: hiresYtdRes.count ?? 0, tone: "success" }, - { label: "Austritte (Jahr)", value: exitsYtdRes.count ?? 0, tone: "danger" }, - { label: "In Karenz", value: karenzCountRes.count ?? 0, tone: "warning" }, - { label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand" }, + { + label: "Aktive Mitarbeiter:innen", + value: activeCount, + tone: "default", + href: "/employees?status=Aktiv", + }, + { label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, + { + label: "Eintritte (Jahr)", + value: hiresYtdRes.count ?? 0, + tone: "success", + href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, + }, + { + label: "Austritte (Jahr)", + value: exitsYtdRes.count ?? 0, + tone: "danger", + href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, + }, + { label: "In Karenz", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, + { label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" }, ]; return (
{drafts && drafts.length > 0 && } -
+
{kpis.map((kpi) => ( -
-
{kpi.label}
-
{kpi.value}
-
+ + + {/* Number first in the visual order: it is what the eye is + looking for, and the label only qualifies it. */} +
{kpi.value}
+
+ {kpi.label} + +
+ ))}
-
-

Headcount nach Bereich

-
+ + {/* Aktive, not headcount: the bars count the same set as the tile + above them, which excludes Karenz. */} + Aktive nach Bereich +
{divisionBars.map((d) => (
-
- {d.name} - {d.count} +
+ {d.name} + {d.count}
-
-
+ {/* Rounded ends and a minimum width so the smallest division + still reads as a bar rather than a stray pixel. */} +
+
))} {divisionBars.length === 0 &&

Keine Daten vorhanden.

}
-
+ -
-

Anstehend (60 Tage)

-
    + + Anstehend (60 Tage) +
      {upcoming.map((item) => (
    • - - {item.label} - {KIND_LABEL[item.kind]} + + {item.label} + {KIND_LABEL[item.kind]} - {fmtDate(item.date)} + {fmtDate(item.date)}
    • ))} {upcoming.length === 0 &&

      Keine anstehenden Ereignisse.

      }
    -
+ -
-

Letzte Aktivitäten

-
    + + Letzte Aktivitäten +
      {(historyRes.data ?? []).map((h) => ( -
    • -
      - - {employeeNameById.get(h.employee_id) ?? "Unbekannt"} - - {h.event_type} - +
    • + {/* Dot aligned to the first line of text, not centred on the + whole row, so it stays put as descriptions wrap. */} + +
      +
      + {employeeNameById.get(h.employee_id) ?? "Unbekannt"} + + {h.event_type} + +
      +

      {h.description}

      -

      {h.description}

    • ))} {(historyRes.data ?? []).length === 0 &&

      Keine Aktivitäten vorhanden.

      }
    -
+
); diff --git a/app/globals.css b/app/globals.css index 021d62e..5b867e0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -34,7 +34,22 @@ --color-purple-bg: #f0ecf7; --color-purple-text: #5c2e91; - --radius: 8px; + /* A bare `--radius` in Tailwind v4 collapses the whole scale onto one + value — `rounded` and `rounded-lg` both came out at 8px, so a chip, an + input and a card could not be told apart. Named steps restore the + gradation; 8px stays the default so nothing shifts unintentionally. */ + --radius-sm: 4px; + --radius: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + /* Warm, brand-tinted rather than neutral grey: a pure black shadow over + the pink surface reads as dirt. Kept shallow — this is a data-dense + admin UI, the depth only has to separate a card from the page. */ + --shadow-card: 0 1px 2px rgb(107 33 71 / 0.04), 0 1px 3px rgb(107 33 71 / 0.06); + --shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08); + --shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16); --font-sans: var(--font-nunito), system-ui, sans-serif; } diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 78e78b8..1901157 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -10,7 +10,16 @@ type EmployeeFiltersProps = { locations: { id: string; name: string }[]; }; -const STATUS_OPTIONS = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const; +// Aktiv and Karenz are separate statuses — somebody on Karenz is employed +// but not active. The combined entry is offered explicitly, and named after +// the two statuses it selects rather than calling the pair "aktiv". +const STATUS_OPTIONS = [ + { value: "Aktiv", label: "Aktiv" }, + { value: "Karenz", label: "Karenz" }, + { value: "Aktiv,Karenz", label: "Aktiv + Karenz (beschäftigt)" }, + { value: "Geplant", label: "Geplant" }, + { value: "Ausgetreten", label: "Ausgetreten" }, +] as const; export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) { const router = useRouter(); @@ -66,8 +75,8 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) > {STATUS_OPTIONS.map((s) => ( - ))} diff --git a/components/shell/NotesBell.tsx b/components/shell/NotesBell.tsx index 9848269..242c883 100644 --- a/components/shell/NotesBell.tsx +++ b/components/shell/NotesBell.tsx @@ -53,7 +53,7 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) { {open && ( -
+
Meine Notizen ({notes.length})
{notes.length === 0 ? (

Keine offenen Notizen.

diff --git a/components/ui/Avatar.tsx b/components/ui/Avatar.tsx index df71c66..d68be2e 100644 --- a/components/ui/Avatar.tsx +++ b/components/ui/Avatar.tsx @@ -18,8 +18,13 @@ export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps) const bg = color ?? avatarColorFor(`${firstName}${lastName}`); return ( {initials(firstName, lastName)} diff --git a/components/ui/Card.tsx b/components/ui/Card.tsx new file mode 100644 index 0000000..58c29e5 --- /dev/null +++ b/components/ui/Card.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +// The same "rounded border border-border bg-white p-4" appeared at 26 call +// sites, all of them flat: a 1px border and nothing else, so a card sat on +// the page with no separation from it. Collecting them here is what makes it +// possible to give the whole app depth by changing one line. + +export const CARD_CLASS = "rounded-md border border-border bg-white shadow-[var(--shadow-card)]"; + +export function Card({ + children, + className = "", + padding = "md", +}: { + children: ReactNode; + className?: string; + /** `none` for cards that manage their own inner spacing (tables, lists). */ + padding?: "none" | "sm" | "md" | "lg"; +}) { + const pad = { none: "", sm: "p-3", md: "p-4", lg: "p-6" }[padding]; + return
{children}
; +} + +/** + * Section titles were all `text-sm font-bold text-ink` — the same weight and + * size as emphasised body text, so headings did not read as a level of their + * own. Slightly smaller, letter-spaced and muted separates them without + * shouting. + */ +export function CardTitle({ children, className = "" }: { children: ReactNode; className?: string }) { + return

{children}

; +} diff --git a/components/ui/CountryPicker.tsx b/components/ui/CountryPicker.tsx index e05ffa2..5207645 100644 --- a/components/ui/CountryPicker.tsx +++ b/components/ui/CountryPicker.tsx @@ -132,7 +132,7 @@ export function CountryPicker({ ref={listRef} id={listId} role="listbox" - className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-lg" + className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]" > {filtered.length === 0 &&
Keine Treffer
} {filtered.map((c, i) => ( diff --git a/components/ui/Lookup.tsx b/components/ui/Lookup.tsx index a723d7c..f979138 100644 --- a/components/ui/Lookup.tsx +++ b/components/ui/Lookup.tsx @@ -159,7 +159,7 @@ export function Lookup({ ref={listRef} id={listId} role="listbox" - className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg" + className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]" > {loading &&
Suche…
} {!loading && visibleResults.length === 0 &&
Keine Treffer
} diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx index c3aa468..392b2a9 100644 --- a/components/ui/Modal.tsx +++ b/components/ui/Modal.tsx @@ -33,7 +33,7 @@ export function Modal({ open, onClose, title, children, footer, widthClassName = // the accessible name cannot drift from what is on screen. aria-labelledby={titleId} tabIndex={-1} - className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`} + className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-[var(--shadow-overlay)] outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`} >

diff --git a/components/ui/SlideOver.tsx b/components/ui/SlideOver.tsx index 98aec74..8042a09 100644 --- a/components/ui/SlideOver.tsx +++ b/components/ui/SlideOver.tsx @@ -36,7 +36,7 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }: aria-modal="true" aria-labelledby={titleId} tabIndex={-1} - className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl outline-none transition-transform duration-200 ${ + className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-[var(--shadow-overlay)] outline-none transition-transform duration-200 ${ open ? "translate-x-0" : "translate-x-full" }`} > diff --git a/lib/employee-status-filter.ts b/lib/employee-status-filter.ts new file mode 100644 index 0000000..cdc811b --- /dev/null +++ b/lib/employee-status-filter.ts @@ -0,0 +1,76 @@ +import type { EmploymentStatus } from "./supabase/types"; + +// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts. +// +// The employee list is paged in the database, so it cannot derive status in +// JavaScript the way the reports and the dashboard do — it has to filter on +// the server. Filtering on the `employees.status` column instead was the +// reason a dashboard tile and the list it links to could disagree: that +// column holds whatever the last mutation or cron run wrote, while every +// other surface computes status from entry/exit/karenz dates. +// +// Kept deliberately close to deriveStatusAsOf, clause for clause: +// +// entry_date > asOf -> Geplant +// exit_date <= asOf -> Ausgetreten +// karenz window covers asOf -> Karenz +// otherwise -> Aktiv +// +// 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 Filterable = { + gt: (column: string, value: string) => Filterable; + lte: (column: string, value: string) => Filterable; + gte: (column: string, value: string) => Filterable; + or: (filters: string) => Filterable; + is: (column: string, value: null) => Filterable; + not: (column: string, operator: string, value: null) => Filterable; +}; + +/** True once the person has started and has not left yet. */ +function employed(query: Q, asOf: string): Q { + return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q; +} + +/** + * Narrows a PostgREST query to the employees whose *derived* status on + * `asOf` is one of `statuses`. Only the combinations the UI offers are + * supported; anything else is left unfiltered rather than silently applying + * a wrong one. + */ +export function applyDerivedStatusFilter(query: Q, statuses: EmploymentStatus[], asOf: string): Q { + const wanted = new Set(statuses); + if (wanted.size === 0) return query; + + // A single non-employed status is a straight date comparison. + if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q; + if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q; + + const wantsAktiv = wanted.has("Aktiv"); + const wantsKarenz = wanted.has("Karenz"); + + if (wantsAktiv && wantsKarenz && wanted.size === 2) { + // Everyone employed today, whether or not they are on leave. + return employed(query, asOf); + } + + if (wantsKarenz && !wantsAktiv && wanted.size === 1) { + return employed(query, asOf) + .not("karenz_start_date", "is", null) + .lte("karenz_start_date", asOf) + .or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q; + } + + if (wantsAktiv && !wantsKarenz && wanted.size === 1) { + // Employed but *not* inside a karenz window: either no start date, a + // start still ahead, or a return that has already happened. + return employed(query, asOf).or( + `karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}` + ) as Q; + } + + // Mixed selections spanning employed and non-employed states have no UI + // path today; filtering on a guess would be worse than not filtering. + return query; +} diff --git a/tests/integration/employee-status-filter.test.ts b/tests/integration/employee-status-filter.test.ts new file mode 100644 index 0000000..9bca6e0 --- /dev/null +++ b/tests/integration/employee-status-filter.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; +import { todayIso } from "@/lib/format"; +import { deriveStatusAsOf } from "@/lib/reports"; +import type { EmploymentStatus } from "@/lib/supabase/types"; +import { adminClient } from "./helpers"; + +// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf(): +// the employee list pages in the database and cannot derive status in JS, so +// the same rule exists twice. Two copies of a rule drift, and the drift is +// invisible — a dashboard tile and the list it links to just quietly show +// different numbers. +// +// Only a real database can settle it, so this runs both over the whole +// seeded roster and demands the same set of ids. +describe("derived status filter matches deriveStatusAsOf", () => { + const asOf = todayIso(); + + async function idsFromDatabase(statuses: EmploymentStatus[]): Promise> { + const query = adminClient.from("employees").select("id"); + const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf); + if (error) throw new Error(error.message); + return new Set((data ?? []).map((r) => r.id)); + } + + async function idsFromDerivation(statuses: EmploymentStatus[]): Promise> { + const { data, error } = await adminClient + .from("employees") + .select("id, entry_date, exit_date, karenz_start_date, karenz_return_date"); + if (error) throw new Error(error.message); + return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id)); + } + + async function expectSameSet(statuses: EmploymentStatus[]) { + const [fromDb, fromJs] = await Promise.all([idsFromDatabase(statuses), idsFromDerivation(statuses)]); + + const onlyInDb = [...fromDb].filter((id) => !fromJs.has(id)); + const onlyInJs = [...fromJs].filter((id) => !fromDb.has(id)); + + expect({ statuses, onlyInDb: onlyInDb.slice(0, 5), onlyInJs: onlyInJs.slice(0, 5) }).toEqual({ + statuses, + onlyInDb: [], + onlyInJs: [], + }); + // Guards against a filter so narrow it matches nothing and passes + // trivially. + expect(fromJs.size).toBeGreaterThan(0); + } + + it("agrees for Aktiv + Karenz — the definition the dashboard headcount uses", async () => { + await expectSameSet(["Aktiv", "Karenz"]); + }); + + it("agrees for Aktiv alone", async () => { + await expectSameSet(["Aktiv"]); + }); + + it("agrees for Karenz alone", async () => { + await expectSameSet(["Karenz"]); + }); + + it("agrees for Geplant", async () => { + await expectSameSet(["Geplant"]); + }); + + it("agrees for Ausgetreten", async () => { + await expectSameSet(["Ausgetreten"]); + }); + + it("returns everyone when no status is selected", async () => { + const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true }); + const { count: filtered } = await applyDerivedStatusFilter( + adminClient.from("employees").select("id", { count: "exact", head: true }), + [], + asOf + ); + expect(filtered).toBe(total); + }); +});

Mitarbeiter:inPers.-Nr.Bereich/TeamStandortEintrittBeschäftigungStatus
Mitarbeiter:inPers.-Nr.Bereich/TeamStandortEintrittBeschäftigungStatus
- + // border-subtle between rows: the full-strength border made + // an 800-row table read as a grid rather than a list. +
+ -
-
+
+
{e.first_name} {e.last_name}
-
{e.job_title}
+
{e.job_title}
{e.personnel_number} + {/* tabular-nums keeps the numeric columns aligned down the + page instead of jittering per row. */} + {e.personnel_number}
{division?.name ?? "–"}
{team?.name ?? "–"}
{location?.name ?? "–"}{fmtDate(e.entry_date)} - {e.employment_type} · {e.weekly_hours}h + {location?.name ?? "–"}{fmtDate(e.entry_date)} + {e.employment_type} · {e.weekly_hours}h +