Die Liste filterte ueber die Datumsspalten, beschriftete die Zeilen aber mit
employees.status. Sobald die Spalte nachhaengt, widersprechen sich die
beiden — und sie haengt regelmaessig nach: terminate_employee setzt sie nur,
wenn das Austrittsdatum nicht in der Zukunft liegt, und es gibt keinen Lauf,
der das spaeter nachzieht (Migration 20260814100000 sagt das selbst).
Beim Kunden waren beide Richtungen zu sehen. Der Filter "Ausgetreten" fand
48 Personen, von denen mehrere als "Aktiv" beschriftet waren; der Filter
"Geplant" zeigte Nichtantritte, deren Spalte laengst "Ausgetreten" trug.
StatusChip nimmt deshalb jetzt die Zeile und den Stichtag und leitet selbst
ab. Die Spalte laesst sich nicht mehr hineinreichen — die zweite Quelle ist
nicht bloss ungenutzt, es gibt sie an dieser Stelle nicht mehr.
Dazu drei Stellen, die an derselben Spalte hingen:
* Die Akte entschied mit ihr ueber die Knoepfe. An einer Person, die seit
zwei Wochen ausgetreten ist, stand "Austritt" weiter zur Verfuegung.
* Die Sortierung nach Status ordnete nach einem Wert, der nirgends auf der
Seite steht.
* Die Karte "Anstehend" zaehlte kuenftige Eintritte und Rueckkehren ueber
die Spalte und damit anders als die Liste, auf die sie verlinkt.
Und eine Klausel, die in der Ableitung fehlte: ein Nichtantritt traegt als
Austrittsdatum den Eintrittstag. Liegt der in der Zukunft, ist auch der
Austritt groesser als der Stichtag — die vorige Korrektur verglich nur gegen
den Stichtag und blieb damit wirkungslos. Endet ein Verhaeltnis nicht
spaeter, als es beginnt, gab es keinen Tag Beschaeftigung, zu keinem
Stichtag.
243 lines
11 KiB
TypeScript
243 lines
11 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}
|
|
{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" && 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} />
|
|
<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>
|
|
);
|
|
}
|