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

1351 строка
50 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Box,
  5. Button,
  6. Card,
  7. Stack,
  8. Typography,
  9. Chip,
  10. CircularProgress,
  11. Grid,
  12. FormControl,
  13. InputLabel,
  14. Select,
  15. MenuItem,
  16. Checkbox,
  17. ListItemText,
  18. SelectChangeEvent,
  19. Dialog,
  20. DialogTitle,
  21. DialogContent,
  22. DialogActions,
  23. Tabs,
  24. Tab,
  25. Tooltip,
  26. IconButton,
  27. Avatar,
  28. } from "@mui/material";
  29. import ChevronLeft from "@mui/icons-material/ChevronLeft";
  30. import ChevronRight from "@mui/icons-material/ChevronRight";
  31. import { useTranslation } from "react-i18next";
  32. import type { StockInLine } from "@/app/api/stockIn";
  33. import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
  34. import QcStockInModal from "../Qc/QcStockInModal";
  35. import { useSession } from "next-auth/react";
  36. import { SessionWithTokens } from "@/config/authConfig";
  37. import dayjs from "dayjs";
  38. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  39. import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
  40. import { AUTH, hasAbility } from "@/authorities";
  41. import PrinterSelect from "@/components/common/PrinterSelect";
  42. import {
  43. AllJoborderProductProcessInfoResponse,
  44. updateJo,
  45. fetchProductProcessesByJobOrderId,
  46. completeProductProcessLine,
  47. assignJobOrderPickOrder,
  48. fetchJoborderProductProcessesPage,
  49. JobOrderProductProcessBucketCounts,
  50. printFGStockInLabel,
  51. PrintFGStockInLabelRequest,
  52. } from "@/app/api/jo/actions";
  53. import { StockInLineInput } from "@/app/api/stockIn";
  54. import { PrinterCombo } from "@/app/api/settings/printer";
  55. import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan";
  56. export type ProductionProcessListTab =
  57. | "needs_action"
  58. | "pending"
  59. | "processing"
  60. | "carried_over"
  61. | "pending_qc"
  62. | "putawayed";
  63. export type ProductionProcessListPersistedState = {
  64. date: string;
  65. itemCode: string | null;
  66. jobOrderCode: string | null;
  67. filter: "all" | "drink" | "Powder_Mixture" | "other";
  68. page: number;
  69. selectedItemCodes: string[];
  70. /**
  71. * Unified list tabs:
  72. * needs_action | pending | processing | carried_over | pending_qc | putawayed
  73. * Legacy: all → needs_action; fine pick buckets remapped to pending/processing.
  74. */
  75. pickBucket: ProductionProcessListTab | string;
  76. };
  77. interface ProductProcessListProps {
  78. onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void;
  79. onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void;
  80. printerCombo: PrinterCombo[];
  81. /** @deprecated Derived from pickBucket when unified tabs are used; kept for compatibility. */
  82. qcReady?: boolean;
  83. includePutaway?: boolean | null;
  84. /** all | completed | notCompleted */
  85. putawayStatus?: string | null;
  86. disableDateFilter?: boolean;
  87. listPersistedState: ProductionProcessListPersistedState;
  88. onListPersistedStateChange: React.Dispatch<
  89. React.SetStateAction<ProductionProcessListPersistedState>
  90. >;
  91. }
  92. export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType";
  93. /** Cards per visible page: 2 rows × 3 columns. */
  94. const CARDS_PER_PAGE = 6;
  95. /** Fetch once; client slides pages of CARDS_PER_PAGE (no refetch on page change). */
  96. const FETCH_SIZE = 200;
  97. /** Include unfinished from (searchDate - LOOKBACK_DAYS) .. searchDate; picked_not_started capped at search date on backend. */
  98. const PRODUCTION_LOOKBACK_DAYS = 4;
  99. const PENDING_FINE_BUCKETS = new Set([
  100. "not_picked_not_started",
  101. "picked_not_started",
  102. ]);
  103. const PROCESSING_FINE_BUCKETS = new Set([
  104. "picked_started",
  105. "not_picked_started",
  106. ]);
  107. const EMPTY_BUCKET_COUNTS: JobOrderProductProcessBucketCounts = {
  108. notPickedNotStarted: 0,
  109. pickedNotStarted: 0,
  110. pickedStarted: 0,
  111. notPickedStarted: 0,
  112. };
  113. /** 預設依 JobOrder.planStart 搜索:今天往前 3 天~往後 3 天(含當日) */
  114. function defaultPlanStartRange() {
  115. return {
  116. from: dayjs().subtract(0, "day").format("YYYY-MM-DD"),
  117. to: dayjs().add(0, "day").format("YYYY-MM-DD"),
  118. };
  119. }
  120. export function createDefaultProductionProcessListPersistedState(): ProductionProcessListPersistedState {
  121. return {
  122. date: dayjs().format("YYYY-MM-DD"),
  123. itemCode: null,
  124. jobOrderCode: null,
  125. filter: "all",
  126. page: 0,
  127. selectedItemCodes: [],
  128. pickBucket: "needs_action",
  129. };
  130. }
  131. function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab {
  132. const v = (raw || "needs_action").trim();
  133. if (
  134. v === "pending" ||
  135. v === "processing" ||
  136. v === "carried_over" ||
  137. v === "pending_qc" ||
  138. v === "putawayed" ||
  139. v === "needs_action"
  140. ) {
  141. return v;
  142. }
  143. if (v === "all") return "needs_action";
  144. if (v === "not_picked_not_started" || v === "picked_not_started") return "pending";
  145. if (v === "picked_started" || v === "not_picked_started") return "processing";
  146. return "needs_action";
  147. }
  148. function isProcessCarriedOver(
  149. p: AllJoborderProductProcessInfoResponse,
  150. searchDay: ReturnType<typeof dayjs> | null,
  151. ): boolean {
  152. if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false;
  153. return dayjs(p.date).startOf("day").isBefore(searchDay);
  154. }
  155. function isPutawayCompleted(
  156. p: AllJoborderProductProcessInfoResponse,
  157. ): boolean {
  158. return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
  159. }
  160. /** Waiting QC put-away (has SIL, not completed/rejected). */
  161. function isWaitingQcPutAway(
  162. p: AllJoborderProductProcessInfoResponse,
  163. ): boolean {
  164. if (p.stockInLineId == null) return false;
  165. const s = String(p.stockInLineStatus ?? "").trim().toLowerCase();
  166. return s !== "completed" && s !== "rejected";
  167. }
  168. /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */
  169. const ProductProcessList: React.FC<ProductProcessListProps> = ({
  170. onSelectProcess,
  171. printerCombo,
  172. onSelectMatchingStock,
  173. disableDateFilter = false,
  174. listPersistedState,
  175. onListPersistedStateChange,
  176. }) => {
  177. const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard","jo"]);
  178. const { data: session } = useSession() as { data: SessionWithTokens | null };
  179. const sessionToken = session as SessionWithTokens | null;
  180. const [loading, setLoading] = useState(false);
  181. /** Bumps on Search/Reset so the same criteria still refetch. */
  182. const [listReloadKey, setListReloadKey] = useState(0);
  183. const [productionCache, setProductionCache] = useState<
  184. AllJoborderProductProcessInfoResponse[]
  185. >([]);
  186. const [pendingQcCache, setPendingQcCache] = useState<
  187. AllJoborderProductProcessInfoResponse[]
  188. >([]);
  189. const [putawayedCache, setPutawayedCache] = useState<
  190. AllJoborderProductProcessInfoResponse[]
  191. >([]);
  192. const [bucketCounts, setBucketCounts] =
  193. useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS);
  194. const [pendingQcCount, setPendingQcCount] = useState(0);
  195. const [putawayedCount, setPutawayedCount] = useState(0);
  196. const [carriedOverCount, setCarriedOverCount] = useState(0);
  197. const [openModal, setOpenModal] = useState<boolean>(false);
  198. const [modalInfo, setModalInfo] = useState<StockInLineInput>();
  199. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  200. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  201. /** 完成工單:僅 ADMIN */
  202. const canManageUpdateJo = hasAbility(abilities, AUTH.ADMIN);
  203. type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other";
  204. const listTab = normalizeListTab(listPersistedState.pickBucket);
  205. const isProductionTab =
  206. listTab === "needs_action" || listTab === "pending" || listTab === "processing";
  207. const isCarriedOverTab = listTab === "carried_over";
  208. /** Production unfinished tabs + pending QC + off-plan: carry-over window. */
  209. const enableCarryOver =
  210. !disableDateFilter &&
  211. (isProductionTab || listTab === "pending_qc" || isCarriedOverTab);
  212. const labelPrinterCombo = useMemo(
  213. () => (printerCombo || []).filter((p) => p.type === "Label"),
  214. [printerCombo],
  215. );
  216. const printerOptions = useMemo(
  217. () => (labelPrinterCombo.length > 0 ? labelPrinterCombo : printerCombo || []),
  218. [labelPrinterCombo, printerCombo],
  219. );
  220. const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>(
  221. () => printerOptions[0] ?? null,
  222. );
  223. const printInFlightRef = useRef(false);
  224. const loadSeqRef = useRef(0);
  225. const hasLoadedOnceRef = useRef(false);
  226. const [printingSilId, setPrintingSilId] = useState<number | null>(null);
  227. useEffect(() => {
  228. if (!printerOptions.length) {
  229. setSelectedPrinter(null);
  230. return;
  231. }
  232. setSelectedPrinter((prev) => {
  233. if (prev && printerOptions.some((p) => p.id === prev.id)) return prev;
  234. return printerOptions[0];
  235. });
  236. }, [printerOptions]);
  237. const appliedSearch = useMemo(
  238. () => ({
  239. date: listPersistedState.date,
  240. itemCode: listPersistedState.itemCode,
  241. jobOrderCode: listPersistedState.jobOrderCode,
  242. }),
  243. [
  244. listPersistedState.date,
  245. listPersistedState.itemCode,
  246. listPersistedState.jobOrderCode,
  247. ],
  248. );
  249. const filter = listPersistedState.filter;
  250. const page = listPersistedState.page;
  251. const selectedItemCodes = listPersistedState.selectedItemCodes;
  252. const searchDay = useMemo(
  253. () => (appliedSearch.date ? dayjs(appliedSearch.date).startOf("day") : null),
  254. [appliedSearch.date],
  255. );
  256. // Generic confirm dialog for actions (update job order / etc.)
  257. const [confirmOpen, setConfirmOpen] = useState(false);
  258. const [confirmMessage, setConfirmMessage] = useState("");
  259. const [confirmLoading, setConfirmLoading] = useState(false);
  260. const [pendingConfirmAction, setPendingConfirmAction] = useState<null | (() => Promise<void>)>(null);
  261. const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
  262. if (!currentUserId) {
  263. alert(t("Unable to get user ID"));
  264. return;
  265. }
  266. try {
  267. console.log("🔄 Assigning pick order:", pickOrderId, "to user:", currentUserId);
  268. // 调用分配 API 并读取响应
  269. const assignResult = await assignJobOrderPickOrder(pickOrderId, currentUserId);
  270. console.log("📦 Assign result:", assignResult);
  271. // 检查分配是否成功
  272. if (assignResult.message === "Successfully assigned") {
  273. console.log("✅ Successfully assigned pick order");
  274. console.log("✅ Pick order ID:", assignResult.id);
  275. console.log("✅ Pick order code:", assignResult.code);
  276. // 分配成功后,导航到 second scan 页面
  277. if (onSelectMatchingStock && jobOrderId) {
  278. onSelectMatchingStock(jobOrderId, productProcessId,pickOrderId);
  279. } else {
  280. alert(t("Assignment successful"));
  281. }
  282. } else {
  283. // 分配失败
  284. console.error("Assignment failed:", assignResult.message);
  285. alert(t(`Assignment failed: ${assignResult.message || "Unknown error"}`));
  286. }
  287. } catch (error: any) {
  288. console.error(" Error assigning pick order:", error);
  289. alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`));
  290. }
  291. }, [currentUserId, t, onSelectMatchingStock]);
  292. const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => {
  293. if (!process.stockInLineId) {
  294. alert(t("Invalid Stock In Line Id"));
  295. return;
  296. }
  297. setModalInfo({
  298. id: process.stockInLineId,
  299. //itemId: process.itemId, // 如果 process 中有 itemId,添加这一行
  300. //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT),
  301. });
  302. setOpenModal(true);
  303. }, [t]);
  304. const handleApplySearch = useCallback(
  305. (inputs: Record<SearchParam | `${SearchParam}To`, string>) => {
  306. const selectedProcessType = (inputs.processType || "all") as ProcessFilter;
  307. setListReloadKey((k) => k + 1);
  308. onListPersistedStateChange((prev) => ({
  309. ...prev,
  310. filter: selectedProcessType,
  311. date: disableDateFilter ? "" : (inputs.date || "").trim(),
  312. itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null,
  313. jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null,
  314. selectedItemCodes: [],
  315. page: 0,
  316. }));
  317. },
  318. [disableDateFilter, onListPersistedStateChange],
  319. );
  320. const handleResetSearch = useCallback(() => {
  321. setListReloadKey((k) => k + 1);
  322. onListPersistedStateChange((prev) => ({
  323. ...prev,
  324. filter: "all",
  325. date: disableDateFilter ? "" : defaultPlanStartRange().from,
  326. itemCode: null,
  327. jobOrderCode: null,
  328. selectedItemCodes: [],
  329. page: 0,
  330. pickBucket: "needs_action",
  331. }));
  332. }, [disableDateFilter, onListPersistedStateChange]);
  333. /** Load all tab datasets once per search; tab switches use cache only. */
  334. const loadAllCaches = useCallback(async (options?: { silent?: boolean }) => {
  335. const silent = options?.silent === true;
  336. const seq = ++loadSeqRef.current;
  337. if (!silent) setLoading(true);
  338. try {
  339. const typeParam = filter === "all" ? undefined : filter;
  340. const base = {
  341. date: disableDateFilter ? undefined : appliedSearch.date,
  342. itemCode: appliedSearch.itemCode,
  343. jobOrderCode: appliedSearch.jobOrderCode,
  344. type: typeParam,
  345. page: 0,
  346. size: FETCH_SIZE,
  347. };
  348. const lookback = disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS;
  349. const [prod, pendingQc, putawayed] = await Promise.all([
  350. fetchJoborderProductProcessesPage({
  351. ...base,
  352. qcReady: false,
  353. lookbackDays: lookback,
  354. bucket: "all",
  355. }),
  356. fetchJoborderProductProcessesPage({
  357. ...base,
  358. qcReady: true,
  359. includePutaway: true,
  360. putawayStatus: "notCompleted",
  361. lookbackDays: lookback,
  362. }),
  363. fetchJoborderProductProcessesPage({
  364. ...base,
  365. qcReady: true,
  366. includePutaway: true,
  367. putawayStatus: "completed",
  368. }),
  369. ]);
  370. if (seq !== loadSeqRef.current) return;
  371. const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p));
  372. const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p));
  373. const putawayContent = putawayed?.content || [];
  374. setProductionCache(prodContent);
  375. setPendingQcCache(qcContent);
  376. setPutawayedCache(putawayContent);
  377. if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
  378. else setBucketCounts(EMPTY_BUCKET_COUNTS);
  379. setPendingQcCount(pendingQc?.totalJobOrders || 0);
  380. setPutawayedCount(putawayed?.totalJobOrders || 0);
  381. setCarriedOverCount(prod?.carriedOverCount ?? 0);
  382. } catch (e) {
  383. console.error(e);
  384. if (seq !== loadSeqRef.current || silent) return;
  385. setProductionCache([]);
  386. setPendingQcCache([]);
  387. setPutawayedCache([]);
  388. setBucketCounts(EMPTY_BUCKET_COUNTS);
  389. setPendingQcCount(0);
  390. setPutawayedCount(0);
  391. setCarriedOverCount(0);
  392. } finally {
  393. if (seq === loadSeqRef.current) setLoading(false);
  394. }
  395. }, [appliedSearch, disableDateFilter, filter, listReloadKey]);
  396. useEffect(() => {
  397. const silent = hasLoadedOnceRef.current;
  398. hasLoadedOnceRef.current = true;
  399. void loadAllCaches(silent ? { silent: true } : undefined);
  400. }, [loadAllCaches]);
  401. const offPlanRows = useMemo(() => {
  402. if (!searchDay) return [] as AllJoborderProductProcessInfoResponse[];
  403. const merged = [...pendingQcCache, ...productionCache].filter((p) =>
  404. isProcessCarriedOver(p, searchDay),
  405. );
  406. const byJo = new Map<number, AllJoborderProductProcessInfoResponse>();
  407. for (const p of merged) {
  408. if (p.jobOrderId == null) continue;
  409. const existing = byJo.get(p.jobOrderId);
  410. if (!existing) {
  411. byJo.set(p.jobOrderId, p);
  412. } else if (existing.stockInLineId == null && p.stockInLineId != null) {
  413. byJo.set(p.jobOrderId, p);
  414. }
  415. }
  416. return Array.from(byJo.values());
  417. }, [productionCache, pendingQcCache, searchDay]);
  418. /** Active tab rows from cache — no refetch on tab switch. */
  419. const tabProcesses = useMemo(() => {
  420. if (listTab === "pending_qc") return pendingQcCache;
  421. if (listTab === "putawayed") return putawayedCache;
  422. if (listTab === "carried_over") return offPlanRows;
  423. return productionCache;
  424. }, [listTab, productionCache, pendingQcCache, putawayedCache, offPlanRows]);
  425. // QC ready: same JO — all lines Completed/Pass/autoPass (sibling 包裝 autoPass on backend) + has SIL
  426. const jobOrderQcReadyById = useMemo(() => {
  427. const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>();
  428. for (const p of tabProcesses) {
  429. if (p.jobOrderId == null) continue;
  430. const arr = byJobOrder.get(p.jobOrderId) ?? [];
  431. arr.push(p);
  432. byJobOrder.set(p.jobOrderId, arr);
  433. }
  434. const result = new Map<number, boolean>();
  435. const isDone = (status: unknown) => {
  436. const s = String(status ?? "").trim().toLowerCase().replace(/\s+/g, "");
  437. return s === "completed" || s === "pass" || s === "autopass";
  438. };
  439. byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
  440. const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
  441. const allLines = jobOrderProcesses.flatMap((p) => p.lines ?? []);
  442. const allLinesDone =
  443. allLines.length > 0 && allLines.every((l) => isDone(l.status));
  444. result.set(jobOrderId, hasStockInLine && allLinesDone);
  445. });
  446. return result;
  447. }, [tabProcesses]);
  448. const handleListTabChange = useCallback(
  449. (_: React.SyntheticEvent, value: string) => {
  450. const next = normalizeListTab(value);
  451. onListPersistedStateChange((prev) => ({
  452. ...prev,
  453. pickBucket: next,
  454. page: 0,
  455. }));
  456. },
  457. [onListPersistedStateChange],
  458. );
  459. const pendingCount =
  460. bucketCounts.notPickedNotStarted + bucketCounts.pickedNotStarted;
  461. const processingCount =
  462. bucketCounts.pickedStarted + bucketCounts.notPickedStarted;
  463. const needsActionCount = pendingCount + processingCount;
  464. const offPlanTabCount = offPlanRows.length;
  465. const filteredProcesses = useMemo(() => {
  466. let list = tabProcesses;
  467. if (listTab === "pending") {
  468. list = list.filter((p) =>
  469. PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
  470. );
  471. } else if (listTab === "processing") {
  472. list = list.filter((p) =>
  473. PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
  474. );
  475. }
  476. if (selectedItemCodes.length === 0) return list;
  477. return list.filter((p) => selectedItemCodes.includes(p.itemCode));
  478. }, [tabProcesses, selectedItemCodes, listTab]);
  479. const displayTotalJobOrders = isCarriedOverTab
  480. ? filteredProcesses.length
  481. : isProductionTab
  482. ? listTab === "pending"
  483. ? pendingCount
  484. : listTab === "processing"
  485. ? processingCount
  486. : needsActionCount
  487. : listTab === "pending_qc"
  488. ? pendingQcCount
  489. : listTab === "putawayed"
  490. ? putawayedCount
  491. : filteredProcesses.length;
  492. const displayCarriedOverCount = useMemo(() => {
  493. if (!enableCarryOver || !searchDay) return 0;
  494. if (!isProductionTab || listTab === "needs_action") return carriedOverCount;
  495. return filteredProcesses.filter((p) => {
  496. if (!p.date || !dayjs(p.date).isValid()) return false;
  497. return dayjs(p.date).startOf("day").isBefore(searchDay);
  498. }).length;
  499. }, [
  500. enableCarryOver,
  501. searchDay,
  502. isProductionTab,
  503. listTab,
  504. carriedOverCount,
  505. filteredProcesses,
  506. ]);
  507. const pageChunks = useMemo(() => {
  508. const chunks: AllJoborderProductProcessInfoResponse[][] = [];
  509. for (let i = 0; i < filteredProcesses.length; i += CARDS_PER_PAGE) {
  510. chunks.push(filteredProcesses.slice(i, i + CARDS_PER_PAGE));
  511. }
  512. return chunks.length > 0 ? chunks : [[]];
  513. }, [filteredProcesses]);
  514. const totalPages = pageChunks.length;
  515. const safePage = Math.min(page, Math.max(0, totalPages - 1));
  516. const scrollerRef = useRef<HTMLDivElement | null>(null);
  517. const scrollSyncLockRef = useRef(false);
  518. const scrollToPage = useCallback(
  519. (targetPage: number, behavior: ScrollBehavior = "smooth") => {
  520. const el = scrollerRef.current;
  521. if (!el) return;
  522. const clamped = Math.max(0, Math.min(targetPage, totalPages - 1));
  523. scrollSyncLockRef.current = true;
  524. el.scrollTo({ left: clamped * el.clientWidth, behavior });
  525. onListPersistedStateChange((prev) =>
  526. prev.page === clamped ? prev : { ...prev, page: clamped },
  527. );
  528. window.setTimeout(() => {
  529. scrollSyncLockRef.current = false;
  530. }, behavior === "smooth" ? 450 : 50);
  531. },
  532. [totalPages, onListPersistedStateChange],
  533. );
  534. const goPrevPage = useCallback(() => {
  535. if (safePage <= 0) return;
  536. scrollToPage(safePage - 1);
  537. }, [safePage, scrollToPage]);
  538. const goNextPage = useCallback(() => {
  539. if (safePage + 1 >= totalPages) return;
  540. scrollToPage(safePage + 1);
  541. }, [safePage, totalPages, scrollToPage]);
  542. const handleScrollerScroll = useCallback(() => {
  543. if (scrollSyncLockRef.current) return;
  544. const el = scrollerRef.current;
  545. if (!el || el.clientWidth <= 0) return;
  546. const nextPage = Math.round(el.scrollLeft / el.clientWidth);
  547. const clamped = Math.max(0, Math.min(nextPage, totalPages - 1));
  548. if (clamped !== page) {
  549. onListPersistedStateChange((prev) =>
  550. prev.page === clamped ? prev : { ...prev, page: clamped },
  551. );
  552. }
  553. }, [page, totalPages, onListPersistedStateChange]);
  554. // After data reload, jump to persisted page without animation.
  555. useEffect(() => {
  556. const el = scrollerRef.current;
  557. if (!el || loading) return;
  558. const clamped = Math.min(page, Math.max(0, totalPages - 1));
  559. scrollSyncLockRef.current = true;
  560. el.scrollTo({ left: clamped * el.clientWidth, behavior: "auto" });
  561. window.setTimeout(() => {
  562. scrollSyncLockRef.current = false;
  563. }, 50);
  564. }, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps
  565. const renderBucketTabLabel = useCallback(
  566. (labelKey: string, count: number) =>
  567. count > 0 ? `${t(labelKey)} (${count})` : t(labelKey),
  568. [t],
  569. );
  570. const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => {
  571. if (!canManageUpdateJo) return;
  572. if (!process.jobOrderId) {
  573. alert(t("Invalid Job Order Id"));
  574. return;
  575. }
  576. try {
  577. setLoading(true); // 可选:已有 loading state 可复用
  578. // 1) 拉取该 JO 的所有 process,取出全部 lineId
  579. const processes = await fetchProductProcessesByJobOrderId(process.jobOrderId);
  580. const lineIds = (processes ?? [])
  581. .flatMap(p => (p as any).productProcessLines ?? [])
  582. .map(l => l.id)
  583. .filter(Boolean);
  584. // 2) 逐个调用 completeProductProcessLine
  585. for (const lineId of lineIds) {
  586. try {
  587. await completeProductProcessLine(lineId);
  588. } catch (e) {
  589. console.error("completeProductProcessLine failed for lineId:", lineId, e);
  590. }
  591. }
  592. // 3) 更新 JO 状态
  593. // await updateJo({ id: process.jobOrderId, status: "completed" });
  594. // 4) 刷新列表
  595. await loadAllCaches();
  596. } catch (e) {
  597. console.error(e);
  598. alert(t("An error has occurred. Please try again later."));
  599. } finally {
  600. setLoading(false);
  601. }
  602. }, [t, loadAllCaches, canManageUpdateJo]);
  603. const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
  604. setConfirmMessage(message);
  605. setPendingConfirmAction(() => action);
  606. setConfirmOpen(true);
  607. }, []);
  608. const closeConfirm = useCallback(() => {
  609. setConfirmOpen(false);
  610. setPendingConfirmAction(null);
  611. setConfirmMessage("");
  612. setConfirmLoading(false);
  613. }, []);
  614. const onConfirm = useCallback(async () => {
  615. if (!pendingConfirmAction) return;
  616. setConfirmLoading(true);
  617. try {
  618. await pendingConfirmAction();
  619. } finally {
  620. closeConfirm();
  621. }
  622. }, [pendingConfirmAction, closeConfirm]);
  623. const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => {
  624. setOpenModal(false);
  625. if (updatedStockInLine) {
  626. void loadAllCaches({ silent: true });
  627. }
  628. }, [loadAllCaches]);
  629. const handlePrintLabel = useCallback(
  630. async (process: AllJoborderProductProcessInfoResponse) => {
  631. if (printInFlightRef.current) return;
  632. if (!selectedPrinter) {
  633. alert(t("Please select a label printer"));
  634. return;
  635. }
  636. if (!process.stockInLineId) {
  637. alert(t("Invalid Stock In Line Id"));
  638. return;
  639. }
  640. printInFlightRef.current = true;
  641. setPrintingSilId(process.stockInLineId);
  642. try {
  643. const data: PrintFGStockInLabelRequest = {
  644. stockInLineId: process.stockInLineId,
  645. printerId: selectedPrinter.id,
  646. printQty: 1,
  647. };
  648. await printFGStockInLabel(data);
  649. alert(t("Print job sent successfully"));
  650. } catch (error: any) {
  651. console.error("Error printing:", error);
  652. alert(t(`Print failed: ${error?.message || "Unknown error"}`));
  653. } finally {
  654. setPrintingSilId(null);
  655. printInFlightRef.current = false;
  656. }
  657. },
  658. [selectedPrinter, t],
  659. );
  660. const searchedItemOptions = useMemo(
  661. () =>
  662. Array.from(
  663. new Map(
  664. [...productionCache, ...pendingQcCache, ...putawayedCache]
  665. .filter((p) => !!p.itemCode)
  666. .map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]),
  667. ).values(),
  668. ),
  669. [productionCache, pendingQcCache, putawayedCache],
  670. );
  671. /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */
  672. const searchCriteria: Criterion<SearchParam>[] = useMemo(() => {
  673. const base: Criterion<SearchParam>[] = [
  674. ...(disableDateFilter
  675. ? []
  676. : [{
  677. type: "date",
  678. label: t("Search date"),
  679. paramName: "date",
  680. defaultValue: appliedSearch.date,
  681. preFilledValue: appliedSearch.date,
  682. } as Criterion<SearchParam>]),
  683. {
  684. type: "text",
  685. label: t("Item Code"),
  686. paramName: "itemCode",
  687. preFilledValue: appliedSearch.itemCode ?? "",
  688. },
  689. {
  690. type: "text",
  691. label: t("Job Order Code"),
  692. paramName: "jobOrderCode",
  693. preFilledValue: appliedSearch.jobOrderCode ?? "",
  694. },
  695. {
  696. type: "select",
  697. label: t("Process Type"),
  698. paramName: "processType",
  699. options: ["all", "drink", "Powder_Mixture", "other"],
  700. preFilledValue: filter,
  701. },
  702. ];
  703. return base;
  704. }, [appliedSearch, disableDateFilter, filter, t]);
  705. /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜索後需 remount 才會與 appliedSearch 一致 */
  706. const searchBoxKey = useMemo(
  707. () =>
  708. [
  709. disableDateFilter ? "" : appliedSearch.date,
  710. appliedSearch.itemCode ?? "",
  711. appliedSearch.jobOrderCode ?? "",
  712. filter,
  713. ].join("|"),
  714. [appliedSearch, disableDateFilter, filter],
  715. );
  716. const handleSelectedItemCodesChange = useCallback(
  717. (e: SelectChangeEvent<string[]>) => {
  718. const nextValue = e.target.value;
  719. const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue;
  720. onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes }));
  721. },
  722. [onListPersistedStateChange],
  723. );
  724. return (
  725. <Box>
  726. {loading ? (
  727. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  728. <CircularProgress />
  729. </Box>
  730. ) : (
  731. <Box>
  732. <SearchBox<SearchParam>
  733. key={searchBoxKey}
  734. criteria={searchCriteria}
  735. onSearch={handleApplySearch}
  736. onReset={handleResetSearch}
  737. extraActions={
  738. <FormControl size="small" sx={{ minWidth: 260 }}>
  739. <InputLabel>{t("Searched Item")}</InputLabel>
  740. <Select
  741. multiple
  742. value={selectedItemCodes}
  743. label={t("Item Code")}
  744. renderValue={(selected) =>
  745. (selected as string[]).length === 0 ? t("All") : (selected as string[]).join(", ")
  746. }
  747. onChange={handleSelectedItemCodesChange}
  748. >
  749. {searchedItemOptions.map((item) => (
  750. <MenuItem key={item.itemCode} value={item.itemCode}>
  751. <Checkbox checked={selectedItemCodes.includes(item.itemCode)} />
  752. <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
  753. </MenuItem>
  754. ))}
  755. </Select>
  756. </FormControl>
  757. }
  758. />
  759. <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
  760. {!disableDateFilter && (
  761. <>
  762. {t("Search date")}:{" "}
  763. {appliedSearch.date && dayjs(appliedSearch.date).isValid()
  764. ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT)
  765. : "-"}
  766. {" | "}
  767. </>
  768. )}
  769. {t("Total job orders")}: {displayTotalJobOrders}
  770. {enableCarryOver && displayCarriedOverCount > 0
  771. ? ` | ${t("Including carried over")}: ${displayCarriedOverCount}`
  772. : ""}
  773. {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""}
  774. </Typography>
  775. <Stack
  776. direction="row"
  777. alignItems="center"
  778. justifyContent="space-between"
  779. spacing={2}
  780. sx={{ mb: 2, flexWrap: "wrap", rowGap: 1 }}
  781. >
  782. <Tabs
  783. value={listTab}
  784. onChange={handleListTabChange}
  785. variant="scrollable"
  786. scrollButtons="auto"
  787. sx={{
  788. flex: 1,
  789. minWidth: 0,
  790. borderBottom: 1,
  791. borderColor: "divider",
  792. "& .MuiTabs-flexContainer": {
  793. columnGap: 0.5,
  794. rowGap: 1,
  795. },
  796. "& .MuiTab-root": {
  797. overflow: "visible",
  798. minWidth: "auto",
  799. px: 1,
  800. },
  801. }}
  802. >
  803. <Tab
  804. value="needs_action"
  805. label={renderBucketTabLabel("Needs action", needsActionCount)}
  806. />
  807. <Tab
  808. value="pending"
  809. label={renderBucketTabLabel("pending", pendingCount)}
  810. />
  811. <Tab
  812. value="processing"
  813. label={renderBucketTabLabel("Processing", processingCount)}
  814. />
  815. <Tab
  816. value="pending_qc"
  817. label={renderBucketTabLabel(
  818. "Waiting QC Put Away",
  819. pendingQcCount,
  820. )}
  821. />
  822. <Tab
  823. value="putawayed"
  824. label={renderBucketTabLabel("Put Awayed", putawayedCount)}
  825. />
  826. <Tab
  827. value="carried_over"
  828. label={renderBucketTabLabel(
  829. "Off-plan unfinished",
  830. offPlanTabCount,
  831. )}
  832. />
  833. </Tabs>
  834. <PrinterSelect
  835. label={t("Label Printer")}
  836. printers={printerOptions}
  837. value={selectedPrinter}
  838. onChange={setSelectedPrinter}
  839. placeholder={t("Label Printer")}
  840. />
  841. </Stack>
  842. <Box
  843. sx={{
  844. display: "flex",
  845. alignItems: "stretch",
  846. gap: 1,
  847. }}
  848. onKeyDown={(e) => {
  849. if (e.key === "ArrowLeft") goPrevPage();
  850. if (e.key === "ArrowRight") goNextPage();
  851. }}
  852. >
  853. <IconButton
  854. aria-label={t("Previous page")}
  855. onClick={goPrevPage}
  856. disabled={safePage <= 0 || filteredProcesses.length === 0}
  857. sx={{ alignSelf: "center" }}
  858. >
  859. <ChevronLeft />
  860. </IconButton>
  861. <Box
  862. ref={scrollerRef}
  863. onScroll={handleScrollerScroll}
  864. sx={{
  865. flex: 1,
  866. minWidth: 0,
  867. display: "flex",
  868. overflowX: "auto",
  869. scrollSnapType: "x mandatory",
  870. scrollBehavior: "smooth",
  871. WebkitOverflowScrolling: "touch",
  872. scrollbarWidth: "none",
  873. msOverflowStyle: "none",
  874. "&::-webkit-scrollbar": { display: "none" },
  875. }}
  876. >
  877. {pageChunks.map((chunk, pageIndex) => (
  878. <Box
  879. key={`page-${pageIndex}`}
  880. sx={{
  881. minWidth: "100%",
  882. width: "100%",
  883. flexShrink: 0,
  884. scrollSnapAlign: "start",
  885. scrollSnapStop: "always",
  886. px: 0.5,
  887. boxSizing: "border-box",
  888. }}
  889. >
  890. <Grid container spacing={2} alignItems="stretch">
  891. {chunk.map((process) => {
  892. const status = String(process.status || "");
  893. const statusLower = status.toLowerCase();
  894. const displayStatus =
  895. statusLower === "in_progress" ? "processing" : status;
  896. const bucket = String(process.pickProcessBucket ?? "");
  897. const waitingQc = isWaitingQcPutAway(process);
  898. // Avoid t("completed") → purchaseOrder「已上架」; use explicit keys.
  899. let chipLabel: string;
  900. let statusColor: "success" | "warning" | "primary" | "default";
  901. if (listTab === "putawayed") {
  902. chipLabel = t("Put Awayed");
  903. statusColor = "success";
  904. } else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) {
  905. chipLabel = t("Waiting QC Put Away");
  906. statusColor = "warning";
  907. } else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) {
  908. chipLabel = t("pending");
  909. statusColor = "default";
  910. } else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) {
  911. chipLabel = t("processing");
  912. statusColor = "primary";
  913. } else if (statusLower === "completed") {
  914. chipLabel = t("Completed");
  915. statusColor = "success";
  916. } else {
  917. chipLabel = t(displayStatus);
  918. statusColor =
  919. statusLower === "in_progress" ||
  920. statusLower === "processing"
  921. ? "primary"
  922. : "default";
  923. }
  924. const jobOrderCode =
  925. (process as any).jobOrderCode ??
  926. (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A");
  927. const canQc =
  928. process.jobOrderId != null &&
  929. process.stockInLineId != null &&
  930. jobOrderQcReadyById.get(process.jobOrderId) === true;
  931. const joDay = process.date
  932. ? dayjs(process.date).startOf("day")
  933. : null;
  934. const isCarriedOver =
  935. isCarriedOverTab ||
  936. Boolean(
  937. enableCarryOver &&
  938. searchDay?.isValid() &&
  939. joDay?.isValid() &&
  940. joDay.isBefore(searchDay),
  941. );
  942. const bomDescription = process.bomDescription
  943. ? String(process.bomDescription).trim()
  944. : "";
  945. const bomType = process.bomType
  946. ? String(process.bomType).trim()
  947. : "";
  948. const chipSx = {
  949. flexShrink: 0,
  950. height: 28,
  951. borderRadius: "14px",
  952. "& .MuiChip-label": {
  953. typography: "body2",
  954. px: 1.25,
  955. lineHeight: 1.2,
  956. },
  957. } as const;
  958. return (
  959. <Grid
  960. key={process.id}
  961. item
  962. xs={12}
  963. sm={6}
  964. md={4}
  965. sx={{ display: "flex" }}
  966. >
  967. <Card
  968. sx={{
  969. width: "100%",
  970. height: "100%",
  971. display: "flex",
  972. flexDirection: "column",
  973. border: "1px solid",
  974. borderColor: isCarriedOver ? "warning.main" : "divider",
  975. borderRadius: 2,
  976. boxShadow: "none",
  977. bgcolor: isCarriedOver
  978. ? "warning.light"
  979. : "background.paper",
  980. }}
  981. >
  982. <Box
  983. sx={{
  984. p: 2,
  985. flexGrow: 1,
  986. display: "flex",
  987. flexDirection: "column",
  988. minHeight: 0,
  989. }}
  990. >
  991. <Stack
  992. direction="row"
  993. alignItems="flex-start"
  994. spacing={0.75}
  995. >
  996. <Typography
  997. variant="body1"
  998. color="text.primary"
  999. fontWeight={600}
  1000. title={
  1001. [process.itemCode, process.itemName]
  1002. .filter(Boolean)
  1003. .join(" ") || undefined
  1004. }
  1005. sx={{
  1006. flex: 1,
  1007. minWidth: 0,
  1008. display: "-webkit-box",
  1009. WebkitLineClamp: 2,
  1010. WebkitBoxOrient: "vertical",
  1011. overflow: "hidden",
  1012. lineHeight: 1.35,
  1013. }}
  1014. >
  1015. {[process.itemCode, process.itemName].filter(Boolean).join(" ") || "-"}
  1016. </Typography>
  1017. {isCarriedOver ? (
  1018. <Tooltip title={t("Carried over from past day")}>
  1019. <Avatar
  1020. aria-label={t("Carried over from past day")}
  1021. sx={{
  1022. width: 28,
  1023. height: 28,
  1024. flexShrink: 0,
  1025. bgcolor: "error.main",
  1026. color: "error.contrastText",
  1027. fontSize: "1rem",
  1028. fontWeight: 800,
  1029. }}
  1030. >
  1031. !
  1032. </Avatar>
  1033. </Tooltip>
  1034. ) : null}
  1035. </Stack>
  1036. <Stack
  1037. direction="row"
  1038. alignItems="center"
  1039. spacing={0.75}
  1040. flexWrap="nowrap"
  1041. useFlexGap
  1042. sx={{
  1043. mt: 0.75,
  1044. minWidth: 0,
  1045. overflowX: "auto",
  1046. scrollbarWidth: "none",
  1047. "&::-webkit-scrollbar": { display: "none" },
  1048. }}
  1049. >
  1050. <Tooltip title={t("Production Priority")}>
  1051. <Avatar
  1052. aria-label={`${t("Production Priority")}: ${process.productionPriority ?? "-"}`}
  1053. sx={{
  1054. width: 28,
  1055. height: 28,
  1056. flexShrink: 0,
  1057. bgcolor: "primary.main",
  1058. color: "primary.contrastText",
  1059. fontSize: "0.8rem",
  1060. fontWeight: 700,
  1061. }}
  1062. >
  1063. {process.productionPriority ?? "-"}
  1064. </Avatar>
  1065. </Tooltip>
  1066. {bomDescription ? (
  1067. <Chip
  1068. size="small"
  1069. label={t(bomDescription)}
  1070. variant="outlined"
  1071. sx={chipSx}
  1072. />
  1073. ) : null}
  1074. {bomType ? (
  1075. <Chip
  1076. size="small"
  1077. label={t(bomType)}
  1078. color="info"
  1079. variant="outlined"
  1080. sx={chipSx}
  1081. />
  1082. ) : null}
  1083. <Chip
  1084. size="small"
  1085. label={chipLabel}
  1086. color={statusColor as any}
  1087. sx={chipSx}
  1088. />
  1089. </Stack>
  1090. <Stack
  1091. spacing={0.35}
  1092. sx={{ mt: 0.75, color: "text.secondary" }}
  1093. >
  1094. <Typography variant="body2">
  1095. {t("Required Qty")}: {process.requiredQty} (
  1096. {process.uom})
  1097. </Typography>
  1098. <Typography variant="body2">
  1099. {t("Production date")}:{" "}
  1100. {process.date
  1101. ? dayjs(process.date as any).format(
  1102. OUTPUT_DATE_FORMAT,
  1103. )
  1104. : "-"}
  1105. </Typography>
  1106. <Typography variant="body2">
  1107. {t("Assume Time Need")}:{" "}
  1108. {process.timeNeedToComplete} {t("minutes")}
  1109. </Typography>
  1110. </Stack>
  1111. <Stack
  1112. direction="row"
  1113. flexWrap="wrap"
  1114. useFlexGap
  1115. spacing={1}
  1116. sx={{ mt: "auto", pt: 1.5 }}
  1117. >
  1118. <Button
  1119. variant="contained"
  1120. size="small"
  1121. onClick={() =>
  1122. onSelectProcess(process.jobOrderId, process.id)
  1123. }
  1124. >
  1125. {t("View Details")}
  1126. </Button>
  1127. <Button
  1128. variant="contained"
  1129. size="small"
  1130. disabled={
  1131. process.assignedTo != null ||
  1132. process.matchStatus == "completed" ||
  1133. process.pickOrderStatus != "completed"
  1134. }
  1135. onClick={() =>
  1136. handleAssignPickOrder(
  1137. process.pickOrderId,
  1138. process.jobOrderId,
  1139. process.id,
  1140. )
  1141. }
  1142. >
  1143. {t("Matching Stock")}
  1144. </Button>
  1145. {canManageUpdateJo && statusLower !== "completed" && (
  1146. <Button
  1147. variant="contained"
  1148. size="small"
  1149. onClick={() =>
  1150. openConfirm(
  1151. t("Confirm to update this Job Order?"),
  1152. async () => {
  1153. await handleUpdateJo(process);
  1154. },
  1155. )
  1156. }
  1157. >
  1158. {t("Update Job Order")}
  1159. </Button>
  1160. )}
  1161. {canQc && (
  1162. <Button
  1163. variant="contained"
  1164. size="small"
  1165. onClick={() => handleViewStockIn(process)}
  1166. >
  1167. {t("view stockin")}
  1168. </Button>
  1169. )}
  1170. {listTab === "putawayed" &&
  1171. process.stockInLineId != null && (
  1172. <Button
  1173. variant="contained"
  1174. size="small"
  1175. color="secondary"
  1176. disabled={
  1177. !selectedPrinter ||
  1178. printingSilId === process.stockInLineId
  1179. }
  1180. onClick={() => void handlePrintLabel(process)}
  1181. >
  1182. {printingSilId === process.stockInLineId
  1183. ? t("Printing")
  1184. : t("Print Label")}
  1185. </Button>
  1186. )}
  1187. </Stack>
  1188. <Typography
  1189. variant="caption"
  1190. color="text.secondary"
  1191. sx={{ mt: 1.25 }}
  1192. >
  1193. {jobOrderCode}
  1194. {" · "}
  1195. {t("Lot No")}: {process.lotNo ?? "-"}
  1196. </Typography>
  1197. </Box>
  1198. </Card>
  1199. </Grid>
  1200. );
  1201. })}
  1202. </Grid>
  1203. </Box>
  1204. ))}
  1205. </Box>
  1206. <IconButton
  1207. aria-label={t("Next page")}
  1208. onClick={goNextPage}
  1209. disabled={
  1210. safePage + 1 >= totalPages || filteredProcesses.length === 0
  1211. }
  1212. sx={{ alignSelf: "center" }}
  1213. >
  1214. <ChevronRight />
  1215. </IconButton>
  1216. </Box>
  1217. <QcStockInModal
  1218. session={sessionToken}
  1219. open={openModal}
  1220. onClose={closeNewModal}
  1221. inputDetail={modalInfo}
  1222. printerCombo={printerCombo}
  1223. warehouse={[]}
  1224. printSource="productionProcess"
  1225. uiMode="default"
  1226. />
  1227. <Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth>
  1228. <DialogTitle>{t("Confirm")}</DialogTitle>
  1229. <DialogContent>
  1230. <Typography variant="body2">{confirmMessage}</Typography>
  1231. </DialogContent>
  1232. <DialogActions>
  1233. <Button onClick={closeConfirm} disabled={confirmLoading}>
  1234. {t("Cancel")}
  1235. </Button>
  1236. <Button
  1237. variant="contained"
  1238. onClick={onConfirm}
  1239. disabled={confirmLoading || !pendingConfirmAction}
  1240. >
  1241. {confirmLoading ? t("Processing...") : t("Confirm")}
  1242. </Button>
  1243. </DialogActions>
  1244. </Dialog>
  1245. {filteredProcesses.length > 0 && (
  1246. <Typography
  1247. variant="body2"
  1248. color="text.secondary"
  1249. align="center"
  1250. sx={{ mt: 2 }}
  1251. >
  1252. {safePage + 1} / {totalPages}
  1253. </Typography>
  1254. )}
  1255. </Box>
  1256. )}
  1257. </Box>
  1258. );
  1259. };
  1260. export default ProductProcessList;