Form primitives, keyboard-operable comboboxes, dialog focus, route states

Accessibility work on the UI layer, all of it rooted in one structural gap:
there were no form primitives, so every field was hand-assembled and every
field got the same details wrong.

Form primitives
- components/ui/Field.tsx (Field/TextField/SelectField/TextareaField) and
  Button.tsx. Field generates the control id with useId and derives htmlFor
  from it, which is what makes the association impossible to omit rather
  than merely conventional.
- 92 labels existed, 4 used htmlFor, and no input carried an id at all: a
  screen reader announced an unnamed edit box and clicking a label focused
  nothing. Now every label resolves to its control (0 unassociated), and the
  input class chain that appeared verbatim 85 times appears zero times.
- Field also takes a render prop, so Lookup, CountryPicker and Picklist get
  the same wiring instead of a second, partial solution.
- SearchInput replaces three hand-rolled copies of the icon-in-a-box search
  whose input had only a placeholder — not a label — and killed its own
  focus ring with outline-none and nothing in its place.
- Toggle groups (workdays, reorg change type) became fieldsets with
  aria-pressed; colour alone was carrying the selected state.

Comboboxes
- Lookup and CountryPicker were text inputs with a div of clickable buttons
  underneath: typeable, but no keyboard path to a result and nothing telling
  a screen reader a list had appeared. Both now carry role=combobox,
  aria-expanded/controls/activedescendant and listbox semantics, with arrow
  keys, Enter and Escape. Escape stops propagation, or it would close the
  surrounding dialog along with the dropdown.

Dialogs
- useDialogFocus centralises what Modal and SlideOver each owed the
  keyboard and neither provided beyond Escape: focus into the dialog on
  open, Tab and Shift+Tab cycling within it, focus restored to the trigger
  on close.
- SlideOver stays mounted for its transition, and aria-hidden does not
  remove anything from the tab order — so every closed panel was leaving
  invisible tab stops at the end of the page. `inert` fixes that.

Route states
- loading.tsx, error.tsx, not-found.tsx and global-error.tsx. Every page in
  the (app) group is server-rendered per request, so without loading.tsx a
  navigation showed nothing at all until the server answered, and a render
  error dropped the user on Next's own screen with no way back.

Tests
- 22 component tests (vitest jsdom project). Two of them found limits of the
  environment rather than of the code: jsdom implements neither `inert` nor
  scrollIntoView, so the inert test asserts the attribute and the missing
  scrollIntoView — which was taking the whole render down from inside an
  effect — is stubbed in the setup file.
This commit is contained in:
2026-07-25 13:10:52 +02:00
parent 4be5f2264e
commit d9367a8ce4
44 changed files with 1770 additions and 1127 deletions

47
app/(app)/error.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { AlertTriangle, RotateCcw } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
// Without this file a failed render drops the user on Next.js's own error
// screen — no navigation, no way back, and in production just "a client-side
// exception occurred". `reset()` re-renders the segment, which is enough for
// the common case of a transient Supabase timeout.
export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("Route error:", error);
}, [error]);
return (
<div className="mx-auto flex max-w-lg flex-col items-start gap-4 rounded border border-border bg-white p-6">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-danger-bg text-danger-text">
<AlertTriangle className="h-5 w-5" />
</span>
<div>
<h1 className="text-lg font-bold text-ink">Diese Ansicht konnte nicht geladen werden</h1>
<p className="mt-1 text-sm text-ink-body">
Die Daten wurden nicht verändert. Meist hilft ein erneuter Versuch; bleibt der Fehler, wenden Sie sich bitte an die
IT-Betreuung.
</p>
</div>
{/* The digest is the only handle on the server-side stack trace, which
is deliberately not sent to the browser in production. */}
{error.digest && (
<p className="rounded bg-surface px-3 py-2 font-mono text-xs text-ink-muted">
Fehlerkennung: {error.digest}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button onClick={reset}>
<RotateCcw className="h-4 w-4" />
Erneut versuchen
</Button>
<Link href="/" className={LINK_BUTTON_CLASS}>
Zur Übersicht
</Link>
</div>
</div>
);
}

30
app/(app)/loading.tsx Normal file
View File

@@ -0,0 +1,30 @@
// Every page in this group is server-rendered per request (they all read
// from Supabase), so without this the browser sits on the previous page with
// no feedback until the server answers — on the employee list, long enough
// to look broken.
export default function Loading() {
return (
<div className="flex flex-col gap-4" role="status" aria-label="Wird geladen">
<div className="h-9 w-64 animate-pulse rounded bg-border-subtle" />
<div className="flex flex-wrap gap-3">
{[240, 160, 160].map((w, i) => (
<div key={i} style={{ width: w }} className="h-10 animate-pulse rounded bg-border-subtle" />
))}
</div>
<div className="overflow-hidden rounded border border-border bg-white">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex items-center gap-4 border-b border-border-subtle px-4 py-3 last:border-0">
<div className="h-9 w-9 shrink-0 animate-pulse rounded-full bg-border-subtle" />
<div className="flex flex-1 flex-col gap-1.5">
<div className="h-3.5 w-48 animate-pulse rounded bg-border-subtle" />
<div className="h-3 w-32 animate-pulse rounded bg-border-subtle" />
</div>
<div className="hidden h-3.5 w-24 animate-pulse rounded bg-border-subtle sm:block" />
<div className="hidden h-3.5 w-20 animate-pulse rounded bg-border-subtle md:block" />
</div>
))}
</div>
<span className="sr-only">Daten werden geladen</span>
</div>
);
}

View File

@@ -1,4 +1,6 @@
import { login, logout } from "@/actions/auth"; import { login, logout } from "@/actions/auth";
import { Button } from "@/components/ui/Button";
import { CONTROL_CLASS } from "@/components/ui/Field";
// The query string is attacker-controlled, so the login page renders a message // The query string is attacker-controlled, so the login page renders a message
// looked up by code rather than whatever text ?error= carries. Reflecting the // looked up by code rather than whatever text ?error= carries. Reflecting the
@@ -50,7 +52,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
type="email" type="email"
required required
autoComplete="username" autoComplete="username"
className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" className={CONTROL_CLASS}
/> />
</div> </div>
<div> <div>
@@ -63,15 +65,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
type="password" type="password"
required required
autoComplete="current-password" autoComplete="current-password"
className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" className={CONTROL_CLASS}
/> />
</div> </div>
<button <Button type="submit" fullWidth className="mt-2">
type="submit"
className="mt-2 rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
Anmelden Anmelden
</button> </Button>
</form> </form>
</div> </div>
</div> </div>

44
app/global-error.tsx Normal file
View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
// Last resort: catches errors thrown by the root layout itself, where
// (app)/error.tsx is not mounted yet. It replaces <html>, so it cannot use
// the app's fonts, Tailwind layer or shared components — hence the inline
// styles. Kept deliberately plain; anything clever here can fail too.
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("Global error:", error);
}, [error]);
return (
<html lang="de-AT">
<body style={{ margin: 0, backgroundColor: "#f9f1f5", fontFamily: "system-ui, sans-serif", color: "#2d1c26" }}>
<div style={{ maxWidth: 480, margin: "15vh auto", padding: 24, background: "#fff", border: "1px solid #eedde6", borderRadius: 8 }}>
<h1 style={{ fontSize: 18, margin: "0 0 8px" }}>Die Anwendung konnte nicht geladen werden</h1>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "#503b47", margin: "0 0 16px" }}>
Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen.
</p>
{error.digest && (
<p style={{ fontSize: 12, fontFamily: "monospace", color: "#7a636f", margin: "0 0 16px" }}>Fehlerkennung: {error.digest}</p>
)}
<button
onClick={reset}
style={{
background: "#d6046e",
color: "#fff",
border: "none",
borderRadius: 4,
padding: "8px 16px",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
}}
>
Erneut versuchen
</button>
</div>
</body>
</html>
);
}

19
app/not-found.tsx Normal file
View File

@@ -0,0 +1,19 @@
import Link from "next/link";
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
export default function NotFound() {
return (
<div className="flex min-h-dvh items-center justify-center bg-surface p-4">
<div className="w-full max-w-md rounded border border-border bg-white p-6">
<p className="text-sm font-semibold text-brand-700">404</p>
<h1 className="mt-1 text-lg font-bold text-ink">Seite nicht gefunden</h1>
<p className="mt-1 text-sm text-ink-body">
Die aufgerufene Adresse existiert nicht. Möglicherweise wurde der Datensatz gelöscht oder der Link ist veraltet.
</p>
<Link href="/" className={`${LINK_BUTTON_CLASS} mt-4`}>
Zur Übersicht
</Link>
</div>
</div>
);
}

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput";
const ACTIONS = [ const ACTIONS = [
"Neueinstellung", "Neueinstellung",
@@ -50,19 +51,12 @@ export function AuditFilters() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2"> <SearchInput label="Audit-Log durchsuchen" placeholder="Objekt, Details, Benutzer:in…" value={q} onChange={setQ} />
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Objekt, Details, Benutzer:in…"
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
<select <select
aria-label="Nach Aktion filtern"
defaultValue={searchParams.get("action") ?? ""} defaultValue={searchParams.get("action") ?? ""}
onChange={(e) => updateParam("action", e.target.value)} onChange={(e) => updateParam("action", e.target.value)}
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink" className={FILTER_SELECT_CLASS}
> >
<option value="">Alle Aktionen</option> <option value="">Alle Aktionen</option>
{ACTIONS.map((a) => ( {ACTIONS.map((a) => (

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { addEmployeeDependent } from "@/actions/employees"; import { addEmployeeDependent } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format"; import { todayIso } from "@/lib/format";
@@ -83,61 +85,30 @@ export function AddDependentModal({
title="Angehörige:n hinzufügen" title="Angehörige:n hinzufügen"
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button onClick={handleSubmit} pending={pending}>
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Hinzufügen Hinzufügen
</button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
<input
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="Vorname" required value={firstName} onChange={setFirstName} />
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label> <TextField label="Nachname" required value={lastName} onChange={setLastName} />
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
<div> <TextField label="SVNR" inputMode="numeric" value={svNummer} onChange={setSvNummer} />
<label className="mb-1 block text-sm font-semibold text-ink">Nachname*</label> <TextField label="Geburtsdatum" required type="date" value={birthDate} onChange={setBirthDate} />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <SelectField
</div> label="Verwandtschaftsverhältnis"
</div> required
<div>
<label className="mb-1 block text-sm font-semibold text-ink">SVNR</label>
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geburtsdatum*</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Verwandtschaftsverhältnis*</label>
<select
value={relationship} value={relationship}
onChange={(e) => setRelationship(e.target.value as RelationshipType)} onChange={(v) => setRelationship(v as RelationshipType)}
className="w-full rounded border border-border px-3 py-2 text-sm" options={RELATIONSHIPS.map((r) => ({ value: r, label: r }))}
> />
{RELATIONSHIPS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
</div> </div>
</Modal> </Modal>
); );

View File

@@ -4,6 +4,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { deleteEmployeeDependent } from "@/actions/employees"; import { deleteEmployeeDependent } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
@@ -38,13 +39,9 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Angehörige</h3> <h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Angehörige</h3>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <Button variant="ghost" size="sm" onClick={() => setModalOpen(true)} className="!px-1 text-brand-700 hover:!bg-transparent hover:underline">
type="button"
onClick={() => setModalOpen(true)}
className="flex items-center gap-1 text-xs font-semibold text-brand-700 hover:text-brand-600"
>
<Plus className="h-3.5 w-3.5" /> Hinzufügen <Plus className="h-3.5 w-3.5" /> Hinzufügen
</button> </Button>
<span className="text-xs text-ink-muted">{dependents.length} Personen</span> <span className="text-xs text-ink-muted">{dependents.length} Personen</span>
</div> </div>
</div> </div>
@@ -73,15 +70,15 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {
<td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? ""}</td> <td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? ""}</td>
<td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td> <td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td>
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
<button <Button
type="button" variant="icon"
onClick={() => handleDelete(d.id)} onClick={() => handleDelete(d.id)}
disabled={deletingId === d.id} pending={deletingId === d.id}
aria-label="Angehörige:n entfernen" aria-label={`${d.first_name} ${d.last_name} als Angehörige:n entfernen`}
className="text-ink-muted hover:text-danger-solid disabled:opacity-50" className="hover:!text-danger-solid"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </Button>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircl
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format"; import { fmtFullName, tenure } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
@@ -97,31 +98,34 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
)} )}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />} {canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && ( {isActive && (
<button <Button
variant="secondary"
size="sm"
onClick={() => setPanel("terminate")} onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg" className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
> >
<XCircle className="h-4 w-4" /> Austritt <XCircle className="h-4 w-4" /> Austritt
</button> </Button>
)} )}
{employee.status === "Ausgetreten" && ( {employee.status === "Ausgetreten" && (
<button <Button size="sm" onClick={() => setPanel("rehire")}>
onClick={() => setPanel("rehire")}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen <RotateCcw className="h-4 w-4" /> Wiedereinstellen
</button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-1 border-b border-border"> {/* Tabs, so the list gets the tablist role and each button says
whether it is the selected one. */}
<div role="tablist" aria-label="Mitarbeiterdetails" className="flex gap-1 overflow-x-auto border-b border-border">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t} key={t}
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`border-b-2 px-4 py-2 text-sm font-semibold ${ className={`whitespace-nowrap border-b-2 px-4 py-2 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-brand-500 ${
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink" tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`} }`}
> >
@@ -166,8 +170,8 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) { function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) {
return ( return (
<button onClick={onClick} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="secondary" size="sm" onClick={onClick}>
<Icon className="h-4 w-4" /> {label} <Icon className="h-4 w-4" /> {label}
</button> </Button>
); );
} }

View File

@@ -1,8 +1,9 @@
"use client"; "use client";
import { Search } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { SearchInput } from "@/components/ui/SearchInput";
type EmployeeFiltersProps = { type EmployeeFiltersProps = {
divisions: { id: string; name: string }[]; divisions: { id: string; name: string }[];
@@ -40,19 +41,15 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2"> <SearchInput label="Mitarbeiter:innen durchsuchen" placeholder="Name, Pers.-Nr., Titel…" value={q} onChange={setQ} />
<Search className="h-4 w-4 shrink-0 text-ink-muted" /> {/* aria-label rather than a visible label: the filter bar is a single
<input horizontal row, and each select's first option already names it on
value={q} screen. */}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, Pers.-Nr., Titel…"
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
<select <select
aria-label="Nach Bereich filtern"
defaultValue={searchParams.get("division") ?? ""} defaultValue={searchParams.get("division") ?? ""}
onChange={(e) => updateParam("division", e.target.value)} onChange={(e) => updateParam("division", e.target.value)}
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink" className={FILTER_SELECT_CLASS}
> >
<option value="">Alle Bereiche</option> <option value="">Alle Bereiche</option>
{divisions.map((d) => ( {divisions.map((d) => (
@@ -62,9 +59,10 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
))} ))}
</select> </select>
<select <select
aria-label="Nach Status filtern"
defaultValue={searchParams.get("status") ?? ""} defaultValue={searchParams.get("status") ?? ""}
onChange={(e) => updateParam("status", e.target.value)} onChange={(e) => updateParam("status", e.target.value)}
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink" className={FILTER_SELECT_CLASS}
> >
<option value="">Alle Status</option> <option value="">Alle Status</option>
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => (
@@ -74,9 +72,10 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
))} ))}
</select> </select>
<select <select
aria-label="Nach Standort filtern"
defaultValue={searchParams.get("location") ?? ""} defaultValue={searchParams.get("location") ?? ""}
onChange={(e) => updateParam("location", e.target.value)} onChange={(e) => updateParam("location", e.target.value)}
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink" className={FILTER_SELECT_CLASS}
> >
<option value="">Alle Standorte</option> <option value="">Alle Standorte</option>
{locations.map((l) => ( {locations.map((l) => (

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { SelectField } from "@/components/ui/Field";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -24,39 +25,41 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <SelectField
<label className="mb-1 block text-xs font-semibold text-ink-muted">Angestellte:r / Arbeiter:in</label> label="Angestellte:r / Arbeiter:in"
<select dense
value={value.workerType} value={value.workerType}
onChange={(e) => onChange({ workerType: e.target.value as WorkerType })} onChange={(v) => onChange({ workerType: v as WorkerType })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "Angestellte:r", label: "Angestellte:r" },
<option value="Angestellte:r">Angestellte:r</option> { value: "Arbeiter:in", label: "Arbeiter:in" },
<option value="Arbeiter:in">Arbeiter:in</option> ]}
</select> />
</div> <SelectField
<div> label="Kollektivvertrag"
<label className="mb-1 block text-xs font-semibold text-ink-muted">Kollektivvertrag</label> dense
<select
value={value.collectiveAgreement} value={value.collectiveAgreement}
onChange={(e) => onChange({ collectiveAgreement: e.target.value as CollectiveAgreement })} onChange={(v) => onChange({ collectiveAgreement: v as CollectiveAgreement })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "Handel", label: "Handel" },
<option value="Handel">Handel</option> { value: "Süßwaren", label: "Süßwaren" },
<option value="Süßwaren">Süßwaren</option> ]}
</select> />
</div>
</div> </div>
<div> {/* Toggle group, not a set of fields: a fieldset names the group, and
<label className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</label> aria-pressed is what tells a screen reader a day is selected —
colour alone does not. */}
<fieldset>
<legend className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</legend>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => ( {WEEKDAYS.map((day) => (
<button <button
key={day} key={day}
type="button" type="button"
aria-pressed={value.workDays.includes(day)}
onClick={() => toggleWorkDay(day)} onClick={() => toggleWorkDay(day)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold ${ className={`rounded-full px-3 py-1.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
value.workDays.includes(day) ? "bg-brand-500 text-white" : "border border-border text-ink-muted hover:bg-surface" value.workDays.includes(day) ? "bg-brand-500 text-white" : "border border-border text-ink-muted hover:bg-surface"
}`} }`}
> >
@@ -64,7 +67,7 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
</button> </button>
))} ))}
</div> </div>
</div> </fieldset>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-ink-body"> <label className="flex items-center gap-2 text-sm text-ink-body">

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { TextField } from "@/components/ui/Field";
import { formatSvnr, requiresAustrianSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr"; import { formatSvnr, requiresAustrianSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
type SvNummerFieldProps = { type SvNummerFieldProps = {
@@ -10,7 +11,7 @@ type SvNummerFieldProps = {
locationCountry: string | null | undefined; locationCountry: string | null | undefined;
/** ISO yyyy-mm-dd; enables the cross-check against the TTMMJJ tail. */ /** ISO yyyy-mm-dd; enables the cross-check against the TTMMJJ tail. */
birthDate?: string | null; birthDate?: string | null;
labelClassName?: string; dense?: boolean;
}; };
/** /**
@@ -18,42 +19,28 @@ type SvNummerFieldProps = {
* same rule. Errors are shown only once the field has been left, so the * same rule. Errors are shown only once the field has been left, so the
* message does not flash while the ten digits are still being typed. * message does not flash while the ten digits are still being typed.
*/ */
export function SvNummerField({ value, onChange, locationCountry, birthDate, labelClassName }: SvNummerFieldProps) { export function SvNummerField({ value, onChange, locationCountry, birthDate, dense }: SvNummerFieldProps) {
const [touched, setTouched] = useState(false); const [touched, setTouched] = useState(false);
const applies = requiresAustrianSvnr(locationCountry); const applies = requiresAustrianSvnr(locationCountry);
const error = applies && value.trim() !== "" ? validateSvnr(value, birthDate) : null; const error = applies && value.trim() !== "" ? validateSvnr(value, birthDate) : null;
const showError = touched && error !== null;
return ( return (
<div> <TextField
<label htmlFor="sv-nummer" className={labelClassName ?? "mb-1 block text-sm font-semibold text-ink"}> label="SV-Nummer"
SV-Nummer dense={dense}
</label>
<input
id="sv-nummer"
value={value} value={value}
onChange={onChange}
inputMode="numeric" inputMode="numeric"
placeholder={applies ? "1237 010180" : undefined} placeholder={applies ? "1237 010180" : undefined}
onChange={(e) => onChange(e.target.value)} error={touched && error ? svnrErrorMessage(error) : null}
hint={applies ? "10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ)." : undefined}
onBlur={() => { onBlur={() => {
setTouched(true); setTouched(true);
// Normalise to the conventional "NNNN TTMMJJ" spacing once the // Normalise to the conventional "NNNN TTMMJJ" spacing once the value
// value is complete; anything else is left exactly as typed. // is complete; anything else is left exactly as typed.
const formatted = formatSvnr(value); const formatted = formatSvnr(value);
if (formatted !== value) onChange(formatted); if (formatted !== value) onChange(formatted);
}} }}
aria-invalid={showError || undefined}
aria-describedby={showError ? "sv-nummer-error" : undefined}
className={`w-full rounded border px-3 py-2 text-sm ${showError ? "border-danger-solid" : "border-border"}`}
/> />
{showError && (
<p id="sv-nummer-error" className="mt-1 text-xs font-semibold text-danger-text">
{svnrErrorMessage(error)}
</p>
)}
{applies && !showError && (
<p className="mt-1 text-xs text-ink-muted">10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ).</p>
)}
</div>
); );
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { Field } from "@/components/ui/Field";
import { Picklist } from "@/components/ui/Picklist"; import { Picklist } from "@/components/ui/Picklist";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles"; import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
@@ -11,14 +12,12 @@ export type TitleValue = { titlePrefix: string[]; titleSuffix: string[] };
export function TitleFields({ value, onChange }: { value: TitleValue; onChange: (patch: Partial<TitleValue>) => void }) { export function TitleFields({ value, onChange }: { value: TitleValue; onChange: (patch: Partial<TitleValue>) => void }) {
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <Field label="Titel (vorangestellt)" dense>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (vorangestellt)</label> {(p) => <Picklist {...p} options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} />}
<Picklist options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} /> </Field>
</div> <Field label="Titel (nachgestellt)" dense>
<div> {(p) => <Picklist {...p} options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />}
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (nachgestellt)</label> </Field>
<Picklist options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />
</div>
</div> </div>
); );
} }

View File

@@ -7,7 +7,9 @@ import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields"; import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { SvNummerField } from "@/components/employees/SvNummerField"; import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields"; import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { Button } from "@/components/ui/Button";
import { CountryPicker } from "@/components/ui/CountryPicker"; import { CountryPicker } from "@/components/ui/CountryPicker";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries"; import { UN_COUNTRIES } from "@/lib/countries";
@@ -151,149 +153,97 @@ export function DatenAendernPanel({
subtitle={`${fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} · ${employee.job_title}`} subtitle={`${fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={pending || !svNummerOk} pending={pending}
disabled={!svNummerOk}
title={svNummerOk ? undefined : "Die SV-Nummer ist ungültig."} title={svNummerOk ? undefined : "Die SV-Nummer ist ungültig."}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
> >
Speichern Speichern
</button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
<input
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div> <div>
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3> <h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="Vorname" dense value={firstName} onChange={setFirstName} />
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label> <TextField label="Nachname" dense value={lastName} onChange={setLastName} />
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Nachname</label>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div> </div>
<TitleFields value={titles} onChange={updateTitles} /> <TitleFields value={titles} onChange={updateTitles} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <SelectField
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label> label="Geschlecht"
<select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm"> dense
<option value="m">männlich</option> value={gender}
<option value="w">weiblich</option> onChange={(v) => setGender(v as GenderType)}
</select> options={[
</div> { value: "m", label: "männlich" },
<div> { value: "w", label: "weiblich" },
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geburtsdatum</label> ]}
<input
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
<TextField label="Geburtsdatum" dense type="date" value={birthDate} onChange={setBirthDate} />
</div> </div>
</div> <SvNummerField value={svNummer} onChange={setSvNummer} locationCountry={locationCountry} birthDate={birthDate || null} dense />
<SvNummerField <Field label="Staatsbürgerschaft" dense>
value={svNummer} {(p) => (
onChange={setSvNummer} <CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
locationCountry={locationCountry} )}
birthDate={birthDate || null} </Field>
labelClassName="mb-1 block text-xs font-semibold text-ink-muted" <TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
/>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse (Straße und Hausnummer)</label>
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3"> <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<div> <TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
<label className="mb-1 block text-xs font-semibold text-ink-muted">Postleitzahl</label> <TextField label="Ort" dense value={city} onChange={setCity} />
<input value={postalCode} onChange={(e) => setPostalCode(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Ort</label>
<input value={city} onChange={(e) => setCity(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label>
<CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">E-Mail</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Telefon</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
<Field label="Land" dense>
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
</Field>
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div> </div>
</div> </div>
<div> <div>
<h3 className="mb-3 text-sm font-bold text-ink">Vertrag</h3> <h3 className="mb-3 text-sm font-bold text-ink">Vertrag</h3>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <SelectField
<label className="mb-1 block text-xs font-semibold text-ink-muted">Beschäftigungsausmaß</label> label="Beschäftigungsausmaß"
<select dense
value={employmentType} value={employmentType}
onChange={(e) => handleEmploymentTypeChange(e.target.value as EmploymentType)} onChange={(v) => handleEmploymentTypeChange(v as EmploymentType)}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "Vollzeit", label: "Vollzeit" },
<option value="Vollzeit">Vollzeit</option> { value: "Teilzeit", label: "Teilzeit" },
<option value="Teilzeit">Teilzeit</option> ]}
</select> />
</div> <TextField
<div> label="Wochenstunden"
<label className="mb-1 block text-xs font-semibold text-ink-muted">Wochenstunden</label> dense
<input
type="number" type="number"
step="0.5" step="0.5"
value={weeklyHours} value={weeklyHours}
disabled={employmentType === "Vollzeit"} disabled={employmentType === "Vollzeit"}
onChange={(e) => setWeeklyHours(e.target.value)} onChange={setWeeklyHours}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
/> />
</div> <SelectField
<div> label="Vertragsart"
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vertragsart</label> dense
<select
value={contractType} value={contractType}
onChange={(e) => setContractType(e.target.value as ContractType)} onChange={(v) => setContractType(v as ContractType)}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "unbefristet", label: "unbefristet" },
<option value="unbefristet">unbefristet</option> { value: "befristet", label: "befristet" },
<option value="befristet">befristet</option> ]}
</select>
</div>
{contractType === "befristet" && (
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Befristet bis*</label>
<input
type="date"
value={contractEndDate}
onChange={(e) => setContractEndDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div> {contractType === "befristet" && (
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
)} )}
</div> </div>
</div> </div>

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { adjustKarenzReturn, recordKarenzReturn, startKarenz } from "@/actions/employees"; import { adjustKarenzReturn, recordKarenzReturn, startKarenz } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
@@ -108,41 +110,33 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`} subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
{/* Karenz keeps the warning tone it uses everywhere else. */}
{!isOnKarenz && ( {!isOnKarenz && (
<button onClick={handleStart} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"> <Button onClick={handleStart} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Karenz erfassen Karenz erfassen
</button> </Button>
)} )}
{isOnKarenz && mode === "adjust" && ( {isOnKarenz && mode === "adjust" && (
<button onClick={handleAdjust} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"> <Button onClick={handleAdjust} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Speichern Speichern
</button> </Button>
)} )}
{isOnKarenz && mode === "return" && ( {isOnKarenz && mode === "return" && (
<button onClick={handleReturn} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"> <Button onClick={handleReturn} pending={pending} className="!bg-warning-text text-white hover:brightness-110">
Wiedereintritt erfassen Wiedereintritt erfassen
</button> </Button>
)} )}
</> </>
} }
> >
{!isOnKarenz && ( {!isOnKarenz && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <TextField label="Karenzbeginn" required type="date" value={karenzStart} onChange={setKarenzStart} />
<label className="mb-1 block text-sm font-semibold text-ink">Karenzbeginn*</label> <TextField label="Geplante Rückkehr" required type="date" value={plannedReturn} onChange={setPlannedReturn} />
<input type="date" value={karenzStart} onChange={(e) => setKarenzStart(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <TextareaField label="Anmerkung" rows={3} value={startNote} onChange={setStartNote} />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geplante Rückkehr*</label>
<input type="date" value={plannedReturn} onChange={(e) => setPlannedReturn(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Anmerkung</label>
<textarea value={startNote} onChange={(e) => setStartNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div> </div>
)} )}
@@ -160,57 +154,40 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
{mode === "adjust" && ( {mode === "adjust" && (
<> <>
<p className="text-sm text-ink-muted">Aktuelles Rückkehrdatum: {fmtDate(employee.karenz_return_date)}</p> <p className="text-sm text-ink-muted">Aktuelles Rückkehrdatum: {fmtDate(employee.karenz_return_date)}</p>
<div> <TextField label="Neues Rückkehrdatum" required type="date" value={newReturnDate} onChange={setNewReturnDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Neues Rückkehrdatum*</label>
<input
type="date"
value={newReturnDate}
onChange={(e) => setNewReturnDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
{diffDays !== 0 && ( {diffDays !== 0 && (
<div className={`rounded px-3 py-2 text-sm ${diffDays > 0 ? "bg-warning-bg text-warning-text" : "bg-success-bg text-success-text"}`}> <div className={`rounded px-3 py-2 text-sm ${diffDays > 0 ? "bg-warning-bg text-warning-text" : "bg-success-bg text-success-text"}`}>
{diffDays > 0 ? `Verlängerung um ${diffDays} Tage` : `Verkürzung um ${Math.abs(diffDays)} Tage`} {diffDays > 0 ? `Verlängerung um ${diffDays} Tage` : `Verkürzung um ${Math.abs(diffDays)} Tage`}
</div> </div>
)} )}
<div> <TextareaField label="Grund/Anmerkung" rows={3} value={adjustNote} onChange={setAdjustNote} />
<label className="mb-1 block text-sm font-semibold text-ink">Grund/Anmerkung</label>
<textarea value={adjustNote} onChange={(e) => setAdjustNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</> </>
)} )}
{mode === "return" && ( {mode === "return" && (
<> <>
<div> <TextField label="Rückkehrdatum" required type="date" value={returnDate} onChange={setReturnDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Rückkehrdatum*</label> <SelectField
<input type="date" value={returnDate} onChange={(e) => setReturnDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> label="Beschäftigungsausmaß"
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label>
<select
value={employmentMode} value={employmentMode}
onChange={(e) => setEmploymentMode(e.target.value as EmploymentMode)} onChange={(v) => setEmploymentMode(v as EmploymentMode)}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "unverändert", label: "unverändert" },
<option value="unverändert">unverändert</option> { value: "Vollzeit", label: "Vollzeit (38,5h)" },
<option value="Vollzeit">Vollzeit (38,5h)</option> { value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
<option value="Teilzeit">Teilzeit-Elternteilzeit</option> ]}
</select> />
</div>
{employmentMode === "Teilzeit" && ( {employmentMode === "Teilzeit" && (
<div> <TextField
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden (unter 38,5)*</label> label="Wochenstunden"
<input required
type="number" type="number"
step="0.5" step="0.5"
max="38" max="38"
value={weeklyHours} value={weeklyHours}
onChange={(e) => setWeeklyHours(e.target.value)} onChange={setWeeklyHours}
className="w-full rounded border border-border px-3 py-2 text-sm" hint="Muss unter 38,5 liegen."
/> />
</div>
)} )}
</> </>
)} )}

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { promoteEmployee } from "@/actions/employees"; import { promoteEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { Database, PaygradeType } from "@/lib/supabase/types"; import type { Database, PaygradeType } from "@/lib/supabase/types";
@@ -56,47 +58,26 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`} subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button {/* Promotion keeps its own purple, matching the Beförderung badge
onClick={handleSubmit} used in the history tab. */}
disabled={pending} <Button onClick={handleSubmit} pending={pending} className="!bg-purple-text text-white hover:brightness-110">
className="rounded bg-purple-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Befördern Befördern
</button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label> <TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<input <SelectField
type="date" label="Paygrade"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Neue Position*</label>
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade</label>
<select
value={paygrade} value={paygrade}
onChange={(e) => setPaygrade(e.target.value as PaygradeType)} onChange={(v) => setPaygrade(v as PaygradeType)}
className="w-full rounded border border-border px-3 py-2 text-sm" options={PAYGRADES}
> />
{PAYGRADES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { rehireEmployee } from "@/actions/employees"; import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
@@ -41,16 +43,12 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
subtitle={`${employee.first_name} ${employee.last_name}`} subtitle={`${employee.first_name} ${employee.last_name}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button onClick={handleSubmit} pending={pending}>
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Wiedereinstellen Wiedereinstellen
</button> </Button>
</> </>
} }
> >
@@ -60,15 +58,7 @@ export function RehirePanel({ open, onClose, employee }: { open: boolean; onClos
<p className="mt-1 text-ink">{employee.job_title}</p> <p className="mt-1 text-ink">{employee.job_title}</p>
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p> <p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
</div> </div>
<div> <TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Wiedereintritt am*</label>
<input
type="date"
value={rehireDate}
onChange={(e) => setRehireDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { terminateEmployee } from "@/actions/employees"; import { terminateEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
@@ -53,16 +55,12 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`} subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button variant="danger" onClick={handleSubmit} pending={pending}>
onClick={handleSubmit}
disabled={pending}
className="rounded bg-danger-solid px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Austritt bestätigen Austritt bestätigen
</button> </Button>
</> </>
} }
> >
@@ -72,31 +70,16 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet. {directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
</div> </div>
)} )}
<div> <TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Austrittsdatum*</label> <SelectField
<input label="Beendigungsart"
type="date" value={reason}
value={exitDate} onChange={setReason}
onChange={(e) => setExitDate(e.target.value)} options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div> <TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
<div> <fieldset>
<label className="mb-1 block text-sm font-semibold text-ink">Beendigungsart</label> <legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
<select value={reason} onChange={(e) => setReason(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
{EXIT_REASONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Anmerkung</label>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<p className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{CHECKLIST_ITEMS.map((item, i) => ( {CHECKLIST_ITEMS.map((item, i) => (
<label key={item} className="flex items-center gap-2 text-sm text-ink-body"> <label key={item} className="flex items-center gap-2 text-sm text-ink-body">
@@ -109,7 +92,7 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
</label> </label>
))} ))}
</div> </div>
</div> </fieldset>
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { transferEmployee } from "@/actions/employees"; import { transferEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
@@ -66,67 +68,42 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`} subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button onClick={handleSubmit} pending={pending}>
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Versetzen Versetzen
</button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label> <SelectField
<input label="Neuer Bereich"
type="date" required
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Neuer Bereich*</label>
<select
value={divisionId} value={divisionId}
onChange={(e) => { onChange={(v) => {
setDivisionId(e.target.value); setDivisionId(v);
setTeamId(""); setTeamId("");
}} }}
className="w-full rounded border border-border px-3 py-2 text-sm" options={divisions.map((d) => ({ value: d.id, label: d.name }))}
> />
{divisions.map((d) => ( <SelectField
<option key={d.id} value={d.id}> label="Neues Team"
{d.name} required
</option> value={teamId}
))} onChange={setTeamId}
</select> placeholder="Bitte wählen…"
</div> options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
<div> />
<label className="mb-1 block text-sm font-semibold text-ink">Neues Team*</label> <TextField
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm"> label="Neuer Titel (optional)"
<option value="">Bitte wählen</option> value={newTitle}
{teamsInDivision.map((t) => ( onChange={setNewTitle}
<option key={t.id} value={t.id}> placeholder={employee.job_title}
{t.name} hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Neuer Titel (optional)</label>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={employee.job_title}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div>
<p className="text-xs text-ink-muted">Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt.</p>
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { addEmployeeNote, completeEmployeeNote } from "@/actions/employees"; import { addEmployeeNote, completeEmployeeNote } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors"; import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
@@ -65,35 +67,26 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
<div className="flex flex-col gap-3 rounded border border-border p-4"> <div className="flex flex-col gap-3 rounded border border-border p-4">
<h3 className="text-sm font-bold text-ink">Neue Notiz erfassen</h3> <h3 className="text-sm font-bold text-ink">Neue Notiz erfassen</h3>
<div> <TextareaField
<textarea label="Notiztext"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3} rows={3}
value={noteText}
onChange={setNoteText}
placeholder="Notiz zum/zur Mitarbeiter:in … (z. B. Gesprächsinhalt, Vereinbarung, Beobachtung)" placeholder="Notiz zum/zur Mitarbeiter:in … (z. B. Gesprächsinhalt, Vereinbarung, Beobachtung)"
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <SelectField
<label className="mb-1 block text-sm font-semibold text-ink">Kategorie</label> label="Kategorie"
<select value={category} onChange={(e) => setCategory(e.target.value as NoteCategory)} className="w-full rounded border border-border px-3 py-2 text-sm"> value={category}
{CATEGORIES.map((c) => ( onChange={(v) => setCategory(v as NoteCategory)}
<option key={c} value={c}> options={CATEGORIES.map((c) => ({ value: c, label: c }))}
{c} />
</option> <TextField label="Wiedervorlage am (optional)" type="date" value={dueDate} onChange={setDueDate} />
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wiedervorlage am (optional)</label>
<input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={handleSubmit} disabled={pending} className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"> <Button onClick={handleSubmit} pending={pending}>
Notiz speichern Notiz speichern
</button> </Button>
</div> </div>
</div> </div>
@@ -116,14 +109,15 @@ export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: N
<p className="mt-1 text-sm text-ink">{n.note_text}</p> <p className="mt-1 text-sm text-ink">{n.note_text}</p>
{n.due_date && !n.done && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>} {n.due_date && !n.done && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
{!n.done && ( {!n.done && (
<button <Button
type="button" variant="ghost"
size="sm"
onClick={() => handleComplete(n.id)} onClick={() => handleComplete(n.id)}
disabled={completingId === n.id} pending={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50" className="mt-2 !px-0 text-success-text hover:!bg-transparent hover:underline"
> >
Erledigt Erledigt
</button> </Button>
)} )}
</li> </li>
))} ))}

View File

@@ -1,6 +1,7 @@
import { Network } from "lucide-react"; import { Network } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { LINK_BUTTON_CLASS } from "@/components/ui/Button";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string }; type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
@@ -22,10 +23,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
{/* ?focus= drives the same highlight/auto-expand path the org chart {/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on search already uses, so the person is unfolded and centred on
arrival instead of the user hunting for them. */} arrival instead of the user hunting for them. */}
<Link <Link href={`/orgchart?focus=${employeeId}`} className={LINK_BUTTON_CLASS}>
href={`/orgchart?focus=${employeeId}`}
className="flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
Im Organigramm anzeigen Im Organigramm anzeigen
</Link> </Link>

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { hireEmployee } from "@/actions/employees"; import { hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts"; import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
@@ -123,51 +124,45 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
footer={ footer={
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button onClick={handleSaveDraft} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={handleSaveDraft}>
Als Entwurf speichern Als Entwurf speichern
</button> </Button>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{step > 0 && ( {step > 0 && (
<button <Button variant="secondary" onClick={() => setStep((s) => s - 1)}>
onClick={() => setStep((s) => s - 1)}
className="rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
Zurück Zurück
</button> </Button>
)} )}
{step < 3 && ( {step < 3 && (
<button <Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
onClick={() => setStep((s) => s + 1)}
disabled={!stepValid}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
Weiter Weiter
</button> </Button>
)} )}
{step === 3 && ( {step === 3 && (
<button <Button onClick={handleSubmit} pending={submitting}>
onClick={handleSubmit}
disabled={submitting}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Anlegen Anlegen
</button> </Button>
)} )}
</div> </div>
</div> </div>
} }
> >
{/* A progress trail, not navigation: only completed steps are
reachable, so the rest are genuinely disabled rather than inert
buttons that silently swallow a click. */}
<div className="mb-6 flex items-center justify-center gap-3"> <div className="mb-6 flex items-center justify-center gap-3">
{STEP_LABELS.map((label, i) => ( {STEP_LABELS.map((label, i) => (
<button <button
key={label} key={label}
type="button" type="button"
disabled={i >= step}
aria-current={i === step ? "step" : undefined}
onClick={() => i < step && setStep(i)} onClick={() => i < step && setStep(i)}
className={`flex items-center gap-2 text-xs font-semibold ${ className={`flex items-center gap-2 rounded text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:cursor-default ${
i === step ? "text-brand-700" : i < step ? "text-ink-body" : "text-ink-muted" i === step ? "text-brand-700" : i < step ? "text-ink-body" : "text-ink-muted"
}`} }`}
> >

View File

@@ -1,5 +1,6 @@
import { SvNummerField } from "@/components/employees/SvNummerField"; import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields } from "@/components/employees/TitleFields"; import { TitleFields } from "@/components/employees/TitleFields";
import { SelectField, TextField } from "@/components/ui/Field";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
type StepPersonProps = { type StepPersonProps = {
@@ -12,32 +13,22 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label> <TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
<input value={draft.firstName} onChange={(e) => update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Nachname*</label>
<input value={draft.lastName} onChange={(e) => update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div> </div>
<TitleFields value={draft} onChange={update} /> <TitleFields value={draft} onChange={update} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <SelectField
<label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label> label="Geschlecht"
<select required
value={draft.gender} value={draft.gender}
onChange={(e) => update({ gender: e.target.value as HireDraftData["gender"] })} onChange={(gender) => update({ gender: gender as HireDraftData["gender"] })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "m", label: "männlich" },
<option value="m">männlich</option> { value: "w", label: "weiblich" },
<option value="w">weiblich</option> ]}
</select> />
</div> <TextField label="Geburtsdatum" required type="date" value={draft.birthDate} onChange={(birthDate) => update({ birthDate })} />
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geburtsdatum*</label>
<input type="date" value={draft.birthDate} onChange={(e) => update({ birthDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div> </div>
<SvNummerField <SvNummerField
value={draft.svNummer} value={draft.svNummer}
@@ -46,26 +37,17 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
birthDate={draft.birthDate || null} birthDate={draft.birthDate || null}
/> />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label> <TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
<input type="email" value={draft.email} onChange={(e) => update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Telefon</label>
<input value={draft.phone} onChange={(e) => update({ phone: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Standort*</label>
<select value={draft.locationId} onChange={(e) => update({ locationId: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="">Bitte wählen</option>
{locations.map((l) => (
<option key={l.id} value={l.id}>
{l.name} ({l.country})
</option>
))}
</select>
</div> </div>
<SelectField
label="Standort"
required
value={draft.locationId}
onChange={(locationId) => update({ locationId })}
placeholder="Bitte wählen…"
options={locations.map((l) => ({ value: l.id, label: `${l.name} (${l.country})` }))}
/>
</div> </div>
); );
} }

View File

@@ -1,4 +1,6 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup"; import { Lookup } from "@/components/ui/Lookup";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
@@ -20,23 +22,32 @@ export function StepPosition({ draft, update, openPositions }: StepPositionProps
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Position*</label>
{!selected ? ( {!selected ? (
<Field
label="Position"
required
hint={openPositions.length === 0 ? "Derzeit sind keine offenen Positionen vorhanden." : undefined}
>
{(p) => (
<Lookup<OpenPositionResolved> <Lookup<OpenPositionResolved>
{...p}
placeholder="Positionsname oder -nummer…" placeholder="Positionsname oder -nummer…"
onSearch={search} onSearch={search}
onSelect={(p) => update({ positionId: p.id })} onSelect={(pos) => update({ positionId: pos.id })}
renderResult={(p) => ( renderResult={(pos) => (
<div> <div>
<div className="font-semibold text-ink">{p.title}</div> <div className="font-semibold text-ink">{pos.title}</div>
<div className="text-xs text-ink-muted"> <div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel} {pos.position_number} · {pos.orgLabel}
</div> </div>
</div> </div>
)} )}
/> />
)}
</Field>
) : ( ) : (
<div>
<p className="mb-1 block text-sm font-semibold text-ink">Position</p>
<div className="flex items-center justify-between rounded border border-border bg-surface p-3"> <div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div> <div>
<div className="text-sm font-semibold text-ink">{selected.title}</div> <div className="text-sm font-semibold text-ink">{selected.title}</div>
@@ -45,31 +56,32 @@ export function StepPosition({ draft, update, openPositions }: StepPositionProps
</div> </div>
<div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div> <div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div>
</div> </div>
<button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben"> <Button variant="icon" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
<X className="h-4 w-4 text-ink-muted" /> <X className="h-4 w-4" />
</button> </Button>
</div>
</div> </div>
)} )}
{openPositions.length === 0 && <p className="mt-1 text-xs text-ink-muted">Derzeit sind keine offenen Positionen vorhanden.</p>}
</div>
<div> <SelectField
<label className="mb-1 block text-sm font-semibold text-ink">Besetzung*</label> label="Besetzung"
<select required
value={draft.besetzung} value={draft.besetzung}
onChange={(e) => update({ besetzung: e.target.value as HireDraftData["besetzung"] })} onChange={(v) => update({ besetzung: v as HireDraftData["besetzung"] })}
className="w-full rounded border border-border px-3 py-2 text-sm" placeholder="Bitte wählen…"
> options={[
<option value="">Bitte wählen</option> { value: "Extern", label: "Extern" },
<option value="Extern">Extern</option> { value: "Intern", label: "Intern" },
<option value="Intern">Intern</option> ]}
</select> />
</div>
<div> <TextField
<label className="mb-1 block text-sm font-semibold text-ink">Führungskraft</label> label="Führungskraft"
<input value={selected?.managerName ?? ""} disabled className="w-full rounded border border-border bg-surface px-3 py-2 text-sm text-ink-muted" /> value={selected?.managerName ?? ""}
</div> onChange={() => {}}
disabled
hint="Ergibt sich aus der gewählten Position."
/>
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields"; import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import { SelectField, TextField } from "@/components/ui/Field";
import type { PaygradeType } from "@/lib/supabase/types"; import type { PaygradeType } from "@/lib/supabase/types";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
@@ -22,72 +23,53 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="Eintrittsdatum" required type="date" value={draft.entryDate} onChange={(entryDate) => update({ entryDate })} />
<label className="mb-1 block text-sm font-semibold text-ink">Eintrittsdatum*</label> <SelectField
<input type="date" value={draft.entryDate} onChange={(e) => update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> label="Vertragsart"
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Vertragsart</label>
<select
value={draft.contractType} value={draft.contractType}
onChange={(e) => update({ contractType: e.target.value as HireDraftData["contractType"] })} onChange={(v) => update({ contractType: v as HireDraftData["contractType"] })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "unbefristet", label: "unbefristet" },
<option value="unbefristet">unbefristet</option> { value: "befristet", label: "befristet" },
<option value="befristet">befristet</option> ]}
</select>
</div>
</div>
{draft.contractType === "befristet" && (
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Befristet bis*</label>
<input
type="date"
value={draft.contractEndDate}
onChange={(e) => update({ contractEndDate: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div> </div>
{draft.contractType === "befristet" && (
<TextField
label="Befristet bis"
required
type="date"
value={draft.contractEndDate}
onChange={(contractEndDate) => update({ contractEndDate })}
/>
)} )}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <SelectField
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label> label="Beschäftigungsausmaß"
<select
value={draft.employmentType} value={draft.employmentType}
onChange={(e) => handleEmploymentTypeChange(e.target.value as HireDraftData["employmentType"])} onChange={(v) => handleEmploymentTypeChange(v as HireDraftData["employmentType"])}
className="w-full rounded border border-border px-3 py-2 text-sm" options={[
> { value: "Vollzeit", label: "Vollzeit" },
<option value="Vollzeit">Vollzeit</option> { value: "Teilzeit", label: "Teilzeit" },
<option value="Teilzeit">Teilzeit</option> ]}
</select> />
</div> <TextField
<div> label="Wochenstunden"
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden</label>
<input
type="number" type="number"
step="0.5" step="0.5"
value={draft.weeklyHours} value={draft.weeklyHours}
disabled={draft.employmentType === "Vollzeit"} disabled={draft.employmentType === "Vollzeit"}
onChange={(e) => update({ weeklyHours: e.target.value })} onChange={(weeklyHours) => update({ weeklyHours })}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
/> />
</div> </div>
</div> <SelectField
<div> label="Paygrade"
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade*</label> required
<select
value={draft.paygrade} value={draft.paygrade}
onChange={(e) => update({ paygrade: e.target.value as PaygradeType })} onChange={(v) => update({ paygrade: v as PaygradeType })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={PAYGRADES}
> hint={PAYGRADES.find((p) => p.value === draft.paygrade)?.description}
{PAYGRADES.map((p) => ( />
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-ink-muted">{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}</p>
</div>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p> <p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
<div> <div>

View File

@@ -2,6 +2,8 @@
import { CalendarClock } from "lucide-react"; import { CalendarClock } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
type AsOfPickerProps = { type AsOfPickerProps = {
@@ -44,12 +46,12 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
type="date" type="date"
value={asOf} value={asOf}
onChange={(e) => setAsOf(e.target.value || undefined)} onChange={(e) => setAsOf(e.target.value || undefined)}
className="rounded border border-border px-3 py-2 text-sm" className={FILTER_SELECT_CLASS}
/> />
{!isToday && ( {!isToday && (
<button type="button" onClick={() => setAsOf(undefined)} className="text-xs font-semibold text-brand-700 hover:underline"> <Button variant="ghost" size="sm" onClick={() => setAsOf(undefined)} className="!px-1 text-brand-700 hover:!bg-transparent hover:underline">
Heute Heute
</button> </Button>
)} )}
<span className="text-xs text-ink-muted"> <span className="text-xs text-ink-muted">
{isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`} {isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`}

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { ChevronDown, ChevronRight, Search } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { SearchInput } from "@/components/ui/SearchInput";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee } from "./types"; import type { ChartNode, OrgEmployee } from "./types";
@@ -167,29 +169,13 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
return ( return (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border px-3 py-2"> <SearchInput label="Organigramm durchsuchen" placeholder="Name, Pers.-Nr., Titel…" value={query} onChange={setQuery} />
<Search className="h-4 w-4 shrink-0 text-ink-muted" /> <Button variant="secondary" size="sm" onClick={() => setExpanded(new Set(root.map((r) => r.id)))}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name, Pers.-Nr., Titel…"
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
<button
type="button"
onClick={() => setExpanded(new Set(root.map((r) => r.id)))}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Bereiche anzeigen Bereiche anzeigen
</button> </Button>
<button <Button variant="secondary" size="sm" onClick={() => setExpanded(new Set())}>
type="button"
onClick={() => setExpanded(new Set())}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Alles einklappen Alles einklappen
</button> </Button>
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} /> <SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div> </div>
{mode === "list" ? ( {mode === "list" ? (

View File

@@ -4,6 +4,8 @@ import { RotateCcw, X } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg"; import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup"; import { Lookup } from "@/components/ui/Lookup";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
@@ -190,28 +192,20 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2> <h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <TextField label="Name der Reorganisation" required value={name} onChange={setName} />
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label> <TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
<input
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
</div> </div>
<div className="mt-4 flex gap-2"> <fieldset className="mt-4">
<legend className="sr-only">Art der Änderung</legend>
<div className="flex flex-wrap gap-2">
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => ( {(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
<button <button
key={kind} key={kind}
type="button" type="button"
aria-pressed={changeType === kind}
onClick={() => setChangeType(kind)} onClick={() => setChangeType(kind)}
className={`rounded px-3 py-1.5 text-sm font-semibold ${ className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface" changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
}`} }`}
> >
@@ -219,13 +213,16 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
</button> </button>
))} ))}
</div> </div>
</fieldset>
<div className="mt-4 grid grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Quelle</label>
{changeType === "emp" && ( {changeType === "emp" && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Field label="Quelle: Mitarbeiter:innen">
{(p) => (
<Lookup<OrgEmployee> <Lookup<OrgEmployee>
{...p}
placeholder="Mitarbeiter:in suchen…" placeholder="Mitarbeiter:in suchen…"
onSearch={searchLocalEmployees} onSearch={searchLocalEmployees}
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])} onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
@@ -238,12 +235,19 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
</div> </div>
)} )}
/> />
)}
</Field>
{selectedEmployees.length > 0 && ( {selectedEmployees.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{selectedEmployees.map((e) => ( {selectedEmployees.map((e) => (
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700"> <span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
{e.first_name} {e.last_name} {e.first_name} {e.last_name}
<button type="button" onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}> <button
type="button"
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</span> </span>
@@ -253,78 +257,60 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
</div> </div>
)} )}
{changeType === "team" && ( {changeType === "team" && (
<select value={sourceTeamId} onChange={(e) => setSourceTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm"> <SelectField
<option value="">Team wählen</option> label="Quelle: Team"
{teams.map((t) => ( value={sourceTeamId}
<option key={t.id} value={t.id}> onChange={setSourceTeamId}
{t.name} placeholder="Team wählen…"
</option> options={teams.map((t) => ({ value: t.id, label: t.name }))}
))} />
</select>
)} )}
{changeType === "abt" && ( {changeType === "abt" && (
<select value={sourceDeptId} onChange={(e) => setSourceDeptId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm"> <SelectField
<option value="">Abteilung wählen</option> label="Quelle: Abteilung"
{departments.map((d) => ( value={sourceDeptId}
<option key={d.id} value={d.id}> onChange={setSourceDeptId}
{d.name} placeholder="Abteilung wählen…"
</option> options={departments.map((d) => ({ value: d.id, label: d.name }))}
))} />
</select>
)} )}
{changeType === "dept" && ( {changeType === "dept" && (
<select <SelectField
label="Quelle: Bereich"
value={sourceDivisionId} value={sourceDivisionId}
onChange={(e) => setSourceDivisionId(e.target.value)} onChange={setSourceDivisionId}
className="w-full rounded border border-border px-3 py-2 text-sm" placeholder="Bereich wählen…"
> options={divisions.map((d) => ({ value: d.id, label: d.name }))}
<option value="">Bereich wählen</option> />
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
)} )}
</div> </div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Ziel-Bereich* / Ziel-Team*</label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<select <SelectField
label="Ziel-Bereich"
required
value={targetDivisionId} value={targetDivisionId}
onChange={(e) => { onChange={(v) => {
setTargetDivisionId(e.target.value); setTargetDivisionId(v);
setTargetTeamId(""); setTargetTeamId("");
}} }}
className="w-full rounded border border-border px-3 py-2 text-sm" placeholder="Ziel-Bereich wählen…"
> options={divisions.map((d) => ({ value: d.id, label: d.name }))}
<option value="">Ziel-Bereich wählen</option> />
{divisions.map((d) => ( <SelectField
<option key={d.id} value={d.id}> label="Ziel-Team"
{d.name} required
</option> value={targetTeamId}
))} onChange={setTargetTeamId}
</select> placeholder="Ziel-Team wählen…"
<select value={targetTeamId} onChange={(e) => setTargetTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm"> options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
<option value="">Ziel-Team wählen</option> />
{teamsInTargetDivision.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div> </div>
</div> </div>
<button <Button variant="secondary" onClick={handleAddMove} className="mt-4">
type="button"
onClick={handleAddMove}
className="mt-4 rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
+ Zur Reorganisation hinzufügen + Zur Reorganisation hinzufügen
</button> </Button>
</div> </div>
{pendingMoves.length > 0 && ( {pendingMoves.length > 0 && (
@@ -340,9 +326,14 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
</span> </span>
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span> <span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
</div> </div>
<button type="button" onClick={() => removeMove(m.id)} aria-label="Entfernen" className="text-ink-muted hover:text-danger-solid"> <Button
variant="icon"
onClick={() => removeMove(m.id)}
aria-label={`${m.label} aus der Reorganisation entfernen`}
className="hover:!text-danger-solid"
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -376,22 +367,13 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
</tbody> </tbody>
</table> </table>
<div className="mt-4 flex gap-2"> <div className="mt-4 flex flex-wrap gap-2">
<button <Button variant="secondary" onClick={() => setPendingMoves([])}>
type="button"
onClick={() => setPendingMoves([])}
className="rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
Verwerfen Verwerfen
</button> </Button>
<button <Button onClick={handleApply} pending={applying}>
type="button"
onClick={handleApply}
disabled={applying}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Reorganisation durchführen Reorganisation durchführen
</button> </Button>
</div> </div>
</div> </div>
)} )}
@@ -408,15 +390,10 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)} wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
</span> </span>
</div> </div>
<button <Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
type="button"
onClick={() => handleUndo(s.id)}
disabled={undoingId === s.id}
className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-semibold text-ink-body hover:bg-surface disabled:opacity-50"
>
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
Rückgängig machen Rückgängig machen
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions"; import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup"; import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
@@ -60,24 +62,17 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
title="Position ausschreiben" title="Position ausschreiben"
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={onClose}>
Abbrechen Abbrechen
</button> </Button>
<button <Button onClick={handleSubmit} pending={pending}>
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Ausschreiben Ausschreiben
</button> </Button>
</> </>
} }
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <TextField label="Titel" required value={title} onChange={setTitle} />
<label className="mb-1 block text-sm font-semibold text-ink">Titel*</label>
<input value={title} onChange={(e) => setTitle(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<label className="flex items-center gap-2 text-sm text-ink-body"> <label className="flex items-center gap-2 text-sm text-ink-body">
<input <input
type="checkbox" type="checkbox"
@@ -89,25 +84,30 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
/> />
Führungsposition (Teamleitung) Führungsposition (Teamleitung)
</label> </label>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">
{isLead ? "Übergeordnete Bereichsleitung*" : "Übergeordnete Teamleitung*"}
</label>
{!superior ? ( {!superior ? (
<Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
{(p) => (
<Lookup<SuperiorSearchResult> <Lookup<SuperiorSearchResult>
{...p}
placeholder="Name oder Titel…" placeholder="Name oder Titel…"
onSearch={(q) => searchSuperiors(q, isLead)} onSearch={(q) => searchSuperiors(q, isLead)}
onSelect={setSuperior} onSelect={setSuperior}
renderResult={(p) => ( renderResult={(r) => (
<div> <div>
<div className="font-semibold text-ink"> <div className="font-semibold text-ink">
{p.first_name} {p.last_name} {r.first_name} {r.last_name}
</div> </div>
<div className="text-xs text-ink-muted">{p.job_title}</div> <div className="text-xs text-ink-muted">{r.job_title}</div>
</div> </div>
)} )}
/> />
)}
</Field>
) : ( ) : (
<div>
<p className="mb-1 block text-sm font-semibold text-ink">
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
</p>
<div className="flex items-center justify-between rounded border border-border bg-surface p-3"> <div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div> <div>
<div className="text-sm font-semibold text-ink"> <div className="text-sm font-semibold text-ink">
@@ -115,34 +115,23 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
</div> </div>
<div className="text-xs text-ink-muted">{superior.job_title}</div> <div className="text-xs text-ink-muted">{superior.job_title}</div>
</div> </div>
<button type="button" onClick={() => setSuperior(null)} className="text-xs text-ink-muted hover:text-ink"> <Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
Ändern Ändern
</button> </Button>
</div>
</div> </div>
)} )}
</div>
{isLead && ( {isLead && (
<div> <SelectField
<label className="mb-1 block text-sm font-semibold text-ink">Zu leitendes Team*</label> label="Zu leitendes Team"
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm"> required
<option value="">Bitte wählen</option> value={teamId}
{teams.map((t) => ( onChange={setTeamId}
<option key={t.id} value={t.id}> placeholder="Bitte wählen…"
{t.name} options={teams.map((t) => ({ value: t.id, label: t.name }))}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Gültig ab*</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div> )}
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
</div> </div>
</Modal> </Modal>
); );

View File

@@ -4,6 +4,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { deletePosition } from "@/actions/positions"; import { deletePosition } from "@/actions/positions";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
@@ -40,14 +41,10 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2> <h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
<button <Button onClick={() => setCreateOpen(true)}>
type="button"
onClick={() => setCreateOpen(true)}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-600"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Position ausschreiben Position ausschreiben
</button> </Button>
</div> </div>
{openPositions.length === 0 ? ( {openPositions.length === 0 ? (
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p> <p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
@@ -59,15 +56,15 @@ export function PositionsPageClient({ openPositions, teams }: PositionsPageClien
<div key={p.id} className="flex flex-col rounded border border-border p-3"> <div key={p.id} className="flex flex-col rounded border border-border p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="text-sm font-semibold text-ink">{p.title}</div> <div className="text-sm font-semibold text-ink">{p.title}</div>
<button <Button
type="button" variant="icon"
onClick={() => handleDelete(p.id)} onClick={() => handleDelete(p.id)}
disabled={deletingId === p.id} pending={deletingId === p.id}
aria-label="Position löschen" aria-label={`Position ${p.title} löschen`}
className="-mr-1 -mt-1 rounded p-2 text-ink-muted hover:text-danger-solid disabled:opacity-50" className="-mr-1 -mt-1 hover:!text-danger-solid"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </Button>
</div> </div>
<div className="text-xs text-ink-muted"> <div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel} {p.position_number} · {p.orgLabel}

View File

@@ -5,6 +5,8 @@ import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { deleteReport, saveReport } from "@/actions/reports"; import { deleteReport, saveReport } from "@/actions/reports";
import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { CONTROL_CLASS, SelectField, TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
@@ -208,81 +210,73 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
return ( return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex rounded border border-border bg-white p-1 text-sm font-semibold"> <div role="tablist" aria-label="Berichtsart" className="flex rounded border border-border bg-white p-1 text-sm font-semibold">
{(["snapshot", "events"] as const).map((m) => (
<button <button
key={m}
type="button" type="button"
onClick={() => switchMode("snapshot")} role="tab"
className={`flex-1 rounded px-3 py-1.5 ${mode === "snapshot" ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`} aria-selected={mode === m}
onClick={() => switchMode(m)}
className={`flex-1 rounded px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 ${
mode === m ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"
}`}
> >
Bestand {m === "snapshot" ? "Bestand" : "Ereignisse"}
</button>
<button
type="button"
onClick={() => switchMode("events")}
className={`flex-1 rounded px-3 py-1.5 ${mode === "events" ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
Ereignisse
</button> </button>
))}
</div> </div>
{mode === "snapshot" ? ( {mode === "snapshot" ? (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <SelectField
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Kennzahl</label> label="Kennzahl"
<select dense
value={props.measure} value={props.measure}
onChange={(e) => updateParams({ measure: e.target.value, split: AVERAGE_MEASURES.includes(e.target.value as Measure) ? undefined : props.split })} onChange={(v) => updateParams({ measure: v, split: AVERAGE_MEASURES.includes(v as Measure) ? undefined : props.split })}
className="w-full rounded border border-border px-3 py-2 text-sm" options={(Object.keys(MEASURE_LABELS) as Measure[]).map((m) => ({ value: m, label: MEASURE_LABELS[m] }))}
> />
{(Object.keys(MEASURE_LABELS) as Measure[]).map((m) => ( <SelectField
<option key={m} value={m}> label="Gruppieren nach"
{MEASURE_LABELS[m]} dense
</option> value={props.group}
))} onChange={(v) => updateParams({ group: v })}
</select> options={(Object.keys(GROUP_LABELS) as GroupDimension[]).map((g) => ({ value: g, label: GROUP_LABELS[g] }))}
</div> />
<div> <SelectField
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Gruppieren nach</label> label="Aufteilen nach"
<select value={props.group} onChange={(e) => updateParams({ group: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm"> dense
{(Object.keys(GROUP_LABELS) as GroupDimension[]).map((g) => (
<option key={g} value={g}>
{GROUP_LABELS[g]}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Aufteilen nach</label>
<select
value={props.split} value={props.split}
disabled={isAverage} disabled={isAverage}
onChange={(e) => updateParams({ split: e.target.value || undefined })} onChange={(v) => updateParams({ split: v || undefined })}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted" hint={isAverage ? "Bei Durchschnittswerten nicht verfügbar." : undefined}
> options={[
<option value="">Keine Aufteilung</option> { value: "", label: "Keine Aufteilung" },
{(Object.keys(GROUP_LABELS) as GroupDimension[]) ...(Object.keys(GROUP_LABELS) as GroupDimension[])
.filter((g) => g !== props.group) .filter((g) => g !== props.group)
.map((g) => ( .map((g) => ({ value: g, label: GROUP_LABELS[g] })),
<option key={g} value={g}> ]}
{GROUP_LABELS[g]} />
</option>
))}
</select>
</div>
<div> <div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Stichtag</label> <div className="flex items-end gap-2">
<div className="flex items-center gap-2"> <TextField
<input label="Stichtag"
dense
className="flex-1"
type="date" type="date"
value={props.asOf || todayIso()} value={props.asOf || todayIso()}
onChange={(e) => updateParams({ asOf: e.target.value })} onChange={(v) => updateParams({ asOf: v })}
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
{props.asOf && ( {props.asOf && (
<button type="button" onClick={() => updateParams({ asOf: undefined })} className="whitespace-nowrap text-xs font-semibold text-brand-700 hover:underline"> <Button
variant="ghost"
size="sm"
onClick={() => updateParams({ asOf: undefined })}
className="!px-1 whitespace-nowrap text-brand-700 hover:!bg-transparent hover:underline"
>
Heute Heute
</button> </Button>
)} )}
</div> </div>
<p className="mt-1 text-xs text-ink-muted"> <p className="mt-1 text-xs text-ink-muted">
@@ -294,77 +288,76 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
) : ( ) : (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <SelectField
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Ereignistyp</label> label="Ereignistyp"
<select value={props.eventType} onChange={(e) => updateParams({ eventType: e.target.value || undefined })} className="w-full rounded border border-border px-3 py-2 text-sm"> dense
<option value="">Alle Ereignistypen</option> value={props.eventType}
{EVENT_TYPES.map((t) => ( onChange={(v) => updateParams({ eventType: v || undefined })}
<option key={t} value={t}> options={[
{EVENT_TYPE_LABELS[t]} { value: "", label: "Alle Ereignistypen" },
</option> ...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })),
))} ]}
</select> />
</div> <SelectField
<div> label="Gruppieren nach"
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Gruppieren nach</label> dense
<select value={props.eventGroup} onChange={(e) => updateParams({ group: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm"> value={props.eventGroup}
{(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]).map((g) => ( onChange={(v) => updateParams({ group: v })}
<option key={g} value={g}> options={(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[]).map((g) => ({ value: g, label: EVENT_GROUP_LABELS[g] }))}
{EVENT_GROUP_LABELS[g]} />
</option> <SelectField
))} label="Aufteilen nach"
</select> dense
</div> value={props.eventSplit}
<div> onChange={(v) => updateParams({ split: v || undefined })}
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Aufteilen nach</label> options={[
<select value={props.eventSplit} onChange={(e) => updateParams({ split: e.target.value || undefined })} className="w-full rounded border border-border px-3 py-2 text-sm"> { value: "", label: "Keine Aufteilung" },
<option value="">Keine Aufteilung</option> ...(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[])
{(Object.keys(EVENT_GROUP_LABELS) as EventGroupDimension[])
.filter((g) => g !== props.eventGroup) .filter((g) => g !== props.eventGroup)
.map((g) => ( .map((g) => ({ value: g, label: EVENT_GROUP_LABELS[g] })),
<option key={g} value={g}> ]}
{EVENT_GROUP_LABELS[g]} />
</option> <fieldset>
))} <legend className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</legend>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-muted">Zeitraum</label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<input <TextField
label="Von"
dense
type="date" type="date"
value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom} value={props.eventFilters.from === EVENT_DATE_OPEN ? "" : props.eventFilters.from || defaultEventFrom}
disabled={props.eventFilters.from === EVENT_DATE_OPEN} disabled={props.eventFilters.from === EVENT_DATE_OPEN}
onChange={(e) => updateParams({ from: e.target.value })} onChange={(v) => updateParams({ from: v })}
className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface"
/> />
<button <Button
type="button" variant="ghost"
size="sm"
onClick={() => updateParams({ from: props.eventFilters.from === EVENT_DATE_OPEN ? defaultEventFrom : EVENT_DATE_OPEN })} onClick={() => updateParams({ from: props.eventFilters.from === EVENT_DATE_OPEN ? defaultEventFrom : EVENT_DATE_OPEN })}
className="mt-1 text-xs font-semibold text-brand-700 hover:underline" className="mt-1 !px-0 text-brand-700 hover:!bg-transparent hover:underline"
> >
{props.eventFilters.from === EVENT_DATE_OPEN ? "Startdatum setzen" : "Ab Anfang (offen)"} {props.eventFilters.from === EVENT_DATE_OPEN ? "Startdatum setzen" : "Ab Anfang (offen)"}
</button> </Button>
</div> </div>
<div> <div>
<input <TextField
label="Bis"
dense
type="date" type="date"
value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo} value={props.eventFilters.to === EVENT_DATE_OPEN ? "" : props.eventFilters.to || defaultEventTo}
disabled={props.eventFilters.to === EVENT_DATE_OPEN} disabled={props.eventFilters.to === EVENT_DATE_OPEN}
onChange={(e) => updateParams({ to: e.target.value })} onChange={(v) => updateParams({ to: v })}
className="w-full rounded border border-border px-2 py-2 text-xs disabled:bg-surface"
/> />
<button <Button
type="button" variant="ghost"
size="sm"
onClick={() => updateParams({ to: props.eventFilters.to === EVENT_DATE_OPEN ? defaultEventTo : EVENT_DATE_OPEN })} onClick={() => updateParams({ to: props.eventFilters.to === EVENT_DATE_OPEN ? defaultEventTo : EVENT_DATE_OPEN })}
className="mt-1 text-xs font-semibold text-brand-700 hover:underline" className="mt-1 !px-0 text-brand-700 hover:!bg-transparent hover:underline"
> >
{props.eventFilters.to === EVENT_DATE_OPEN ? "Enddatum setzen" : "Bis heute (offen)"} {props.eventFilters.to === EVENT_DATE_OPEN ? "Enddatum setzen" : "Bis heute (offen)"}
</button> </Button>
</div>
</div> </div>
</div> </div>
</fieldset>
</div> </div>
</div> </div>
)} )}
@@ -373,9 +366,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Filter</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<select <select
aria-label="Nach Bereich filtern"
value={mode === "snapshot" ? props.filters.division : props.eventFilters.division} value={mode === "snapshot" ? props.filters.division : props.eventFilters.division}
onChange={(e) => updateParams({ division: e.target.value })} onChange={(e) => updateParams({ division: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm" className={CONTROL_CLASS}
> >
<option value="">Alle Bereiche</option> <option value="">Alle Bereiche</option>
{divisions.map((d) => ( {divisions.map((d) => (
@@ -385,9 +379,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
))} ))}
</select> </select>
<select <select
aria-label="Nach Standort filtern"
value={mode === "snapshot" ? props.filters.location : props.eventFilters.location} value={mode === "snapshot" ? props.filters.location : props.eventFilters.location}
onChange={(e) => updateParams({ location: e.target.value })} onChange={(e) => updateParams({ location: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm" className={CONTROL_CLASS}
> >
<option value="">Alle Standorte</option> <option value="">Alle Standorte</option>
{locations.map((l) => ( {locations.map((l) => (
@@ -398,8 +393,8 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</select> </select>
{mode === "snapshot" && ( {mode === "snapshot" && (
<> <>
<div className="rounded border border-border px-3 py-2"> <fieldset className="rounded border border-border px-3 py-2">
<p className="mb-1.5 text-xs font-semibold text-ink-muted">Status (zum Stichtag)</p> <legend className="mb-1.5 text-xs font-semibold text-ink-muted">Status (zum Stichtag)</legend>
<div className="flex flex-wrap gap-x-4 gap-y-1.5"> <div className="flex flex-wrap gap-x-4 gap-y-1.5">
{STATUS_OPTIONS.map((s) => ( {STATUS_OPTIONS.map((s) => (
<label key={s} className="flex items-center gap-1.5 text-sm text-ink-body"> <label key={s} className="flex items-center gap-1.5 text-sm text-ink-body">
@@ -413,11 +408,12 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</label> </label>
))} ))}
</div> </div>
</div> </fieldset>
<select <select
aria-label="Nach Beschäftigungsart filtern"
value={props.filters.employment} value={props.filters.employment}
onChange={(e) => updateParams({ employment: e.target.value })} onChange={(e) => updateParams({ employment: e.target.value })}
className="w-full rounded border border-border px-3 py-2 text-sm" className={CONTROL_CLASS}
> >
<option value="">Alle Beschäftigungsarten</option> <option value="">Alle Beschäftigungsarten</option>
<option value="Vollzeit">Vollzeit</option> <option value="Vollzeit">Vollzeit</option>
@@ -432,14 +428,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vorlagen</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Vorlagen</h3>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{(mode === "snapshot" ? REPORT_PRESETS : EVENT_REPORT_PRESETS).map((preset) => ( {(mode === "snapshot" ? REPORT_PRESETS : EVENT_REPORT_PRESETS).map((preset) => (
<button <Button key={preset.name} variant="secondary" size="sm" onClick={() => applyPreset(preset)} className="!rounded-full !px-2.5 !py-1">
key={preset.name}
type="button"
onClick={() => applyPreset(preset)}
className="rounded-full border border-border px-2.5 py-1 text-xs font-semibold text-ink-body hover:bg-surface"
>
{preset.name} {preset.name}
</button> </Button>
))} ))}
</div> </div>
</div> </div>
@@ -455,11 +446,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p> <p className="mb-2 text-xs text-ink-muted">Alle Ereignisse im gewählten Zeitraum als Rohdaten (eine Zeile pro Ereignis).</p>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
<a href={fullExportHref("csv")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"> <a href={fullExportHref("csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
CSV CSV
</a> </a>
<a href={fullExportHref("xlsx")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"> <a href={fullExportHref("xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" /> <FileSpreadsheet className="h-4 w-4" />
Excel Excel
</a> </a>
@@ -472,12 +463,22 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<ul className="flex flex-col divide-y divide-border"> <ul className="flex flex-col divide-y divide-border">
{savedReports.map((r) => ( {savedReports.map((r) => (
<li key={r.id} className="flex items-center justify-between py-1.5 text-sm"> <li key={r.id} className="flex items-center justify-between py-1.5 text-sm">
<button type="button" onClick={() => applySavedReport(r.config)} className="text-left text-ink-body hover:text-brand-700 hover:underline"> <Button
variant="ghost"
size="sm"
onClick={() => applySavedReport(r.config)}
className="!px-0 !justify-start text-left hover:!bg-transparent hover:text-brand-700 hover:underline"
>
{r.name} {r.name}
</button> </Button>
<button type="button" onClick={() => handleDeleteReport(r.id)} aria-label="Löschen" className="text-ink-muted hover:text-danger-solid"> <Button
variant="icon"
onClick={() => handleDeleteReport(r.id)}
aria-label={`Bericht ${r.name} löschen`}
className="hover:!text-danger-solid"
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -492,22 +493,18 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
<p className="text-xs text-ink-muted">{recordCount} {mode === "snapshot" ? "Datensätze" : "Ereignisse"}</p> <p className="text-xs text-ink-muted">{recordCount} {mode === "snapshot" ? "Datensätze" : "Ereignisse"}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<a href={reportExportHref("csv")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"> <a href={reportExportHref("csv")} className={LINK_BUTTON_CLASS}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
CSV CSV
</a> </a>
<a href={reportExportHref("xlsx")} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"> <a href={reportExportHref("xlsx")} className={LINK_BUTTON_CLASS}>
<FileSpreadsheet className="h-4 w-4" /> <FileSpreadsheet className="h-4 w-4" />
Excel Excel
</a> </a>
<button <Button variant="secondary" size="sm" onClick={() => setSaveModalOpen(true)}>
type="button"
onClick={() => setSaveModalOpen(true)}
className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Bericht speichern Bericht speichern
</button> </Button>
</div> </div>
</div> </div>
@@ -583,29 +580,23 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
title="Bericht speichern" title="Bericht speichern"
footer={ footer={
<> <>
<button onClick={() => setSaveModalOpen(false)} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <Button variant="ghost" onClick={() => setSaveModalOpen(false)}>
Abbrechen Abbrechen
</button> </Button>
<button <Button onClick={handleConfirmSaveReport} pending={savingReport}>
onClick={handleConfirmSaveReport}
disabled={savingReport}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Speichern Speichern
</button> </Button>
</> </>
} }
> >
<div> <TextField
<label className="mb-1 block text-sm font-semibold text-ink">Name für diesen Bericht*</label> label="Name für diesen Bericht"
<input required
value={newReportName} value={newReportName}
onChange={(e) => setNewReportName(e.target.value)} onChange={setNewReportName}
onKeyDown={(e) => e.key === "Enter" && handleConfirmSaveReport()} onKeyDown={(e) => e.key === "Enter" && handleConfirmSaveReport()}
autoFocus autoFocus
className="w-full rounded border border-border px-3 py-2 text-sm"
/> />
</div>
</Modal> </Modal>
</div> </div>
); );

View File

@@ -2,18 +2,18 @@
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useHireWizard } from "@/components/hire/HireWizardContext"; import { useHireWizard } from "@/components/hire/HireWizardContext";
import { Button } from "@/components/ui/Button";
export function NewHireButton() { export function NewHireButton() {
const { openWizard } = useHireWizard(); const { openWizard } = useHireWizard();
return ( return (
<button <Button size="sm" onClick={() => openWizard()}>
type="button"
onClick={() => openWizard()}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Neueinstellung {/* The label is the first thing worth dropping on a narrow bar; the
</button> icon plus the accessible name carry it from there. */}
<span className="hidden sm:inline">Neueinstellung</span>
<span className="sr-only sm:hidden">Neueinstellung</span>
</Button>
); );
} }

74
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,74 @@
"use client";
import type { ButtonHTMLAttributes, ReactNode } from "react";
// The primary-button class chain was written out by hand at a dozen call
// sites and the ghost/secondary ones at many more, each drifting slightly in
// padding and hover colour. Collecting them here also gives every button a
// visible keyboard focus ring, which none of them had.
const BASE =
"inline-flex items-center justify-center gap-1.5 rounded font-semibold transition-colors " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 " +
"disabled:cursor-not-allowed disabled:opacity-50";
const VARIANTS = {
primary: "bg-brand-500 text-white hover:bg-brand-600",
secondary: "border border-border bg-white text-ink-body hover:bg-surface",
ghost: "text-ink-body hover:bg-surface",
danger: "bg-danger-solid text-white hover:brightness-110",
// Square icon-only button; pair with an aria-label.
icon: "text-ink-muted hover:bg-surface hover:text-ink",
} as const;
const SIZES = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
// Meets the 44px touch target without looking oversized on desktop.
icon: "p-2",
} as const;
/**
* For anchors that read as buttons (download links, cross-page actions). A
* real <a> keeps middle-click and "open in new tab" working, which a button
* with an onClick would throw away — so those stay anchors and borrow the
* styling instead.
*/
export const LINK_BUTTON_CLASS = `${BASE} ${VARIANTS.secondary} ${SIZES.sm}`;
type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "className"> & {
variant?: keyof typeof VARIANTS;
size?: keyof typeof SIZES;
/** Disables and shows the busy label; use for in-flight server actions. */
pending?: boolean;
pendingLabel?: string;
fullWidth?: boolean;
className?: string;
children?: ReactNode;
};
export function Button({
variant = "primary",
size,
pending = false,
pendingLabel,
fullWidth,
disabled,
className = "",
children,
type = "button",
...rest
}: ButtonProps) {
const resolvedSize = size ?? (variant === "icon" ? "icon" : "md");
return (
<button
{...rest}
type={type}
disabled={disabled || pending}
aria-busy={pending || undefined}
className={`${BASE} ${VARIANTS[variant]} ${SIZES[resolvedSize]} ${fullWidth ? "w-full" : ""} ${className}`}
>
{pending && pendingLabel ? pendingLabel : children}
</button>
);
}

View File

@@ -1,22 +1,43 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { CONTROL_CLASS } from "./Field";
type CountryPickerProps = { type CountryPickerProps = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
countries: string[]; countries: string[];
placeholder?: string; placeholder?: string;
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: true;
}; };
const MAX_VISIBLE = 50;
// Searchable text combobox constrained to a fixed list (UN member states) // Searchable text combobox constrained to a fixed list (UN member states)
// rather than a Lookup-style "selected card" — a country is a single text // rather than a Lookup-style "selected card" — a country is a single text
// value, not an object with its own detail fields. // value, not an object with its own detail fields.
export function CountryPicker({ value, onChange, countries, placeholder = "Land suchen…" }: CountryPickerProps) { //
// Carries the full combobox contract: without role/aria-expanded/
// aria-activedescendant a screen reader announces a plain text box and never
// mentions that a list appeared, and without the key handling the list is
// reachable by mouse only.
export function CountryPicker({
value,
onChange,
countries,
placeholder = "Land suchen…",
id,
"aria-describedby": describedBy,
"aria-invalid": invalid,
}: CountryPickerProps) {
const [query, setQuery] = useState(value); const [query, setQuery] = useState(value);
const [prevValue, setPrevValue] = useState(value); const [prevValue, setPrevValue] = useState(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Re-sync the local draft text when `value` changes externally (e.g. the // Re-sync the local draft text when `value` changes externally (e.g. the
// surrounding form loads a different employee). Adjusting state directly // surrounding form loads a different employee). Adjusting state directly
@@ -38,33 +59,96 @@ export function CountryPicker({ value, onChange, countries, placeholder = "Land
return () => document.removeEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside);
}, [value]); }, [value]);
const filtered = query.trim() ? countries.filter((c) => c.toLowerCase().includes(query.trim().toLowerCase())) : countries; const filtered = (query.trim() ? countries.filter((c) => c.toLowerCase().includes(query.trim().toLowerCase())) : countries).slice(
0,
MAX_VISIBLE
);
const listId = id ? `${id}-listbox` : undefined;
const activeId = id && filtered[activeIndex] ? `${id}-option-${activeIndex}` : undefined;
function commit(country: string) {
onChange(country);
setQuery(country);
setOpen(false);
}
// Keeps the highlighted row inside the scroll container as it moves.
useEffect(() => {
if (!open) return;
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
setActiveIndex(0);
return;
}
const delta = e.key === "ArrowDown" ? 1 : -1;
setActiveIndex((i) => (filtered.length === 0 ? 0 : (i + delta + filtered.length) % filtered.length));
} else if (e.key === "Enter") {
if (open && filtered[activeIndex]) {
e.preventDefault();
commit(filtered[activeIndex]);
}
} else if (e.key === "Escape") {
if (open) {
// Stop here rather than letting it bubble — otherwise the dialog
// containing this field closes along with the dropdown.
e.stopPropagation();
setOpen(false);
setQuery(value);
}
}
}
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<input <input
id={id}
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open ? activeId : undefined}
aria-autocomplete="list"
aria-describedby={describedBy}
aria-invalid={invalid}
autoComplete="off"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setActiveIndex(0);
setOpen(true); setOpen(true);
}} }}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder} placeholder={placeholder}
className="w-full rounded border border-border px-3 py-2 text-sm" className={CONTROL_CLASS}
/> />
{open && ( {open && (
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"> <div
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"
>
{filtered.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>} {filtered.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
{filtered.slice(0, 50).map((c) => ( {filtered.map((c, i) => (
<button <button
key={c} key={c}
id={id ? `${id}-option-${i}` : undefined}
role="option"
aria-selected={i === activeIndex}
data-active={i === activeIndex}
type="button" type="button"
onClick={() => { // Mouse down would blur the input and close the list before
onChange(c); // the click landed.
setQuery(c); onMouseDown={(e) => e.preventDefault()}
setOpen(false); onMouseEnter={() => setActiveIndex(i)}
}} onClick={() => commit(c)}
className="block w-full px-3 py-2 text-left text-sm hover:bg-surface" className={`block w-full px-3 py-2 text-left text-sm ${i === activeIndex ? "bg-brand-100 text-brand-700" : "hover:bg-surface"}`}
> >
{c} {c}
</button> </button>

182
components/ui/Field.tsx Normal file
View File

@@ -0,0 +1,182 @@
"use client";
import { useId, type ReactNode, type SelectHTMLAttributes, type InputHTMLAttributes, type TextareaHTMLAttributes } from "react";
// Form primitives.
//
// Before these existed the same class chain was written out by hand at 85
// call sites, each with a bare `<label>` next to a bare `<input>` and no
// connection between them — 92 labels, 4 of which used htmlFor, and not a
// single input carried an id. Screen readers announced an unnamed edit box
// and clicking a label focused nothing. Generating the id here makes that
// impossible to get wrong, and gives every field one place to fix focus
// styling, error display and sizing.
export const CONTROL_CLASS =
"w-full rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 " +
"disabled:cursor-not-allowed disabled:bg-surface disabled:text-ink-muted";
const INVALID_CLASS = "border-danger-solid";
/** Auto-width select for filter bars, where the label is an aria-label. */
export const FILTER_SELECT_CLASS =
"rounded border border-border bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500";
type FieldChildProps = {
id: string;
"aria-describedby": string | undefined;
"aria-invalid": true | undefined;
};
type FieldShellProps = {
label: string;
/** Marks the label and sets required on the control. */
required?: boolean;
/** Helper text below the control; announced with the field. */
hint?: string;
/** Replaces the hint when set and marks the control invalid. */
error?: string | null;
/** Smaller label, used inside dense side panels. */
dense?: boolean;
className?: string;
};
/**
* Escape hatch for controls this module does not wrap (Lookup, CountryPicker,
* Picklist). Hands the wiring to the caller instead of guessing at it:
*
* <Field label="Land">{(p) => <CountryPicker {...p} … />}</Field>
*/
export function Field({
label,
required,
hint,
error,
dense,
className,
children,
}: FieldShellProps & { children: (props: FieldChildProps) => ReactNode }) {
const id = useId();
const messageId = `${id}-message`;
const message = error ?? hint;
return (
<div className={className}>
<label htmlFor={id} className={dense ? "mb-1 block text-xs font-semibold text-ink-muted" : "mb-1 block text-sm font-semibold text-ink"}>
{label}
{required && <span aria-hidden> *</span>}
{required && <span className="sr-only"> (Pflichtfeld)</span>}
</label>
{children({
id,
"aria-describedby": message ? messageId : undefined,
"aria-invalid": error ? true : undefined,
})}
{message && (
<p id={messageId} className={`mt-1 text-xs ${error ? "font-semibold text-danger-text" : "text-ink-muted"}`}>
{message}
</p>
)}
</div>
);
}
type TextFieldProps = FieldShellProps &
Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "id" | "className"> & {
value: string;
/** Receives the value directly — every call site wanted e.target.value. */
onChange: (value: string) => void;
};
export function TextField({ label, required, hint, error, dense, className, value, onChange, ...rest }: TextFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<input
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
/>
)}
</Field>
);
}
type Option = { value: string; label: string; disabled?: boolean };
type SelectFieldProps = FieldShellProps &
Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "id" | "className" | "children"> & {
value: string;
onChange: (value: string) => void;
options: readonly Option[];
/** Prepends a disabled placeholder, for "Bitte wählen…" selects. */
placeholder?: string;
};
export function SelectField({
label,
required,
hint,
error,
dense,
className,
value,
onChange,
options,
placeholder,
...rest
}: SelectFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<select
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
</select>
)}
</Field>
);
}
type TextareaFieldProps = FieldShellProps &
Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "id" | "className"> & {
value: string;
onChange: (value: string) => void;
};
export function TextareaField({ label, required, hint, error, dense, className, value, onChange, ...rest }: TextareaFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<textarea
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
/>
)}
</Field>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
type LookupProps<T> = { type LookupProps<T> = {
placeholder?: string; placeholder?: string;
@@ -9,21 +9,42 @@ type LookupProps<T> = {
renderResult: (item: T) => ReactNode; renderResult: (item: T) => ReactNode;
onSelect: (item: T) => void; onSelect: (item: T) => void;
minChars?: number; minChars?: number;
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: true;
}; };
// Generic async search-select used by the position lookup (hire wizard), // Generic async search-select used by the position lookup (hire wizard),
// manager/superior lookup (create position), and employee multi-select // manager/superior lookup (create position), and the reorg workbench.
// (reorg workbench) in later phases. //
export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, onSelect, minChars = 2 }: LookupProps<T>) { // 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 [query, setQuery] = useState("");
const [results, setResults] = useState<T[]>([]); const [results, setResults] = useState<T[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
// Derived from "have we finished searching for the current query yet", // Derived from "have we finished searching for the current query yet",
// rather than a separate state flag flipped synchronously at the top of // rather than a separate state flag flipped synchronously at the top of
// the effect below — the effect only ever sets state from the async // the effect below — the effect only ever sets state from the async
// search's own completion callback now. // search's own completion callback now.
const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null); const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const tooShort = query.trim().length < minChars; const tooShort = query.trim().length < minChars;
const loading = !tooShort && lastSearchedQuery !== query.trim(); const loading = !tooShort && lastSearchedQuery !== query.trim();
@@ -35,6 +56,7 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
onSearch(query.trim()).then((res) => { onSearch(query.trim()).then((res) => {
if (cancelled) return; if (cancelled) return;
setResults(res); setResults(res);
setActiveIndex(0);
setOpen(true); setOpen(true);
setLastSearchedQuery(query.trim()); setLastSearchedQuery(query.trim());
}); });
@@ -50,6 +72,7 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
// needing a synchronous setState inside the effect above. // needing a synchronous setState inside the effect above.
const showDropdown = open && !tooShort; const showDropdown = open && !tooShort;
const visibleResults = tooShort ? [] : results; const visibleResults = tooShort ? [] : results;
const selectable = loading ? [] : visibleResults;
useEffect(() => { useEffect(() => {
function onClickOutside(e: MouseEvent) { function onClickOutside(e: MouseEvent) {
@@ -61,46 +84,100 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
return () => document.removeEventListener("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 ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2"> <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" /> <Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<input <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} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder} placeholder={placeholder}
className="w-full text-sm outline-none placeholder:text-ink-muted" className="w-full text-sm outline-none placeholder:text-ink-muted"
/> />
{query && ( {query && (
<button <button type="button" onClick={reset} aria-label="Zurücksetzen" className="rounded p-0.5 hover:bg-surface">
type="button"
onClick={() => {
setQuery("");
setResults([]);
setOpen(false);
}}
aria-label="Zurücksetzen"
>
<X className="h-4 w-4 text-ink-muted" /> <X className="h-4 w-4 text-ink-muted" />
</button> </button>
)} )}
</div> </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 && ( {showDropdown && (
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"> <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-lg"
>
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche</div>} {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.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
{!loading && {!loading &&
visibleResults.map((item, i) => ( visibleResults.map((item, i) => (
<button <button
key={i} key={i}
id={`${id}-option-${i}`}
role="option"
aria-selected={i === activeIndex}
data-active={i === activeIndex}
type="button" type="button"
onClick={() => { // Mouse down would blur the input and close the list before
onSelect(item); // the click landed.
setQuery(""); onMouseDown={(e) => e.preventDefault()}
setResults([]); onMouseEnter={() => setActiveIndex(i)}
setOpen(false); onClick={() => choose(item)}
}} className={`block w-full px-3 py-2 text-left text-sm ${i === activeIndex ? "bg-brand-100" : "hover:bg-surface"}`}
className="block w-full px-3 py-2 text-left text-sm hover:bg-surface"
> >
{renderResult(item)} {renderResult(item)}
</button> </button>

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, type ReactNode } from "react"; import { useId, useRef, type ReactNode } from "react";
import { Button } from "./Button";
import { useDialogFocus } from "./useDialogFocus";
type ModalProps = { type ModalProps = {
open: boolean; open: boolean;
@@ -13,14 +15,9 @@ type ModalProps = {
}; };
export function Modal({ open, onClose, title, children, footer, widthClassName = "max-w-lg" }: ModalProps) { export function Modal({ open, onClose, title, children, footer, widthClassName = "max-w-lg" }: ModalProps) {
useEffect(() => { const dialogRef = useRef<HTMLDivElement>(null);
if (!open) return; const titleId = useId();
const onKey = (e: KeyboardEvent) => { useDialogFocus(open, onClose, dialogRef);
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null; if (!open) return null;
@@ -29,16 +26,22 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
// a field is focused), centred from sm up. // a field is focused), centred from sm up.
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4"> <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4">
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} // Points at the real heading rather than duplicating the string, so
className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl sm:max-h-[85dvh] sm:rounded ${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}`}
> >
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4"> <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 className="text-lg font-bold text-ink">{title}</h2> <h2 id={titleId} className="text-lg font-bold text-ink">
<button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface"> {title}
</h2>
<Button variant="icon" onClick={onClose} aria-label="Schließen" className="-mr-1">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div> <div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && ( {footer && (

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { CONTROL_CLASS } from "./Field";
// Dropdown-to-add, chip-to-remove multi-select for short, fixed option // Dropdown-to-add, chip-to-remove multi-select for short, fixed option
// lists (no search needed) — e.g. academic titles. The dropdown only offers // lists (no search needed) — e.g. academic titles. The dropdown only offers
@@ -11,11 +12,16 @@ export function Picklist({
value, value,
onChange, onChange,
placeholder = "Hinzufügen…", placeholder = "Hinzufügen…",
// Forwarded onto the <select> so a surrounding <Field> can label it.
id,
"aria-describedby": describedBy,
}: { }: {
options: string[]; options: string[];
value: string[]; value: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
placeholder?: string; placeholder?: string;
id?: string;
"aria-describedby"?: string;
}) { }) {
const available = options.filter((o) => !value.includes(o)); const available = options.filter((o) => !value.includes(o));
@@ -27,12 +33,14 @@ export function Picklist({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<select <select
key={value.length} key={value.length}
id={id}
aria-describedby={describedBy}
defaultValue="" defaultValue=""
onChange={(e) => { onChange={(e) => {
if (e.target.value) onChange([...value, e.target.value]); if (e.target.value) onChange([...value, e.target.value]);
}} }}
disabled={available.length === 0} disabled={available.length === 0}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted" className={CONTROL_CLASS}
> >
<option value="" disabled> <option value="" disabled>
{available.length > 0 ? placeholder : "Alle Optionen ausgewählt"} {available.length > 0 ? placeholder : "Alle Optionen ausgewählt"}

View File

@@ -0,0 +1,45 @@
"use client";
import { Search } from "lucide-react";
import { useId } from "react";
// The icon-in-a-box search field used by the employee list, the audit log and
// the org chart. Each was a hand-rolled copy whose <input> carried only a
// placeholder — no label at all — and killed its own focus ring with
// outline-none. A placeholder is not a label: it disappears on the first
// keystroke and is not reliably announced.
export function SearchInput({
value,
onChange,
placeholder,
label,
className = "",
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
/** Visually hidden; this is what a screen reader announces. */
label: string;
className?: string;
}) {
const id = useId();
return (
<div
className={`flex min-w-[240px] flex-1 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 ${className}`}
>
<Search className="h-4 w-4 shrink-0 text-ink-muted" aria-hidden />
<label htmlFor={id} className="sr-only">
{label}
</label>
<input
id={id}
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
// The ring lives on the wrapper so it encloses the icon too.
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
);
}

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, type ReactNode } from "react"; import { useId, useRef, type ReactNode } from "react";
import { Button } from "./Button";
import { useDialogFocus } from "./useDialogFocus";
type SlideOverProps = { type SlideOverProps = {
open: boolean; open: boolean;
@@ -13,37 +15,41 @@ type SlideOverProps = {
}; };
export function SlideOver({ open, onClose, title, subtitle, children, footer }: SlideOverProps) { export function SlideOver({ open, onClose, title, subtitle, children, footer }: SlideOverProps) {
useEffect(() => { const dialogRef = useRef<HTMLDivElement>(null);
if (!open) return; const titleId = useId();
const onKey = (e: KeyboardEvent) => { useDialogFocus(open, onClose, dialogRef);
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return ( return (
<div className={`fixed inset-0 z-50 ${open ? "pointer-events-auto" : "pointer-events-none"}`} aria-hidden={!open}> // Stays mounted so the slide transition can run, which means a closed
// panel's fields are still in the tab order — aria-hidden does not remove
// them. `inert` does, and also blocks clicks, so several closed panels no
// longer pile up invisible tab stops at the end of the page.
<div className="fixed inset-0 z-50" inert={!open}>
<div <div
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`} className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
onClick={onClose} onClick={onClose}
aria-hidden
/> />
<div <div
ref={dialogRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-labelledby={titleId}
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${ 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 ${
open ? "translate-x-0" : "translate-x-full" open ? "translate-x-0" : "translate-x-full"
}`} }`}
> >
<div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4"> <div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-lg font-bold text-ink">{title}</h2> <h2 id={titleId} className="text-lg font-bold text-ink">
{title}
</h2>
{subtitle && <p className="truncate text-sm text-ink-muted">{subtitle}</p>} {subtitle && <p className="truncate text-sm text-ink-muted">{subtitle}</p>}
</div> </div>
<button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface"> <Button variant="icon" onClick={onClose} aria-label="Schließen" className="-mr-1">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div> <div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && ( {footer && (

View File

@@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
// Everything a dialog owes the keyboard, in one place so Modal and SlideOver
// cannot drift apart. Both previously handled only Escape: focus stayed on
// whatever was behind the overlay, Tab walked straight out of the dialog into
// the page underneath, and closing left focus on <body> — so the next Tab
// started again from the top of the document.
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
function focusableWithin(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null || el.getClientRects().length > 0
);
}
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
const restoreToRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
const container = containerRef.current;
restoreToRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
// First field rather than the close button: the point of opening these is
// to fill them in. autoFocus on a child wins, since it has already run.
if (container && !container.contains(document.activeElement)) {
const [first] = focusableWithin(container);
(first ?? container).focus();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab" || !container) return;
const focusable = focusableWithin(container);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
// Wrap around at both ends, and pull focus back in if it somehow
// escaped (a click on the backdrop, say).
if (!container.contains(active)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
// Back to whatever opened the dialog, so the next Tab continues from
// there instead of restarting at the top of the page.
restoreToRef.current?.focus();
};
}, [open, onClose, containerRef]);
}

View File

@@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Lookup } from "@/components/ui/Lookup";
type Person = { id: string; name: string };
const PEOPLE: Person[] = [
{ id: "1", name: "Katharina Aigner" },
{ id: "2", name: "Kurt Aigner" },
{ id: "3", name: "Simon Aigner" },
];
function renderLookup(onSelect = vi.fn()) {
const onSearch = vi.fn(async (q: string) => PEOPLE.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())));
render(<Lookup<Person> onSearch={onSearch} onSelect={onSelect} renderResult={(p) => <span>{p.name}</span>} placeholder="Suchen…" />);
return { onSelect, input: screen.getByRole("combobox") };
}
// The component was a text input with a div of clickable buttons under it:
// typeable, but no keyboard path to a result and nothing announcing that a
// list had appeared.
describe("Lookup keyboard operation", () => {
it("exposes combobox semantics", async () => {
const { input } = renderLookup();
expect(input).toHaveAttribute("aria-expanded", "false");
await userEvent.type(input, "Aigner");
expect(await screen.findByRole("listbox")).toBeInTheDocument();
expect(input).toHaveAttribute("aria-expanded", "true");
expect(await screen.findAllByRole("option")).toHaveLength(3);
});
it("moves the selection with the arrow keys", async () => {
const { input } = renderLookup();
await userEvent.type(input, "Aigner");
await screen.findByRole("listbox");
const options = screen.getAllByRole("option");
expect(options[0]).toHaveAttribute("aria-selected", "true");
await userEvent.keyboard("{ArrowDown}");
expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
// aria-activedescendant is what tells a screen reader which row is
// current while focus stays in the input.
expect(input).toHaveAttribute("aria-activedescendant", screen.getAllByRole("option")[1].id);
});
it("wraps around at the ends", async () => {
const { input } = renderLookup();
await userEvent.type(input, "Aigner");
await screen.findByRole("listbox");
await userEvent.keyboard("{ArrowUp}");
expect(screen.getAllByRole("option")[2]).toHaveAttribute("aria-selected", "true");
await userEvent.keyboard("{ArrowDown}");
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
});
it("selects the highlighted result with Enter", async () => {
const { input, onSelect } = renderLookup();
await userEvent.type(input, "Aigner");
await screen.findByRole("listbox");
await userEvent.keyboard("{ArrowDown}{Enter}");
expect(onSelect).toHaveBeenCalledWith(PEOPLE[1]);
// Clears itself afterwards, ready for the next search.
expect(input).toHaveValue("");
});
it("closes on Escape without selecting", async () => {
const { input, onSelect } = renderLookup();
await userEvent.type(input, "Aigner");
await screen.findByRole("listbox");
await userEvent.keyboard("{Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(onSelect).not.toHaveBeenCalled();
});
it("does not open below the minimum query length", async () => {
const { input } = renderLookup();
await userEvent.type(input, "A");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("still selects on click", async () => {
const { input, onSelect } = renderLookup();
await userEvent.type(input, "Kurt");
await screen.findByRole("listbox");
await userEvent.click(screen.getByRole("option", { name: "Kurt Aigner" }));
expect(onSelect).toHaveBeenCalledWith(PEOPLE[1]);
});
});

View File

@@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { SlideOver } from "@/components/ui/SlideOver";
function ModalHarness({ onClose = () => {} }: { onClose?: () => void }) {
const [first, setFirst] = useState("");
const [second, setSecond] = useState("");
return (
<Modal
open
onClose={onClose}
title="Testdialog"
footer={
<Button onClick={() => {}}>Speichern</Button>
}
>
<TextField label="Erstes Feld" value={first} onChange={setFirst} />
<TextField label="Zweites Feld" value={second} onChange={setSecond} />
</Modal>
);
}
describe("Modal focus management", () => {
it("names the dialog from its visible heading", () => {
render(<ModalHarness />);
expect(screen.getByRole("dialog", { name: "Testdialog" })).toBeInTheDocument();
});
it("moves focus into the dialog on open", () => {
render(<ModalHarness />);
// Not the page behind it: without this, the first Tab would start from
// the top of the document.
expect(document.body.contains(document.activeElement)).toBe(true);
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
});
it("keeps Tab inside the dialog and wraps at the end", async () => {
render(<ModalHarness />);
const dialog = screen.getByRole("dialog");
for (let i = 0; i < 8; i++) {
await userEvent.tab();
expect(dialog.contains(document.activeElement)).toBe(true);
}
});
it("wraps backwards on Shift+Tab", async () => {
render(<ModalHarness />);
const dialog = screen.getByRole("dialog");
for (let i = 0; i < 8; i++) {
await userEvent.tab({ shift: true });
expect(dialog.contains(document.activeElement)).toBe(true);
}
});
it("closes on Escape", async () => {
const onClose = vi.fn();
render(<ModalHarness onClose={onClose} />);
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
});
it("returns focus to the element that opened it", async () => {
function Toggle() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Öffnen</button>
<Modal open={open} onClose={() => setOpen(false)} title="Testdialog">
<p>Inhalt</p>
</Modal>
</>
);
}
render(<Toggle />);
const trigger = screen.getByRole("button", { name: "Öffnen" });
await userEvent.click(trigger);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
expect(trigger).toHaveFocus();
});
});
describe("SlideOver", () => {
it("marks a closed panel inert", () => {
// It stays mounted for the slide transition, so without `inert` its
// fields stay in the tab order behind the page — aria-hidden does not
// remove them.
//
// Asserted on the attribute rather than by tabbing: jsdom does not
// implement inert at all ("inert" in HTMLElement.prototype === false),
// so a focus-based assertion here would be testing jsdom, not this
// component. Browsers enforce it.
const { container } = render(
<SlideOver open={false} onClose={() => {}} title="Geschlossen">
<button>Im Panel</button>
</SlideOver>
);
expect(container.firstElementChild).toHaveAttribute("inert");
});
it("is not inert while open", () => {
const { container } = render(
<SlideOver open onClose={() => {}} title="Offen">
<button>Im Panel</button>
</SlideOver>
);
expect(container.firstElementChild).not.toHaveAttribute("inert");
});
it("traps focus once open", async () => {
render(
<>
<button>Dahinter</button>
<SlideOver open onClose={() => {}} title="Offen" footer={<Button>Speichern</Button>}>
<button>Im Panel</button>
</SlideOver>
</>
);
const dialog = screen.getByRole("dialog", { name: "Offen" });
for (let i = 0; i < 6; i++) {
await userEvent.tab();
expect(dialog.contains(document.activeElement)).toBe(true);
}
});
});

View File

@@ -33,6 +33,13 @@ if (!("DOMMatrixReadOnly" in globalThis)) {
vi.stubGlobal("DOMMatrixReadOnly", DOMMatrixReadOnlyStub); vi.stubGlobal("DOMMatrixReadOnly", DOMMatrixReadOnlyStub);
} }
// Not implemented by jsdom at all, and the comboboxes call it to keep the
// highlighted row in view — an unstubbed call throws inside the effect and
// takes the render down with it.
if (!("scrollIntoView" in Element.prototype)) {
Object.defineProperty(Element.prototype, "scrollIntoView", { configurable: true, value: () => {} });
}
// jsdom reports 0 for every layout box; React Flow reads these to size nodes. // jsdom reports 0 for every layout box; React Flow reads these to size nodes.
Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, value: NODE_WIDTH }); Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, value: NODE_WIDTH });
Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, value: NODE_HEIGHT }); Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, value: NODE_HEIGHT });