Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.
Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.
Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch
update employees set job_title = coalesce(payload->>'new_title', ...)
also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.
Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
272 lines
12 KiB
TypeScript
272 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 { 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>
|
|
|
|
<TransferPanel
|
|
open={panel === "transfer"}
|
|
onClose={() => setPanel(null)}
|
|
employee={employee}
|
|
openPositions={openPositions}
|
|
/>
|
|
<PromotePanel
|
|
open={panel === "promote"}
|
|
onClose={() => setPanel(null)}
|
|
employee={employee}
|
|
openPositions={openPositions}
|
|
/>
|
|
<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}
|
|
/>
|
|
{/* 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>
|
|
);
|
|
}
|