Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
This commit is contained in:
@@ -17,7 +17,7 @@ import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
@@ -36,14 +36,13 @@ type EmployeeDetailProps = {
|
||||
teams: Team[];
|
||||
locations: Location[];
|
||||
openPositions: OpenPosition[];
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag & Gehalt", "Organisation", "Historie"] as const;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const;
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, manager, directReports, history, divisions, departments, teams, locations, canEdit } = props;
|
||||
const { employee, manager, directReports, history, divisions, departments, teams, locations } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
@@ -77,32 +76,30 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
|
||||
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
|
||||
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,29 +119,25 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
|
||||
<div className="rounded border border-border bg-white p-6">
|
||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
|
||||
{tab === "Vertrag & Gehalt" && <VertragTab employee={employee} />}
|
||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<TransferPanel
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</>
|
||||
)}
|
||||
<TransferPanel
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { UN_COUNTRIES } from "@/lib/countries";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Mode = "adjust" | "return";
|
||||
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg" },
|
||||
@@ -23,12 +23,11 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [newTitle, setNewTitle] = useState(employee.job_title);
|
||||
const [newSalary, setNewSalary] = useState(String(employee.monthly_salary_gross ?? ""));
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !newTitle || !newSalary) {
|
||||
if (!effectiveDate || !newTitle) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +36,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_title: newTitle,
|
||||
new_salary: Number(newSalary),
|
||||
new_paygrade: paygrade,
|
||||
});
|
||||
setPending(false);
|
||||
@@ -85,15 +83,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
<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">Neues Bruttogehalt (14x/Jahr)*</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSalary}
|
||||
onChange={(e) => setNewSalary(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
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
|
||||
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fmtDate, fmtEUR } from "@/lib/format";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
@@ -21,7 +21,6 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Bruttogehalt (14x/Jahr)", fmtEUR(employee.monthly_salary_gross)],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
@@ -27,24 +27,18 @@ type HireWizardProps = {
|
||||
export function HireWizard({ open, onClose, openPositions, locations, resumeDraft, initialPositionId }: HireWizardProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [draft, setDraft] = useState<HireDraftData>(EMPTY_HIRE_DRAFT);
|
||||
const [draftId, setDraftId] = useState<string | undefined>(undefined);
|
||||
// The parent remounts this component (via a changing `key`) each time it's
|
||||
// freshly opened, so these initializers — reading resumeDraft/
|
||||
// initialPositionId once at mount — are the reset, no effect needed.
|
||||
const [step, setStep] = useState(() => resumeDraft?.step ?? 0);
|
||||
const [draft, setDraft] = useState<HireDraftData>(() =>
|
||||
resumeDraft
|
||||
? { ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) }
|
||||
: { ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" }
|
||||
);
|
||||
const [draftId] = useState<string | undefined>(() => resumeDraft?.id);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (resumeDraft) {
|
||||
setDraft({ ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) });
|
||||
setStep(resumeDraft.step);
|
||||
setDraftId(resumeDraft.id);
|
||||
} else {
|
||||
setDraft({ ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" });
|
||||
setStep(0);
|
||||
setDraftId(undefined);
|
||||
}
|
||||
}, [open, resumeDraft, initialPositionId]);
|
||||
|
||||
const selectedPosition = useMemo(
|
||||
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
|
||||
[openPositions, draft.positionId]
|
||||
@@ -57,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.salary),
|
||||
Boolean(draft.entryDate),
|
||||
true,
|
||||
][step];
|
||||
|
||||
@@ -89,7 +83,6 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
contract_end_date: draft.contractType === "befristet" ? draft.contractEndDate : undefined,
|
||||
employment_type: draft.employmentType,
|
||||
weekly_hours: Number(draft.weeklyHours),
|
||||
monthly_salary_gross: Number(draft.salary),
|
||||
paygrade: draft.paygrade,
|
||||
source: draft.besetzung,
|
||||
});
|
||||
|
||||
@@ -29,17 +29,24 @@ export function HireWizardProvider({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
|
||||
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||
// internal draft/step state is (re-)initialized directly from the current
|
||||
// resumeDraft/initialPositionId props at mount time — no reset-on-open
|
||||
// effect needed inside HireWizard itself.
|
||||
const [openKey, setOpenKey] = useState(0);
|
||||
|
||||
function openWizard(options?: OpenWizardOptions) {
|
||||
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||
setInitialPositionId(options?.positionId);
|
||||
setOpen(true);
|
||||
setOpenKey((k) => k + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<HireWizardContext.Provider value={{ openWizard }}>
|
||||
{children}
|
||||
<HireWizard
|
||||
key={openKey}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
openPositions={openPositions}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fmtDate, fmtEUR } from "@/lib/format";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -34,7 +34,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Bruttogehalt (14x/Jahr)", fmtEUR(Number(draft.salary))],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
];
|
||||
|
||||
|
||||
@@ -72,10 +72,6 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Bruttogehalt/Monat (14x)*</label>
|
||||
<input type="number" value={draft.salary} onChange={(e) => update({ salary: 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
|
||||
|
||||
@@ -19,7 +19,6 @@ export type HireDraftData = {
|
||||
contractEndDate: string;
|
||||
employmentType: EmploymentType;
|
||||
weeklyHours: string;
|
||||
salary: string;
|
||||
paygrade: PaygradeType;
|
||||
};
|
||||
|
||||
@@ -39,6 +38,5 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
contractEndDate: "",
|
||||
employmentType: "Vollzeit",
|
||||
weeklyHours: "38.5",
|
||||
salary: "",
|
||||
paygrade: "B",
|
||||
};
|
||||
|
||||
@@ -39,7 +39,6 @@ type ReportsPageClientProps = {
|
||||
function formatValue(measure: Measure, value: number): string {
|
||||
if (["headcount", "hires", "exits"].includes(measure)) return String(Math.round(value));
|
||||
if (measure === "fte") return value.toFixed(1);
|
||||
if (measure === "avg_salary") return new Intl.NumberFormat("de-AT", { style: "currency", currency: "EUR" }).format(value);
|
||||
if (measure === "parttime_rate" || measure === "female_share") return `${value.toFixed(1)}%`;
|
||||
if (measure === "avg_age" || measure === "avg_tenure") return `${value.toFixed(1)} Jahre`;
|
||||
return value.toFixed(1);
|
||||
|
||||
@@ -22,21 +22,18 @@ function titleFor(pathname: string): string {
|
||||
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
role?: string;
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, role, canEdit }: TopbarProps) {
|
||||
export function Topbar({ userLabel }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
|
||||
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
{canEdit && <NewHireButton />}
|
||||
<NewHireButton />
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
|
||||
<span className="font-semibold text-ink">{userLabel}</span>
|
||||
{role && <span className="text-xs text-ink-muted">({role === "hr_admin" ? "HR-Admin" : "Manager"})</span>}
|
||||
<form action={logout}>
|
||||
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface">
|
||||
<LogOut className="h-4 w-4" />
|
||||
|
||||
@@ -14,10 +14,18 @@ type CountryPickerProps = {
|
||||
// value, not an object with its own detail fields.
|
||||
export function CountryPicker({ value, onChange, countries, placeholder = "Land suchen…" }: CountryPickerProps) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => setQuery(value), [value]);
|
||||
// Re-sync the local draft text when `value` changes externally (e.g. the
|
||||
// surrounding form loads a different employee). Adjusting state directly
|
||||
// during render — rather than in an effect — avoids an extra render pass
|
||||
// and the synchronous setState-in-effect this used to do.
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value);
|
||||
setQuery(value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
|
||||
@@ -18,30 +18,38 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<T[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Derived from "have we finished searching for the current query yet",
|
||||
// rather than a separate state flag flipped synchronously at the top of
|
||||
// the effect below — the effect only ever sets state from the async
|
||||
// search's own completion callback now.
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tooShort = query.trim().length < minChars;
|
||||
const loading = !tooShort && lastSearchedQuery !== query.trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length < minChars) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (tooShort) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onSearch(query.trim()).then((res) => {
|
||||
if (cancelled) return;
|
||||
setResults(res);
|
||||
setOpen(true);
|
||||
setLoading(false);
|
||||
setLastSearchedQuery(query.trim());
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, minChars, onSearch]);
|
||||
}, [query, minChars, onSearch, tooShort]);
|
||||
|
||||
// Derived rather than reset via effect: once the query drops below
|
||||
// minChars, hide the dropdown and any stale results immediately without
|
||||
// needing a synchronous setState inside the effect above.
|
||||
const showDropdown = open && !tooShort;
|
||||
const visibleResults = tooShort ? [] : results;
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
@@ -77,12 +85,12 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
{showDropdown && (
|
||||
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg">
|
||||
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche…</div>}
|
||||
{!loading && results.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||
{!loading && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||
{!loading &&
|
||||
results.map((item, i) => (
|
||||
visibleResults.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user