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.
256 lines
9.6 KiB
TypeScript
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);
|
|
}
|