Files
alpenwerk-hr/components/orgchart/GraphOrgChart.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

149 lines
5.3 KiB
TypeScript

"use client";
import "@xyflow/react/dist/style.css";
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Edge,
} from "@xyflow/react";
import { useEffect, useMemo } from "react";
import { collectVisible, layoutWithDagre } from "./graphLayout";
import { OrgChartNode, type OrgChartRFNode } from "./OrgChartNode";
import type { ChartNode, ChartNodeKind } from "./types";
// Referentially stable across renders — React Flow treats a new nodeTypes
// object as a change and remounts every custom node otherwise.
const NODE_TYPES = { orgNode: OrgChartNode };
// React Flow nimmt echte Farbwerte, keine Klassen — deshalb stehen die
// Token hier als Literal. Sie folgen KIND_ACCENT in OrgChartNode; laeuft das
// eine dem anderen davon, zeigt die Uebersichtskarte andere Farben als die
// Karten, ueber die sie liegt.
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
person: "#164194", // brand-500, Manner Blau
role: "#54277f", // purple-text
group: "#756c6e", // ink-muted
vacancy: "#f69686", // accent-500, Manner Rosa
};
export type GraphOrgChartProps = {
tree: ChartNode[];
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | null;
/** Nur die Struktursicht reicht sie herein; in der Mitarbeitersicht gibt es keine Einheiten. */
onAddUnit?: (unitId: string) => void;
onDeleteUnit?: (unitId: string) => void;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
return (
<ReactFlowProvider>
<GraphOrgChartInner {...props} />
</ReactFlowProvider>
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit, onDeleteUnit }: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
const positions = layoutWithDagre(visibleNodes, visibleEdges);
const rfNodes: OrgChartRFNode[] = visibleNodes.map((n) => ({
id: n.id,
type: "orgNode",
position: positions.get(n.id) ?? { x: 0, y: 0 },
draggable: false,
data: {
chartNode: n,
expanded: n.children.length > 0 && isExpanded(n.id),
hasChildren: n.children.length > 0,
childCount: n.children.length,
onToggle,
onAddUnit,
onDeleteUnit,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: "smoothstep",
pathOptions: { borderRadius: 14 },
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle, onAddUnit, onDeleteUnit]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(rfNodes);
setEdges(rfEdges);
const raf = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 300 }));
return () => cancelAnimationFrame(raf);
}, [rfNodes, rfEdges, setNodes, setEdges, fitView]);
useEffect(() => {
if (!matchedIds || matchedIds.size === 0) return;
const t = setTimeout(() => {
const ids = [...matchedIds].filter((id) => visibleNodes.some((n) => n.id === id));
if (ids.length > 0) fitView({ nodes: ids.map((id) => ({ id })), padding: 0.3, duration: 400 });
}, 150);
return () => clearTimeout(t);
}, [matchedIds, visibleNodes, fitView]);
return (
// dvh, not vh: on iOS/Android the browser chrome collapses on scroll, and
// vh is measured against the *expanded* viewport — the canvas would hang
// off the bottom of the screen for as long as the toolbar is showing.
<div className="orgchart-canvas h-[70dvh] min-h-[420px] w-full overflow-hidden rounded-lg border border-border bg-white">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
nodesDraggable={false}
nodesConnectable={false}
edgesFocusable={false}
// Deliberately NOT elementsSelectable={false}: React Flow computes
// `hasPointerEvents = isSelectable || isDraggable || onClick || …`
// and sets pointer-events:none on the node wrapper when all of them
// are off — which kills the expand button and the name link inside
// the card. Selection stays on and is just styled away in CSS.
minZoom={0.1}
maxZoom={1.75}
fitView
fitViewOptions={{ padding: 0.2 }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#f0dbd4" />
<Controls showInteractive={false} />
{/* Hidden under lg via CSS: on a phone it would cover a real
fraction of the canvas for little navigational benefit. */}
<MiniMap
pannable
zoomable
ariaLabel="Übersichtskarte"
maskColor="rgba(253, 246, 244, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#164194"}
nodeStrokeWidth={0}
nodeBorderRadius={3}
/>
</ReactFlow>
</div>
);
}
type OrgChartNodeDataLike = { chartNode: { kind: ChartNodeKind } };