import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react' import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, applyNodeChanges } from '@xyflow/react' import type { Node, Edge, NodeChange } from '@xyflow/react' import '@xyflow/react/dist/style.css' import type { OrgRole, OrgEmployee } from '../types/visual' import { StructureCanvasNode, type StructureCanvasNodeData } from './StructureCanvasNode' import { computeDagreLayout } from './dagreLayout' const nodeTypes = { roleNode: StructureCanvasNode } export interface StructureCanvasHandle { /** Re-run dagre and animate nodes to tidy positions. */ autoLayout: () => void } interface StructureCanvasProps { roles: OrgRole[] employees: OrgEmployee[] /** * role_id -> recruited names for the currently selected session. When * provided it takes precedence over the global `employees` for the node * subtitle, so the canvas reflects the selected session's hires. Null/absent * -> fall back to the global org employees. */ sessionRecruitmentByRole?: Record | null selectedRoleId: string | null onSelectRole: (roleId: string | null) => void onReparent: (roleId: string, newParentId: string) => void readOnly?: boolean } /** * Constrained canvas for org structure. * Positions are managed internally. See D1 + D3 in the plan doc: * - dagre runs on mount, on role add/delete, and on explicit autoLayout() calls. * - Role-field updates do NOT reflow the graph. * - Reparenting reflows (a new parent->child edge would leave the graph stale). */ export const StructureCanvas = forwardRef(function StructureCanvas(props, ref) { return ( ) }) function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef }) { const employeesByRole = useMemo(() => { const m = new Map() for (const e of employees) { const roleIds = e.role_ids?.length ? e.role_ids : [e.role_id] for (const roleId of roleIds) { if (!roleId) continue const arr = m.get(roleId) ?? [] arr.push(e) m.set(roleId, arr) } } return m }, [employees]) // Names of the actually recruited people per role. // - When the selected session carries a recruitment map, it is authoritative // (a role absent from it is unstaffed *for that session*). // - Otherwise fall back to the global org employees, excluding placeholder/ // default employees (which carry the role name itself, not a real hire). const recruitedNamesByRole = useCallback( (roleId: string): string[] => { if (sessionRecruitmentByRole) return sessionRecruitmentByRole[roleId] ?? [] return (employeesByRole.get(roleId) ?? []) .filter(e => !e.is_default_employee) .map(e => e.name) .filter(Boolean) }, [employeesByRole, sessionRecruitmentByRole], ) // Layout invalidation key. Incrementing -> dagre re-runs. const [layoutVersion, setLayoutVersion] = useState(0) // Re-layout automatically when the set of role IDs changes (add/delete). const roleIdsKey = useMemo(() => roles.map(r => r.role_id).sort().join('|'), [roles]) const prevRoleIdsKeyRef = useRef(roleIdsKey) useEffect(() => { if (prevRoleIdsKeyRef.current !== roleIdsKey) { prevRoleIdsKeyRef.current = roleIdsKey setLayoutVersion(v => v + 1) } }, [roleIdsKey]) // Expose imperative autoLayout() to parent so the "Auto-layout" button can trigger it. useImperativeHandle(forwardedRef, () => ({ autoLayout: () => setLayoutVersion(v => v + 1), }), []) const [dropTargetId, setDropTargetId] = useState(null) // Free-form drag positions: a node the user has dragged sticks where it was // dropped (purely visual — never changes the org structure). Cleared whenever // dagre re-runs (autoLayout / role add-delete / reparent) so an explicit // "Auto-layout" tidies everything back into the hierarchy. const [manualPositions, setManualPositions] = useState>({}) useEffect(() => { setManualPositions({}) }, [layoutVersion]) // Compute layout once per layoutVersion bump (NOT on every roles update). const laidOut = useMemo(() => { const ownerNode: Node = { id: 'owner', type: 'roleNode', position: { x: 0, y: 0 }, draggable: false, data: { roleId: 'owner', name: 'You (Owner)', responsibility: '', icon: null, employeeCount: 0, employeeNames: [], isOwner: true, isSelected: false, isDropTarget: false, }, } const roleNodes: Node[] = roles.map(r => ({ id: r.role_id, type: 'roleNode', position: { x: 0, y: 0 }, // Always draggable: in editable mode a drop onto another node reparents; // otherwise the drag is a free visual reposition (no structural change). draggable: true, data: { roleId: r.role_id, name: r.name, responsibility: r.responsibility, icon: r.icon ?? null, employeeCount: recruitedNamesByRole(r.role_id).length, employeeNames: recruitedNamesByRole(r.role_id), isOwner: false, isSelected: false, isDropTarget: false, }, })) const all = [ownerNode, ...roleNodes] const edges: Edge[] = roles.map(r => ({ id: `e-${r.reports_to}-${r.role_id}`, source: r.reports_to, target: r.role_id, type: 'smoothstep', })) const positioned = computeDagreLayout(all, edges) return { nodes: positioned, edges } // eslint-disable-next-line react-hooks/exhaustive-deps -- layoutVersion is the explicit reflow trigger }, [layoutVersion, readOnly]) // Live nodes: start from laid-out positions; overlay current role data // (name/icon/etc.) without re-running dagre. const liveNodes = useMemo(() => { return laidOut.nodes.map(n => { if (n.id === 'owner') return n const role = roles.find(r => r.role_id === n.id) if (!role) return n // node for a deleted role -- invariant: layoutVersion will bump and this clears const manual = manualPositions[n.id] return { ...n, position: manual ?? n.position, draggable: true, data: { ...n.data, name: role.name, responsibility: role.responsibility, icon: role.icon ?? null, employeeCount: recruitedNamesByRole(role.role_id).length, employeeNames: recruitedNamesByRole(role.role_id), isSelected: role.role_id === selectedRoleId, isDropTarget: role.role_id === dropTargetId, }, } }) }, [laidOut.nodes, roles, employeesByRole, recruitedNamesByRole, selectedRoleId, dropTargetId, readOnly, manualPositions]) const [stateNodes, setStateNodes] = useState(liveNodes) useEffect(() => { setStateNodes(liveNodes) }, [liveNodes]) const handleNodesChange = useCallback((changes: NodeChange[]) => { setStateNodes(curr => applyNodeChanges(changes, curr)) }, []) const handleNodeDrag = useCallback((_evt: any, node: Node) => { // Reparent drop-target highlighting only applies in editable mode. In // read-only mode the drag is a pure visual reposition -- no target. if (readOnly) return // Bounding-box hit test for drop target const W = 220, H = 80 const pt = { x: node.position.x + W / 2, y: node.position.y + H / 2 } const hit = stateNodes.find(n => { if (n.id === node.id) return false return pt.x >= n.position.x && pt.x <= n.position.x + W && pt.y >= n.position.y && pt.y <= n.position.y + H }) setDropTargetId(hit?.id ?? null) }, [stateNodes, readOnly]) const handleNodeDragStop = useCallback((_evt: any, node: Node) => { const target = dropTargetId setDropTargetId(null) // Remember the dropped position so the node stays put (visual only). const keepPosition = () => setManualPositions(prev => ({ ...prev, [node.id]: { x: node.position.x, y: node.position.y } })) // No valid reparent (read-only, no/own target, or a cycle) -> free reposition. if (readOnly || !target || target === node.id || isDescendant(roles, node.id, target)) { keepPosition() return } onReparent(node.id, target) // A reparent changes the edge structure -- reflow (also clears manual positions). setLayoutVersion(v => v + 1) }, [dropTargetId, roles, readOnly, onReparent]) const handleNodeClick = useCallback((_evt: any, node: Node) => { onSelectRole(node.id === 'owner' ? null : node.id) }, [onSelectRole]) // @xyflow/react v12's `.react-flow` CSS class does NOT set height/width. // Wrap in a sized div so the canvas has a definite box to render into — // this is the library's documented integration pattern for v12. return (
) } // Note: no ReactFlow `proOptions.hideAttribution` used -- @xyflow/react v12 is MIT // with the attribution fully removed at the library level, so no workaround needed. /** Helper: is `candidateDescendantId` a descendant of `rootId`? */ function isDescendant(roles: OrgRole[], rootId: string, candidateDescendantId: string): boolean { const children = roles.filter(r => r.reports_to === rootId).map(r => r.role_id) for (const c of children) { if (c === candidateDescendantId) return true if (isDescendant(roles, c, candidateDescendantId)) return true } return false }