- 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.
148 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|