251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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<string, string[]> | 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<StructureCanvasHandle, StructureCanvasProps>(function StructureCanvas(props, ref) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<StructureCanvasInner {...props} forwardedRef={ref} />
|
|
</ReactFlowProvider>
|
|
)
|
|
})
|
|
|
|
function StructureCanvasInner({ roles, employees, sessionRecruitmentByRole, selectedRoleId, onSelectRole, onReparent, readOnly, forwardedRef }: StructureCanvasProps & { forwardedRef: React.ForwardedRef<StructureCanvasHandle> }) {
|
|
const employeesByRole = useMemo(() => {
|
|
const m = new Map<string, OrgEmployee[]>()
|
|
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<string | null>(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<Record<string, { x: number; y: number }>>({})
|
|
useEffect(() => { setManualPositions({}) }, [layoutVersion])
|
|
|
|
// Compute layout once per layoutVersion bump (NOT on every roles update).
|
|
const laidOut = useMemo(() => {
|
|
const ownerNode: Node<StructureCanvasNodeData> = {
|
|
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<StructureCanvasNodeData>[] = 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 (
|
|
<div style={{ width: '100%', height: '100%' }}>
|
|
<ReactFlow
|
|
nodes={stateNodes}
|
|
edges={laidOut.edges}
|
|
nodeTypes={nodeTypes}
|
|
onNodesChange={handleNodesChange}
|
|
onNodeDrag={handleNodeDrag}
|
|
onNodeDragStop={handleNodeDragStop}
|
|
onNodeClick={handleNodeClick}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.32, minZoom: 0.45, maxZoom: 1.2 }}
|
|
nodesConnectable={false}
|
|
edgesFocusable={false}
|
|
style={{ width: '100%', height: '100%' }}
|
|
>
|
|
<Background gap={28} size={1} color="rgba(240, 237, 232, 0.10)" />
|
|
<Controls showInteractive={false} />
|
|
<MiniMap pannable nodeStrokeWidth={0} maskColor="rgba(12, 17, 27, 0.6)" />
|
|
</ReactFlow>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|