"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Card, Stack, Typography, Chip, CircularProgress, Grid, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText, SelectChangeEvent, Dialog, DialogTitle, DialogContent, DialogActions, Tabs, Tab, Tooltip, IconButton, Avatar, } from "@mui/material"; import ChevronLeft from "@mui/icons-material/ChevronLeft"; import ChevronRight from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; import QcStockInModal from "../Qc/QcStockInModal"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; import { AUTH, hasAbility } from "@/authorities"; import { AllJoborderProductProcessInfoResponse, updateJo, fetchProductProcessesByJobOrderId, completeProductProcessLine, assignJobOrderPickOrder, fetchJoborderProductProcessesPage, JobOrderProductProcessBucketCounts, } from "@/app/api/jo/actions"; import { StockInLineInput } from "@/app/api/stockIn"; import { PrinterCombo } from "@/app/api/settings/printer"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; export type ProductionProcessListTab = | "needs_action" | "pending" | "processing" | "carried_over" | "pending_qc" | "putawayed"; export type ProductionProcessListPersistedState = { date: string; itemCode: string | null; jobOrderCode: string | null; filter: "all" | "drink" | "Powder_Mixture" | "other"; page: number; selectedItemCodes: string[]; /** * Unified list tabs: * needs_action | pending | processing | carried_over | pending_qc | putawayed * Legacy: all → needs_action; fine pick buckets remapped to pending/processing. */ pickBucket: ProductionProcessListTab | string; }; interface ProductProcessListProps { onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void; onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void; printerCombo: PrinterCombo[]; /** @deprecated Derived from pickBucket when unified tabs are used; kept for compatibility. */ qcReady?: boolean; includePutaway?: boolean | null; /** all | completed | notCompleted */ putawayStatus?: string | null; disableDateFilter?: boolean; listPersistedState: ProductionProcessListPersistedState; onListPersistedStateChange: React.Dispatch< React.SetStateAction >; } export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; /** Cards per visible page: 2 rows × 3 columns. */ const CARDS_PER_PAGE = 6; /** Fetch once; client slides pages of CARDS_PER_PAGE (no refetch on page change). */ const FETCH_SIZE = 200; /** Include unfinished from (searchDate - LOOKBACK_DAYS) .. searchDate; picked_not_started capped at search date on backend. */ const PRODUCTION_LOOKBACK_DAYS = 4; const PENDING_FINE_BUCKETS = new Set([ "not_picked_not_started", "picked_not_started", ]); const PROCESSING_FINE_BUCKETS = new Set([ "picked_started", "not_picked_started", ]); const EMPTY_BUCKET_COUNTS: JobOrderProductProcessBucketCounts = { notPickedNotStarted: 0, pickedNotStarted: 0, pickedStarted: 0, notPickedStarted: 0, }; /** 預設依 JobOrder.planStart 搜索:今天往前 3 天~往後 3 天(含當日) */ function defaultPlanStartRange() { return { from: dayjs().subtract(0, "day").format("YYYY-MM-DD"), to: dayjs().add(0, "day").format("YYYY-MM-DD"), }; } export function createDefaultProductionProcessListPersistedState(): ProductionProcessListPersistedState { return { date: dayjs().format("YYYY-MM-DD"), itemCode: null, jobOrderCode: null, filter: "all", page: 0, selectedItemCodes: [], pickBucket: "needs_action", }; } function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab { const v = (raw || "needs_action").trim(); if ( v === "pending" || v === "processing" || v === "carried_over" || v === "pending_qc" || v === "putawayed" || v === "needs_action" ) { return v; } if (v === "all") return "needs_action"; if (v === "not_picked_not_started" || v === "picked_not_started") return "pending"; if (v === "picked_started" || v === "not_picked_started") return "processing"; return "needs_action"; } function isProcessCarriedOver( p: AllJoborderProductProcessInfoResponse, searchDay: ReturnType | null, ): boolean { if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false; return dayjs(p.date).startOf("day").isBefore(searchDay); } function isPutawayCompleted( p: AllJoborderProductProcessInfoResponse, ): boolean { return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed"; } /** Waiting QC put-away (has SIL, not completed/rejected). */ function isWaitingQcPutAway( p: AllJoborderProductProcessInfoResponse, ): boolean { if (p.stockInLineId == null) return false; const s = String(p.stockInLineStatus ?? "").trim().toLowerCase(); return s !== "completed" && s !== "rejected"; } /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.3 | 2026-08-05 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, onSelectMatchingStock, disableDateFilter = false, listPersistedState, onListPersistedStateChange, }) => { const { t } = useTranslation( ["common", "productionProcess","purchaseOrder","dashboard"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); const [processes, setProcesses] = useState([]); const [productionCache, setProductionCache] = useState< AllJoborderProductProcessInfoResponse[] >([]); const [pendingQcCache, setPendingQcCache] = useState< AllJoborderProductProcessInfoResponse[] >([]); const [bucketCounts, setBucketCounts] = useState(EMPTY_BUCKET_COUNTS); const [pendingQcCount, setPendingQcCount] = useState(0); const [putawayedCount, setPutawayedCount] = useState(0); const [carriedOverCount, setCarriedOverCount] = useState(0); const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; const abilities = session?.abilities ?? session?.user?.abilities ?? []; /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */ const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS); type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; const listTab = normalizeListTab(listPersistedState.pickBucket); const isProductionTab = listTab === "needs_action" || listTab === "pending" || listTab === "processing"; const isCarriedOverTab = listTab === "carried_over"; const qcReady = listTab === "pending_qc" || listTab === "putawayed"; const putawayStatus = listTab === "putawayed" ? "completed" : listTab === "pending_qc" ? "notCompleted" : null; const includePutaway = qcReady ? true : null; /** Production unfinished tabs + pending QC + off-plan: carry-over window. */ const enableCarryOver = !disableDateFilter && (isProductionTab || listTab === "pending_qc" || isCarriedOverTab); const appliedSearch = useMemo( () => ({ date: listPersistedState.date, itemCode: listPersistedState.itemCode, jobOrderCode: listPersistedState.jobOrderCode, }), [ listPersistedState.date, listPersistedState.itemCode, listPersistedState.jobOrderCode, ], ); const filter = listPersistedState.filter; const page = listPersistedState.page; const selectedItemCodes = listPersistedState.selectedItemCodes; const searchDay = useMemo( () => (appliedSearch.date ? dayjs(appliedSearch.date).startOf("day") : null), [appliedSearch.date], ); const [totalJobOrders, setTotalJobOrders] = useState(0); // Generic confirm dialog for actions (update job order / etc.) const [confirmOpen, setConfirmOpen] = useState(false); const [confirmMessage, setConfirmMessage] = useState(""); const [confirmLoading, setConfirmLoading] = useState(false); const [pendingConfirmAction, setPendingConfirmAction] = useState Promise)>(null); // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass // 才允许打开 QcStockInModal(避免仅某个 productProcess 完成就提前出现 view stockin)。 const jobOrderQcReadyById = useMemo(() => { const lineDone = (status: unknown) => { const s = String(status ?? "").trim().toLowerCase(); return s === "completed" || s === "pass"; }; const byJobOrder = new Map(); for (const p of processes) { if (p.jobOrderId == null) continue; const arr = byJobOrder.get(p.jobOrderId) ?? []; arr.push(p); byJobOrder.set(p.jobOrderId, arr); } const result = new Map(); const isDone = (status: unknown) => { const s = String(status ?? "").trim().toLowerCase(); return s === "completed" || s === "pass"; }; byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); const packingProcesses = jobOrderProcesses.filter( (p) => String((p as any).code ?? "").trim() === "包裝" ); const nonPackingProcesses = jobOrderProcesses.filter( (p) => String((p as any).code ?? "").trim() !== "包裝" ); const allNonPackingDone = nonPackingProcesses.length === 0 || nonPackingProcesses.every((p) => { const lines = p.lines ?? []; return lines.length > 0 && lines.every((l) => isDone(l.status)); }); const hasOnePackingDone = packingProcesses.length > 0 && packingProcesses.some((p) => { const lines = p.lines ?? []; return lines.some((l) => isDone(l.status)); }); const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); }); return result; }, [processes]); const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { if (!currentUserId) { alert(t("Unable to get user ID")); return; } try { console.log("🔄 Assigning pick order:", pickOrderId, "to user:", currentUserId); // 调用分配 API 并读取响应 const assignResult = await assignJobOrderPickOrder(pickOrderId, currentUserId); console.log("📦 Assign result:", assignResult); // 检查分配是否成功 if (assignResult.message === "Successfully assigned") { console.log("✅ Successfully assigned pick order"); console.log("✅ Pick order ID:", assignResult.id); console.log("✅ Pick order code:", assignResult.code); // 分配成功后,导航到 second scan 页面 if (onSelectMatchingStock && jobOrderId) { onSelectMatchingStock(jobOrderId, productProcessId,pickOrderId); } else { alert(t("Assignment successful")); } } else { // 分配失败 console.error("Assignment failed:", assignResult.message); alert(t(`Assignment failed: ${assignResult.message || "Unknown error"}`)); } } catch (error: any) { console.error(" Error assigning pick order:", error); alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`)); } }, [currentUserId, t, onSelectMatchingStock]); const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => { if (!process.stockInLineId) { alert(t("Invalid Stock In Line Id")); return; } setModalInfo({ id: process.stockInLineId, //itemId: process.itemId, // 如果 process 中有 itemId,添加这一行 //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT), }); setOpenModal(true); }, [t]); const handleApplySearch = useCallback( (inputs: Record) => { const selectedProcessType = (inputs.processType || "all") as ProcessFilter; onListPersistedStateChange((prev) => ({ ...prev, filter: selectedProcessType, date: disableDateFilter ? "" : (inputs.date || "").trim(), itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, selectedItemCodes: [], page: 0, })); }, [disableDateFilter, onListPersistedStateChange], ); const handleResetSearch = useCallback(() => { onListPersistedStateChange((prev) => ({ ...prev, filter: "all", date: disableDateFilter ? "" : defaultPlanStartRange().from, itemCode: null, jobOrderCode: null, selectedItemCodes: [], page: 0, pickBucket: "needs_action", })); }, [disableDateFilter, onListPersistedStateChange]); const fetchProcesses = useCallback(async () => { setLoading(true); try { const typeParam = filter === "all" ? undefined : filter; const base = { date: disableDateFilter ? undefined : appliedSearch.date, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, type: typeParam, page: 0, size: FETCH_SIZE, }; if (isCarriedOverTab) { const [prod, pendingQc] = await Promise.all([ fetchJoborderProductProcessesPage({ ...base, qcReady: false, lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, bucket: "all", }), fetchJoborderProductProcessesPage({ ...base, qcReady: true, includePutaway: true, putawayStatus: "notCompleted", lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, }), ]); const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); setProductionCache(prodContent); setPendingQcCache(qcContent); if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); setPendingQcCount(pendingQc?.totalJobOrders || 0); setCarriedOverCount(prod?.carriedOverCount ?? 0); const search = appliedSearch.date ? dayjs(appliedSearch.date).startOf("day") : null; const merged = [...qcContent, ...prodContent].filter((p) => isProcessCarriedOver(p, search), ); // Dedupe by jobOrderId (prefer waiting-QC row when it has stockInLineId) const byJo = new Map(); for (const p of merged) { if (p.jobOrderId == null) continue; const existing = byJo.get(p.jobOrderId); if (!existing) { byJo.set(p.jobOrderId, p); } else if (existing.stockInLineId == null && p.stockInLineId != null) { byJo.set(p.jobOrderId, p); } } const content = Array.from(byJo.values()); setProcesses(content); setTotalJobOrders(content.length); return; } // Production tabs share one fetch (bucket=all); pending/processing filter client-side. const data = await fetchJoborderProductProcessesPage({ ...base, qcReady, includePutaway, putawayStatus, lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, bucket: isProductionTab ? "all" : undefined, }); const content = data?.content || []; setProcesses(content); setTotalJobOrders(data?.totalJobOrders || 0); if (isProductionTab) { setProductionCache(content); if (data?.bucketCounts) setBucketCounts(data.bucketCounts); } if (qcReady && putawayStatus === "notCompleted") { setPendingQcCache(content); setPendingQcCount(data?.totalJobOrders || 0); } if (qcReady && putawayStatus === "completed") { setPutawayedCount(data?.totalJobOrders || 0); } setCarriedOverCount(data?.carriedOverCount ?? 0); } catch (e) { console.error(e); setProcesses([]); setTotalJobOrders(0); if (isProductionTab) setBucketCounts(EMPTY_BUCKET_COUNTS); setCarriedOverCount(0); } finally { setLoading(false); } }, [ appliedSearch, disableDateFilter, filter, qcReady, includePutaway, putawayStatus, enableCarryOver, isProductionTab, isCarriedOverTab, ]); useEffect(() => { fetchProcesses(); }, [fetchProcesses]); /** Keep production + QC tab badges fresh even when not on that tab. */ useEffect(() => { let cancelled = false; const typeParam = filter === "all" ? undefined : filter; const base = { date: disableDateFilter ? undefined : appliedSearch.date, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, type: typeParam, page: 0, size: FETCH_SIZE, }; (async () => { try { const [prod, pendingQc, putawayed] = await Promise.all([ fetchJoborderProductProcessesPage({ ...base, qcReady: false, lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, bucket: "all", }), fetchJoborderProductProcessesPage({ ...base, qcReady: true, includePutaway: true, putawayStatus: "notCompleted", lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS, }), fetchJoborderProductProcessesPage({ ...base, size: 1, qcReady: true, includePutaway: true, putawayStatus: "completed", }), ]); if (cancelled) return; if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); if (prod?.content) { setProductionCache(prod.content.filter((p) => !isPutawayCompleted(p))); } if (pendingQc?.content) { setPendingQcCache( pendingQc.content.filter((p) => !isPutawayCompleted(p)), ); } setPendingQcCount(pendingQc?.totalJobOrders || 0); setPutawayedCount(putawayed?.totalJobOrders || 0); if (prod?.carriedOverCount != null) setCarriedOverCount(prod.carriedOverCount); } catch (e) { console.error(e); } })(); return () => { cancelled = true; }; }, [appliedSearch, disableDateFilter, filter]); const handleListTabChange = useCallback( (_: React.SyntheticEvent, value: string) => { const next = normalizeListTab(value); onListPersistedStateChange((prev) => ({ ...prev, pickBucket: next, page: 0, })); }, [onListPersistedStateChange], ); const pendingCount = bucketCounts.notPickedNotStarted + bucketCounts.pickedNotStarted; const processingCount = bucketCounts.pickedStarted + bucketCounts.notPickedStarted; const needsActionCount = pendingCount + processingCount; const offPlanTabCount = useMemo(() => { if (!searchDay) return 0; const ids = new Set(); for (const p of productionCache) { if ( !isPutawayCompleted(p) && isProcessCarriedOver(p, searchDay) && p.jobOrderId != null ) { ids.add(p.jobOrderId); } } for (const p of pendingQcCache) { if ( !isPutawayCompleted(p) && isProcessCarriedOver(p, searchDay) && p.jobOrderId != null ) { ids.add(p.jobOrderId); } } return ids.size; }, [productionCache, pendingQcCache, searchDay]); const filteredProcesses = useMemo(() => { let list = processes; if (listTab === "pending") { list = list.filter((p) => PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), ); } else if (listTab === "processing") { list = list.filter((p) => PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")), ); } // carried_over: fetch already filtered to off-plan only if (selectedItemCodes.length === 0) return list; return list.filter((p) => selectedItemCodes.includes(p.itemCode)); }, [processes, selectedItemCodes, listTab]); const displayTotalJobOrders = isCarriedOverTab ? filteredProcesses.length : isProductionTab ? listTab === "pending" ? pendingCount : listTab === "processing" ? processingCount : totalJobOrders || needsActionCount : totalJobOrders; const displayCarriedOverCount = useMemo(() => { if (!enableCarryOver || !searchDay) return 0; if (!isProductionTab || listTab === "needs_action") return carriedOverCount; return filteredProcesses.filter((p) => { if (!p.date || !dayjs(p.date).isValid()) return false; return dayjs(p.date).startOf("day").isBefore(searchDay); }).length; }, [ enableCarryOver, searchDay, isProductionTab, listTab, carriedOverCount, filteredProcesses, ]); const pageChunks = useMemo(() => { const chunks: AllJoborderProductProcessInfoResponse[][] = []; for (let i = 0; i < filteredProcesses.length; i += CARDS_PER_PAGE) { chunks.push(filteredProcesses.slice(i, i + CARDS_PER_PAGE)); } return chunks.length > 0 ? chunks : [[]]; }, [filteredProcesses]); const totalPages = pageChunks.length; const safePage = Math.min(page, Math.max(0, totalPages - 1)); const scrollerRef = useRef(null); const scrollSyncLockRef = useRef(false); const scrollToPage = useCallback( (targetPage: number, behavior: ScrollBehavior = "smooth") => { const el = scrollerRef.current; if (!el) return; const clamped = Math.max(0, Math.min(targetPage, totalPages - 1)); scrollSyncLockRef.current = true; el.scrollTo({ left: clamped * el.clientWidth, behavior }); onListPersistedStateChange((prev) => prev.page === clamped ? prev : { ...prev, page: clamped }, ); window.setTimeout(() => { scrollSyncLockRef.current = false; }, behavior === "smooth" ? 450 : 50); }, [totalPages, onListPersistedStateChange], ); const goPrevPage = useCallback(() => { if (safePage <= 0) return; scrollToPage(safePage - 1); }, [safePage, scrollToPage]); const goNextPage = useCallback(() => { if (safePage + 1 >= totalPages) return; scrollToPage(safePage + 1); }, [safePage, totalPages, scrollToPage]); const handleScrollerScroll = useCallback(() => { if (scrollSyncLockRef.current) return; const el = scrollerRef.current; if (!el || el.clientWidth <= 0) return; const nextPage = Math.round(el.scrollLeft / el.clientWidth); const clamped = Math.max(0, Math.min(nextPage, totalPages - 1)); if (clamped !== page) { onListPersistedStateChange((prev) => prev.page === clamped ? prev : { ...prev, page: clamped }, ); } }, [page, totalPages, onListPersistedStateChange]); // After data reload, jump to persisted page without animation. useEffect(() => { const el = scrollerRef.current; if (!el || loading) return; const clamped = Math.min(page, Math.max(0, totalPages - 1)); scrollSyncLockRef.current = true; el.scrollTo({ left: clamped * el.clientWidth, behavior: "auto" }); window.setTimeout(() => { scrollSyncLockRef.current = false; }, 50); }, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps const renderBucketTabLabel = useCallback( (labelKey: string, count: number) => count > 0 ? `${t(labelKey)} (${count})` : t(labelKey), [t], ); const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { if (!canManageUpdateJo) return; if (!process.jobOrderId) { alert(t("Invalid Job Order Id")); return; } try { setLoading(true); // 可选:已有 loading state 可复用 // 1) 拉取该 JO 的所有 process,取出全部 lineId const processes = await fetchProductProcessesByJobOrderId(process.jobOrderId); const lineIds = (processes ?? []) .flatMap(p => (p as any).productProcessLines ?? []) .map(l => l.id) .filter(Boolean); // 2) 逐个调用 completeProductProcessLine for (const lineId of lineIds) { try { await completeProductProcessLine(lineId); } catch (e) { console.error("completeProductProcessLine failed for lineId:", lineId, e); } } // 3) 更新 JO 状态 // await updateJo({ id: process.jobOrderId, status: "completed" }); // 4) 刷新列表 await fetchProcesses(); } catch (e) { console.error(e); alert(t("An error has occurred. Please try again later.")); } finally { setLoading(false); } }, [t, fetchProcesses, canManageUpdateJo]); const openConfirm = useCallback((message: string, action: () => Promise) => { setConfirmMessage(message); setPendingConfirmAction(() => action); setConfirmOpen(true); }, []); const closeConfirm = useCallback(() => { setConfirmOpen(false); setPendingConfirmAction(null); setConfirmMessage(""); setConfirmLoading(false); }, []); const onConfirm = useCallback(async () => { if (!pendingConfirmAction) return; setConfirmLoading(true); try { await pendingConfirmAction(); } finally { closeConfirm(); } }, [pendingConfirmAction, closeConfirm]); const closeNewModal = useCallback(() => { // const response = updateJo({ id: 1, status: "storing" }); setOpenModal(false); // Close the modal first // fetchProcesses(); // setTimeout(() => { // }, 300); // Add a delay to avoid immediate re-trigger of useEffect }, [fetchProcesses]); const searchedItemOptions = useMemo( () => Array.from( new Map( processes .filter((p) => !!p.itemCode) .map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]), ).values(), ), [processes], ); /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ const searchCriteria: Criterion[] = useMemo(() => { const base: Criterion[] = [ ...(disableDateFilter ? [] : [{ type: "date", label: t("Search date"), paramName: "date", defaultValue: appliedSearch.date, preFilledValue: appliedSearch.date, } as Criterion]), { type: "text", label: t("Item Code"), paramName: "itemCode", preFilledValue: appliedSearch.itemCode ?? "", }, { type: "text", label: t("Job Order Code"), paramName: "jobOrderCode", preFilledValue: appliedSearch.jobOrderCode ?? "", }, { type: "select", label: t("Process Type"), paramName: "processType", options: ["all", "drink", "Powder_Mixture", "other"], preFilledValue: filter, }, ]; return base; }, [appliedSearch, disableDateFilter, filter, t]); /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜索後需 remount 才會與 appliedSearch 一致 */ const searchBoxKey = useMemo( () => [ disableDateFilter ? "" : appliedSearch.date, appliedSearch.itemCode ?? "", appliedSearch.jobOrderCode ?? "", filter, ].join("|"), [appliedSearch, disableDateFilter, filter], ); const handleSelectedItemCodesChange = useCallback( (e: SelectChangeEvent) => { const nextValue = e.target.value; const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue; onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes })); }, [onListPersistedStateChange], ); return ( {loading ? ( ) : ( key={searchBoxKey} criteria={searchCriteria} onSearch={handleApplySearch} onReset={handleResetSearch} extraActions={ {t("Searched Item")} } /> {!disableDateFilter && ( <> {t("Search date")}:{" "} {appliedSearch.date && dayjs(appliedSearch.date).isValid() ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT) : "-"} {" | "} )} {t("Total job orders")}: {displayTotalJobOrders} {enableCarryOver && displayCarriedOverCount > 0 ? ` | ${t("Including carried over")}: ${displayCarriedOverCount}` : ""} {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""} { if (e.key === "ArrowLeft") goPrevPage(); if (e.key === "ArrowRight") goNextPage(); }} > {pageChunks.map((chunk, pageIndex) => ( {chunk.map((process) => { const status = String(process.status || ""); const statusLower = status.toLowerCase(); const displayStatus = statusLower === "in_progress" ? "processing" : status; const bucket = String(process.pickProcessBucket ?? ""); const waitingQc = isWaitingQcPutAway(process); // Avoid t("completed") → purchaseOrder「已上架」; use explicit keys. let chipLabel: string; let statusColor: "success" | "warning" | "primary" | "default"; if (listTab === "putawayed") { chipLabel = t("Put Awayed"); statusColor = "success"; } else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) { chipLabel = t("Waiting QC Put Away"); statusColor = "warning"; } else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) { chipLabel = t("pending"); statusColor = "default"; } else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) { chipLabel = t("processing"); statusColor = "primary"; } else if (qcReady) { chipLabel = putawayStatus === "completed" ? t("Put Awayed") : t("Waiting QC Put Away"); statusColor = putawayStatus === "completed" ? "success" : "warning"; } else if (statusLower === "completed") { chipLabel = t("Completed"); statusColor = "success"; } else { chipLabel = t(displayStatus); statusColor = statusLower === "in_progress" || statusLower === "processing" ? "primary" : "default"; } const jobOrderCode = (process as any).jobOrderCode ?? (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A"); const canQc = process.jobOrderId != null && process.stockInLineId != null && jobOrderQcReadyById.get(process.jobOrderId) === true; const joDay = process.date ? dayjs(process.date).startOf("day") : null; const isCarriedOver = isCarriedOverTab || Boolean( enableCarryOver && searchDay?.isValid() && joDay?.isValid() && joDay.isBefore(searchDay), ); const bomDescription = process.bomDescription ? String(process.bomDescription).trim() : ""; const bomType = process.bomType ? String(process.bomType).trim() : ""; const chipSx = { flexShrink: 0, height: 28, borderRadius: "14px", "& .MuiChip-label": { typography: "body2", px: 1.25, lineHeight: 1.2, }, } as const; return ( {[process.itemCode, process.itemName].filter(Boolean).join(" ") || "-"} {isCarriedOver ? ( ! ) : null} {process.productionPriority ?? "-"} {bomDescription ? ( ) : null} {bomType ? ( ) : null} {t("Required Qty")}: {process.requiredQty} ( {process.uom}) {t("Production date")}:{" "} {process.date ? dayjs(process.date as any).format( OUTPUT_DATE_FORMAT, ) : "-"} {t("Assume Time Need")}:{" "} {process.timeNeedToComplete} {t("minutes")} {statusLower !== "completed" && ( )} {canQc && ( )} {jobOrderCode} {" · "} {t("Lot No")}: {process.lotNo ?? "-"} ); })} ))} = totalPages || filteredProcesses.length === 0 } sx={{ alignSelf: "center" }} > {t("Confirm")} {confirmMessage} {filteredProcesses.length > 0 && ( {safePage + 1} / {totalPages} )} )} ); }; export default ProductProcessList;