Files
alpenwerk-hr/components/orgchart/PositionTree.tsx
Andrei Laas a6b6a7d67c
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m30s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m22s
Eine Organisationseinheit aus dem Organigramm heraus anlegen
Bisher gab es dafuer nur den Import. Jetzt sitzt auf jeder Einheit im
Organigramm -- in der Grafik wie in der Liste -- ein Plus, das eine
untergeordnete Einheit anlegt, wahlweise gleich mit Leitungsplanstelle.

Bewusst nur dieser eine Fall. Umbenennen, verschieben und schliessen fehlen
nicht aus Zeitmangel: org_units.parent_id traegt kein Datum, ein Verschieben
aenderte damit auch jede Auswertung auf einen vergangenen Stichtag, und der
fruehere Stand waere danach nirgends mehr ablesbar. Anlegen stellt diese Frage
nicht, weil vorher nichts da war -- und es kann dabei auch kein Kreis
entstehen, was hier mehr wiegt als es klingt: die rekursive Abfrage in
om_reporting_lines hat weder Tiefenbegrenzung noch Kreiserkennung.

Die Leitungsplanstelle entsteht ueber create_position statt durch eine zweite
Fassung derselben Logik -- dort haengen Jobkatalog, Nummernvergabe und die
Pruefung "je Einheit genau eine Leitung".

Die Orgnummer wird eingetragen, nicht vergeben. Vorgeschlagen wird die naechste
freie Nummer der bestehenden Reihe, und nur dann, wenn sich im Bestand genau
eine Systematik ablesen laesst -- eine plausibel aussehende, aber erfundene
Nummer prueft niemand nach.

Das Datum kommt nicht aus dem Stichtag der Ansicht. Wer sich die Struktur zum
letzten Jahresende ansieht und auf Plus drueckt, will in aller Regel eine
Einheit von heute anlegen.
2026-09-28 16:28:33 +02:00

256 lines
9.6 KiB
TypeScript

"use client";
import { ChevronDown, ChevronRight, Plus } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
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");
// 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;
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} />
) : (
<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} />
))}
</div>
)}
{eltern && <CreateOrgUnitModal parent={eltern} units={units} onClose={() => setAnlegenUnter(null)} />}
</div>
);
}
function ListNode({
node,
depth,
expanded,
onToggle,
onAddUnit,
}: {
node: ChartNode;
depth: number;
expanded: Set<string>;
onToggle: (id: string) => void;
onAddUnit: (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 && (
<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} />
))}
</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);
}