FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

1194 строки
42 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Autocomplete,
  5. Box,
  6. Button,
  7. Dialog,
  8. DialogContent,
  9. DialogTitle,
  10. FormControl,
  11. IconButton,
  12. InputLabel,
  13. MenuItem,
  14. Paper,
  15. Select,
  16. Stack,
  17. Table,
  18. TableBody,
  19. TableCell,
  20. TableContainer,
  21. TableHead,
  22. TableRow,
  23. Tooltip,
  24. Typography,
  25. } from "@mui/material";
  26. import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
  27. import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
  28. import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
  29. import StorefrontIcon from "@mui/icons-material/Storefront";
  30. import { Add, Close, Delete, Search } from "@mui/icons-material";
  31. import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
  32. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  33. import dayjs, { Dayjs } from "dayjs";
  34. import { useTranslation } from "react-i18next";
  35. import { GridColDef } from "@mui/x-data-grid";
  36. import Swal from "sweetalert2";
  37. import StyledDataGrid from "../StyledDataGrid";
  38. import {
  39. DoDetail,
  40. DoDetailLine,
  41. DoReplenishmentRecord,
  42. fetchDoDetail,
  43. fetchDoReplenishmentList,
  44. fetchDoSearch,
  45. submitDoReplenishment,
  46. } from "@/app/api/do/actions";
  47. import { arrayToDateString } from "@/app/utils/formatUtil";
  48. import {
  49. REPLENISHMENT_FIELD_ICON_SX,
  50. REPLENISHMENT_TABLE_AUTOCOMPLETE_SX,
  51. REPLENISHMENT_TABLE_ENTRY_ROW_SX,
  52. REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX,
  53. REPLENISHMENT_TABLE_SX,
  54. REPLENISHMENT_LOOKUP_BUTTON_SX,
  55. REPLENISHMENT_SOURCE_HEADER_SX,
  56. REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX,
  57. REPLENISHMENT_TEXTFIELD_SX,
  58. ReplenishmentFieldLabel,
  59. ReplenishmentItemEntryPlainText,
  60. ReplenishmentTextField,
  61. replenishmentSearchGridInputSx,
  62. replenishmentSearchGridLabelSx,
  63. replenishmentSearchGridShopRowSx,
  64. } from "./ReplenishmentFilterField";
  65. export type ReplenishmentStatus = "pending" | "processing" | "completed";
  66. export type ReplenishmentDraftRow = {
  67. rowId: string;
  68. deliveryDate: string;
  69. sourceDoId: number;
  70. sourceDoCode: string;
  71. sourceDoLineId: number;
  72. itemId?: number;
  73. itemNo: string;
  74. itemName: string;
  75. originalQty: number;
  76. replenishQty: number;
  77. shortUom?: string;
  78. shopCode?: string;
  79. shopName?: string;
  80. truckLaneCode?: string;
  81. };
  82. export type ReplenishmentRecord = ReplenishmentDraftRow & {
  83. id: number;
  84. code: string;
  85. targetDoId?: number;
  86. targetDoCode?: string;
  87. pickOrderLineId?: number;
  88. status: ReplenishmentStatus;
  89. created: string;
  90. };
  91. type SourceDoContext = {
  92. doId: number;
  93. doCode: string;
  94. shopCode?: string;
  95. shopName?: string;
  96. truckLaneCode?: string | null;
  97. status: string;
  98. lines: DoDetailLine[];
  99. };
  100. function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord {
  101. return {
  102. rowId: `record-${record.id}`,
  103. deliveryDate: record.deliveryDate,
  104. sourceDoId: record.sourceDoId,
  105. sourceDoCode: record.sourceDoCode ?? "",
  106. sourceDoLineId: record.sourceDoLineId,
  107. itemId: record.itemId,
  108. itemNo: record.itemNo ?? "",
  109. itemName: record.itemName ?? "",
  110. originalQty: 0,
  111. replenishQty: Number(record.replenishQty),
  112. shortUom: record.shortUom,
  113. shopCode: record.shopCode,
  114. shopName: record.shopName,
  115. truckLaneCode: record.truckLaneCode,
  116. id: record.id,
  117. code: record.code,
  118. targetDoId: record.targetDoId,
  119. targetDoCode: record.targetDoCode,
  120. pickOrderLineId: record.pickOrderLineId,
  121. status: record.status as ReplenishmentStatus,
  122. created: record.created ?? "",
  123. };
  124. }
  125. /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */
  126. function matchesShopInput(detail: DoDetail, shopInput: string): boolean {
  127. const normalized = shopInput.trim().toLowerCase();
  128. if (!normalized) return false;
  129. const code = detail.shopCode?.toLowerCase() ?? "";
  130. const name = detail.shopName?.toLowerCase() ?? "";
  131. return code.includes(normalized) || name.startsWith(normalized);
  132. }
  133. function matchesDeliveryDate(
  134. estimatedArrivalDate: number[] | undefined,
  135. deliveryDateStr: string,
  136. ): boolean {
  137. if (!estimatedArrivalDate?.length) return false;
  138. return arrayToDateString(estimatedArrivalDate) === deliveryDateStr;
  139. }
  140. function lineUomDisplay(line?: DoDetailLine | null): string {
  141. if (!line) return "";
  142. return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim();
  143. }
  144. type DraftDoGroup = {
  145. sourceDoId: number;
  146. sourceDoCode: string;
  147. rows: ReplenishmentDraftRow[];
  148. };
  149. type DraftShopGroup = {
  150. shopKey: string;
  151. shopCode: string;
  152. shopName?: string;
  153. dos: DraftDoGroup[];
  154. };
  155. function draftShopGroupKey(row: ReplenishmentDraftRow): string {
  156. return row.shopCode?.trim() || row.shopName?.trim() || "—";
  157. }
  158. function groupDraftRowsByShopAndDo(rows: ReplenishmentDraftRow[]): DraftShopGroup[] {
  159. const shopMap = new Map<string, DraftShopGroup>();
  160. for (const row of rows) {
  161. const shopKey = draftShopGroupKey(row);
  162. let shopGroup = shopMap.get(shopKey);
  163. if (!shopGroup) {
  164. shopGroup = {
  165. shopKey,
  166. shopCode: row.shopCode?.trim() || "",
  167. shopName: row.shopName,
  168. dos: [],
  169. };
  170. shopMap.set(shopKey, shopGroup);
  171. }
  172. let doGroup = shopGroup.dos.find((group) => group.sourceDoId === row.sourceDoId);
  173. if (!doGroup) {
  174. doGroup = {
  175. sourceDoId: row.sourceDoId,
  176. sourceDoCode: row.sourceDoCode,
  177. rows: [],
  178. };
  179. shopGroup.dos.push(doGroup);
  180. }
  181. doGroup.rows.push(row);
  182. }
  183. return Array.from(shopMap.values())
  184. .sort((a, b) => a.shopKey.localeCompare(b.shopKey, undefined, { numeric: true }))
  185. .map((shopGroup) => ({
  186. ...shopGroup,
  187. dos: shopGroup.dos
  188. .sort((a, b) =>
  189. a.sourceDoCode.localeCompare(b.sourceDoCode, undefined, { numeric: true }),
  190. )
  191. .map((doGroup) => ({
  192. ...doGroup,
  193. rows: [...doGroup.rows],
  194. })),
  195. }));
  196. }
  197. const DoReplenishmentTab: React.FC = () => {
  198. const { t } = useTranslation("do");
  199. const inFlightRef = useRef(false);
  200. const itemCodeInputRef = useRef<HTMLInputElement>(null);
  201. const [isSubmitting, setIsSubmitting] = useState(false);
  202. const [isLookingUp, setIsLookingUp] = useState(false);
  203. const [deliveryDate, setDeliveryDate] = useState<Dayjs | null>(dayjs());
  204. const [doCodeSuffix, setDoCodeSuffix] = useState("");
  205. const [shopInput, setShopInput] = useState("");
  206. const [sourceDo, setSourceDo] = useState<SourceDoContext | null>(null);
  207. const [selectedLine, setSelectedLine] = useState<DoDetailLine | null>(null);
  208. const [replenishQtyInput, setReplenishQtyInput] = useState("");
  209. const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]);
  210. const [records, setRecords] = useState<ReplenishmentRecord[]>([]);
  211. const [isLoadingTracking, setIsLoadingTracking] = useState(false);
  212. const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all");
  213. const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null);
  214. const [trackingDialogOpen, setTrackingDialogOpen] = useState(false);
  215. const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? "";
  216. const handleLookupSourceDo = useCallback(async () => {
  217. const suffix = doCodeSuffix.trim();
  218. const shop = shopInput.trim();
  219. if (suffix.length !== 4) {
  220. await Swal.fire({
  221. icon: "warning",
  222. title: t("DO code suffix must be exactly 4 characters"),
  223. });
  224. return;
  225. }
  226. if (!deliveryDateStr) {
  227. await Swal.fire({ icon: "warning", title: t("Delivery date is required") });
  228. return;
  229. }
  230. if (!shop) {
  231. await Swal.fire({ icon: "warning", title: t("Shop code or name is required") });
  232. return;
  233. }
  234. setIsLookingUp(true);
  235. try {
  236. const searchRes = await fetchDoSearch(
  237. suffix,
  238. "",
  239. "completed",
  240. "",
  241. "",
  242. `${deliveryDateStr}T00:00:00`,
  243. "",
  244. 1,
  245. 100,
  246. undefined,
  247. null,
  248. null,
  249. );
  250. const candidates = searchRes.records.filter(
  251. (r) =>
  252. r.code.endsWith(suffix) &&
  253. matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr),
  254. );
  255. if (candidates.length === 0) {
  256. await Swal.fire({ icon: "error", title: t("Source DO not found") });
  257. setSourceDo(null);
  258. return;
  259. }
  260. const details = await Promise.all(candidates.map((c) => fetchDoDetail(c.id)));
  261. const matched = details.filter((d) => matchesShopInput(d, shop));
  262. if (matched.length === 0) {
  263. await Swal.fire({ icon: "error", title: t("Source DO not found") });
  264. setSourceDo(null);
  265. return;
  266. }
  267. if (matched.length > 1) {
  268. await Swal.fire({
  269. icon: "error",
  270. title: t("Multiple source DOs matched"),
  271. text: t("Please verify DO code suffix, delivery date and shop."),
  272. });
  273. setSourceDo(null);
  274. return;
  275. }
  276. const detail = matched[0];
  277. const matchedCandidate = candidates.find((c) => c.id === detail.id);
  278. const resolvedTruckLaneCode =
  279. detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null;
  280. if (detail.status !== "completed") {
  281. await Swal.fire({
  282. icon: "error",
  283. title: t("Source DO must be completed"),
  284. text: t("Only completed delivery orders can be used as replenishment source."),
  285. });
  286. setSourceDo(null);
  287. return;
  288. }
  289. setSourceDo({
  290. doId: detail.id,
  291. doCode: detail.code,
  292. shopCode: detail.shopCode,
  293. shopName: detail.shopName,
  294. truckLaneCode: resolvedTruckLaneCode,
  295. status: detail.status,
  296. lines: detail.deliveryOrderLines ?? [],
  297. });
  298. setSelectedLine(null);
  299. setReplenishQtyInput("");
  300. } catch {
  301. await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") });
  302. } finally {
  303. setIsLookingUp(false);
  304. }
  305. }, [deliveryDateStr, doCodeSuffix, shopInput, t]);
  306. const handleAddDraftRow = useCallback(() => {
  307. if (!sourceDo) {
  308. void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") });
  309. return;
  310. }
  311. if (!deliveryDateStr) {
  312. void Swal.fire({ icon: "warning", title: t("Delivery date is required") });
  313. return;
  314. }
  315. if (!selectedLine) {
  316. void Swal.fire({ icon: "warning", title: t("Please select an item") });
  317. return;
  318. }
  319. const qty = Number(replenishQtyInput);
  320. if (!Number.isFinite(qty) || qty <= 0) {
  321. void Swal.fire({ icon: "warning", title: t("Replenish qty must be greater than zero") });
  322. return;
  323. }
  324. const line = selectedLine;
  325. const existingRowIndex = draftRows.findIndex(
  326. (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId,
  327. );
  328. if (existingRowIndex >= 0) {
  329. setDraftRows((prev) =>
  330. prev.map((row, index) =>
  331. index === existingRowIndex
  332. ? { ...row, replenishQty: row.replenishQty + qty }
  333. : row,
  334. ),
  335. );
  336. } else {
  337. setDraftRows((prev) => [
  338. ...prev,
  339. {
  340. rowId: `draft-${Date.now()}-${prev.length}`,
  341. deliveryDate: deliveryDateStr,
  342. sourceDoId: sourceDo.doId,
  343. sourceDoCode: sourceDo.doCode,
  344. sourceDoLineId: line.id,
  345. itemNo: line.itemNo ?? "",
  346. itemName: line.itemName ?? line.itemNo ?? "",
  347. originalQty: line.qty ?? 0,
  348. replenishQty: qty,
  349. shortUom: lineUomDisplay(line) || undefined,
  350. shopCode: sourceDo.shopCode,
  351. shopName: sourceDo.shopName,
  352. truckLaneCode: sourceDo.truckLaneCode?.trim() || undefined,
  353. },
  354. ]);
  355. }
  356. setSelectedLine(null);
  357. setReplenishQtyInput("");
  358. window.setTimeout(() => itemCodeInputRef.current?.focus(), 0);
  359. }, [
  360. deliveryDateStr,
  361. draftRows,
  362. replenishQtyInput,
  363. selectedLine,
  364. sourceDo,
  365. t,
  366. ]);
  367. const handleRemoveDraftRow = useCallback((rowId: string) => {
  368. setDraftRows((prev) => prev.filter((r) => r.rowId !== rowId));
  369. }, []);
  370. const handleClearDraftRows = useCallback(() => {
  371. setDraftRows([]);
  372. }, []);
  373. const handleSubmit = useCallback(async () => {
  374. if (inFlightRef.current) return;
  375. if (draftRows.length === 0) {
  376. await Swal.fire({ icon: "warning", title: t("No draft rows to submit") });
  377. return;
  378. }
  379. inFlightRef.current = true;
  380. setIsSubmitting(true);
  381. try {
  382. const created = await submitDoReplenishment(
  383. draftRows.map((row) => ({
  384. deliveryDate: row.deliveryDate,
  385. sourceDoId: row.sourceDoId,
  386. sourceDoLineId: row.sourceDoLineId,
  387. replenishQty: row.replenishQty,
  388. truckLaneCode: row.truckLaneCode,
  389. })),
  390. );
  391. setDraftRows([]);
  392. await Swal.fire({
  393. icon: "success",
  394. title: t("Replenishment submitted successfully"),
  395. text: created.map((row) => row.code).join(", "),
  396. });
  397. } catch (error: unknown) {
  398. const message =
  399. error instanceof Error ? error.message : t("Failed to submit replenishment");
  400. await Swal.fire({ icon: "error", title: message });
  401. } finally {
  402. setIsSubmitting(false);
  403. inFlightRef.current = false;
  404. }
  405. }, [draftRows, t]);
  406. const loadTrackingRecords = useCallback(async () => {
  407. setIsLoadingTracking(true);
  408. try {
  409. const data = await fetchDoReplenishmentList({
  410. deliveryDate: trackDateFilter?.format("YYYY-MM-DD"),
  411. status: trackStatusFilter,
  412. });
  413. setRecords(data.map(mapApiRecord));
  414. } catch {
  415. await Swal.fire({ icon: "error", title: t("Failed to load replenishment records") });
  416. } finally {
  417. setIsLoadingTracking(false);
  418. }
  419. }, [trackDateFilter, trackStatusFilter, t]);
  420. useEffect(() => {
  421. if (trackingDialogOpen) {
  422. void loadTrackingRecords();
  423. }
  424. }, [trackingDialogOpen, loadTrackingRecords]);
  425. const trackColumns: GridColDef<ReplenishmentRecord>[] = useMemo(
  426. () => [
  427. { field: "code", headerName: t("Replenishment Code"), width: 140 },
  428. { field: "sourceDoCode", headerName: t("Source DO"), width: 120 },
  429. { field: "shopName", headerName: t("Shop Name"), flex: 1, minWidth: 120 },
  430. {
  431. field: "truckLaneCode",
  432. headerName: t("Truck Lance Code"),
  433. width: 120,
  434. valueGetter: (params) => params.row.truckLaneCode ?? "—",
  435. },
  436. { field: "itemNo", headerName: t("Item No."), width: 100 },
  437. { field: "itemName", headerName: t("Item Name"), flex: 1, minWidth: 120 },
  438. {
  439. field: "replenishQty",
  440. headerName: t("Replenish Qty"),
  441. width: 120,
  442. valueGetter: (params) => {
  443. const row = params.row as ReplenishmentRecord;
  444. return row.shortUom ? `${row.replenishQty} ${row.shortUom}` : row.replenishQty;
  445. },
  446. },
  447. {
  448. field: "targetDoCode",
  449. headerName: t("Target DO"),
  450. width: 120,
  451. valueGetter: (params) => params.row.targetDoCode ?? "—",
  452. },
  453. {
  454. field: "status",
  455. headerName: t("Status"),
  456. width: 110,
  457. valueFormatter: (params) => t(String(params.value)),
  458. },
  459. {
  460. field: "created",
  461. headerName: t("Created"),
  462. width: 160,
  463. valueFormatter: (params) =>
  464. params.value ? dayjs(String(params.value)).format("YYYY-MM-DD HH:mm") : "",
  465. },
  466. ],
  467. [t],
  468. );
  469. const selectedLineUom = lineUomDisplay(selectedLine);
  470. const sourceTruckLaneDisplay = sourceDo
  471. ? sourceDo.truckLaneCode?.trim()
  472. ? sourceDo.truckLaneCode
  473. : t("Truck X")
  474. : "";
  475. const datePickerSlotProps = useMemo(
  476. () => ({
  477. textField: {
  478. size: "small" as const,
  479. fullWidth: true,
  480. variant: "filled" as const,
  481. placeholder: t("replenishmentDatePlaceholder"),
  482. sx: REPLENISHMENT_TEXTFIELD_SX,
  483. InputProps: { disableUnderline: true },
  484. },
  485. }),
  486. [t],
  487. );
  488. const groupedDraftRows = useMemo(
  489. () => groupDraftRowsByShopAndDo(draftRows),
  490. [draftRows],
  491. );
  492. const currentDoDraftRows = useMemo(
  493. () =>
  494. sourceDo
  495. ? draftRows.filter((row) => row.sourceDoId === sourceDo.doId)
  496. : [],
  497. [draftRows, sourceDo],
  498. );
  499. const draftPreviewPanel = (
  500. <Paper
  501. variant="outlined"
  502. sx={{
  503. p: 2,
  504. height: "100%",
  505. display: "flex",
  506. flexDirection: "column",
  507. bgcolor: (theme) => (theme.palette.mode === "dark" ? "grey.900" : "common.white"),
  508. }}
  509. >
  510. <Stack spacing={0.5} sx={{ mb: 1.5 }}>
  511. <Typography variant="subtitle2" fontWeight={700}>
  512. {t("Draft List")}
  513. {draftRows.length > 0 ? ` (${draftRows.length})` : ""}
  514. </Typography>
  515. <Typography variant="caption" color="text.secondary">
  516. {t("Replenishment preview hint")}
  517. </Typography>
  518. </Stack>
  519. {draftRows.length === 0 ? (
  520. <Box
  521. sx={{
  522. flex: 1,
  523. display: "flex",
  524. alignItems: "center",
  525. justifyContent: "center",
  526. minHeight: 120,
  527. border: (theme) => `1px dashed ${theme.palette.divider}`,
  528. borderRadius: 2,
  529. px: 2,
  530. }}
  531. >
  532. <Typography variant="body2" color="text.secondary" textAlign="center">
  533. {t("Replenishment preview empty")}
  534. </Typography>
  535. </Box>
  536. ) : (
  537. <Stack
  538. spacing={1.5}
  539. sx={{
  540. flex: 1,
  541. minHeight: 0,
  542. maxHeight: { xs: 360, lg: "calc(100vh - 280px)" },
  543. overflowY: "auto",
  544. overflowX: "hidden",
  545. pr: 0.25,
  546. pb: 1.5,
  547. "& > *": { flexShrink: 0 },
  548. }}
  549. >
  550. {groupedDraftRows.map((shopGroup) => {
  551. const shopDisplay =
  552. shopGroup.shopCode || shopGroup.shopName?.trim() || shopGroup.shopKey;
  553. return (
  554. <Paper
  555. key={shopGroup.shopKey}
  556. variant="outlined"
  557. sx={{
  558. p: 1.5,
  559. flexShrink: 0,
  560. overflow: "visible",
  561. bgcolor: (theme) =>
  562. theme.palette.mode === "dark" ? "grey.800" : "grey.50",
  563. }}
  564. >
  565. <Typography variant="caption" color="text.secondary" display="block">
  566. {t("Shop Code")}
  567. </Typography>
  568. <Tooltip
  569. title={shopGroup.shopName?.trim() ? shopGroup.shopName : ""}
  570. placement="top"
  571. arrow
  572. disableHoverListener={!shopGroup.shopName?.trim()}
  573. >
  574. <Typography
  575. variant="subtitle2"
  576. fontWeight={700}
  577. sx={{ wordBreak: "break-all", lineHeight: 1.4, mb: 1 }}
  578. >
  579. {shopDisplay}
  580. </Typography>
  581. </Tooltip>
  582. <Stack spacing={1}>
  583. {shopGroup.dos.map((doGroup) => (
  584. <Box
  585. key={`${shopGroup.shopKey}-${doGroup.sourceDoId}`}
  586. sx={(theme) => ({
  587. border: `1px solid ${theme.palette.divider}`,
  588. borderRadius: 1.5,
  589. p: 1.25,
  590. bgcolor:
  591. theme.palette.mode === "dark" ? "grey.900" : "common.white",
  592. })}
  593. >
  594. <Typography variant="caption" color="text.secondary" display="block">
  595. {t("Delivery Order Code")}
  596. </Typography>
  597. <Tooltip title={doGroup.sourceDoCode} placement="top" arrow>
  598. <Typography
  599. variant="body2"
  600. fontWeight={600}
  601. sx={{ wordBreak: "break-all", lineHeight: 1.4, mb: 1 }}
  602. >
  603. {doGroup.sourceDoCode}
  604. </Typography>
  605. </Tooltip>
  606. <Stack spacing={0.75}>
  607. {doGroup.rows.map((row) => {
  608. const qtyLabel = row.shortUom
  609. ? `${row.replenishQty} ${row.shortUom}`
  610. : String(row.replenishQty);
  611. return (
  612. <Box
  613. key={row.rowId}
  614. sx={(theme) => ({
  615. position: "relative",
  616. pr: 4,
  617. py: 0.75,
  618. px: 1,
  619. borderRadius: 1,
  620. bgcolor:
  621. theme.palette.mode === "dark"
  622. ? "grey.800"
  623. : "grey.50",
  624. })}
  625. >
  626. <IconButton
  627. size="small"
  628. color="error"
  629. onClick={() => handleRemoveDraftRow(row.rowId)}
  630. aria-label={t("Delete")}
  631. sx={{ position: "absolute", top: 2, right: 2 }}
  632. >
  633. <Delete fontSize="small" />
  634. </IconButton>
  635. <Stack
  636. direction="row"
  637. spacing={1}
  638. alignItems="flex-start"
  639. sx={{ mb: 0.25 }}
  640. >
  641. <Typography
  642. variant="body2"
  643. fontWeight={600}
  644. sx={{ flexShrink: 0, whiteSpace: "nowrap" }}
  645. >
  646. {row.itemNo}
  647. </Typography>
  648. <Typography
  649. variant="body2"
  650. color="text.secondary"
  651. sx={{
  652. minWidth: 0,
  653. flex: 1,
  654. lineHeight: 1.4,
  655. display: "-webkit-box",
  656. WebkitLineClamp: 2,
  657. WebkitBoxOrient: "vertical",
  658. overflow: "hidden",
  659. }}
  660. >
  661. {row.itemName}
  662. </Typography>
  663. </Stack>
  664. <Typography variant="body2">
  665. <Box component="span" color="text.secondary">
  666. {t("Replenish Qty")}:{" "}
  667. </Box>
  668. {qtyLabel}
  669. </Typography>
  670. </Box>
  671. );
  672. })}
  673. </Stack>
  674. </Box>
  675. ))}
  676. </Stack>
  677. </Paper>
  678. );
  679. })}
  680. </Stack>
  681. )}
  682. {draftRows.length > 0 && (
  683. <Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: 1.5 }}>
  684. <Button variant="outlined" color="inherit" onClick={handleClearDraftRows}>
  685. {t("Clear")}
  686. </Button>
  687. <Button variant="contained" onClick={() => void handleSubmit()} disabled={isSubmitting}>
  688. {t("Submit")}
  689. </Button>
  690. </Stack>
  691. )}
  692. </Paper>
  693. );
  694. return (
  695. <Stack spacing={2}>
  696. <Box
  697. sx={{
  698. display: "grid",
  699. gridTemplateColumns: { xs: "1fr", lg: "minmax(0, 1fr) minmax(340px, 420px)" },
  700. gap: 2,
  701. alignItems: "stretch",
  702. }}
  703. >
  704. <Paper
  705. variant="outlined"
  706. sx={{
  707. position: "relative",
  708. p: 2,
  709. bgcolor: (theme) => (theme.palette.mode === "dark" ? "grey.900" : "grey.50"),
  710. }}
  711. >
  712. <Tooltip title={t("Replenishment Tracking")}>
  713. <IconButton
  714. size="small"
  715. onClick={() => setTrackingDialogOpen(true)}
  716. aria-label={t("Replenishment Tracking")}
  717. sx={{
  718. position: "absolute",
  719. top: 8,
  720. right: 8,
  721. zIndex: 1,
  722. color: "text.secondary",
  723. }}
  724. >
  725. <InfoOutlinedIcon fontSize="small" />
  726. </IconButton>
  727. </Tooltip>
  728. <Stack spacing={2}>
  729. <Box
  730. sx={{
  731. display: "grid",
  732. gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
  733. columnGap: 2,
  734. rowGap: 1,
  735. alignItems: "stretch",
  736. pr: { xs: 4, lg: 4 },
  737. }}
  738. >
  739. <ReplenishmentFieldLabel
  740. icon={<CalendarTodayIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
  741. title={t("Estimated Arrival Date")}
  742. required
  743. sx={replenishmentSearchGridLabelSx(1)}
  744. />
  745. <Box sx={replenishmentSearchGridInputSx(1)}>
  746. <LocalizationProvider dateAdapter={AdapterDayjs}>
  747. <DatePicker
  748. format="YYYY-MM-DD"
  749. value={deliveryDate}
  750. onChange={(v) => {
  751. setDeliveryDate(v);
  752. setSourceDo(null);
  753. }}
  754. slotProps={datePickerSlotProps}
  755. sx={{ width: "100%" }}
  756. />
  757. </LocalizationProvider>
  758. </Box>
  759. <ReplenishmentFieldLabel
  760. icon={<ReceiptLongIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
  761. title={t("DO Code Last 4")}
  762. required
  763. sx={replenishmentSearchGridLabelSx(2)}
  764. />
  765. <Box sx={replenishmentSearchGridInputSx(2)}>
  766. <ReplenishmentTextField
  767. value={doCodeSuffix}
  768. onChange={(e) => {
  769. setDoCodeSuffix(e.target.value.slice(0, 4));
  770. setSourceDo(null);
  771. }}
  772. placeholder={t("replenishmentDoSuffixPlaceholder")}
  773. inputProps={{ maxLength: 4 }}
  774. onKeyDown={(e) => {
  775. if (e.key === "Enter") {
  776. e.preventDefault();
  777. void handleLookupSourceDo();
  778. }
  779. }}
  780. />
  781. </Box>
  782. <ReplenishmentFieldLabel
  783. icon={<StorefrontIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />}
  784. title={t("Shop Code")}
  785. required
  786. sx={replenishmentSearchGridLabelSx(3)}
  787. />
  788. <Box sx={replenishmentSearchGridShopRowSx}>
  789. <ReplenishmentTextField
  790. value={shopInput}
  791. onChange={(e) => {
  792. setShopInput(e.target.value);
  793. setSourceDo(null);
  794. }}
  795. placeholder={t("replenishmentShopPlaceholder")}
  796. onKeyDown={(e) => {
  797. if (e.key === "Enter") {
  798. e.preventDefault();
  799. void handleLookupSourceDo();
  800. }
  801. }}
  802. />
  803. <Button
  804. variant="contained"
  805. disableElevation
  806. startIcon={<Search fontSize="small" />}
  807. onClick={() => void handleLookupSourceDo()}
  808. disabled={isLookingUp}
  809. sx={REPLENISHMENT_LOOKUP_BUTTON_SX}
  810. >
  811. {t("Lookup")}
  812. </Button>
  813. </Box>
  814. </Box>
  815. {sourceDo && (
  816. <Box sx={REPLENISHMENT_SOURCE_HEADER_SX}>
  817. <Typography
  818. variant="body2"
  819. fontWeight={700}
  820. sx={{ wordBreak: "break-word", lineHeight: 1.5, width: "100%" }}
  821. >
  822. {t("Delivery Order Code")}: {sourceDo.doCode}
  823. {" "}
  824. {t("Shop Name")}: {sourceDo.shopName ?? "—"}
  825. {" "}
  826. {t("Truck Lance Code")}:{" "}
  827. {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")}
  828. </Typography>
  829. </Box>
  830. )}
  831. {sourceDo && (
  832. <Stack spacing={1.5}>
  833. <TableContainer
  834. sx={(theme) => ({
  835. border: `1px solid ${theme.palette.divider}`,
  836. borderRadius: 2,
  837. bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white",
  838. })}
  839. >
  840. <Table size="small" sx={REPLENISHMENT_TABLE_SX}>
  841. <TableHead>
  842. <TableRow>
  843. <TableCell sx={{ width: { md: "18%" }, minWidth: { md: 168 } }}>
  844. {t("Replenishment item code")}
  845. </TableCell>
  846. <TableCell sx={{ width: { md: "24%" } }}>{t("Item Name")}</TableCell>
  847. <TableCell align="right" sx={{ width: { md: "10%" }, whiteSpace: "nowrap" }}>
  848. {t("Original Shipment Qty")}
  849. </TableCell>
  850. <TableCell align="right" sx={{ width: { md: "10%" }, whiteSpace: "nowrap" }}>
  851. {t("Replenish Qty")}
  852. </TableCell>
  853. <TableCell sx={{ width: { md: "7%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}>
  854. {t("uom")}
  855. </TableCell>
  856. <TableCell
  857. sx={{
  858. width: { md: "15%" },
  859. minWidth: { md: 96 },
  860. maxWidth: 0,
  861. overflow: "hidden",
  862. }}
  863. >
  864. {t("Truck Lance Code")}
  865. </TableCell>
  866. <TableCell
  867. align="center"
  868. sx={{ width: { md: 108 }, minWidth: 108, whiteSpace: "nowrap" }}
  869. >
  870. {t("Action")}
  871. </TableCell>
  872. </TableRow>
  873. </TableHead>
  874. <TableBody>
  875. {currentDoDraftRows.map((row) => (
  876. <TableRow
  877. key={row.rowId}
  878. hover
  879. sx={(theme) => ({
  880. bgcolor:
  881. theme.palette.mode === "dark"
  882. ? "action.selected"
  883. : "action.hover",
  884. })}
  885. >
  886. <TableCell>{row.itemNo}</TableCell>
  887. <TableCell sx={{ wordBreak: "break-word" }}>{row.itemName}</TableCell>
  888. <TableCell align="right">{row.originalQty}</TableCell>
  889. <TableCell align="right">{row.replenishQty}</TableCell>
  890. <TableCell>{row.shortUom || "—"}</TableCell>
  891. <TableCell
  892. sx={{
  893. maxWidth: 0,
  894. overflow: "hidden",
  895. verticalAlign: "middle",
  896. }}
  897. >
  898. <Tooltip
  899. title={
  900. row.truckLaneCode?.trim() ||
  901. sourceDo.truckLaneCode?.trim() ||
  902. t("Truck X")
  903. }
  904. placement="top"
  905. arrow
  906. >
  907. <Box
  908. component="span"
  909. sx={{
  910. display: "block",
  911. overflow: "hidden",
  912. textOverflow: "ellipsis",
  913. whiteSpace: "nowrap",
  914. minWidth: 0,
  915. }}
  916. >
  917. {row.truckLaneCode?.trim() ||
  918. sourceDo.truckLaneCode?.trim() ||
  919. t("Truck X")}
  920. </Box>
  921. </Tooltip>
  922. </TableCell>
  923. <TableCell align="center">
  924. <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
  925. <IconButton
  926. size="small"
  927. color="error"
  928. onClick={() => handleRemoveDraftRow(row.rowId)}
  929. aria-label={t("Delete")}
  930. >
  931. <Delete fontSize="small" />
  932. </IconButton>
  933. </Box>
  934. </TableCell>
  935. </TableRow>
  936. ))}
  937. <TableRow sx={REPLENISHMENT_TABLE_ENTRY_ROW_SX}>
  938. <TableCell>
  939. <Autocomplete
  940. size="small"
  941. fullWidth
  942. options={sourceDo.lines}
  943. value={selectedLine}
  944. onChange={(_, newValue) => setSelectedLine(newValue)}
  945. getOptionLabel={(line) => line.itemNo ?? ""}
  946. isOptionEqualToValue={(a, b) => a.id === b.id}
  947. filterOptions={(options, { inputValue }) => {
  948. const query = inputValue.trim().toLowerCase();
  949. if (!query) return options;
  950. return options.filter((line) =>
  951. (line.itemNo ?? "").toLowerCase().includes(query),
  952. );
  953. }}
  954. renderInput={(params) => {
  955. const { inputProps } = params;
  956. return (
  957. <ReplenishmentTextField
  958. {...params}
  959. placeholder={t("Replenishment item code")}
  960. inputProps={{
  961. ...inputProps,
  962. ref: (node: HTMLInputElement | null) => {
  963. itemCodeInputRef.current = node;
  964. const { ref } = inputProps;
  965. if (typeof ref === "function") ref(node);
  966. else if (ref) {
  967. (
  968. ref as React.MutableRefObject<HTMLInputElement | null>
  969. ).current = node;
  970. }
  971. },
  972. }}
  973. InputProps={{ ...params.InputProps, disableUnderline: true }}
  974. />
  975. );
  976. }}
  977. sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX}
  978. />
  979. </TableCell>
  980. <TableCell>
  981. <ReplenishmentItemEntryPlainText
  982. value={
  983. selectedLine ? (selectedLine.itemName ?? selectedLine.itemNo ?? "") : ""
  984. }
  985. />
  986. </TableCell>
  987. <TableCell align="right">
  988. <ReplenishmentItemEntryPlainText
  989. reserveSpace
  990. value={selectedLine != null ? String(selectedLine.qty ?? "") : ""}
  991. sx={{ whiteSpace: "nowrap", textAlign: "right", minHeight: "unset" }}
  992. />
  993. </TableCell>
  994. <TableCell align="right">
  995. <Box sx={{ display: "flex", justifyContent: "flex-end" }}>
  996. <ReplenishmentTextField
  997. type="number"
  998. hiddenLabel
  999. fullWidth={false}
  1000. value={replenishQtyInput}
  1001. onChange={(e) => setReplenishQtyInput(e.target.value)}
  1002. inputProps={{ min: 0, step: "any", style: { textAlign: "right" } }}
  1003. sx={(theme) => ({
  1004. ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme),
  1005. width: 72,
  1006. "& .MuiFilledInput-input": {
  1007. textAlign: "right",
  1008. },
  1009. })}
  1010. />
  1011. </Box>
  1012. </TableCell>
  1013. <TableCell>
  1014. <ReplenishmentItemEntryPlainText
  1015. reserveSpace
  1016. value={selectedLineUom}
  1017. sx={{ whiteSpace: "nowrap" }}
  1018. />
  1019. </TableCell>
  1020. <TableCell
  1021. sx={{
  1022. maxWidth: 0,
  1023. overflow: "hidden",
  1024. verticalAlign: "middle",
  1025. }}
  1026. >
  1027. <Tooltip title={sourceTruckLaneDisplay} placement="top" arrow>
  1028. <Box
  1029. component="span"
  1030. sx={{
  1031. display: "block",
  1032. overflow: "hidden",
  1033. textOverflow: "ellipsis",
  1034. whiteSpace: "nowrap",
  1035. minWidth: 0,
  1036. minHeight: (theme) => theme.spacing(5),
  1037. lineHeight: (theme) => theme.spacing(5),
  1038. }}
  1039. >
  1040. {sourceTruckLaneDisplay}
  1041. </Box>
  1042. </Tooltip>
  1043. </TableCell>
  1044. <TableCell align="center">
  1045. <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}>
  1046. <Button
  1047. variant="outlined"
  1048. size="small"
  1049. startIcon={<Add />}
  1050. onClick={handleAddDraftRow}
  1051. sx={{
  1052. borderRadius: 2,
  1053. textTransform: "none",
  1054. whiteSpace: "nowrap",
  1055. px: 1.5,
  1056. fontSize: (theme) => theme.typography.body2.fontSize,
  1057. }}
  1058. >
  1059. {t("Add Row")}
  1060. </Button>
  1061. </Box>
  1062. </TableCell>
  1063. </TableRow>
  1064. </TableBody>
  1065. </Table>
  1066. </TableContainer>
  1067. </Stack>
  1068. )}
  1069. </Stack>
  1070. </Paper>
  1071. <Box sx={{ display: { xs: "none", lg: "block" }, minHeight: 0 }}>
  1072. {draftPreviewPanel}
  1073. </Box>
  1074. </Box>
  1075. <Box sx={{ display: { xs: "block", lg: "none" } }}>{draftPreviewPanel}</Box>
  1076. <Dialog
  1077. open={trackingDialogOpen}
  1078. onClose={() => setTrackingDialogOpen(false)}
  1079. maxWidth="lg"
  1080. fullWidth
  1081. >
  1082. <DialogTitle
  1083. sx={{
  1084. display: "flex",
  1085. alignItems: "center",
  1086. justifyContent: "space-between",
  1087. pr: 1,
  1088. }}
  1089. >
  1090. {t("Replenishment Tracking")}
  1091. <IconButton
  1092. size="small"
  1093. onClick={() => setTrackingDialogOpen(false)}
  1094. aria-label={t("Cancel")}
  1095. >
  1096. <Close fontSize="small" />
  1097. </IconButton>
  1098. </DialogTitle>
  1099. <DialogContent dividers sx={{ p: 0 }}>
  1100. <Box sx={{ px: 2, pt: 1.5, pb: 1 }}>
  1101. <Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
  1102. <Box sx={{ minWidth: 200, maxWidth: 280 }}>
  1103. <LocalizationProvider dateAdapter={AdapterDayjs}>
  1104. <DatePicker
  1105. format="YYYY-MM-DD"
  1106. value={trackDateFilter}
  1107. onChange={(v) => setTrackDateFilter(v)}
  1108. slotProps={datePickerSlotProps}
  1109. />
  1110. </LocalizationProvider>
  1111. </Box>
  1112. <FormControl size="small" sx={{ minWidth: 160 }}>
  1113. <InputLabel>{t("Status")}</InputLabel>
  1114. <Select
  1115. label={t("Status")}
  1116. value={trackStatusFilter}
  1117. onChange={(e) =>
  1118. setTrackStatusFilter(e.target.value as ReplenishmentStatus | "all")
  1119. }
  1120. >
  1121. <MenuItem value="all">{t("All")}</MenuItem>
  1122. <MenuItem value="pending">{t("pending")}</MenuItem>
  1123. <MenuItem value="processing">{t("processing")}</MenuItem>
  1124. <MenuItem value="completed">{t("completed")}</MenuItem>
  1125. </Select>
  1126. </FormControl>
  1127. </Stack>
  1128. </Box>
  1129. <StyledDataGrid
  1130. rows={records}
  1131. columns={trackColumns}
  1132. autoHeight
  1133. loading={isLoadingTracking}
  1134. disableRowSelectionOnClick
  1135. pageSizeOptions={[10, 25, 50]}
  1136. initialState={{ pagination: { paginationModel: { pageSize: 10 } } }}
  1137. />
  1138. </DialogContent>
  1139. </Dialog>
  1140. </Stack>
  1141. );
  1142. };
  1143. export default DoReplenishmentTab;