"use client"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, Card, CardContent, CardActions, Stack, Typography, Chip, CircularProgress, TablePagination, Grid, FormControl, InputLabel, Select, MenuItem, Checkbox, ListItemText, SelectChangeEvent, Dialog, DialogTitle, DialogContent, DialogActions, } from "@mui/material"; 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 } from "@/authorities"; import { AllJoborderProductProcessInfoResponse, updateJo, fetchProductProcessesByJobOrderId, completeProductProcessLine, assignJobOrderPickOrder, fetchJoborderProductProcessesPage } 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 ProductionProcessListPersistedState = { date: string; itemCode: string | null; jobOrderCode: string | null; filter: "all" | "drink" | "other"; page: number; selectedItemCodes: string[]; }; interface ProductProcessListProps { onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void; onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void; printerCombo: PrinterCombo[]; qcReady: boolean; listPersistedState: ProductionProcessListPersistedState; onListPersistedStateChange: React.Dispatch< React.SetStateAction >; } export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; const PAGE_SIZE = 50; /** 預設依 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: [], }; } const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, onSelectMatchingStock, qcReady, listPersistedState, onListPersistedStateChange, }) => { const { t } = useTranslation( ["common", "production","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 [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; const abilities = session?.abilities ?? session?.user?.abilities ?? []; // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); type ProcessFilter = "all" | "drink" | "other"; const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); 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 [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(); byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); const allLinesDone = jobOrderProcesses.length > 0 && jobOrderProcesses.every((p) => { const lines = p.lines ?? []; // 没有 lines 的情况认为未完成,避免误放行 return lines.length > 0 && lines.every((l) => lineDone(l.status)); }); result.set(jobOrderId, hasStockInLine && allLinesDone); }); 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; const fallback = defaultPlanStartRange(); const selectedDate = (inputs.date || "").trim() || fallback.from; onListPersistedStateChange((prev) => ({ ...prev, filter: selectedProcessType, date: selectedDate, itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, selectedItemCodes: [], page: 0, })); }, [onListPersistedStateChange], ); const handleResetSearch = useCallback(() => { const r = defaultPlanStartRange(); onListPersistedStateChange((prev) => ({ ...prev, filter: "all", date: r.from, itemCode: null, jobOrderCode: null, selectedItemCodes: [], page: 0, })); }, [onListPersistedStateChange]); const fetchProcesses = useCallback(async () => { setLoading(true); try { const isDrinkParam = filter === "all" ? undefined : filter === "drink" ? true : false; const data = await fetchJoborderProductProcessesPage({ date: appliedSearch.date, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, qcReady, isDrink: isDrinkParam, page, size: PAGE_SIZE, }); setProcesses(data?.content || []); setTotalJobOrders(data?.totalJobOrders || 0); } catch (e) { console.error(e); setProcesses([]); setTotalJobOrders(0); } finally { setLoading(false); } }, [listPersistedState, qcReady]); useEffect(() => { fetchProcesses(); }, [fetchProcesses]); 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], ); const paged = useMemo(() => { if (selectedItemCodes.length === 0) return processes; return processes.filter((p) => selectedItemCodes.includes(p.itemCode)); }, [processes, selectedItemCodes]); /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ const searchCriteria: Criterion[] = useMemo(() => { const r = defaultPlanStartRange(); return [ { type: "date", label: t("Search date"), paramName: "date", defaultValue: appliedSearch.date, preFilledValue: appliedSearch.date, }, { type: "text", label: "Item Code", paramName: "itemCode", preFilledValue: appliedSearch.itemCode ?? "", }, { type: "text", label: "Job Order Code", paramName: "jobOrderCode", preFilledValue: appliedSearch.jobOrderCode ?? "", }, { type: "select", label: "Type", paramName: "processType", options: ["all", "drink", "other"], preFilledValue: filter, }, ]; }, [appliedSearch, filter, t]); /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜尋後需 remount 才會與 appliedSearch 一致 */ const searchBoxKey = useMemo( () => [ appliedSearch.date, appliedSearch.itemCode ?? "", appliedSearch.jobOrderCode ?? "", filter, ].join("|"), [appliedSearch, 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")} } /> {t("Search date") /* 或在 zh/common.json 加鍵,例如「搜尋日期」 */}:{" "} {appliedSearch.date && dayjs(appliedSearch.date).isValid() ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT) : "-"} {" | "} {t("Total job orders")}: {totalJobOrders} {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${paged.length}` : ""} {paged.map((process) => { const status = String(process.status || ""); const statusLower = status.toLowerCase(); const displayStatus = statusLower === "in_progress" ? "processing" : status; const statusColor = statusLower === "completed" ? "success" : statusLower === "in_progress" || statusLower === "processing" ? "primary" : "default"; const finishedCount = (process.lines || []).filter( (l) => String(l.status ?? "").trim().toLowerCase() === "completed" ).length; const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0; const linesWithStatus = (process.lines || []).filter( (l) => String(l.status ?? "").trim() !== "" ); const dateDisplay = process.date ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT) : "-"; const jobOrderCode = (process as any).jobOrderCode ?? (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A"); const inProgressLines = (process.lines || []) .filter(l => String(l.status ?? "").trim() !== "") .filter(l => String(l.status).toLowerCase() === "in_progress"); const canQc = process.jobOrderId != null && process.stockInLineId != null && jobOrderQcReadyById.get(process.jobOrderId) === true; return ( {t("Job Order")}: {jobOrderCode} {t("Lot No")}: {process.lotNo ?? "-"} {/* {t("Item Name")}: */} {process.itemCode} {process.itemName} {t("Production Priority")}: {process.productionPriority} {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 !== "pending" && linesWithStatus.length > 0 && ( {t("Finished lines")}: {finishedCount} / {totalCount} {inProgressLines.length > 0 && ( {inProgressLines.map(line => ( {t("Operator")}: {line.operatorName || "-"}
{t("Equipment")}: {line.equipmentName || "-"}
))}
)}
)} {statusLower == "pending" && ( {t("t")} {""} )}
{statusLower !== "completed" && ( )} {canQc && ( )}
); })}
{t("Confirm")} {confirmMessage} {totalJobOrders > 0 && ( onListPersistedStateChange((prev) => ({ ...prev, page: p })) } rowsPerPageOptions={[PAGE_SIZE]} /> )}
)}
); }; export default ProductProcessList;