"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,
};