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.
147 lines
5.2 KiB
TypeScript
147 lines
5.2 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;
|
|
};
|
|
|
|
export function GraphOrgChart(props: GraphOrgChartProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<GraphOrgChartInner {...props} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|
|
|
|
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds, onAddUnit }: 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,
|
|
},
|
|
}));
|
|
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]);
|
|
|
|
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 } };
|