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.
This commit is contained in:
158
components/employees/EmployeeDetail.tsx
Normal file
158
components/employees/EmployeeDetail.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user