FPSMS-frontend
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 

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