Files
alpenwerk-hr/components/employees/tabs/NotizenTab.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

129 lines
5.0 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 { useRouter } from "next/navigation";
import { useState } from "react";
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 { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { Database, NoteCategory } from "@/lib/supabase/types";
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
const CATEGORIES: NoteCategory[] = ["Allgemein", "Vertraulich", "Personalgespräch", "Wiedervorlage", "Lob / Anerkennung"];
export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: Note[] }) {
const { showToast } = useToast();
const router = useRouter();
const [category, setCategory] = useState<NoteCategory>("Allgemein");
const [noteText, setNoteText] = useState("");
const [dueDate, setDueDate] = useState("");
const [pending, setPending] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
async function handleSubmit() {
if (!noteText.trim()) {
showToast("Bitte einen Notiztext eingeben.", "error");
return;
}
setPending(true);
const result = await addEmployeeNote({
employee_id: employeeId,
category,
note_text: noteText.trim(),
due_date: dueDate || undefined,
});
setPending(false);
if (result.success) {
showToast("Notiz hinzugefügt.");
setNoteText("");
setDueDate("");
setCategory("Allgemein");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
async function handleComplete(noteId: string) {
setCompletingId(noteId);
const result = await completeEmployeeNote({ note_id: noteId, employee_id: employeeId });
setCompletingId(null);
if (result.success) {
showToast("Notiz erledigt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-start gap-2 rounded bg-info-bg px-4 py-3 text-sm text-info-text">
🔒 Interne HR-Notizen nur für die Personalabteilung sichtbar. Jede Notiz wird mit Verfasser:in und Datum protokolliert.
</div>
<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>
<TextareaField
label="Notiztext"
rows={3}
value={noteText}
onChange={setNoteText}
placeholder="Notiz zum/zur Mitarbeiter:in … (z. B. Gesprächsinhalt, Vereinbarung, Beobachtung)"
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SelectField
label="Kategorie"
value={category}
onChange={(v) => setCategory(v as NoteCategory)}
options={CATEGORIES.map((c) => ({ value: c, label: c }))}
/>
<TextField label="Wiedervorlage am (optional)" type="date" value={dueDate} onChange={setDueDate} />
</div>
<div className="flex justify-end">
<Button onClick={handleSubmit} pending={pending}>
Notiz speichern
</Button>
</div>
</div>
{notes.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Notizen vorhanden.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{notes.map((n) => (
<li key={n.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
<span className="text-sm font-semibold text-ink">{n.author_name}</span>
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
{n.done ? (
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
) : (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
)}
</div>
<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.done && (
<Button
variant="ghost"
size="sm"
onClick={() => handleComplete(n.id)}
pending={completingId === n.id}
className="mt-2 !px-0 text-success-text hover:!bg-transparent hover:underline"
>
Erledigt
</Button>
)}
</li>
))}
</ul>
)}
</div>
);
}