There was none anywhere in the model, so no personnel-cost figure could be produced at all, and an open position could not say whose budget it would charge — which is the first question asked about a vacancy. It hangs on the position, not on the person: the seat costs money even when nobody sits on it. That is exactly the vacancy case. And not on the org unit either, although it usually follows from one — a single seat can be charged elsewhere (project, shared function) without the unit moving. As its own dated assignment table rather than a column, because reassigning is an event with a date. Last year's costs have to stay where they were incurred; as a column, every change would silently rewrite every past report. Half-open [valid_from, valid_to), like position_assignments and om_positions — in SAP OM this is A011. 25 cost centres seeded from the org tree: one per company, division and department, with teams charging to their department, because a team is a span of control and not a budget. All 823 positions were assigned from their own start date, none left over. The number is the first five digits of the org number, so it can be traced rather than looked up. Reassignment refuses three things, each checked: the same cost centre again, a switch on the day the current one started (that period would never have been in force, and the range constraint says so), and a date before the position exists. Verified against the real data, which turned up a defect worth keeping: a position that starts in the future is charged only from its start, so asked about today it had no cost centre — and future positions are exactly what the vacancy list is for. It is now read at the position's own start date. Two audit entries from the probe could not be deleted through the application (the log has no delete policy — correctly), so I removed them with the admin connection. Still open, and the reason this is only the first of the three fields I proposed: location and planned FTE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
9.1 KiB
TypeScript
204 lines
9.1 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 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 { 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;
|
|
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", "Historie", "HR-Notizen"] as const;
|
|
|
|
export function EmployeeDetail(props: EmployeeDetailProps) {
|
|
const { employee, placement, breadcrumb, kostenstelle, 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);
|
|
|
|
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}
|
|
</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 === "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>
|
|
);
|
|
}
|