Files
alpenwerk-hr/components/employees/EmployeeDetail.tsx
Maximilian Stubhan 82d07f0d95
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s
Put the onboarding checklist where the file is
The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:40:02 +02:00

214 lines
9.8 KiB
TypeScript

"use client";
import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircle } from "lucide-react";
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 { fortschritt, type AufgabenStand } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel";
import { PromotePanel } from "./panels/PromotePanel";
import { RehirePanel } from "./panels/RehirePanel";
import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OnboardingTab } from "./tabs/OnboardingTab";
import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
/** Die Planstelle, die die Person heute innehat. */
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
type EmployeeDetailProps = {
employee: EmployeeRow;
placement: PlacementInfo | null;
breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
onboarding: AufgabenStand[];
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
directReports: MiniEmployee[];
history: HistoryRow[];
dependents: Dependent[];
notes: NoteRow[];
locations: Location[];
openPositions: OpenPositionResolved[];
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, kostenstelle, onboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
const location = locations.find((l) => l.id === employee.location_id);
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
// erfahren, dass nichts zu tun ist.
const offeneAufgaben = onboarding.length > 0 ? fortschritt(onboarding).gesamt - fortschritt(onboarding).erledigt : 0;
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten";
return (
<div className="flex flex-col gap-6">
<Link href="/employees" className="flex w-fit items-center gap-1.5 text-sm font-semibold text-ink-muted hover:text-ink">
<ArrowLeft className="h-4 w-4" /> Zurück zu Mitarbeiter:innen
</Link>
<div className="rounded border border-border bg-white p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-4">
<Avatar firstName={employee.first_name} lastName={employee.last_name} size="lg" />
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-extrabold text-ink">
{fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
</h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
</div>
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
<p className="text-xs text-ink-muted">
{breadcrumb}
{placement && (
<>
{" · Planstelle "}
{placement.positionNumber}
{placement.isChief && " (Leitung)"}
</>
)}
</p>
<p className="mt-1 text-xs text-ink-muted">
Pers.-Nr. {employee.personnel_number}
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
</p>
</div>
</div>
<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" ? "Abwesenheit verwalten" : "Langzeitabwesenheit"}
onClick={() => setPanel("karenz")}
/>
</>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{/* Auch bei einem geplanten Eintritt, und dort heisst es anders:
wer nie angetreten ist, „tritt" nicht „aus". Die Person hat
noch keinen Tag gearbeitet, und genau dafür gibt es den Grund
„No Show" — ohne diesen Knopf bliebe sie auf Dauer als
geplanter Eintritt stehen. */}
{(isActive || employee.status === "Geplant") && (
<Button
variant="secondary"
size="sm"
onClick={() => setPanel("terminate")}
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
>
<XCircle className="h-4 w-4" />
{employee.status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{employee.status === "Ausgetreten" && (
<Button size="sm" onClick={() => setPanel("rehire")}>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
</Button>
)}
</div>
</div>
</div>
{/* 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={`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"
}`}
>
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t === "Onboarding" && offeneAufgaben > 0 ? `Onboarding ${offeneAufgaben}` : t}
</button>
))}
</div>
<div className="rounded border border-border bg-white p-6">
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
{tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && (
<OrganisationTab
employeeId={employee.id}
manager={manager}
formalManager={formalManager}
directReports={directReports}
breadcrumb={breadcrumb}
kostenstelle={kostenstelle}
/>
)}
{tab === "Onboarding" && (
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
)}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
<TransferPanel
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<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}
dependents={dependents}
locationCountry={location?.country}
/>
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel
open={panel === "rehire"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
</div>
);
}
function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) {
return (
<Button variant="secondary" size="sm" onClick={onClick}>
<Icon className="h-4 w-4" /> {label}
</Button>
);
}