Files
alpenwerk-hr/components/orgchart/EmployeeTree.tsx
Maximilian Stubhan 108da8d5e6 Phase 4: Org chart (Mitarbeiter/Positionen/Reorganisation) + one more RLS bugfix
- components/orgchart/: 3-way segmented view sharing one server fetch
  (switching tabs doesn't refetch):
  - EmployeeTree: expand/collapse hierarchy from the CEO down, search with
    auto-expand-to-match and highlighting, "Bereiche anzeigen" /
    "Alles einklappen".
  - PositionTree: models the org *structure* (GF -> Bereichsleitung ->
    Abteilung -> Teamleitung -> grouped IC positions by title, expandable
    to the actual holders) independent of who's currently in it, plus
    dashed rows for open requisitions linking to /positions.
  - ReorgWorkbench: batch multiple moves (employees / whole team / whole
    department / whole division as source, always a specific team as
    target), live headcount-impact table, apply via the existing
    apply_reorg RPC, and an undo card wired to undo_reorg.

Bug found via live apply+undo testing: undo_reorg's cleanup DELETE on
employee_history silently matched zero rows, because that table has no
DELETE policy at all (by design, for audit immutability) - RLS filters
DELETE-eligible rows to none rather than erroring. Added
supabase/functions_3.sql: a policy scoped to hr_admin deleting only rows
that carry a reorg_scenario_id, so every other history event type stays
genuinely immutable. Verified live: apply moves an employee and updates
the headcount table correctly; undo reverts team/division/manager AND
now actually removes the Reorganisation history entries it created.

Simplification flagged here (not hidden): the spec's "Ganzes Team /
Ganze Abteilung / Ganzer Bereich" reorg moves the structural org unit
itself to a new division; this implementation resolves all four move
kinds down to individual employee moves against a specific target team,
since the schema's team->department->division chain doesn't support
freely reparenting a team object without also picking a department. The
workbench UI, headcount-impact math, and apply/undo all work correctly
under this model - only the exact "move the team as a unit" semantics
differs from the literal spec wording.
2026-07-13 22:57:49 +02:00

148 lines
5.2 KiB
TypeScript

"use client";
import { ChevronDown, ChevronRight, Search } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Avatar } from "@/components/ui/Avatar";
import type { OrgEmployee } from "./types";
export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const { childrenByManager, totalReportsById, root } = useMemo(() => {
const byManager = new Map<string, OrgEmployee[]>();
for (const e of employees) {
const key = e.manager_id ?? "__root__";
if (!byManager.has(key)) byManager.set(key, []);
byManager.get(key)!.push(e);
}
for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name));
const totals = new Map<string, number>();
function countTotal(id: string): number {
if (totals.has(id)) return totals.get(id)!;
const direct = byManager.get(id) ?? [];
let total = direct.length;
for (const child of direct) total += countTotal(child.id);
totals.set(id, total);
return total;
}
for (const e of employees) countTotal(e.id);
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
}, [employees]);
const matchIds = useMemo(() => {
if (query.trim().length < 2) return null;
const q = query.trim().toLowerCase();
const matches = new Set<string>();
for (const e of employees) {
if (
`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) ||
e.job_title.toLowerCase().includes(q) ||
String(e.personnel_number).includes(q)
) {
matches.add(e.id);
}
}
return matches;
}, [query, employees]);
const ancestorExpandIds = useMemo(() => {
if (!matchIds) return null;
const byId = new Map(employees.map((e) => [e.id, e]));
const toExpand = new Set<string>();
for (const id of matchIds) {
let current = byId.get(id);
while (current?.manager_id) {
toExpand.add(current.manager_id);
current = byId.get(current.manager_id);
}
}
return toExpand;
}, [matchIds, employees]);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function isExpanded(id: string): boolean {
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
}
function renderNode(e: OrgEmployee, depth: number) {
const children = childrenByManager.get(e.id) ?? [];
const hasChildren = children.length > 0;
const expandedNow = isExpanded(e.id);
const isMatch = matchIds?.has(e.id) ?? false;
const totalReports = totalReportsById.get(e.id) ?? 0;
return (
<div key={e.id}>
<div
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }}
>
{hasChildren ? (
<button type="button" onClick={() => toggle(e.id)} className="shrink-0 text-ink-muted">
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
<Link href={`/employees/${e.id}`} className="flex-1 hover:underline">
<span className="text-sm font-semibold text-ink">
{e.first_name} {e.last_name}
</span>
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
</Link>
{hasChildren && (
<span className="shrink-0 text-xs text-ink-muted">
{children.length} direkt · {totalReports} gesamt
</span>
)}
</div>
{hasChildren && expandedNow && <div>{children.map((c) => renderNode(c, depth + 1))}</div>}
</div>
);
}
return (
<div className="rounded border border-border bg-white p-4">
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name, Pers.-Nr., Titel…"
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
/>
</div>
<button
type="button"
onClick={() => setExpanded(new Set(root.map((r) => r.id)))}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Bereiche anzeigen
</button>
<button
type="button"
onClick={() => setExpanded(new Set())}
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
>
Alles einklappen
</button>
</div>
<div>{root.map((r) => renderNode(r, 0))}</div>
</div>
);
}