Visual pass, clickable KPI tiles, and one consistent definition of status

Visual
- `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto
  a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an
  input and a card could not be told apart. Named steps restore the
  gradation (6 / 8 / 12px, measured in the browser).
- Cards were a 1px border and nothing else. Added warm, brand-tinted
  elevation tokens — a neutral black shadow over the pink surface reads as
  dirt — in three steps for cards, dropdowns and overlays, collected behind
  components/ui/Card.tsx so the 26 hand-copied card class chains have one
  definition.
- KPI tiles lead with the number and carry a tone accent; tables got denser
  rows, subtle row rules (the full border strength made 800 rows read as a
  grid), tabular figures in numeric columns and a brand-tinted hover.

KPI tiles now link to the view that shows what they count. Making those
links honest surfaced two reasons the numbers did not agree with their
destinations:

- The dashboard read `employees.status`, while every report derives status
  from entry/exit/karenz dates. A hire whose start date had passed before
  the cron ran was counted differently on the two pages. The dashboard now
  uses the same derivation — and one query instead of five.
- Eintritte/Austritte counted `entry_date`/`exit_date` while the linked
  report counts `employee_history`; rehire_employee sets entry_date but logs
  the event as 'Wiedereintritt', so rehires were missing from the target.
  Both now count history events.
- The employee list filtered on the status column, so it disagreed too. It
  now filters on derived status in SQL (lib/employee-status-filter.ts). That
  restates deriveStatusAsOf a second time, in a second language, so an
  integration test runs both over the full roster and requires identical id
  sets — drift here is otherwise invisible.

Status semantics, per the domain correction: "aktiv" means status Aktiv
alone. Karenz is employed but not active, and has its own tile. The active
headcount, FTE (Karenz contributes no capacity) and the division bars all
follow that; the bars are labelled "Aktive nach Bereich" rather than
"Headcount" to say so. The employee filter still offers the combination,
named after the two statuses it selects instead of calling the pair active.

DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz:
it governs what the Berichte page shows without an explicit status filter,
and therefore what already-saved reports and exports mean.
This commit is contained in:
2026-07-25 13:39:49 +02:00
parent d9367a8ce4
commit 37bb107cd4
14 changed files with 433 additions and 112 deletions

View File

@@ -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)
>
<option value="">Alle Status</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>

View File

@@ -53,7 +53,7 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
</button>
{open && (
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-lg">
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
{notes.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>

View File

@@ -18,8 +18,13 @@ export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps)
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
return (
<span
className={`inline-flex shrink-0 items-center justify-center rounded-full font-bold text-white ${SIZE_CLASSES[size]}`}
// A white ring separates adjacent avatars in the org chart and lets a
// strongly coloured one sit on a tinted row without vibrating against
// it. aria-hidden because the name is always rendered next to it —
// otherwise a screen reader reads the initials and then the name again.
className={`inline-flex shrink-0 select-none items-center justify-center rounded-full font-bold leading-none text-white ring-2 ring-white ${SIZE_CLASSES[size]}`}
style={{ backgroundColor: bg }}
aria-hidden
>
{initials(firstName, lastName)}
</span>

32
components/ui/Card.tsx Normal file
View File

@@ -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 <div className={`${CARD_CLASS} ${pad} ${className}`}>{children}</div>;
}
/**
* 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 <h2 className={`text-xs font-bold uppercase tracking-wider text-ink-muted ${className}`}>{children}</h2>;
}

View File

@@ -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 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
{filtered.map((c, i) => (

View File

@@ -159,7 +159,7 @@ export function Lookup<T>({
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 && <div className="px-3 py-2 text-sm text-ink-muted">Suche</div>}
{!loading && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}

View File

@@ -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}`}
>
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 id={titleId} className="text-lg font-bold text-ink">

View File

@@ -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"
}`}
>