Files
alpenwerk-hr/components/orgchart/ReorgWorkbench.tsx
Maximilian Stubhan d9367a8ce4 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.
2026-07-25 13:11:09 +02:00

405 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { RotateCcw, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
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 { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
type ChangeKind = "emp" | "team" | "abt" | "dept";
type PendingMove = {
id: string;
kind: ChangeKind;
label: string;
employeeIds: string[];
targetTeamId: string;
targetTeamLabel: string;
};
const KIND_LABELS: Record<ChangeKind, string> = {
emp: "Mitarbeiter:in(nen)",
team: "Ganzes Team",
abt: "Ganze Abteilung",
dept: "Ganzer Bereich",
};
type ReorgWorkbenchProps = {
employees: OrgEmployee[];
divisions: OrgDivision[];
departments: OrgDepartment[];
teams: OrgTeam[];
reorgScenarios: ReorgScenarioSummary[];
};
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
const { showToast } = useToast();
const router = useRouter();
const [name, setName] = useState("");
const [effectiveDate, setEffectiveDate] = useState("");
const [changeType, setChangeType] = useState<ChangeKind>("emp");
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
const [sourceTeamId, setSourceTeamId] = useState("");
const [sourceDeptId, setSourceDeptId] = useState("");
const [sourceDivisionId, setSourceDivisionId] = useState("");
const [targetDivisionId, setTargetDivisionId] = useState("");
const [targetTeamId, setTargetTeamId] = useState("");
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
const [applying, setApplying] = useState(false);
const [undoingId, setUndoingId] = useState<string | null>(null);
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
const teamDivisionId = useMemo(() => {
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
const m = new Map<string, string>();
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
return m;
}, [teams, departments]);
const teamsInTargetDivision = useMemo(
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
[teams, teamDivisionId, targetDivisionId]
);
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
return employees
.filter(
(e) =>
!selectedEmployees.some((s) => s.id === e.id) &&
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
)
.slice(0, 20);
}
function resolveEmployeeIds(): string[] {
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
if (changeType === "abt") {
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
}
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
}
function sourceLabel(): string {
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
}
function handleAddMove() {
const employeeIds = resolveEmployeeIds();
if (employeeIds.length === 0) {
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
return;
}
if (!targetTeamId) {
showToast("Bitte ein Ziel-Team wählen.", "error");
return;
}
const targetTeam = teamById.get(targetTeamId);
setPendingMoves((prev) => [
...prev,
{
id: crypto.randomUUID(),
kind: changeType,
label: sourceLabel(),
employeeIds,
targetTeamId,
targetTeamLabel: targetTeam?.name ?? "",
},
]);
setSelectedEmployees([]);
setSourceTeamId("");
setSourceDeptId("");
setSourceDivisionId("");
}
function removeMove(id: string) {
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
}
const divisionBefore = useMemo(() => {
const m = new Map<string, number>();
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
return m;
}, [employees]);
const divisionDelta = useMemo(() => {
const m = new Map<string, number>();
const employeeById = new Map(employees.map((e) => [e.id, e]));
for (const move of pendingMoves) {
const targetDivId = teamDivisionId.get(move.targetTeamId);
for (const empId of move.employeeIds) {
const emp = employeeById.get(empId);
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
}
}
return m;
}, [pendingMoves, employees, teamDivisionId]);
async function handleApply() {
if (!name || !effectiveDate || pendingMoves.length === 0) {
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
return;
}
setApplying(true);
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
kind: m.kind,
label: m.label,
employee_ids: m.employeeIds,
target_team_id: m.targetTeamId,
}));
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
setApplying(false);
if (result.success) {
showToast("Reorganisation durchgeführt.");
setPendingMoves([]);
setName("");
setEffectiveDate("");
router.refresh();
} else {
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
}
}
async function handleUndo(scenarioId: string) {
setUndoingId(scenarioId);
const result = await undoReorg({ scenario_id: scenarioId });
setUndoingId(null);
if (result.success) {
showToast("Reorganisation rückgängig gemacht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
}
}
return (
<div className="flex flex-col gap-4">
<div className="rounded border border-border bg-white p-4">
<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">
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
</div>
<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) => (
<button
key={kind}
type="button"
aria-pressed={changeType === kind}
onClick={() => setChangeType(kind)}
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"
}`}
>
{KIND_LABELS[kind]}
</button>
))}
</div>
</fieldset>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
{changeType === "emp" && (
<div className="flex flex-col gap-2">
<Field label="Quelle: Mitarbeiter:innen">
{(p) => (
<Lookup<OrgEmployee>
{...p}
placeholder="Mitarbeiter:in suchen…"
onSearch={searchLocalEmployees}
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
renderResult={(e) => (
<div>
<div className="font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="text-xs text-ink-muted">{e.job_title}</div>
</div>
)}
/>
)}
</Field>
{selectedEmployees.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{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">
{e.first_name} {e.last_name}
<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" />
</button>
</span>
))}
</div>
)}
</div>
)}
{changeType === "team" && (
<SelectField
label="Quelle: Team"
value={sourceTeamId}
onChange={setSourceTeamId}
placeholder="Team wählen…"
options={teams.map((t) => ({ value: t.id, label: t.name }))}
/>
)}
{changeType === "abt" && (
<SelectField
label="Quelle: Abteilung"
value={sourceDeptId}
onChange={setSourceDeptId}
placeholder="Abteilung wählen…"
options={departments.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
{changeType === "dept" && (
<SelectField
label="Quelle: Bereich"
value={sourceDivisionId}
onChange={setSourceDivisionId}
placeholder="Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
)}
</div>
<div className="flex flex-col gap-2">
<SelectField
label="Ziel-Bereich"
required
value={targetDivisionId}
onChange={(v) => {
setTargetDivisionId(v);
setTargetTeamId("");
}}
placeholder="Ziel-Bereich wählen…"
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Ziel-Team"
required
value={targetTeamId}
onChange={setTargetTeamId}
placeholder="Ziel-Team wählen…"
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
</div>
</div>
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
+ Zur Reorganisation hinzufügen
</Button>
</div>
{pendingMoves.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
<ul className="mb-4 flex flex-col divide-y divide-border">
{pendingMoves.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
<span className="text-ink">
{m.label} {m.targetTeamLabel}
</span>
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
</div>
<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" />
</Button>
</li>
))}
</ul>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-ink-muted">
<th className="py-1 pr-3">Bereich</th>
<th className="py-1 pr-3">Vorher</th>
<th className="py-1 pr-3">Nachher</th>
<th className="py-1 pr-3">Δ</th>
</tr>
</thead>
<tbody>
{Array.from(divisionDelta.entries())
.filter(([, delta]) => delta !== 0)
.map(([divId, delta]) => {
const before = divisionBefore.get(divId) ?? 0;
return (
<tr key={divId} className="border-t border-border">
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
{delta > 0 ? `+${delta}` : delta}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-4 flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setPendingMoves([])}>
Verwerfen
</Button>
<Button onClick={handleApply} pending={applying}>
Reorganisation durchführen
</Button>
</div>
</div>
)}
{reorgScenarios.length > 0 && (
<div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink"> Durchgeführte Reorganisationen rückgängig machbar</h2>
<ul className="flex flex-col divide-y divide-border">
{reorgScenarios.map((s) => (
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-semibold text-ink">{s.name}</span>
<span className="ml-2 text-xs text-ink-muted">
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
</span>
</div>
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
<RotateCcw className="h-3.5 w-3.5" />
Rückgängig machen
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}