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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user