"use client"; import { ChevronDown, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; import type { OpenPositionResolved } from "@/lib/positions"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types"; type ViewMode = "list" | "graph"; function TreeRow({ depth, expandable, expandedNow, onToggle, children, }: { depth: number; expandable: boolean; expandedNow: boolean; onToggle: () => void; children: ReactNode; }) { return (
{expandable ? ( ) : ( )} {children}
); } type PositionTreeProps = { employees: OrgEmployee[]; divisions: OrgDivision[]; departments: OrgDepartment[]; teams: OrgTeam[]; openPositions: OpenPositionResolved[]; }; export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) { const [expanded, setExpanded] = useState>(new Set(["root"])); const [mode, setMode] = useState("list"); // useCallback-stable: GraphOrgChart's layout memo depends on these // references, so unstable functions 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) => expanded.has(id), [expanded]); const ceo = employees.find((e) => e.org_level === 0) ?? null; const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => { const divisionHeadByDivision = new Map(); const teamLeadByTeam = new Map(); const icsByTeamAndTitle = new Map>(); for (const e of employees) { if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e); if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e); if (!e.is_lead && e.org_level === 3 && e.team_id) { if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map()); const byTitle = icsByTeamAndTitle.get(e.team_id)!; if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []); byTitle.get(e.job_title)!.push(e); } } return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle }; }, [employees]); const openByTeam = useMemo(() => { const map = new Map(); for (const p of openPositions) { if (!map.has(p.team_id)) map.set(p.team_id, []); map.get(p.team_id)!.push(p); } return map; }, [openPositions]); // Mirrors the JSX walk below into the generic ChartNode shape for graph // mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title) // the list already uses as `expanded` keys, so one Set drives both. const chartTree = useMemo(() => { if (!ceo) return []; function buildTeam(team: OrgTeam): ChartNode { const teamKey = `team-${team.id}`; const lead = teamLeadByTeam.get(team.id); const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map(); const openForTeam = openByTeam.get(team.id) ?? []; const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({ id: `${teamKey}-${title}`, kind: "group", label: title, sublabel: `${people.length}x besetzt`, children: people.map((p) => ({ id: p.id, kind: "person", label: `${p.first_name} ${p.last_name}`, href: `/employees/${p.id}`, avatar: { firstName: p.first_name, lastName: p.last_name }, children: [], })), })); const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({ id: `vac-${p.id}`, kind: "vacancy", label: `${p.position_number} · ${p.title}`, href: "/positions", children: [], })); return { id: teamKey, kind: "role", label: `Teamleitung ${team.name}`, sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant", vacant: !lead, children: [...titleGroups, ...vacancyNodes], }; } function buildDept(dept: OrgDepartment): ChartNode { return { id: `dept-${dept.id}`, kind: "group", label: `${dept.org_number} · ${dept.name}`, children: teams.filter((t) => t.department_id === dept.id).map(buildTeam), }; } function buildDivision(div: OrgDivision): ChartNode { const head = divisionHeadByDivision.get(div.id); return { id: `div-${div.id}`, kind: "role", label: `Bereichsleitung ${div.name}`, sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant", vacant: !head, children: departments.filter((d) => d.division_id === div.id).map(buildDept), }; } return [ { id: "root", kind: "role", label: "Geschäftsführung", sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`, children: divisions.map(buildDivision), }, ]; }, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]); return (
value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
{mode === "graph" ? ( ) : (
{ceo && ( toggle("root")}> Geschäftsführung besetzt: {ceo.first_name} {ceo.last_name} )} {expanded.has("root") && divisions.map((div) => { const head = divisionHeadByDivision.get(div.id); const divKey = `div-${div.id}`; return (
toggle(divKey)}> Bereichsleitung {div.name} {head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"} {expanded.has(divKey) && departments .filter((d) => d.division_id === div.id) .map((dept) => { const deptKey = `dept-${dept.id}`; return (
toggle(deptKey)}> {dept.org_number} · {dept.name} {expanded.has(deptKey) && teams .filter((t) => t.department_id === dept.id) .map((team) => { const teamKey = `team-${team.id}`; const lead = teamLeadByTeam.get(team.id); const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map(); const openForTeam = openByTeam.get(team.id) ?? []; return (
toggle(teamKey)}> Teamleitung {team.name} {lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"} {expanded.has(teamKey) && ( <> {Array.from(icsByTitle.entries()).map(([title, people]) => { const groupKey = `${teamKey}-${title}`; return (
0} expandedNow={expanded.has(groupKey)} onToggle={() => toggle(groupKey)} > {title} {people.length}x besetzt {expanded.has(groupKey) && people.map((p) => (
{p.first_name} {p.last_name}
))}
); })} {openForTeam.map((p) => ( + {p.position_number} · {p.title} ))} )}
); })}
); })}
); })}
)}
); }