Files
alpenwerk-hr/components/employees/EmployeeDetail.tsx
Andrei Laas 4165f3f8a1 Der Wiedereintritt war da, nur nicht erreichbar
Aus dem Gespraech vom 17.09.2026.

Der Kunde suchte den Knopf "Wiedereintritt" an einer ausgetretenen Person
und hielt ihn fuer verschwunden — "ich dachte eigentlich, das ist schon
implementiert, das war mal drin". Er war drin. Er hing nur an
employee.status, und diese Spalte haengt nach: an einer Person, deren
Austritt erfasst und inzwischen vollzogen war, stand dort weiter "Aktiv".

Das ist mein Fehler beim Statusfix. Umgestellt waren dort `isActive` und
`canEditData` — die fuenf einzelnen Abfragen in derselben Datei blieben
stehen, dazu je eine in KarenzPanel und TerminatePanel. Jetzt liest keine
mehr die Spalte; die beiden Panels bekommen den abgeleiteten Status
uebergeben, statt ihn sich selbst aus der Zeile zu holen.

Betroffen war ausser dem Wiedereintritt auch: welcher Austritts-Knopf
erscheint ("Nicht angetreten" statt "Austritt"), die Beschriftung der
Abwesenheit, die Vorbelegung der Beendigungsart und ob die Zugehoerigkeit
angezeigt wird.

Dazu: die Niederlassung ist aus dem Reiter Organisation wieder raus. Sie
stand dort als unsere Auslegung von Anforderung 8; der Kunde hat sie im
Gespraech gestrichen ("nimm's mal hier raus"). Was mit der Anforderung
gemeint war, bleibt offen.
2026-09-17 11:50:26 +02:00

259 lines
12 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 { 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 { 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>
<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} status={status} />
<DatenAendernPanel
open={panel === "daten"}
onClose={() => setPanel(null)}
employee={employee}
dependents={dependents}
locationCountry={location?.country}
/>
<TerminatePanel
open={panel === "terminate"}
onClose={() => setPanel(null)}
employee={employee}
status={status}
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>
);
}