"use client"; import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; import { deleteOrgUnit } from "@/actions/org"; import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { useToast } from "@/components/ui/Toast"; import { CreateOrgUnitModal } from "./CreateOrgUnitModal"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart"; import { OrgUnitPanel } from "./OrgUnitPanel"; import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; import { fmtName } from "@/lib/format"; // 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"); const { showToast } = useToast(); const router = useRouter(); // 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]); // Die Einheit, unter der gerade eine neue entstehen soll. Als Kennung und // nicht als Objekt: nach dem Anlegen lädt die Seite neu, und ein // festgehaltenes Objekt wäre dann der Stand von vorhin. const [anlegenUnter, setAnlegenUnter] = useState(null); const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []); const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? null) : null; // Die Einheit, deren Angaben offen sind. Der Klick auf die Karte war bisher // ohne Wirkung — bei achtzig Einheiten die naheliegendste Geste ohne Antwort. const [gewaehlt, setGewaehlt] = useState(null); const onSelectUnit = useCallback((unitId: string) => setGewaehlt(unitId), []); const einheit = gewaehlt ? (units.find((u) => u.id === gewaehlt) ?? null) : null; // Ohne Rückfrage, wie beim Entfernen einer Planstelle: entfernbar ist nur, // was nie eine Planstelle getragen hat und nichts unter sich hat. Alles // andere weist die Datenbank mit dem Grund ab, und der steht dann im Toast. const onDeleteUnit = useCallback( async (unitId: string) => { const betroffen = units.find((u) => u.id === unitId); const result = await deleteOrgUnit(unitId); if (result.success) { showToast(`${betroffen ? `${betroffen.org_number} · ${betroffen.name}` : "Einheit"} entfernt.`); // Die Angaben zu einer Einheit, die es nicht mehr gibt, stünden sonst // offen da. setGewaehlt(null); router.refresh(); } else { showToast(result.error ?? "Fehler beim Entfernen.", "error"); } }, [units, showToast, router] ); const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]); // Der Baum in der Form, die der Änderungsdialog einer Planstelle erwartet. // Aus denselben Daten gerechnet statt zusätzlich geladen: `hasChief` heisst // dort „diese Einheit hat schon eine Leitungsplanstelle", und das ist genau // die Leitung im Baum — besetzt oder als Vakanz. const unitOptions = useMemo(() => { const tiefe = new Map(); const berechne = (u: OrgUnitNode, gesehen: Set): number => { const bekannt = tiefe.get(u.id); if (bekannt !== undefined) return bekannt; if (!u.parent_id || gesehen.has(u.id)) return 0; gesehen.add(u.id); const eltern = units.find((e) => e.id === u.parent_id); const d = eltern ? berechne(eltern, gesehen) + 1 : 0; tiefe.set(u.id, d); return d; }; const mitLeitung = new Set([ ...employees.filter((e) => e.is_chief).map((e) => e.org_unit_id), ...vacancies.filter((v) => v.is_chief).map((v) => v.org_unit_id), ]); return units.map((u) => ({ id: u.id, name: u.name, unit_type: u.unit_type, depth: berechne(u, new Set()), hasChief: mitLeitung.has(u.id), })); }, [units, employees, vacancies]); return (
value={mode} onChange={setMode} options={[ { value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }, ]} />
{mode === "graph" ? ( ) : (
{tree.map((node) => ( ))}
)} {eltern && setAnlegenUnter(null)} />} {einheit && ( setGewaehlt(null)} /> )}
); } function ListNode({ node, depth, expanded, onToggle, onAddUnit, onDeleteUnit, onSelectUnit, }: { node: ChartNode; depth: number; expanded: Set; onToggle: (id: string) => void; onAddUnit: (unitId: string) => void; onDeleteUnit: (unitId: string) => void; onSelectUnit: (unitId: string) => void; }) { const expandable = node.children.length > 0; const open = expanded.has(node.id); return (
{expandable ? ( ) : ( )} {node.href ? ( {node.label} ) : node.unitId ? ( ) : ( {node.label} )} {node.sublabel && ( {node.sublabel} )} {/* Derselbe Knopf wie auf der Karte in der Grafik — beide Ansichten kommen aus einem Baum, und eine Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie. */} {node.unitId && !expandable && ( )} {node.unitId && ( )}
{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: fmtName(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", unitId: unit.id, label: `${unit.org_number} · ${unit.name}`, // Die Gesellschaft bleibt ohne Vermerk: sie wird nicht geführt, sondern // ist das Ganze. „Leitung vakant" las sich dort wie eine offene Stelle, // die jemand besetzen müsste, und färbte obendrein den obersten Knoten // als Vakanz ein. Überall sonst ist der Vermerk genau das und gehört // hin — wie im Druck (PrintChart). sublabel: chief ? `Leitung: ${fmtName(chief.first_name, chief.last_name)}${chief.absent ? " (abwesend)" : ""}` : unit.unit_type === "Gesellschaft" ? undefined : "Leitung vakant", vacant: !chief && unit.unit_type !== "Gesellschaft", children: [...subUnits, ...titleGroups, ...vacancyNodes], }; } return (childUnits.get(null) ?? []).map(build); }