"use client"; import { ChevronDown, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useCallback, useMemo, useState } from "react"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; // Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest // verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist // damit eine Datenfrage und keine Änderung an dieser Datei. // // Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe // Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode — // und die beiden konnten auseinanderlaufen, ohne dass es auffiel. type ViewMode = "list" | "graph"; type PositionTreeProps = { employees: OrgEmployee[]; units: OrgUnitNode[]; vacancies: OrgVacancy[]; }; export function PositionTree({ employees, units, vacancies }: PositionTreeProps) { const [expanded, setExpanded] = useState>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`))); const [mode, setMode] = useState("list"); // useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen // Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein // neues Dagre-Layout. 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 tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); return (
value={mode} onChange={setMode} options={[ { value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }, ]} />
{mode === "graph" ? ( ) : (
{tree.map((node) => ( ))}
)}
); } function ListNode({ node, depth, expanded, onToggle, }: { node: ChartNode; depth: number; expanded: Set; onToggle: (id: string) => void; }) { const expandable = node.children.length > 0; const open = expanded.has(node.id); return (
{expandable ? ( ) : ( )} {node.href ? ( {node.label} ) : ( {node.label} )} {node.sublabel && ( {node.sublabel} )}
{open && node.children.map((child) => )}
); } /** * Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten * Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und * zuletzt die unbesetzten Planstellen. */ export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] { const childUnits = new Map(); for (const u of units) { const list = childUnits.get(u.parent_id) ?? []; list.push(u); childUnits.set(u.parent_id, list); } for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number)); const chiefOf = new Map(); const staffOf = new Map(); for (const e of employees) { if (e.is_chief) chiefOf.set(e.org_unit_id, e); else { const list = staffOf.get(e.org_unit_id) ?? []; list.push(e); staffOf.set(e.org_unit_id, list); } } const vacantOf = new Map(); for (const v of vacancies) { const list = vacantOf.get(v.org_unit_id) ?? []; list.push(v); vacantOf.set(v.org_unit_id, list); } function build(unit: OrgUnitNode): ChartNode { const key = `unit-${unit.id}`; const chief = chiefOf.get(unit.id); const subUnits = (childUnits.get(unit.id) ?? []).map(build); // Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen // weniger als eine Zeile „Maschinenbediener:in — 30x besetzt". const byTitle = new Map(); for (const e of staffOf.get(unit.id) ?? []) { const list = byTitle.get(e.job_title) ?? []; list.push(e); byTitle.set(e.job_title, list); } const titleGroups: ChartNode[] = Array.from(byTitle.entries()) .sort(([a], [b]) => a.localeCompare(b, "de")) .map(([title, people]) => ({ id: `${key}-job-${title}`, kind: "group" as const, label: title, sublabel: `${people.length}x besetzt`, children: people .slice() .sort((a, b) => a.last_name.localeCompare(b.last_name, "de")) .map((p) => ({ id: p.id, kind: "person" as const, label: `${p.first_name} ${p.last_name}`, href: `/employees/${p.id}`, avatar: { firstName: p.first_name, lastName: p.last_name }, absent: p.absent, badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined, children: [], })), })); const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? []) .filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile .sort((a, b) => a.position_number.localeCompare(b.position_number)) .map((v) => ({ id: `vac-${v.position_id}`, kind: "vacancy" as const, label: `+ ${v.position_number} · ${v.job_title}`, href: "/positions", vacant: true, children: [], })); return { id: key, kind: "role", label: `${unit.org_number} · ${unit.name}`, sublabel: chief ? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}` : "Leitung vakant", vacant: !chief, children: [...subUnits, ...titleGroups, ...vacancyNodes], }; } return (childUnits.get(null) ?? []).map(build); }