FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

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