From cef025fae80f8767f7a0757432443269d9afd3b7 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 20 Mar 2026 22:43:29 +0800 Subject: [PATCH] update --- src/app/api/jo/actions.ts | 50 +++ .../ProductionProcessDetail.tsx | 58 +++- .../ProductionProcessJobOrderDetail.tsx | 4 +- .../ProductionProcessList.tsx | 295 +++++++++++++++--- .../ProductionProcessPage.tsx | 20 +- src/i18n/zh/common.json | 9 + 6 files changed, 386 insertions(+), 50 deletions(-) diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index f3f10b2..73a0caa 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -356,6 +356,13 @@ export interface AllJoborderProductProcessInfoResponse { FinishedProductProcessLineCount: number; lines: ProductProcessInfoResponse[]; } + +export interface JobOrderProductProcessPageResponse { + content: AllJoborderProductProcessInfoResponse[]; + totalJobOrders: number; + page: number; + size: number; +} export interface ProductProcessInfoResponse { id: number; operatorId?: number; @@ -771,6 +778,49 @@ export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean ); }); +export const fetchJoborderProductProcessesPage = cache(async (params: { + date?: string | null; + itemCode?: string | null; + jobOrderCode?: string | null; + bomIds?: number[] | null; + qcReady?: boolean | null; + isDrink?: boolean | null; + page?: number; + size?: number; +}) => { + const { + date, + itemCode, + jobOrderCode, + bomIds, + qcReady, + isDrink, + page = 0, + size = 50, + } = params; + + const queryParts: string[] = []; + if (date) queryParts.push(`date=${encodeURIComponent(date)}`); + if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`); + if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`); + if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`); + if (qcReady !== undefined && qcReady !== null) queryParts.push(`qcReady=${qcReady}`); + if (isDrink !== undefined && isDrink !== null) queryParts.push(`isDrink=${isDrink}`); + + queryParts.push(`page=${page}`); + queryParts.push(`size=${size}`); + + const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; + + return serverFetchJson( + `${BASE_API_URL}/product-process/Demo/Process/search${query}`, + { + method: "GET", + next: { tags: ["productProcessSearch"] }, + } + ); +}); + /* export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { return serverFetchJson( diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 6080e52..5e28a49 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -110,6 +110,11 @@ const fetchProcessDetailRef = useRef<() => Promise>(); postProdTimeInMinutes: 0, }); + // Pass confirmation dialog (avoid accidental Pass) + const [passConfirmOpen, setPassConfirmOpen] = useState(false); + const [passConfirmLineId, setPassConfirmLineId] = useState(null); + const [passConfirmLoading, setPassConfirmLoading] = useState(false); + const [outputData, setOutputData] = useState({ byproductName: "", byproductQty: "", @@ -257,6 +262,29 @@ const fetchProcessDetailRef = useRef<() => Promise>(); alert(t("Failed to pass line. Please try again.")); } }, [fetchProcessDetail, t]); + + const openPassConfirm = useCallback((lineId: number) => { + setPassConfirmLineId(lineId); + setPassConfirmOpen(true); + }, []); + + const closePassConfirm = useCallback(() => { + setPassConfirmOpen(false); + setPassConfirmLineId(null); + setPassConfirmLoading(false); + }, []); + + const confirmPassLine = useCallback(async () => { + if (!passConfirmLineId) return; + setPassConfirmLoading(true); + try { + await handlePassLine(passConfirmLineId); + closePassConfirm(); + } catch { + // handlePassLine 已经处理 alert,这里兜底收起弹窗 + closePassConfirm(); + } + }, [passConfirmLineId, handlePassLine, closePassConfirm]); const handleCreateNewLine = useCallback(async (lineId: number) => { try { await newProductProcessLine(lineId); @@ -765,7 +793,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -790,7 +818,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -813,7 +841,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { variant="outlined" size="small" color="success" - onClick={() => handlePassLine(line.id)} + onClick={() => openPassConfirm(line.id)} disabled={isPassDisabled} > {t("Pass")} @@ -996,6 +1024,30 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { + + + {t("Confirm")} + + {t("Confirm to Pass this Process?")} + + + + + + ); }; diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 86cc938..c69bb96 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -50,19 +50,21 @@ interface ProductProcessJobOrderDetailProps { jobOrderId: number; onBack: () => void; fromJosave?: boolean; + initialTabIndex?: number; } const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, fromJosave, + initialTabIndex = 0, }) => { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [processData, setProcessData] = useState(null); const [jobOrderLines, setJobOrderLines] = useState([]); const [inventoryData, setInventoryData] = useState([]); - const [tabIndex, setTabIndex] = useState(0); + const [tabIndex, setTabIndex] = useState(initialTabIndex); const [selectedProcessId, setSelectedProcessId] = useState(null); const [operationPriority, setOperationPriority] = useState(50); const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 1eea577..f713ac6 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Button, @@ -12,6 +12,17 @@ import { 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"; @@ -20,15 +31,16 @@ 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 { - fetchAllJoborderProductProcessInfo, AllJoborderProductProcessInfoResponse, updateJo, fetchProductProcessesByJobOrderId, completeProductProcessLine, - assignJobOrderPickOrder + assignJobOrderPickOrder, + fetchJoborderProductProcessesPage } from "@/app/api/jo/actions"; import { StockInLineInput } from "@/app/api/stockIn"; import { PrinterCombo } from "@/app/api/settings/printer"; @@ -37,12 +49,14 @@ 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; } +type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; -const PER_PAGE = 6; +const PAGE_SIZE = 50; -const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => { +const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo ,onSelectMatchingStock, qcReady}) => { const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; @@ -55,6 +69,58 @@ const ProductProcessList: React.FC = ({ onSelectProcess type ProcessFilter = "all" | "drink" | "other"; const [filter, setFilter] = useState("all"); const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); + + const [appliedSearch, setAppliedSearch] = useState<{ + date: string; + itemCode: string | null; + jobOrderCode: string | null; + }>(() => ({ + date: dayjs().format("YYYY-MM-DD"), + itemCode: null, + jobOrderCode: null, + })); + + const [totalJobOrders, setTotalJobOrders] = useState(0); + const [selectedItemCodes, setSelectedItemCodes] = useState([]); + + // 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")); @@ -106,22 +172,55 @@ const ProductProcessList: React.FC = ({ onSelectProcess setOpenModal(true); }, [t]); + const handleApplySearch = useCallback((inputs: Record) => { + const selectedProcessType = (inputs.processType || "all") as ProcessFilter; + setFilter(selectedProcessType); + setAppliedSearch({ + date: inputs.date || dayjs().format("YYYY-MM-DD"), + itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, + jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, + }); + setSelectedItemCodes([]); + setPage(0); + }, []); + + const handleResetSearch = useCallback(() => { + setFilter("all"); + setAppliedSearch({ + date: dayjs().format("YYYY-MM-DD"), + itemCode: null, + jobOrderCode: null, + }); + setSelectedItemCodes([]); + setPage(0); + }, []); + const fetchProcesses = useCallback(async () => { setLoading(true); try { const isDrinkParam = filter === "all" ? undefined : filter === "drink" ? true : false; - - const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); - setProcesses(data || []); - setPage(0); + + 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); } - }, [filter]); + }, [filter, appliedSearch, qcReady, page]); useEffect(() => { fetchProcesses(); @@ -161,6 +260,29 @@ const ProductProcessList: React.FC = ({ onSelectProcess setLoading(false); } }, [t, fetchProcesses]); + + 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 @@ -169,8 +291,56 @@ const ProductProcessList: React.FC = ({ onSelectProcess // }, 300); // Add a delay to avoid immediate re-trigger of useEffect }, [fetchProcesses]); - const startIdx = page * PER_PAGE; - const paged = processes.slice(startIdx, startIdx + PER_PAGE); + 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]); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { + type: "date", + label: "Production date", + paramName: "date", + preFilledValue: dayjs().format("YYYY-MM-DD"), + }, + { + type: "text", + label: "Item Code", + paramName: "itemCode", + }, + { + type: "text", + label: "Job Order Code", + paramName: "jobOrderCode", + }, + { + type: "select", + label: "Type", + paramName: "processType", + options: ["all", "drink", "other"], + preFilledValue: "all", + }, + ], + [], + ); + + const handleSelectedItemCodesChange = useCallback((e: SelectChangeEvent) => { + const nextValue = e.target.value; + setSelectedItemCodes(typeof nextValue === "string" ? nextValue.split(",") : nextValue); + }, []); return ( @@ -180,31 +350,34 @@ const ProductProcessList: React.FC = ({ onSelectProcess ) : ( - - - - - + + criteria={searchCriteria} + onSearch={handleApplySearch} + onReset={handleResetSearch} + extraActions={ + + {t("Searched Item")} + + + } + /> - {t("Total processes")}: {processes.length} + {t("Total job orders")}: {totalJobOrders} {selectedItemCodes.length > 0 ? `| ${t("Filtered")}: ${paged.length}` : ""} @@ -238,6 +411,11 @@ const ProductProcessList: React.FC = ({ onSelectProcess .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 ( = ({ onSelectProcess > {t("Matching Stock")} + {statusLower !== "completed" && ( - )} - {statusLower === "completed" && ( - )} @@ -358,14 +549,32 @@ const ProductProcessList: React.FC = ({ onSelectProcess printSource="productionProcess" uiMode="default" /> - {processes.length > 0 && ( + + {t("Confirm")} + + {confirmMessage} + + + + + + + {totalJobOrders > 0 && ( setPage(p)} - rowsPerPageOptions={[PER_PAGE]} + rowsPerPageOptions={[PAGE_SIZE]} /> )} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index 02e611c..5ffa8f9 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -7,7 +7,6 @@ import ProductionProcessList from "@/components/ProductionProcess/ProductionProc import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; -import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList"; import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; @@ -112,6 +111,7 @@ const ProductionProcessPage: React.FC = ({ printerCo return ( setSelectedProcessId(null)} /> ); @@ -179,6 +179,7 @@ const ProductionProcessPage: React.FC = ({ printerCo {tabIndex === 0 && ( { const id = jobOrderId ?? null; if (id !== null) { @@ -196,9 +197,22 @@ const ProductionProcessPage: React.FC = ({ printerCo )} {tabIndex === 1 && ( - { + const id = jobOrderId ?? null; + if (id !== null) { + setSelectedProcessId(id); + } + }} + onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { + setSelectedMatchingStock({ + jobOrderId: jobOrderId || 0, + productProcessId: productProcessId || 0, + pickOrderId: pickOrderId || 0, + }); + }} /> )} {tabIndex === 2 && ( diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 8580cc8..9ee5daf 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -18,7 +18,16 @@ "Sequence": "順序", "Process Name": "製程名稱", "Process Description": "說明", + "Confirm to Pass this Process?": "確認要通過此工序嗎?", "Equipment Name": "設備", + "Confirm to update this Job Order?": "確認要完成此工單嗎?", + "all": "全部", + "Bom Uom": "BOM 單位", + "Searched Item": "已搜索物料", + "drink": "飲料", + "other": "其他", + "Total job orders": "總工單數量", + "Filtered": "已過濾", "Duration (Minutes)": "時間(分)", "Prep Time (Minutes)": "準備時間", "Post Prod Time (Minutes)": "收尾時間",