Files
alpenwerk-hr/components/orgchart/PositionTree.tsx
Andrei Laas a3dde61ec1
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m29s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m10s
Eine leere Organisationseinheit wieder entfernen
Der Papierkorb neben dem Plus, im Organigramm wie in der Liste, und nur dort,
wo nichts darunter haengt. Ein Knopf, der beim Klick eine Absage erteilt, ist
schlechter als kein Knopf -- die Datenbank prueft es trotzdem, denn sie sieht
auch geschlossene Planstellen, die im Baum gar nicht gezeichnet sind.

Abgewiesen wird mit Grund: untergeordnete Einheiten, Planstellen (geschlossene
zaehlen mit), Kostenstellen, die darauf verweisen, und die Wurzel selbst.

Geloescht und nicht geschlossen, und das ist eine bewusste Einschraenkung.
delete_position macht seit dem OM-Umbau den Unterschied vor: besetzt gewesen →
valid_to, nie besetzt gewesen → weg. Fuer Einheiten laesst sich davon heute nur
die zweite Haelfte umsetzen, weil ein valid_to an einer Einheit zwar
eingetragen, aber nirgends gelesen wuerde -- weder Organigramm noch
orgMapsAbfragen, Berichte, Druck oder die Auswahl beim Anlegen einer Planstelle
schraenken org_units auf den Stichtag ein. Die geschlossene Einheit staende
ueberall weiter da, nur mit einem Datum, das niemand sieht. Das Schliessen zum
Stichtag kommt, wenn org_units gegen den Stichtag gelesen wird -- dieselbe
Arbeit, die auch das Verschieben braucht.
2026-09-28 16:51:26 +02:00

315 lines
12 KiB
TypeScript

"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 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<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
const [mode, setMode] = useState<ViewMode>("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<string | null>(null);
const onAddUnit = useCallback((unitId: string) => setAnlegenUnter(unitId), []);
const eltern = anlegenUnter ? (units.find((u) => u.id === anlegenUnter) ?? 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 einheit = units.find((u) => u.id === unitId);
const result = await deleteOrgUnit(unitId);
if (result.success) {
showToast(`${einheit ? `${einheit.org_number} · ${einheit.name}` : "Einheit"} entfernt.`);
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
},
[units, showToast, router]
);
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode>
value={mode}
onChange={setMode}
options={[
{ value: "list", label: "Liste" },
{ value: "graph", label: "Grafisch" },
]}
/>
</div>
{mode === "graph" ? (
<LazyGraphOrgChart
tree={tree}
isExpanded={isExpanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
) : (
<div className="rounded border border-border bg-white p-4">
{tree.map((node) => (
<ListNode
key={node.id}
node={node}
depth={0}
expanded={expanded}
onToggle={toggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
</div>
);
}
function ListNode({
node,
depth,
expanded,
onToggle,
onAddUnit,
onDeleteUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddUnit: (unitId: string) => void;
onDeleteUnit: (unitId: string) => void;
}) {
const expandable = node.children.length > 0;
const open = expanded.has(node.id);
return (
<div>
<div
className="group/zeile flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
style={{ paddingLeft: depth * 24 + 8 }}
>
{expandable ? (
<button
type="button"
onClick={() => onToggle(node.id)}
className="shrink-0 text-ink-muted"
aria-expanded={open}
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<span className="w-4 shrink-0" />
)}
{node.href ? (
<Link
href={node.href}
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
>
{node.label}
</Link>
) : (
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
)}
{node.sublabel && (
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
)}
{/* 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 && (
<button
type="button"
onClick={() => onDeleteUnit(node.unitId!)}
aria-label={`Einheit ${node.label} entfernen`}
title="Einheit entfernen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-danger-solid
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{node.unitId && (
<button
type="button"
onClick={() => onAddUnit(node.unitId!)}
aria-label={`Einheit unter ${node.label} anlegen`}
title="Untergeordnete Einheit anlegen"
className="ml-1 shrink-0 rounded p-0.5 text-ink-muted opacity-0 transition-opacity hover:text-brand-700
focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500
group-hover/zeile:opacity-100 [@media(hover:none)]:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
{open &&
node.children.map((child) => (
<ListNode
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onAddUnit={onAddUnit}
onDeleteUnit={onDeleteUnit}
/>
))}
</div>
);
}
/**
* 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<string | null, OrgUnitNode[]>();
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<string, OrgEmployee>();
const staffOf = new Map<string, OrgEmployee[]>();
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<string, OrgVacancy[]>();
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<string, OrgEmployee[]>();
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);
}