Files
alpenwerk-hr/components/employees/EmployeeDetail.tsx
Maximilian Stubhan 366731ec85 Phase 2/3: Employees list/detail + mutation RPCs + action panels
- supabase/functions.sql, functions_2.sql: Postgres RPCs for every
  employee/position/reorg mutation (hire, terminate, transfer, promote,
  start/adjust/return karenz, change data, rehire, create position, staff
  internally, apply/undo reorg). Each resolves manager_id server-side,
  writes history + audit atomically, and enforces hr_admin via
  require_hr_admin() (backed by the existing RLS policy).
- actions/employees.ts, positions.ts, reorg.ts: Server Actions wrapping
  the RPCs, returning success/error for client-side toast handling.
- Employees list (search/filter/pagination) and detail (4 tabs: Stammdaten,
  Vertrag & Gehalt, Organisation, Historie) reading from employees_directory.
- 6 action slide-over panels: Transfer, Promote, Karenz (start/adjust/
  return), Daten aendern (person+contract diffing), Terminate (with direct-
  report reparenting warning + offboarding checklist), Rehire.
- lib/org.ts: shared division/department/team/location lookups.

Verified live: promote mutation updates salary, writes history/audit, and
the detail page reflects it after refresh, no console errors.

Note: the spec's Karenz-verwalten panel only covers employees already on
Karenz; added a start-Karenz mode (Karenzbeginn/geplante Rueckkehr) to
cover the Aktiv-employee case implied by the header button but not
specified in the panel list.
2026-07-13 22:07:38 +02:00

159 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircle } from "lucide-react";
import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { StatusChip } from "@/components/ui/StatusChip";
import { tenure } from "@/lib/format";
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 { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
type Division = Database["public"]["Tables"]["divisions"]["Row"];
type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
type EmployeeDetailProps = {
employee: EmployeeRow;
manager: MiniEmployee | null;
directReports: MiniEmployee[];
history: HistoryRow[];
divisions: Division[];
departments: Department[];
teams: Team[];
locations: Location[];
openPositions: OpenPosition[];
canEdit: boolean;
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag & Gehalt", "Organisation", "Historie"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, manager, directReports, history, divisions, departments, teams, locations, canEdit } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
const division = divisions.find((d) => d.id === employee.division_id);
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" ") || "";
const location = locations.find((l) => l.id === employee.location_id);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
return (
<div className="flex flex-col gap-6">
<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">
{employee.first_name} {employee.last_name}
</h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} />
</div>
<p className="text-sm text-ink-body">{employee.job_title}</p>
<p className="text-xs text-ink-muted">{breadcrumb}</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>
{canEdit && (
<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" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
<button
onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
>
<XCircle className="h-4 w-4" /> Austritt
</button>
</>
)}
{employee.status === "Ausgetreten" && (
<button
onClick={() => setPanel("rehire")}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
>
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
</button>
)}
</div>
)}
</div>
</div>
<div className="flex gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`border-b-2 px-4 py-2 text-sm font-semibold ${
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t}
</button>
))}
</div>
<div className="rounded border border-border bg-white p-6">
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
{tab === "Vertrag & Gehalt" && <VertragTab employee={employee} />}
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
{tab === "Historie" && <HistorieTab history={history} />}
</div>
{canEdit && (
<>
<TransferPanel
open={panel === "transfer"}
onClose={() => setPanel(null)}
employee={employee}
divisions={divisions}
departments={departments}
teams={teams}
currentTeamId={employee.team_id}
/>
<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} />
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</>
)}
</div>
);
}
function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) {
return (
<button onClick={onClick} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
<Icon className="h-4 w-4" /> {label}
</button>
);
}