Die naheliegendste Geste in dieser Ansicht war ohne Wirkung: eine Karte im Organigramm liess sich anklicken und antwortete nicht. Jetzt oeffnet sie eine Leiste mit dem, was an der Einheit steht -- Orgnummer, Art, die Kette nach oben, Gueltigkeit, Leitung, Belegschaft, unbesetzte Planstellen, die direkt untergeordneten Einheiten und die Zahl der Personen im ganzen Teilbaum. Dazu dieselben zwei Handlungen wie auf der Karte, nur beschriftet statt als Symbol. Alles daraus stammt aus den Daten, die die Struktursicht ohnehin geladen hat. Kein zusaetzlicher Gang zur Datenbank und damit auch keine zweite Wahrheit -- was die Leiste zeigt, ist dasselbe, woraus der Baum daneben gezeichnet ist. Die Liste klickt sich genauso: beide Ansichten kommen aus einem Baum, und eine Handlung, die es nur in einer von beiden gibt, findet man in der anderen nie. org_units liefert dafuer zusaetzlich valid_from und valid_to, aber nur an die Struktursicht. Der Druck kommt ohne sie aus; auf OrgUnitNode stehen sie deshalb als optionale Felder und nicht als zwei weitere Spalten, die ueberall mitgeschleppt werden.
159 lines
5.4 KiB
TypeScript
159 lines
5.4 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;
|
|
onSelectUnit?: (unitId: string) => void;
|
|
};
|
|
|
|
export function GraphOrgChart(props: GraphOrgChartProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<GraphOrgChartInner {...props} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
function GraphOrgChartInner({
|
|
tree,
|
|
isExpanded,
|
|
onToggle,
|
|
matchedIds,
|
|
onAddUnit,
|
|
onDeleteUnit,
|
|
onSelectUnit,
|
|
}: 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,
|
|
onSelectUnit,
|
|
},
|
|
}));
|
|
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, onSelectUnit]);
|
|
|
|
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 } };
|