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