Files
alpenwerk-hr/components/ui/Lookup.tsx
Maximilian Stubhan 37bb107cd4 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.
2026-07-25 13:39:49 +02:00

190 lines
6.8 KiB
TypeScript

"use client";
import { Search, X } from "lucide-react";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
type LookupProps<T> = {
placeholder?: string;
onSearch: (query: string) => Promise<T[]>;
renderResult: (item: T) => ReactNode;
onSelect: (item: T) => void;
minChars?: number;
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: true;
};
// Generic async search-select used by the position lookup (hire wizard),
// manager/superior lookup (create position), and the reorg workbench.
//
// Implements the combobox contract rather than just looking like one: it was
// a plain text input with a div of clickable buttons underneath, so a
// keyboard user could type but never reach a result, and a screen reader was
// never told a list had appeared. Arrow keys move the selection, Enter takes
// it, Escape closes without also closing the surrounding dialog.
export function Lookup<T>({
placeholder = "Suchen…",
onSearch,
renderResult,
onSelect,
minChars = 2,
id: idProp,
"aria-describedby": describedBy,
"aria-invalid": invalid,
}: LookupProps<T>) {
const generatedId = useId();
const id = idProp ?? generatedId;
const [query, setQuery] = useState("");
const [results, setResults] = useState<T[]>([]);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
// Derived from "have we finished searching for the current query yet",
// rather than a separate state flag flipped synchronously at the top of
// the effect below — the effect only ever sets state from the async
// search's own completion callback now.
const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const tooShort = query.trim().length < minChars;
const loading = !tooShort && lastSearchedQuery !== query.trim();
useEffect(() => {
if (tooShort) return;
let cancelled = false;
const timeout = setTimeout(() => {
onSearch(query.trim()).then((res) => {
if (cancelled) return;
setResults(res);
setActiveIndex(0);
setOpen(true);
setLastSearchedQuery(query.trim());
});
}, 200);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [query, minChars, onSearch, tooShort]);
// Derived rather than reset via effect: once the query drops below
// minChars, hide the dropdown and any stale results immediately without
// needing a synchronous setState inside the effect above.
const showDropdown = open && !tooShort;
const visibleResults = tooShort ? [] : results;
const selectable = loading ? [] : visibleResults;
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
useEffect(() => {
if (!showDropdown) return;
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" });
}, [activeIndex, showDropdown]);
function reset() {
setQuery("");
setResults([]);
setOpen(false);
setActiveIndex(0);
}
function choose(item: T) {
onSelect(item);
reset();
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
if (selectable.length === 0) return;
e.preventDefault();
const delta = e.key === "ArrowDown" ? 1 : -1;
setActiveIndex((i) => (i + delta + selectable.length) % selectable.length);
} else if (e.key === "Enter") {
if (showDropdown && selectable[activeIndex]) {
e.preventDefault();
choose(selectable[activeIndex]);
}
} else if (e.key === "Escape") {
if (showDropdown) {
// Without this the Escape also reaches Modal/SlideOver and closes
// the whole dialog behind the dropdown.
e.stopPropagation();
setOpen(false);
}
}
}
const listId = `${id}-listbox`;
return (
<div ref={containerRef} className="relative">
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2 focus-within:outline-2 focus-within:outline-offset-1 focus-within:outline-brand-500">
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<input
id={id}
role="combobox"
aria-expanded={showDropdown}
aria-controls={listId}
aria-activedescendant={showDropdown && selectable[activeIndex] ? `${id}-option-${activeIndex}` : undefined}
aria-autocomplete="list"
aria-describedby={describedBy}
aria-invalid={invalid}
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
className="w-full text-sm outline-none placeholder:text-ink-muted"
/>
{query && (
<button type="button" onClick={reset} aria-label="Zurücksetzen" className="rounded p-0.5 hover:bg-surface">
<X className="h-4 w-4 text-ink-muted" />
</button>
)}
</div>
{/* Announces "Suche…" / result count without stealing focus. */}
<span aria-live="polite" className="sr-only">
{showDropdown ? (loading ? "Suche läuft" : `${visibleResults.length} Treffer`) : ""}
</span>
{showDropdown && (
<div
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-[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>}
{!loading &&
visibleResults.map((item, i) => (
<button
key={i}
id={`${id}-option-${i}`}
role="option"
aria-selected={i === activeIndex}
data-active={i === activeIndex}
type="button"
// Mouse down would blur the input and close the list before
// the click landed.
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActiveIndex(i)}
onClick={() => choose(item)}
className={`block w-full px-3 py-2 text-left text-sm ${i === activeIndex ? "bg-brand-100" : "hover:bg-surface"}`}
>
{renderResult(item)}
</button>
))}
</div>
)}
</div>
);
}