Files
alpenwerk-hr/components/employees/EmployeeDetail.tsx
Maximilian Stubhan f96773da0f Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.

Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
2026-07-15 20:34:27 +02:00

157 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 { 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 { 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"]["Tables"]["employees"]["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[];
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, manager, directReports, history, divisions, departments, teams, locations } = 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">
<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">
{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>
<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" && <VertragTab employee={employee} />}
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
{tab === "Historie" && <HistorieTab history={history} />}
</div>
<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>
);
}