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.
 
 

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