Files
alpenwerk-hr/components/employees/EmployeeDetail.tsx
Andrei Laas be9ca4758f
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Wiedereintritt wieder moeglich, und die Panels lesen die Akte neu
Zwei Befunde aus dem Test vom 29.09., beide Klasse A.

K.01 -- rehire_employee scheitert bei jedem Aufruf: der case-Ausdruck fuer den
Status liefert text, die Spalte ist ein Aufzaehlungstyp. Genau das wurde am
10.08. schon einmal behoben. Vier spaetere Migrationen haben die Funktion neu
erzeugt und den Zusatz nicht mitgenommen -- zwei davon aus einer aelteren
Datei, zwei aus der laufenden Definition. Daraus die Lehre, die vorher nicht
dastand: aus dem laufenden Stand zu erzeugen schuetzt davor, Verhalten zu
verlieren, nicht davor, einen bereits vorhandenen Fehler mitzunehmen. Die
Selbstpruefung benennt deshalb jetzt das Erwartete und nicht nur das Neue.

H.05 -- "Daten aendern" schrieb veraltete Werte zurueck. Die Panels bleiben
eingebunden, damit ihr Ein- und Ausfahren laufen kann, belegen ihre Felder aber
mit useState(employee.…) vor -- und das laeuft nur beim ersten Aufbau. Nach
einer Befoerderung brachte router.refresh() die frische Akte herein, der
Zustand im Panel blieb der von vorhin, und beim naechsten Speichern ging er als
Ganzes an change_employee_data: der eben gesetzte Hay-Grade stand wieder auf
dem alten Wert. Jedes Panel bekommt jetzt einen key aus employees.updated_at,
der sich bei jeder Aenderung an der Zeile bewegt und sonst nie. Der
Wiedereintritts-Assistent daneben macht es seit jeher so.
2026-09-29 18:42:30 +02:00

297 lines
14 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 type { AufgabenStand } from "@/lib/checklist";
import { fmtFullName, tenure } from "@/lib/format";
import { fortschritt as fortschrittOffboarding, gehoertOffboarding } from "@/lib/offboarding";
import { fortschritt as fortschrittOnboarding } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions";
import { deriveStatusAsOf } from "@/lib/reports";
import type { Database } from "@/lib/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel";
import { PromotePanel } from "./panels/PromotePanel";
import { RehireWizard } from "../hire/RehireWizard";
import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OffboardingTab } from "./tabs/OffboardingTab";
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[];
/** Der Stand der Offboarding-Checkliste; leer heisst: es gibt keine. */
offboarding: 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[];
/** Der Tag, zu dem die Akte gelesen wurde — derselbe wie in loadEmployeeDetail. */
today: string;
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const ALLE_TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Offboarding", "Historie", "HR-Notizen"] as const;
type Tab = (typeof ALLE_TABS)[number];
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions, today } = props;
const [tab, setTab] = useState<Tab>("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 offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0;
const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0;
const zeigtOffboarding = gehoertOffboarding(employee);
const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding);
// Abgeleitet, nicht aus `employee.status` gelesen. Die Spalte hängt nach,
// wenn ein Austritt mit einem damals künftigen Datum erfasst wurde — es gibt
// keinen Lauf, der sie später nachzieht (Migration 20260814100000). Hier
// entschied sie nicht nur über eine Beschriftung, sondern über die Knöpfe:
// an einer Person, die seit zwei Wochen ausgetreten ist, stand „Austritt"
// weiter zur Verfügung, und „Daten ändern" ebenfalls.
const status = deriveStatusAsOf(employee, today);
const isActive = status === "Aktiv" || status === "Karenz";
const canEditData = 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 employee={employee} asOf={today} />
</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}
{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={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 || 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" />
{status === "Geplant" ? "Nicht angetreten" : "Austritt"}
</Button>
)}
{/* Der Knopf hing an `employee.status` — der Spalte, die
nachhängt. An einer Person, deren Austritt erfasst und
inzwischen vollzogen war, stand dort weiter „Aktiv", und der
Wiedereintritt war schlicht nicht erreichbar. Der Kunde hat
ihn deshalb für verschwunden gehalten („ich dachte, das ist
schon implementiert, das war mal drin").
*
Beim Statusfix waren nur `isActive` und `canEditData`
umgestellt worden — die fünf einzelnen Abfragen in dieser
Datei blieben stehen. Jetzt liest keine mehr die Spalte. */}
{status === "Ausgetreten" && (
<Button size="sm" onClick={() => setPanel("rehire")}>
<RotateCcw className="h-4 w-4" /> Wiedereintritt
</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" && offeneOnboarding > 0
? `Onboarding ${offeneOnboarding}`
: t === "Offboarding" && offeneOffboarding > 0
? `Offboarding ${offeneOffboarding}`
: 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 === "Offboarding" && zeigtOffboarding && (
<OffboardingTab employeeId={employee.id} staende={offboarding} vorhanden={offboarding.length > 0} />
)}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>
{/* Der `key` an jedem Panel ist kein Feinschliff, sondern der Unterschied
zwischen „zeigt Altes" und „schreibt Altes zurück".
Die Panels bleiben eingebunden, damit ihr Ein- und Ausfahren laufen
kann. Sie belegen ihre Felder aber mit useState(employee.…) vor, und
das läuft nur beim ersten Aufbau. Nach einer Beförderung bringt
router.refresh() zwar die frische Akte herein — der Zustand im Panel
bleibt der von vorhin. Beim nächsten Speichern geht er als Ganzes an
change_employee_data, und der eben gesetzte Hay-Grade steht wieder
auf dem alten Wert. Gemeldet im Test vom 29.09. (H.05).
employees.updated_at wechselt bei jeder Änderung an der Zeile
(Trigger trg_employees_touch_updated_at), also genau dann, wenn die
Vorbelegung neu zu lesen ist — und sonst nie. Der Wiedereintritts-
Assistent darunter macht es seit jeher so. */}
<TransferPanel
key={`transfer-${employee.updated_at}`}
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<PromotePanel
key={`promote-${employee.updated_at}`}
open={panel === "promote"}
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
/>
<KarenzPanel
key={`karenz-${employee.updated_at}`}
open={panel === "karenz"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
/>
<DatenAendernPanel
key={`daten-${employee.updated_at}`}
open={panel === "daten"}
onClose={() => setPanel(null)}
employee={employee}
dependents={dependents}
locationCountry={location?.country}
/>
<TerminatePanel
key={`terminate-${employee.updated_at}`}
open={panel === "terminate"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
directReportCount={directReports.length}
/>
{/* Der `key` sorgt fürs Zurücksetzen: beim Öffnen entsteht der
Assistent neu und liest die Vorbelegung frisch aus der Akte. Ohne
ihn trüge er nach einem Abbruch die halb ausgefüllten Angaben des
vorigen Anlaufs. */}
{panel === "rehire" && (
<RehireWizard
key={employee.updated_at}
open
onClose={() => setPanel(null)}
employee={employee}
openPositions={openPositions}
locations={locations}
/>
)}
</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>
);
}