Positions
- Removed the "Besetzen" action, the StaffInternallyModal behind it and the
now-unreachable staffPositionInternally server action: a position is filled
through the hire process, not from the positions list. Note that
transfer_employee has no position_id at all and never touched `positions`,
so with staff_position_internally out of the UI, hire_employee is the only
thing that closes a position — a transfer into an open one leaves it open.
The RPC itself is still in the database and still covered by its tests.
SVNR
- Austrian social security numbers are now validated: ten digits, weighted
check digit mod 11, and the TTMMJJ tail cross-checked against birth_date,
which is what catches a transposed date that a valid check digit would let
through. A serial whose weighted sum lands on 11 is rejected rather than
wrapped — those are never issued.
- Applies to Austrian locations only; the German/Czech/Slovenian equivalents
have their own formats and stay free-form.
- Enforced by a trigger, not inside hire_employee/change_employee_data, for
the same reason as the assignment history: both have been redefined by
half a dozen migrations. Only a *newly written* value is checked, so a
legacy number never blocks an unrelated transfer or address change.
- The seed drew a random four-digit prefix, so its check digit was right
only by chance and every seeded Austrian row would now be rejected;
it computes the check digit properly now.
Tech stack
- next 16.2.11 closes nine advisories against 16.2.10, including a
middleware/proxy bypass in App Router apps on Turbopack — proxy.ts is this
app's entry gate. RLS remains the real boundary, so the blast radius was a
blank page rather than data, but it is a patch-level fix. Also react
19.2.8, tailwind 4.3.3, lucide-react 1.26, supabase-js/ssr, postcss.
- CI runs lint, typecheck, schema/type drift, tests and build; a second job
replays every migration onto an empty database and runs the integration
suite against it, so a migration that cannot be replayed from scratch
fails here instead of during a restore.
- scripts/check-schema-types.mjs diffs the hand-written lib/supabase/types.ts
against the migrations. Reading the SQL rather than a live database keeps
Postgres out of the fast CI job. Verified in both directions.
- vitest now runs two projects: node for logic, jsdom for components. The
first component test covers the org chart expand control, which broke
earlier this session when elementsSelectable={false} made React Flow
compute pointer-events:none for the whole node; re-introducing that prop
fails three of these tests.
- Content-Security-Policy is emitted report-only. Enforcing a policy derived
from inspection rather than from violation reports risks blanking the app;
'unsafe-inline' on script-src is required until a nonce is threaded through
proxy.ts, which is a separate change.
- Fixed supabase/seed.ts, which this session's SVNR change had broken: the
extensionless "../lib/svnr" import does not resolve under Node's ESM
loader, so the seed failed at startup.
- engines pinned to node >=22 <25, tsconfig target ES2022, and the dead
test:e2e script removed (no Playwright is installed).
174 lines
8.1 KiB
TypeScript
174 lines
8.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 { StatusChip } from "@/components/ui/StatusChip";
|
||
import { fmtFullName, 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 { 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 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 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 };
|
||
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[];
|
||
dependents: Dependent[];
|
||
notes: NoteRow[];
|
||
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", "HR-Notizen"] as const;
|
||
|
||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||
const { employee, manager, directReports, history, dependents, notes, 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";
|
||
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} />
|
||
</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")} />
|
||
</>
|
||
)}
|
||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
||
{isActive && (
|
||
<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 === "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} directReports={directReports} breadcrumb={breadcrumb} />
|
||
)}
|
||
{tab === "Historie" && <HistorieTab history={history} />}
|
||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||
</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}
|
||
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} />
|
||
</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>
|
||
);
|
||
}
|