"use client"; import { ChevronDown, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Avatar } from "@/components/ui/Avatar"; import { Button } from "@/components/ui/Button"; import { SearchInput } from "@/components/ui/SearchInput"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import type { ChartNode, OrgEmployee } from "./types"; type ViewMode = "list" | "graph"; export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmployee[]; focusId?: string | null }) { // Seeded with the focus target so their own reports are already unfolded; // the chain *above* them comes from ancestorExpandIds. const [expanded, setExpanded] = useState>(() => (focusId ? new Set([focusId]) : new Set())); const [query, setQuery] = useState(""); const [mode, setMode] = useState("list"); const focusRef = useRef(null); const { childrenByManager, totalReportsById, root } = useMemo(() => { const byManager = new Map(); 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)); // Nothing in the schema forbids a manager_id cycle (A reports to B // reports to A), and a reorg that moves a lead under one of their own // reports would create one. Every walk below is recursive, so an // unguarded cycle is an infinite recursion that hangs the tab rather // than a wrong number — hence the on-path set. const totals = new Map(); function countTotal(id: string, path: Set): number { const memo = totals.get(id); if (memo !== undefined) return memo; if (path.has(id)) return 0; path.add(id); let total = 0; for (const child of byManager.get(id) ?? []) total += 1 + countTotal(child.id, path); path.delete(id); totals.set(id, total); return total; } for (const e of employees) countTotal(e.id, new Set()); return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] }; }, [employees]); const matchIds = useMemo(() => { // A focus target behaves exactly like a search hit — same ancestor // auto-expand, same ring, same fitView in graph mode — until the user // starts typing, at which point their own search takes over. if (query.trim().length < 2) return focusId ? new Set([focusId]) : null; const q = query.trim().toLowerCase(); const matches = new Set(); 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, focusId]); // The chain above the focused person is auto-expanded, so the row only // exists after that render — scroll once it does. useEffect(() => { if (!focusId) return; focusRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }); }, [focusId, mode]); const ancestorExpandIds = useMemo(() => { if (!matchIds) return null; const byId = new Map(employees.map((e) => [e.id, e])); const toExpand = new Set(); for (const id of matchIds) { let current = byId.get(id); while (current?.manager_id && !toExpand.has(current.manager_id)) { toExpand.add(current.manager_id); current = byId.get(current.manager_id); } } return toExpand; }, [matchIds, employees]); // useCallback-stable: GraphOrgChart's layout memo depends on this // reference, so an unstable function would force a Dagre re-layout on // every unrelated re-render. const toggle = useCallback((id: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]); const chartTree = useMemo(() => { function toChartNode(e: OrgEmployee, ancestors: Set): ChartNode { const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []); const total = totalReportsById.get(e.id) ?? 0; const nextAncestors = new Set(ancestors).add(e.id); return { id: e.id, kind: "person", label: `${e.first_name} ${e.last_name}`, sublabel: e.job_title, href: `/employees/${e.id}`, avatar: { firstName: e.first_name, lastName: e.last_name }, totalReports: total, matched: matchIds?.has(e.id) ?? false, children: children.map((c) => toChartNode(c, nextAncestors)), }; } return root.map((r) => toChartNode(r, new Set())); }, [root, childrenByManager, totalReportsById, matchIds]); function renderNode(e: OrgEmployee, depth: number, ancestors: Set) { const children = ancestors.has(e.id) ? [] : (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 (
{hasChildren ? ( ) : ( )} {e.first_name} {e.last_name} {e.job_title} {hasChildren && ( {children.length} direkt · {totalReports} gesamt )}
{hasChildren && expandedNow && (
{children.map((c) => renderNode(c, depth + 1, new Set(ancestors).add(e.id)))}
)}
); } return (
value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
{mode === "list" ? (
{root.map((r) => renderNode(r, 0, new Set()))}
) : ( )}
); }