Initial commit

This commit is contained in:
LZH-YS1998
2026-07-01 17:56:31 +08:00
commit d78931979d
731 changed files with 311088 additions and 0 deletions
@@ -0,0 +1,250 @@
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
}