"use client"; import { Plus, Trash2 } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { ladePlanstellenDerEinheit, type PlanstelleDerEinheit } from "@/actions/positions"; import { EditPositionModal } from "@/components/positions/EditPositionModal"; import type { UnitOption } from "@/components/positions/CreatePositionModal"; import { Button } from "@/components/ui/Button"; import { SlideOver } from "@/components/ui/SlideOver"; import { fmtDate, fmtName } from "@/lib/format"; import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types"; // Was an einer Organisationseinheit steht — beim Klick auf ihre Karte. // // Alles hier kommt aus den Daten, die die Struktursicht ohnehin geladen hat: // Einheiten, Personen und Vakanzen zum eingestellten Stichtag. Kein zusätzlicher // Gang zur Datenbank, und damit auch keine zweite Wahrheit — was die Karte // zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist. function Zeile({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } export function OrgUnitPanel({ unit, units, unitOptions, employees, vacancies, onAddUnit, onDeleteUnit, onClose, }: { unit: OrgUnitNode; units: OrgUnitNode[]; /** Für den Änderungsdialog: derselbe Baum, nur in seiner Form. */ unitOptions: UnitOption[]; employees: OrgEmployee[]; vacancies: OrgVacancy[]; onAddUnit: (unitId: string) => void; onDeleteUnit: (unitId: string) => void; onClose: () => void; }) { // Erst beim Öffnen geladen, und nur für diese eine Einheit — siehe den Grund // bei ladePlanstellenDerEinheit. const [stellen, setStellen] = useState(null); const [kostenstellen, setKostenstellen] = useState<{ id: string; code: string; name: string }[]>([]); const [heute, setHeute] = useState(""); const [bearbeitet, setBearbeitet] = useState(null); const laden = useCallback(async () => { const daten = await ladePlanstellenDerEinheit(unit.id); setStellen(daten.planstellen); setKostenstellen(daten.kostenstellen); setHeute(daten.heute); }, [unit.id]); useEffect(() => { let verworfen = false; // Der Nachtrag einer Antwort, die zu einer anderen Einheit gehört, wäre // hier besonders tückisch: die Leiste sähe richtig aus und zeigte fremde // Planstellen. void ladePlanstellenDerEinheit(unit.id).then((daten) => { if (verworfen) return; setStellen(daten.planstellen); setKostenstellen(daten.kostenstellen); setHeute(daten.heute); }); return () => { verworfen = true; }; }, [unit.id]); const kinder = units.filter((u) => u.parent_id === unit.id); // Die Kette nach oben, mit Schutz gegen einen Kreis in parent_id. Der ist // heute nicht herstellbar, aber diese Schleife wäre die erste Stelle, die // ihn ausbaden müsste — und ein hängender Browser sagt niemandem, woran es // liegt. const kette: OrgUnitNode[] = []; const gesehen = new Set(); let lauf = unit.parent_id ? units.find((u) => u.id === unit.parent_id) : undefined; while (lauf && !gesehen.has(lauf.id)) { gesehen.add(lauf.id); kette.unshift(lauf); lauf = lauf.parent_id ? units.find((u) => u.id === lauf!.parent_id) : undefined; } const eigene = employees.filter((e) => e.org_unit_id === unit.id); const leitung = eigene.find((e) => e.is_chief); const belegschaft = eigene.filter((e) => !e.is_chief); const offene = vacancies.filter((v) => v.org_unit_id === unit.id); // Alles darunter, nicht nur die direkten Kinder — die Frage hinter „wie // gross ist dieser Bereich" meint immer den ganzen Teilbaum. const teilbaum = new Set([unit.id]); for (let gewachsen = true; gewachsen; ) { gewachsen = false; for (const u of units) { if (u.parent_id && teilbaum.has(u.parent_id) && !teilbaum.has(u.id)) { teilbaum.add(u.id); gewachsen = true; } } } const imTeilbaum = employees.filter((e) => teilbaum.has(e.org_unit_id)).length; // Dieselbe Bedingung wie am Papierkorb auf der Karte: nur ein Blatt ohne // Planstellen lässt sich entfernen. Die Datenbank prüft strenger — sie sieht // auch geschlossene Planstellen, die hier nicht gezeichnet sind. const entfernbar = kinder.length === 0 && eigene.length === 0 && offene.length === 0 && unit.parent_id !== null; return (
{unit.org_number} {unit.unit_type} {kette.length > 0 ? kette.map((u) => u.name).join(" › ") : "—"} {unit.valid_from ? fmtDate(unit.valid_from) : "—"} {unit.valid_to ? fmtDate(unit.valid_to) : "unbefristet"}

Besetzung

{leitung ? ( {fmtName(leitung.first_name, leitung.last_name)} ) : ( vakant )} {belegschaft.length} {offene.length}

Struktur

{kinder.length} {imTeilbaum}
{kinder.length > 0 && (

Direkt darunter

    {kinder.map((k) => (
  • {k.org_number} · {k.name}{" "} ({k.unit_type})
  • ))}
)}

Planstellen{stellen ? ` (${stellen.length})` : ""}

{stellen === null ? (

Lädt…

) : stellen.length === 0 ? (

In dieser Einheit besteht keine Planstelle.

) : (
    {stellen.map((s) => (
  • {/* Auch die besetzten sind anklickbar. Was sich daran ändern lässt, entscheidet die Datenbankfunktion: Einheit und Gültigkeitsende bleiben bei einer besetzten Stelle gesperrt, Tätigkeit und Kontierung nicht. */}
  • ))}
)} Alle unbesetzten Planstellen
{entfernbar && ( )}
{/* Derselbe Dialog wie auf der Seite Positionen. Ein zweiter, der dasselbe tut, liefe beim ersten Zusatz auseinander. */} {bearbeitet && ( { setBearbeitet(null); // Die Liste stammt aus dem Zustand dieser Leiste; ohne erneutes // Laden stünde nach dem Umkontieren weiter die alte Kostenstelle // da, und der Dialog daneben zeigte die neue. void laden(); }} /> )}
); }