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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircl
import Link from "next/link";
import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format";
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")} />}
{isActive && (
<button
<Button
variant="secondary"
size="sm"
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
</button>
</Button>
)}
{employee.status === "Ausgetreten" && (
<button
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"
>
<Button size="sm" onClick={() => setPanel("rehire")}>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
</button>
</Button>
)}
</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) => (
<button
key={t}
role="tab"
aria-selected={tab === 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"
}`}
>
@@ -166,8 +170,8 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) {
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}
</button>
</Button>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { TextField } from "@/components/ui/Field";
import { formatSvnr, requiresAustrianSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
type SvNummerFieldProps = {
@@ -10,7 +11,7 @@ type SvNummerFieldProps = {
locationCountry: string | null | undefined;
/** ISO yyyy-mm-dd; enables the cross-check against the TTMMJJ tail. */
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
* 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 applies = requiresAustrianSvnr(locationCountry);
const error = applies && value.trim() !== "" ? validateSvnr(value, birthDate) : null;
const showError = touched && error !== null;
return (
<div>
<label htmlFor="sv-nummer" className={labelClassName ?? "mb-1 block text-sm font-semibold text-ink"}>
SV-Nummer
</label>
<input
id="sv-nummer"
value={value}
inputMode="numeric"
placeholder={applies ? "1237 010180" : undefined}
onChange={(e) => onChange(e.target.value)}
onBlur={() => {
setTouched(true);
// Normalise to the conventional "NNNN TTMMJJ" spacing once the
// value is complete; anything else is left exactly as typed.
const formatted = formatSvnr(value);
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>
<TextField
label="SV-Nummer"
dense={dense}
value={value}
onChange={onChange}
inputMode="numeric"
placeholder={applies ? "1237 010180" : undefined}
error={touched && error ? svnrErrorMessage(error) : null}
hint={applies ? "10 Ziffern: laufende Nummer, Prüfziffer, Geburtsdatum (TTMMJJ)." : undefined}
onBlur={() => {
setTouched(true);
// Normalise to the conventional "NNNN TTMMJJ" spacing once the value
// is complete; anything else is left exactly as typed.
const formatted = formatSvnr(value);
if (formatted !== value) onChange(formatted);
}}
/>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { Field } from "@/components/ui/Field";
import { Picklist } from "@/components/ui/Picklist";
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 }) {
return (
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (vorangestellt)</label>
<Picklist options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (nachgestellt)</label>
<Picklist options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />
</div>
<Field label="Titel (vorangestellt)" dense>
{(p) => <Picklist {...p} options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} />}
</Field>
<Field label="Titel (nachgestellt)" dense>
{(p) => <Picklist {...p} options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />}
</Field>
</div>
);
}

View File

@@ -7,7 +7,9 @@ import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { Button } from "@/components/ui/Button";
import { CountryPicker } from "@/components/ui/CountryPicker";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
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}`}
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
</button>
<button
</Button>
<Button
onClick={handleSubmit}
disabled={pending || !svNummerOk}
pending={pending}
disabled={!svNummerOk}
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
</button>
</Button>
</>
}
>
<div className="flex flex-col gap-6">
<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>
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label>
<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>
<TextField label="Vorname" dense value={firstName} onChange={setFirstName} />
<TextField label="Nachname" dense value={lastName} onChange={setLastName} />
</div>
<TitleFields value={titles} onChange={updateTitles} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label>
<select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="m">männlich</option>
<option value="w">weiblich</option>
</select>
</div>
<div>
<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"
/>
</div>
</div>
<SvNummerField
value={svNummer}
onChange={setSvNummer}
locationCountry={locationCountry}
birthDate={birthDate || null}
labelClassName="mb-1 block text-xs font-semibold text-ink-muted"
/>
<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" />
<SelectField
label="Geschlecht"
dense
value={gender}
onChange={(v) => setGender(v as GenderType)}
options={[
{ value: "m", label: "männlich" },
{ value: "w", label: "weiblich" },
]}
/>
<TextField label="Geburtsdatum" dense type="date" value={birthDate} onChange={setBirthDate} />
</div>
<SvNummerField value={svNummer} onChange={setSvNummer} locationCountry={locationCountry} birthDate={birthDate || null} dense />
<Field label="Staatsbürgerschaft" dense>
{(p) => (
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
)}
</Field>
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Postleitzahl</label>
<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" />
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
<TextField label="Ort" dense value={city} onChange={setCity} />
</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>
<h3 className="mb-3 text-sm font-bold text-ink">Vertrag</h3>
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Beschäftigungsausmaß</label>
<select
value={employmentType}
onChange={(e) => handleEmploymentTypeChange(e.target.value as EmploymentType)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Vollzeit">Vollzeit</option>
<option value="Teilzeit">Teilzeit</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Wochenstunden</label>
<input
type="number"
step="0.5"
value={weeklyHours}
disabled={employmentType === "Vollzeit"}
onChange={(e) => setWeeklyHours(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vertragsart</label>
<select
value={contractType}
onChange={(e) => setContractType(e.target.value as ContractType)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="unbefristet">unbefristet</option>
<option value="befristet">befristet</option>
</select>
</div>
<SelectField
label="Beschäftigungsausmaß"
dense
value={employmentType}
onChange={(v) => handleEmploymentTypeChange(v as EmploymentType)}
options={[
{ value: "Vollzeit", label: "Vollzeit" },
{ value: "Teilzeit", label: "Teilzeit" },
]}
/>
<TextField
label="Wochenstunden"
dense
type="number"
step="0.5"
value={weeklyHours}
disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours}
/>
<SelectField
label="Vertragsart"
dense
value={contractType}
onChange={(v) => setContractType(v as ContractType)}
options={[
{ value: "unbefristet", label: "unbefristet" },
{ value: "befristet", label: "befristet" },
]}
/>
{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>
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
)}
</div>
</div>

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
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 { SlideOver } from "@/components/ui/SlideOver";
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}`}
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
</button>
</Button>
{/* Karenz keeps the warning tone it uses everywhere else. */}
{!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
</button>
</Button>
)}
{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
</button>
</Button>
)}
{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
</button>
</Button>
)}
</>
}
>
{!isOnKarenz && (
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Karenzbeginn*</label>
<input type="date" value={karenzStart} onChange={(e) => setKarenzStart(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">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>
<TextField label="Karenzbeginn" required type="date" value={karenzStart} onChange={setKarenzStart} />
<TextField label="Geplante Rückkehr" required type="date" value={plannedReturn} onChange={setPlannedReturn} />
<TextareaField label="Anmerkung" rows={3} value={startNote} onChange={setStartNote} />
</div>
)}
@@ -160,57 +154,40 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
{mode === "adjust" && (
<>
<p className="text-sm text-ink-muted">Aktuelles Rückkehrdatum: {fmtDate(employee.karenz_return_date)}</p>
<div>
<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>
<TextField label="Neues Rückkehrdatum" required type="date" value={newReturnDate} onChange={setNewReturnDate} />
{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"}`}>
{diffDays > 0 ? `Verlängerung um ${diffDays} Tage` : `Verkürzung um ${Math.abs(diffDays)} Tage`}
</div>
)}
<div>
<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>
<TextareaField label="Grund/Anmerkung" rows={3} value={adjustNote} onChange={setAdjustNote} />
</>
)}
{mode === "return" && (
<>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Rückkehrdatum*</label>
<input type="date" value={returnDate} onChange={(e) => setReturnDate(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">Beschäftigungsausmaß</label>
<select
value={employmentMode}
onChange={(e) => setEmploymentMode(e.target.value as EmploymentMode)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="unverändert">unverändert</option>
<option value="Vollzeit">Vollzeit (38,5h)</option>
<option value="Teilzeit">Teilzeit-Elternteilzeit</option>
</select>
</div>
<TextField label="Rückkehrdatum" required type="date" value={returnDate} onChange={setReturnDate} />
<SelectField
label="Beschäftigungsausmaß"
value={employmentMode}
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
options={[
{ value: "unverändert", label: "unverändert" },
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
]}
/>
{employmentMode === "Teilzeit" && (
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden (unter 38,5)*</label>
<input
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={(e) => setWeeklyHours(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<TextField
label="Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
hint="Muss unter 38,5 liegen."
/>
)}
</>
)}

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
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 { useToast } from "@/components/ui/Toast";
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}`}
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
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-purple-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
</Button>
{/* Promotion keeps its own purple, matching the Beförderung badge
used in the history tab. */}
<Button onClick={handleSubmit} pending={pending} className="!bg-purple-text text-white hover:brightness-110">
Befördern
</button>
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<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>
<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}
onChange={(e) => setPaygrade(e.target.value as PaygradeType)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{PAYGRADES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<TextField label="Neue Position" required value={newTitle} onChange={setNewTitle} />
<SelectField
label="Paygrade"
value={paygrade}
onChange={(v) => setPaygrade(v as PaygradeType)}
options={PAYGRADES}
/>
</div>
</SlideOver>
);

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { rehireEmployee } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
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}`}
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
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
</Button>
<Button onClick={handleSubmit} pending={pending}>
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="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
</div>
<div>
<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>
<TextField label="Wiedereintritt am" required type="date" value={rehireDate} onChange={setRehireDate} />
</div>
</SlideOver>
);

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
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 { useToast } from "@/components/ui/Toast";
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}`}
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
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-danger-solid px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
</Button>
<Button variant="danger" onClick={handleSubmit} pending={pending}>
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.
</div>
)}
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Austrittsdatum*</label>
<input
type="date"
value={exitDate}
onChange={(e) => setExitDate(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">Beendigungsart</label>
<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>
<TextField label="Austrittsdatum" required type="date" value={exitDate} onChange={setExitDate} />
<SelectField
label="Beendigungsart"
value={reason}
onChange={setReason}
options={EXIT_REASONS.map((r) => ({ value: r, label: r }))}
/>
<TextareaField label="Anmerkung" rows={3} value={note} onChange={setNote} />
<fieldset>
<legend className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</legend>
<div className="flex flex-col gap-2">
{CHECKLIST_ITEMS.map((item, i) => (
<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>
))}
</div>
</div>
</fieldset>
</div>
</SlideOver>
);

View File

@@ -3,6 +3,8 @@
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
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 { useToast } from "@/components/ui/Toast";
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}`}
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
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
</Button>
<Button onClick={handleSubmit} pending={pending}>
Versetzen
</button>
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<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>
<label className="mb-1 block text-sm font-semibold text-ink">Neuer Bereich*</label>
<select
value={divisionId}
onChange={(e) => {
setDivisionId(e.target.value);
setTeamId("");
}}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{divisions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Neues Team*</label>
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
<option value="">Bitte wählen</option>
{teamsInDivision.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</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>
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<SelectField
label="Neuer Bereich"
required
value={divisionId}
onChange={(v) => {
setDivisionId(v);
setTeamId("");
}}
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
/>
<SelectField
label="Neues Team"
required
value={teamId}
onChange={setTeamId}
placeholder="Bitte wählen…"
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
/>
<TextField
label="Neuer Titel (optional)"
value={newTitle}
onChange={setNewTitle}
placeholder={employee.job_title}
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
/>
</div>
</SlideOver>
);

View File

@@ -3,6 +3,8 @@
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";
@@ -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">
<h3 className="text-sm font-bold text-ink">Neue Notiz erfassen</h3>
<div>
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
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>
<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">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Kategorie</label>
<select value={category} onChange={(e) => setCategory(e.target.value as NoteCategory)} className="w-full rounded border border-border px-3 py-2 text-sm">
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</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>
<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} 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
</button>
</Button>
</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>
{n.due_date && !n.done && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
{!n.done && (
<button
type="button"
<Button
variant="ghost"
size="sm"
onClick={() => handleComplete(n.id)}
disabled={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
pending={completingId === n.id}
className="mt-2 !px-0 text-success-text hover:!bg-transparent hover:underline"
>
Erledigt
</button>
</Button>
)}
</li>
))}

View File

@@ -1,6 +1,7 @@
import { Network } from "lucide-react";
import Link from "next/link";
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 };
@@ -22,10 +23,7 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
{/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on
arrival instead of the user hunting for them. */}
<Link
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"
>
<Link href={`/orgchart?focus=${employeeId}`} className={LINK_BUTTON_CLASS}>
<Network className="h-4 w-4" />
Im Organigramm anzeigen
</Link>