"use client"; import { memo, useEffect, useState } from "react"; import { Handle, Position, useReactFlow, useUpdateNodeInternals, type Node, type NodeProps, } from "@xyflow/react"; import { Box, Chip, Divider, IconButton, Paper, Tooltip, Typography, } from "@mui/material"; import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import { useTranslation } from "react-i18next"; import ItemTracingDocLink from "./ItemTracingDocLink"; import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink"; import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle"; import { TraceFlowNodeData } from "./buildReactFlowGraph"; import { NODE_HEIGHT, NODE_WIDTH, NODE_WIDTH_BRANCH, DO_GROUP_HEADER } from "./traceFlowConstants"; import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout"; import { kindColor, kindLabelKey } from "./traceFlowNodeUtils"; import { formatQty, formatSignedQty } from "./traceQtyUtils"; import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils"; import { pickStatusValueColor } from "./traceLabelUtils"; const PHASE_LABEL_INNER = 96; /** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */ const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320; const GroupCollapseButton = ({ collapsed, onToggle, collapseLabel, expandLabel, }: { collapsed: boolean; onToggle?: () => void; collapseLabel: string; expandLabel: string; }) => ( { e.stopPropagation(); onToggle?.(); }} onMouseDown={(e) => e.stopPropagation()} sx={{ pointerEvents: "all", p: 0.25, ml: "auto", flexShrink: 0, color: "warning.dark", }} > {collapsed ? ( ) : ( )} ); export const TraceFlowEventNode = memo(function TraceFlowEventNode({ id, data, }: NodeProps>) { const { t } = useTranslation("itemTracing"); const { setNodes } = useReactFlow(); const updateNodeInternals = useUpdateNodeInternals(); const node = data.layoutNode; const color = kindColor(node.kind); const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC"; const isStockTake = node.kind === "STOCK_TAKE"; const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null; const [lifecycleExpanded, setLifecycleExpanded] = useState(false); const lifecycleStages = hasLifecycle ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail) : []; const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH; useEffect(() => { if (!hasLifecycle) return; const nextH = lifecycleExpanded ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA : NODE_HEIGHT; setNodes((nds) => nds.map((n) => { if (n.id !== id) return n; return { ...n, height: nextH, style: { ...(n.style ?? {}), width: nodeWidth, height: nextH }, measured: { width: n.measured?.width ?? n.width ?? nodeWidth, height: nextH, }, }; }), ); const raf = requestAnimationFrame(() => updateNodeInternals(id)); return () => cancelAnimationFrame(raf); }, [ hasLifecycle, lifecycleExpanded, id, nodeWidth, setNodes, updateNodeInternals, ]); const chipLabel = node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim() ? node.putawayStatusLabel : isQc && node.qcTypeLabel?.trim() ? node.qcTypeLabel : t(kindLabelKey(node.kind)); const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp"); const isDoOut = node.kind === "DO_OUT"; const doOutKindChipSx = { height: data.compact ? 18 : 20, "& .MuiChip-label": { px: 0.75, fontSize: data.compact ? "0.65rem" : "0.7rem", fontWeight: 600, }, } as const; const stopCardClick = (e: React.MouseEvent) => { e.stopPropagation(); }; const doOutboundKindChips = isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? ( <> {node.doOutboundIsExtra ? ( ) : null} {node.doOutboundIsReplenish ? ( ) : null} ) : null; const titleContent = isDoOut ? ( {node.docLinkKind && node.refCode ? ( ) : ( node.refCode || "—" )} ) : node.docLinkKind && node.refCode ? ( ) : ( node.title ); const incomingCount = data.incomingHandleCount ?? 0; const outgoingCount = data.outgoingHandleCount ?? 0; const searchActive = data.searchActive ?? false; const searchMatch = data.searchMatch ?? false; const searchFocused = data.searchFocused ?? false; const nodeSelected = data.nodeSelected ?? false; const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; const activeCount = lifecycleStages.filter((s) => s.isActive).length; const card = ( {incomingCount > 0 && Array.from({ length: incomingCount }, (_, i) => ( ))} {isQc && ( )} {doOutboundKindChips} {titleContent} {node.subtitle?.trim() ? ( {node.subtitle} ) : null} {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? ( {t("Item")}: {node.meta?.trim() || node.traceItemCode} ) : null} {node.qty != null && ( {node.kind === "PURCHASE" ? t("detailOrderQty") : node.kind === "STOCK_TAKE" ? t("after") : node.kind === "ADJUSTMENT" ? t("variance") : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL" ? t("unqualifiedQty") : node.kind === "PRODUCTION_STEP" ? t("processOutputQty") : t("qty")} :{" "} {node.kind === "ADJUSTMENT" ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom) : formatQty(node.qty, node.uom)} )} {node.traceLotNo ? ( ) : null} {node.warehouseCode?.trim() ? ( {t("warehouse")}: {node.warehouseCode} ) : null} {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && node.processingStatusLabel?.trim() ? ( {t("processingStatus")}:{" "} { const c = pickStatusValueColor(node.processingStatus); return c === "default" ? "text.primary" : `${c}.main`; })(), fontWeight: 700, }} > {node.processingStatusLabel} ) : null} {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") && node.matchStatusLabel?.trim() ? ( {t("matchStatus")}:{" "} { const c = pickStatusValueColor(node.matchStatus); return c === "default" ? "text.primary" : `${c}.main`; })(), fontWeight: 700, }} > {node.matchStatusLabel} ) : null} {dateLabel} {hasLifecycle ? ( { e.stopPropagation(); setLifecycleExpanded((v) => !v); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); setLifecycleExpanded((v) => !v); } }} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 0.5, width: "100%", py: 0.25, px: 0.5, borderRadius: 1, cursor: "pointer", userSelect: "none", "&:hover": { bgcolor: "action.hover" }, }} > {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} {lifecycleExpanded ? ( ) : ( )} ) : null} {outgoingCount > 0 && Array.from({ length: outgoingCount }, (_, i) => ( ))} ); // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body). const lifecyclePanel = hasLifecycle && lifecycleExpanded ? ( e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} sx={{ width: nodeWidth, maxWidth: nodeWidth, mt: 0.75, p: 1.25, boxShadow: 4, borderColor: "secondary.main", borderWidth: 1.5, maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24, overflow: "auto", overscrollBehavior: "contain", bgcolor: "background.paper", pointerEvents: "all", }} > {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })} ) : null; const wrapped = ( {card} {lifecyclePanel} ); if (node.meta) { return ( {wrapped} ); } return wrapped; }); export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({ data, }: NodeProps>) { return ( {data.phaseLabel} ); }); export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({ data, }: NodeProps>) { return ( {data.dateLabel} ); }); export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({ data, }: NodeProps>) { const { t } = useTranslation("itemTracing"); const node = data.layoutNode; const incomingCount = data.incomingHandleCount ?? 0; const searchActive = data.searchActive ?? false; const searchMatch = data.searchMatch ?? false; const searchFocused = data.searchFocused ?? false; const collapsed = data.groupCollapsed === true; const width = node.groupBoxWidth ?? NODE_WIDTH; const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; return ( alpha(theme.palette.background.paper, 0.45), opacity: searchActive && !searchMatch ? 0.35 : 1, boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, position: "relative", overflow: "hidden", pointerEvents: "none", }} > {incomingCount > 0 && Array.from({ length: incomingCount }, (_, i) => ( ))} alpha(theme.palette.warning.light, 0.35), borderBottom: collapsed ? 0 : 1, borderColor: "warning.light", }} > {node.title} {node.groupTotalQty != null ? ( {t("flowDoGroupTotalQty", { qtyLabel: formatQty(node.groupTotalQty, node.groupUom), })} ) : null} ); }); export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({ data, }: NodeProps>) { const { t } = useTranslation("itemTracing"); const node = data.layoutNode; const incomingCount = data.incomingHandleCount ?? 0; const outgoingCount = data.outgoingHandleCount ?? 0; const searchActive = data.searchActive ?? false; const searchMatch = data.searchMatch ?? false; const searchFocused = data.searchFocused ?? false; const collapsed = data.groupCollapsed === true; const width = node.groupBoxWidth ?? NODE_WIDTH; const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT); const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const }; return ( alpha(theme.palette.background.paper, 0.45), opacity: searchActive && !searchMatch ? 0.35 : 1, boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined, position: "relative", overflow: "hidden", pointerEvents: "none", }} > {incomingCount > 0 && Array.from({ length: incomingCount }, (_, i) => ( ))} alpha(theme.palette.warning.light, 0.35), borderBottom: collapsed ? 0 : 1, borderColor: "warning.light", }} > {node.title} {outgoingCount > 0 && Array.from({ length: outgoingCount }, (_, i) => ( ))} ); }); export const traceFlowNodeTypes = { traceEvent: TraceFlowEventNode, doGroup: TraceFlowDoGroupNode, pickGroup: TraceFlowPickGroupNode, phaseLabel: TraceFlowPhaseLabelNode, dateHeader: TraceFlowDateHeaderNode, };