FPSMS-frontend
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 

879 рядки
27 KiB

  1. "use client";
  2. import { memo, useEffect, useState } from "react";
  3. import {
  4. Handle,
  5. Position,
  6. useReactFlow,
  7. useUpdateNodeInternals,
  8. type Node,
  9. type NodeProps,
  10. } from "@xyflow/react";
  11. import {
  12. Box,
  13. Chip,
  14. Divider,
  15. IconButton,
  16. Paper,
  17. Tooltip,
  18. Typography,
  19. } from "@mui/material";
  20. import { alpha } from "@mui/material/styles";
  21. import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
  22. import ExpandLessIcon from "@mui/icons-material/ExpandLess";
  23. import { useTranslation } from "react-i18next";
  24. import ItemTracingDocLink from "./ItemTracingDocLink";
  25. import ItemTracingLotTraceLink from "./ItemTracingLotTraceLink";
  26. import ItemTracingStockTakeLifecycle from "./ItemTracingStockTakeLifecycle";
  27. import { TraceFlowNodeData } from "./buildReactFlowGraph";
  28. import { NODE_HEIGHT, NODE_WIDTH, NODE_WIDTH_BRANCH, DO_GROUP_HEADER } from "./traceFlowConstants";
  29. import { traceFlowHandleTopPercent } from "./traceFlowEdgeLayout";
  30. import { kindColor, kindLabelKey } from "./traceFlowNodeUtils";
  31. import { formatQty, formatSignedQty } from "./traceQtyUtils";
  32. import { buildStockTakeLifecycleStages } from "./traceStockTakeUtils";
  33. import { pickStatusValueColor, resolveDoOutboundQtyColor } from "./traceLabelUtils";
  34. const PHASE_LABEL_INNER = 96;
  35. /** Expanded stock-take stage panel (mt + maxHeight + padding) below the fixed card. */
  36. const STOCK_TAKE_LIFECYCLE_PANEL_EXTRA = 320;
  37. const GroupCollapseButton = ({
  38. collapsed,
  39. onToggle,
  40. collapseLabel,
  41. expandLabel,
  42. }: {
  43. collapsed: boolean;
  44. onToggle?: () => void;
  45. collapseLabel: string;
  46. expandLabel: string;
  47. }) => (
  48. <IconButton
  49. size="small"
  50. aria-label={collapsed ? expandLabel : collapseLabel}
  51. aria-expanded={!collapsed}
  52. onClick={(e) => {
  53. e.stopPropagation();
  54. onToggle?.();
  55. }}
  56. onMouseDown={(e) => e.stopPropagation()}
  57. sx={{
  58. pointerEvents: "all",
  59. p: 0.25,
  60. ml: "auto",
  61. flexShrink: 0,
  62. color: "warning.dark",
  63. }}
  64. >
  65. {collapsed ? (
  66. <ExpandMoreIcon fontSize="small" />
  67. ) : (
  68. <ExpandLessIcon fontSize="small" />
  69. )}
  70. </IconButton>
  71. );
  72. /** FP-MTMS Version Checklist | Functions Ref. No. 23 | v1.0.0 | 2026-07-20 */
  73. export const TraceFlowEventNode = memo(function TraceFlowEventNode({
  74. id,
  75. data,
  76. }: NodeProps<Node<TraceFlowNodeData>>) {
  77. const { t } = useTranslation("itemTracing");
  78. const { setNodes } = useReactFlow();
  79. const updateNodeInternals = useUpdateNodeInternals();
  80. const node = data.layoutNode;
  81. const color = kindColor(node.kind);
  82. const isQc = node.kind === "QC" || node.kind === "MATERIAL_QC";
  83. const isStockTake = node.kind === "STOCK_TAKE";
  84. const hasLifecycle = isStockTake && node.stockTakeRecordDetail != null;
  85. const [lifecycleExpanded, setLifecycleExpanded] = useState(false);
  86. const lifecycleStages = hasLifecycle
  87. ? buildStockTakeLifecycleStages(node.stockTakeRecordDetail)
  88. : [];
  89. const nodeWidth = data.compact ? NODE_WIDTH_BRANCH : NODE_WIDTH;
  90. useEffect(() => {
  91. if (!hasLifecycle) return;
  92. const nextH = lifecycleExpanded
  93. ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA
  94. : NODE_HEIGHT;
  95. setNodes((nds) =>
  96. nds.map((n) => {
  97. if (n.id !== id) return n;
  98. return {
  99. ...n,
  100. height: nextH,
  101. style: { ...(n.style ?? {}), width: nodeWidth, height: nextH },
  102. measured: {
  103. width: n.measured?.width ?? n.width ?? nodeWidth,
  104. height: nextH,
  105. },
  106. };
  107. }),
  108. );
  109. const raf = requestAnimationFrame(() => updateNodeInternals(id));
  110. return () => cancelAnimationFrame(raf);
  111. }, [
  112. hasLifecycle,
  113. lifecycleExpanded,
  114. id,
  115. nodeWidth,
  116. setNodes,
  117. updateNodeInternals,
  118. ]);
  119. const chipLabel =
  120. node.kind === "PUTAWAY" && node.putawayStatusLabel?.trim()
  121. ? node.putawayStatusLabel
  122. : isQc && node.qcTypeLabel?.trim()
  123. ? node.qcTypeLabel
  124. : t(kindLabelKey(node.kind));
  125. const dateLabel = node.timestamp?.trim() ? node.timestamp : t("noTimestamp");
  126. const isDoOut = node.kind === "DO_OUT";
  127. const doOutKindChipSx = {
  128. height: data.compact ? 18 : 20,
  129. "& .MuiChip-label": {
  130. px: 0.75,
  131. fontSize: data.compact ? "0.65rem" : "0.7rem",
  132. fontWeight: 600,
  133. },
  134. } as const;
  135. const stopCardClick = (e: React.MouseEvent) => {
  136. e.stopPropagation();
  137. };
  138. const doOutboundKindChips =
  139. isDoOut && (node.doOutboundIsExtra || node.doOutboundIsReplenish) ? (
  140. <>
  141. {node.doOutboundIsExtra ? (
  142. <Chip
  143. size="small"
  144. label={t("doOutboundExtra")}
  145. color="secondary"
  146. sx={doOutKindChipSx}
  147. onClick={stopCardClick}
  148. onMouseDown={stopCardClick}
  149. />
  150. ) : null}
  151. {node.doOutboundIsReplenish ? (
  152. <Chip
  153. size="small"
  154. label={t("doOutboundReplenish")}
  155. color="success"
  156. sx={doOutKindChipSx}
  157. onClick={stopCardClick}
  158. onMouseDown={stopCardClick}
  159. />
  160. ) : null}
  161. </>
  162. ) : null;
  163. const titleContent = isDoOut ? (
  164. <Box
  165. component="span"
  166. sx={{
  167. display: "inline-flex",
  168. flexWrap: "wrap",
  169. alignItems: "center",
  170. gap: 0.5,
  171. maxWidth: "100%",
  172. }}
  173. >
  174. {node.docLinkKind && node.refCode ? (
  175. <ItemTracingDocLink
  176. kind={node.docLinkKind}
  177. code={node.refCode}
  178. id={node.refId}
  179. consoCode={node.consoCode ?? node.refCode}
  180. ticketNo={node.docLinkTicketNo}
  181. targetDate={node.docLinkTargetDate}
  182. openInNewTab
  183. />
  184. ) : (
  185. node.refCode || "—"
  186. )}
  187. </Box>
  188. ) : node.docLinkKind && node.refCode ? (
  189. <ItemTracingDocLink
  190. kind={node.docLinkKind}
  191. code={node.refCode}
  192. id={node.refId}
  193. consoCode={node.consoCode ?? node.refCode}
  194. ticketNo={node.docLinkTicketNo}
  195. targetDate={node.docLinkTargetDate}
  196. openInNewTab
  197. />
  198. ) : (
  199. node.title
  200. );
  201. const incomingCount = data.incomingHandleCount ?? 0;
  202. const outgoingCount = data.outgoingHandleCount ?? 0;
  203. const searchActive = data.searchActive ?? false;
  204. const searchMatch = data.searchMatch ?? false;
  205. const searchFocused = data.searchFocused ?? false;
  206. const nodeSelected = data.nodeSelected ?? false;
  207. const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
  208. const activeCount = lifecycleStages.filter((s) => s.isActive).length;
  209. const card = (
  210. <Paper
  211. variant="outlined"
  212. sx={{
  213. width: nodeWidth,
  214. minWidth: nodeWidth,
  215. maxWidth: nodeWidth,
  216. height: NODE_HEIGHT,
  217. display: "flex",
  218. flexDirection: "column",
  219. overflow: "hidden",
  220. position: "relative",
  221. borderColor: searchFocused
  222. ? "primary.main"
  223. : nodeSelected
  224. ? "primary.main"
  225. : searchMatch
  226. ? "warning.main"
  227. : isQc
  228. ? `${color}.main`
  229. : undefined,
  230. borderWidth: searchFocused || searchMatch || nodeSelected ? 2 : isQc ? 2 : 1,
  231. opacity: searchActive && !searchMatch ? 0.35 : 1,
  232. transition: "opacity 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease",
  233. boxShadow: searchFocused ? 8 : nodeSelected ? 6 : searchMatch ? 4 : undefined,
  234. cursor: "pointer",
  235. "&:hover": { boxShadow: searchFocused || searchMatch || nodeSelected ? undefined : 6 },
  236. }}
  237. >
  238. {incomingCount > 0 &&
  239. Array.from({ length: incomingCount }, (_, i) => (
  240. <Handle
  241. key={`in-${i}`}
  242. id={`in-${i}`}
  243. type="target"
  244. position={Position.Left}
  245. style={{
  246. ...hiddenHandleStyle,
  247. top: traceFlowHandleTopPercent(i, incomingCount),
  248. left: 0,
  249. }}
  250. />
  251. ))}
  252. <Box sx={{ height: 4, bgcolor: `${color}.main`, flexShrink: 0 }} />
  253. <Box
  254. sx={{
  255. p: data.compact ? 1 : 1.25,
  256. pb: hasLifecycle ? (data.compact ? 0.5 : 0.75) : undefined,
  257. flex: 1,
  258. minHeight: 0,
  259. overflow: "hidden",
  260. }}
  261. >
  262. <Box
  263. sx={{
  264. display: "flex",
  265. alignItems: "center",
  266. flexWrap: "wrap",
  267. gap: 0.5,
  268. mb: 0.75,
  269. }}
  270. >
  271. {isQc && (
  272. <Box
  273. sx={{
  274. width: 8,
  275. height: 8,
  276. bgcolor: `${color}.main`,
  277. transform: "rotate(45deg)",
  278. flexShrink: 0,
  279. }}
  280. />
  281. )}
  282. <Chip size="small" label={chipLabel} color={color} />
  283. {doOutboundKindChips}
  284. </Box>
  285. <Box
  286. sx={{
  287. lineHeight: 1.3,
  288. fontSize: data.compact ? "0.8rem" : undefined,
  289. fontWeight: 700,
  290. overflow: "hidden",
  291. overflowWrap: "anywhere",
  292. wordBreak: "break-word",
  293. ...(isDoOut
  294. ? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 0.5 }
  295. : {
  296. display: "-webkit-box",
  297. WebkitLineClamp: 2,
  298. WebkitBoxOrient: "vertical",
  299. }),
  300. }}
  301. title={typeof node.refCode === "string" && node.refCode.trim() ? node.refCode : node.title}
  302. >
  303. {titleContent}
  304. </Box>
  305. {node.subtitle?.trim() ? (
  306. <Typography
  307. variant="caption"
  308. color="text.secondary"
  309. display="block"
  310. title={node.subtitle}
  311. sx={{
  312. mt: 0.5,
  313. overflow: "hidden",
  314. overflowWrap: "anywhere",
  315. wordBreak: "break-word",
  316. display: "-webkit-box",
  317. WebkitLineClamp: 2,
  318. WebkitBoxOrient: "vertical",
  319. }}
  320. >
  321. {node.subtitle}
  322. </Typography>
  323. ) : null}
  324. {node.kind === "MATERIAL_PICK" && node.traceItemCode?.trim() ? (
  325. <Typography
  326. variant="body2"
  327. fontWeight={600}
  328. title={node.meta?.trim() || node.traceItemCode}
  329. sx={{
  330. mt: 0.5,
  331. fontSize: data.compact ? "0.8rem" : undefined,
  332. overflow: "hidden",
  333. overflowWrap: "anywhere",
  334. wordBreak: "break-word",
  335. display: "-webkit-box",
  336. WebkitLineClamp: 2,
  337. WebkitBoxOrient: "vertical",
  338. }}
  339. >
  340. {t("Item")}: {node.meta?.trim() || node.traceItemCode}
  341. </Typography>
  342. ) : null}
  343. {(node.qty != null || node.kind === "STOCK_TAKE") && (
  344. <Typography
  345. variant="body2"
  346. fontWeight={600}
  347. title={
  348. node.kind === "ADJUSTMENT"
  349. ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
  350. : formatQty(node.qty, node.uom)
  351. }
  352. sx={{
  353. mt: 0.5,
  354. fontSize: data.compact ? "0.8rem" : undefined,
  355. overflow: "hidden",
  356. overflowWrap: "anywhere",
  357. wordBreak: "break-word",
  358. display: "-webkit-box",
  359. WebkitLineClamp: 2,
  360. WebkitBoxOrient: "vertical",
  361. color:
  362. node.kind === "DO_OUT" ||
  363. node.kind === "JO_OUT" ||
  364. node.kind === "MATERIAL_PICK"
  365. ? resolveDoOutboundQtyColor(
  366. node.qty,
  367. node.kind === "DO_OUT" ? node.doOutboundQtyChanged : false,
  368. )
  369. : undefined,
  370. }}
  371. >
  372. {node.kind === "PURCHASE"
  373. ? t("detailOrderQty")
  374. : node.kind === "STOCK_TAKE"
  375. ? t("after")
  376. : node.kind === "ADJUSTMENT"
  377. ? t("variance")
  378. : node.kind === "QC" || node.kind === "MATERIAL_QC" || node.kind === "FAIL"
  379. ? t("unqualifiedQty")
  380. : node.kind === "PRODUCTION_STEP"
  381. ? t("processOutputQty")
  382. : t("qty")}
  383. :{" "}
  384. {node.kind === "ADJUSTMENT"
  385. ? formatSignedQty(node.qty, node.adjustmentDirection, node.uom)
  386. : formatQty(node.qty, node.uom)}
  387. </Typography>
  388. )}
  389. {node.kind === "STOCK_TAKE" ? (
  390. <Typography
  391. variant="body2"
  392. fontWeight={600}
  393. title={formatQty(node.stockTakeVarianceQty, node.uom)}
  394. sx={{
  395. mt: 0.25,
  396. fontSize: data.compact ? "0.8rem" : undefined,
  397. overflow: "hidden",
  398. overflowWrap: "anywhere",
  399. wordBreak: "break-word",
  400. display: "-webkit-box",
  401. WebkitLineClamp: 2,
  402. WebkitBoxOrient: "vertical",
  403. color:
  404. node.stockTakeVarianceQty != null && node.stockTakeVarianceQty > 0
  405. ? "success.main"
  406. : node.stockTakeVarianceQty != null && node.stockTakeVarianceQty < 0
  407. ? "error.main"
  408. : undefined,
  409. }}
  410. >
  411. {t("variance")}: {formatQty(node.stockTakeVarianceQty, node.uom)}
  412. </Typography>
  413. ) : null}
  414. {node.traceLotNo ? (
  415. <ItemTracingLotTraceLink
  416. label={
  417. node.kind === "BYPRODUCT"
  418. ? t("traceByproductLot")
  419. : node.kind === "REPACK"
  420. ? t("traceRepackLot")
  421. : t("traceMaterialLot")
  422. }
  423. lotNo={node.traceLotNo}
  424. itemCode={node.traceItemCode}
  425. stopPropagation
  426. sx={{ mt: 0.5, display: "inline-block" }}
  427. />
  428. ) : null}
  429. {node.warehouseCode?.trim() ? (
  430. <Typography
  431. variant="caption"
  432. color="text.secondary"
  433. display="block"
  434. title={node.warehouseCode}
  435. sx={{
  436. mt: 0.5,
  437. fontWeight: 600,
  438. overflow: "hidden",
  439. overflowWrap: "anywhere",
  440. wordBreak: "break-word",
  441. display: "-webkit-box",
  442. WebkitLineClamp: 2,
  443. WebkitBoxOrient: "vertical",
  444. }}
  445. >
  446. {t("warehouse")}: {node.warehouseCode}
  447. </Typography>
  448. ) : null}
  449. {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
  450. node.processingStatusLabel?.trim() ? (
  451. <Typography
  452. variant="caption"
  453. color="text.secondary"
  454. display="block"
  455. title={node.processingStatusLabel}
  456. sx={{
  457. mt: 0.5,
  458. fontWeight: 600,
  459. overflow: "hidden",
  460. overflowWrap: "anywhere",
  461. wordBreak: "break-word",
  462. display: "-webkit-box",
  463. WebkitLineClamp: 1,
  464. WebkitBoxOrient: "vertical",
  465. }}
  466. >
  467. {t("processingStatus")}:{" "}
  468. <Box
  469. component="span"
  470. sx={{
  471. color: (() => {
  472. const c = pickStatusValueColor(node.processingStatus);
  473. return c === "default" ? "text.primary" : `${c}.main`;
  474. })(),
  475. fontWeight: 700,
  476. }}
  477. >
  478. {node.processingStatusLabel}
  479. </Box>
  480. </Typography>
  481. ) : null}
  482. {(node.kind === "JO_OUT" || node.kind === "MATERIAL_PICK") &&
  483. node.matchStatusLabel?.trim() ? (
  484. <Typography
  485. variant="caption"
  486. color="text.secondary"
  487. display="block"
  488. title={node.matchStatusLabel}
  489. sx={{
  490. mt: 0.5,
  491. fontWeight: 600,
  492. overflow: "hidden",
  493. overflowWrap: "anywhere",
  494. wordBreak: "break-word",
  495. display: "-webkit-box",
  496. WebkitLineClamp: 1,
  497. WebkitBoxOrient: "vertical",
  498. }}
  499. >
  500. {t("matchStatus")}:{" "}
  501. <Box
  502. component="span"
  503. sx={{
  504. color: (() => {
  505. const c = pickStatusValueColor(node.matchStatus);
  506. return c === "default" ? "text.primary" : `${c}.main`;
  507. })(),
  508. fontWeight: 700,
  509. }}
  510. >
  511. {node.matchStatusLabel}
  512. </Box>
  513. </Typography>
  514. ) : null}
  515. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>
  516. {dateLabel}
  517. </Typography>
  518. </Box>
  519. {hasLifecycle ? (
  520. <Box
  521. sx={{
  522. flexShrink: 0,
  523. borderTop: 1,
  524. borderColor: "divider",
  525. px: data.compact ? 0.75 : 1,
  526. py: 0.25,
  527. }}
  528. >
  529. <Box
  530. role="button"
  531. tabIndex={0}
  532. aria-expanded={lifecycleExpanded}
  533. aria-label={lifecycleExpanded ? t("stockTakeStageCollapse") : t("stockTakeStageExpand")}
  534. onClick={(e) => {
  535. e.stopPropagation();
  536. setLifecycleExpanded((v) => !v);
  537. }}
  538. onKeyDown={(e) => {
  539. if (e.key === "Enter" || e.key === " ") {
  540. e.preventDefault();
  541. e.stopPropagation();
  542. setLifecycleExpanded((v) => !v);
  543. }
  544. }}
  545. sx={{
  546. display: "flex",
  547. alignItems: "center",
  548. justifyContent: "space-between",
  549. gap: 0.5,
  550. width: "100%",
  551. py: 0.25,
  552. px: 0.5,
  553. borderRadius: 1,
  554. cursor: "pointer",
  555. userSelect: "none",
  556. "&:hover": { bgcolor: "action.hover" },
  557. }}
  558. >
  559. <Typography variant="caption" fontWeight={700} color="secondary.main" noWrap>
  560. {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
  561. </Typography>
  562. {lifecycleExpanded ? (
  563. <ExpandLessIcon fontSize="small" color="secondary" />
  564. ) : (
  565. <ExpandMoreIcon fontSize="small" color="secondary" />
  566. )}
  567. </Box>
  568. </Box>
  569. ) : null}
  570. {outgoingCount > 0 &&
  571. Array.from({ length: outgoingCount }, (_, i) => (
  572. <Handle
  573. key={`out-${i}`}
  574. id={`out-${i}`}
  575. type="source"
  576. position={Position.Right}
  577. style={{
  578. ...hiddenHandleStyle,
  579. top: traceFlowHandleTopPercent(i, outgoingCount),
  580. right: 0,
  581. }}
  582. />
  583. ))}
  584. </Paper>
  585. );
  586. // Inline under the card so pan/zoom keeps the panel attached (Popper portals to body).
  587. const lifecyclePanel =
  588. hasLifecycle && lifecycleExpanded ? (
  589. <Paper
  590. variant="outlined"
  591. className="nowheel nodrag nopan"
  592. onClick={(e) => e.stopPropagation()}
  593. onMouseDown={(e) => e.stopPropagation()}
  594. onWheel={(e) => e.stopPropagation()}
  595. sx={{
  596. width: nodeWidth,
  597. maxWidth: nodeWidth,
  598. mt: 0.75,
  599. p: 1.25,
  600. boxShadow: 4,
  601. borderColor: "secondary.main",
  602. borderWidth: 1.5,
  603. maxHeight: STOCK_TAKE_LIFECYCLE_PANEL_EXTRA - 24,
  604. overflow: "auto",
  605. overscrollBehavior: "contain",
  606. bgcolor: "background.paper",
  607. pointerEvents: "all",
  608. }}
  609. >
  610. <Typography variant="caption" fontWeight={700} color="secondary.main" sx={{ mb: 1, display: "block" }}>
  611. {t("nodeStockTake")} · {t("stockTakeStageDetail", { count: activeCount })}
  612. </Typography>
  613. <Divider sx={{ mb: 1 }} />
  614. <ItemTracingStockTakeLifecycle stages={lifecycleStages} uom={node.uom} />
  615. </Paper>
  616. ) : null;
  617. const wrapped = (
  618. <Box
  619. sx={{
  620. width: nodeWidth,
  621. height: lifecycleExpanded
  622. ? NODE_HEIGHT + STOCK_TAKE_LIFECYCLE_PANEL_EXTRA
  623. : NODE_HEIGHT,
  624. overflow: "visible",
  625. }}
  626. >
  627. {card}
  628. {lifecyclePanel}
  629. </Box>
  630. );
  631. if (node.meta) {
  632. return (
  633. <Tooltip title={node.meta} arrow placement="top">
  634. {wrapped}
  635. </Tooltip>
  636. );
  637. }
  638. return wrapped;
  639. });
  640. export const TraceFlowPhaseLabelNode = memo(function TraceFlowPhaseLabelNode({
  641. data,
  642. }: NodeProps<Node<TraceFlowNodeData>>) {
  643. return (
  644. <Box
  645. sx={{
  646. width: PHASE_LABEL_INNER,
  647. display: "flex",
  648. alignItems: "center",
  649. justifyContent: "center",
  650. px: 0.75,
  651. py: 1,
  652. borderLeft: `4px solid ${data.phaseColor ?? "#757575"}`,
  653. backgroundImage: `linear-gradient(${data.phaseColor ?? "#757575"}14, ${data.phaseColor ?? "#757575"}14)`,
  654. borderRadius: 1,
  655. }}
  656. >
  657. <Typography
  658. variant="caption"
  659. fontWeight={700}
  660. sx={{
  661. writingMode: "vertical-rl",
  662. textOrientation: "mixed",
  663. color: data.phaseColor ?? "#757575",
  664. letterSpacing: 0.5,
  665. }}
  666. >
  667. {data.phaseLabel}
  668. </Typography>
  669. </Box>
  670. );
  671. });
  672. export const TraceFlowDateHeaderNode = memo(function TraceFlowDateHeaderNode({
  673. data,
  674. }: NodeProps<Node<TraceFlowNodeData>>) {
  675. return (
  676. <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ width: 80, textAlign: "center" }}>
  677. {data.dateLabel}
  678. </Typography>
  679. );
  680. });
  681. export const TraceFlowDoGroupNode = memo(function TraceFlowDoGroupNode({
  682. data,
  683. }: NodeProps<Node<TraceFlowNodeData>>) {
  684. const { t } = useTranslation("itemTracing");
  685. const node = data.layoutNode;
  686. const incomingCount = data.incomingHandleCount ?? 0;
  687. const searchActive = data.searchActive ?? false;
  688. const searchMatch = data.searchMatch ?? false;
  689. const searchFocused = data.searchFocused ?? false;
  690. const collapsed = data.groupCollapsed === true;
  691. const width = node.groupBoxWidth ?? NODE_WIDTH;
  692. const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
  693. const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
  694. return (
  695. <Box
  696. sx={{
  697. width,
  698. height,
  699. border: 2,
  700. borderStyle: "dashed",
  701. borderColor: searchFocused
  702. ? "primary.main"
  703. : searchMatch
  704. ? "warning.main"
  705. : "warning.light",
  706. borderRadius: 1,
  707. bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45),
  708. opacity: searchActive && !searchMatch ? 0.35 : 1,
  709. boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
  710. position: "relative",
  711. overflow: "hidden",
  712. pointerEvents: "none",
  713. }}
  714. >
  715. {incomingCount > 0 &&
  716. Array.from({ length: incomingCount }, (_, i) => (
  717. <Handle
  718. key={`in-${i}`}
  719. id={`in-${i}`}
  720. type="target"
  721. position={Position.Left}
  722. style={{
  723. ...hiddenHandleStyle,
  724. top: traceFlowHandleTopPercent(i, incomingCount),
  725. left: 0,
  726. pointerEvents: "all",
  727. }}
  728. />
  729. ))}
  730. <Box
  731. sx={{
  732. height: DO_GROUP_HEADER,
  733. px: 1.25,
  734. display: "flex",
  735. alignItems: "center",
  736. gap: 1,
  737. overflow: "hidden",
  738. bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35),
  739. borderBottom: collapsed ? 0 : 1,
  740. borderColor: "warning.light",
  741. }}
  742. >
  743. <Typography variant="caption" fontWeight={700} noWrap sx={{ color: "warning.dark", minWidth: 0 }}>
  744. {node.title}
  745. </Typography>
  746. {node.groupTotalQty != null ? (
  747. <Typography variant="caption" fontWeight={600} noWrap sx={{ color: "warning.dark", flexShrink: 0 }}>
  748. {t("flowDoGroupTotalQty", {
  749. qtyLabel: formatQty(node.groupTotalQty, node.groupUom),
  750. })}
  751. </Typography>
  752. ) : null}
  753. <GroupCollapseButton
  754. collapsed={collapsed}
  755. onToggle={data.onToggleGroupCollapse}
  756. collapseLabel={t("flowGroupCollapse")}
  757. expandLabel={t("flowGroupExpand")}
  758. />
  759. </Box>
  760. </Box>
  761. );
  762. });
  763. export const TraceFlowPickGroupNode = memo(function TraceFlowPickGroupNode({
  764. data,
  765. }: NodeProps<Node<TraceFlowNodeData>>) {
  766. const { t } = useTranslation("itemTracing");
  767. const node = data.layoutNode;
  768. const incomingCount = data.incomingHandleCount ?? 0;
  769. const outgoingCount = data.outgoingHandleCount ?? 0;
  770. const searchActive = data.searchActive ?? false;
  771. const searchMatch = data.searchMatch ?? false;
  772. const searchFocused = data.searchFocused ?? false;
  773. const collapsed = data.groupCollapsed === true;
  774. const width = node.groupBoxWidth ?? NODE_WIDTH;
  775. const height = collapsed ? DO_GROUP_HEADER : (node.groupBoxHeight ?? NODE_HEIGHT);
  776. const hiddenHandleStyle = { opacity: 0, width: 8, height: 8, transform: "none" as const };
  777. return (
  778. <Box
  779. sx={{
  780. width,
  781. height,
  782. border: 2,
  783. borderStyle: "dashed",
  784. borderColor: searchFocused
  785. ? "primary.main"
  786. : searchMatch
  787. ? "warning.main"
  788. : "warning.light",
  789. borderRadius: 1,
  790. bgcolor: (theme) => alpha(theme.palette.background.paper, 0.45),
  791. opacity: searchActive && !searchMatch ? 0.35 : 1,
  792. boxShadow: searchFocused ? 4 : searchMatch ? 2 : undefined,
  793. position: "relative",
  794. overflow: "hidden",
  795. pointerEvents: "none",
  796. }}
  797. >
  798. {incomingCount > 0 &&
  799. Array.from({ length: incomingCount }, (_, i) => (
  800. <Handle
  801. key={`in-${i}`}
  802. id={`in-${i}`}
  803. type="target"
  804. position={Position.Left}
  805. style={{
  806. ...hiddenHandleStyle,
  807. top: traceFlowHandleTopPercent(i, incomingCount),
  808. left: 0,
  809. pointerEvents: "all",
  810. }}
  811. />
  812. ))}
  813. <Box
  814. sx={{
  815. height: DO_GROUP_HEADER,
  816. px: 1.25,
  817. display: "flex",
  818. alignItems: "center",
  819. gap: 0.5,
  820. bgcolor: (theme) => alpha(theme.palette.warning.light, 0.35),
  821. borderBottom: collapsed ? 0 : 1,
  822. borderColor: "warning.light",
  823. }}
  824. >
  825. <Typography variant="caption" fontWeight={700} noWrap sx={{ flex: 1, color: "warning.dark", minWidth: 0 }}>
  826. {node.title}
  827. </Typography>
  828. <GroupCollapseButton
  829. collapsed={collapsed}
  830. onToggle={data.onToggleGroupCollapse}
  831. collapseLabel={t("flowGroupCollapse")}
  832. expandLabel={t("flowGroupExpand")}
  833. />
  834. </Box>
  835. {outgoingCount > 0 &&
  836. Array.from({ length: outgoingCount }, (_, i) => (
  837. <Handle
  838. key={`out-${i}`}
  839. id={`out-${i}`}
  840. type="source"
  841. position={Position.Right}
  842. style={{
  843. ...hiddenHandleStyle,
  844. top: traceFlowHandleTopPercent(i, outgoingCount),
  845. right: 0,
  846. pointerEvents: "all",
  847. }}
  848. />
  849. ))}
  850. </Box>
  851. );
  852. });
  853. export const traceFlowNodeTypes = {
  854. traceEvent: TraceFlowEventNode,
  855. doGroup: TraceFlowDoGroupNode,
  856. pickGroup: TraceFlowPickGroupNode,
  857. phaseLabel: TraceFlowPhaseLabelNode,
  858. dateHeader: TraceFlowDateHeaderNode,
  859. };