From 7d8f781016f24addfa5f5b00d7c6b5e0dabc9474 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 5 Aug 2026 11:20:56 +0800 Subject: [PATCH] =?UTF-8?q?actualPickLotLineId=20+=20COALESCE=20=E8=AE=80?= =?UTF-8?q?=E5=8F=96=20ledger=20lotQtyBefore/After=20+=20inventoryLotLineI?= =?UTF-8?q?d=20=E6=88=90=E5=93=81=E5=87=BA=E5=80=89=E5=93=A1=E5=B7=A5?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E5=A0=B1=E5=91=8A=20rep-016=20Truck=20X=202F?= =?UTF-8?q?/4F=20suggestion=20Just=20Complete=20/=20isIssueJustComplete=20?= =?UTF-8?q?Workbench=20=E7=95=A5=E9=81=8E=20classic=20confirmLotSubstituti?= =?UTF-8?q?on=20=E5=81=9C=20JO=20planStart=EF=BC=8Frenumber=20=E7=94=9F?= =?UTF-8?q?=E7=94=A2=E6=B5=81=E7=A8=8B=20lookback=20+=20tabs=EF=BC=8FQC=20?= =?UTF-8?q?chip=EF=BC=8FRelease=E2=86=92Cancel=20FP-MTMS=20code=20comments?= =?UTF-8?q?=20Report=20=E5=BA=97=E9=8B=AA=E8=A8=82=E5=96=AE=E8=A3=9C?= =?UTF-8?q?=E8=B2=A8=E8=A8=98=E9=8C=84=20=E5=B7=A5=E5=96=AE=E7=94=9F?= =?UTF-8?q?=E7=94=A2=E6=B5=81=E7=A8=8B=20UI=20=E5=86=8D=E9=87=8D=E6=A7=8B?= =?UTF-8?q?=20=E5=B7=A5=E5=96=AE=E6=8F=90=E6=96=99=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E5=8A=A0=E5=BC=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/report/page.tsx | 16 +- src/app/(main)/report/reportCategories.ts | 2 +- .../report/shopOrderReplenishmentReportApi.ts | 161 +++ src/app/api/doworkbench/actions.ts | 3 + src/app/api/jo/actions.ts | 41 +- .../WorkbenchGoodPickExecutionDetail.tsx | 3 +- .../InventorySearch/InventorySearch.tsx | 6 +- .../JoWorkbench/JoPickOrderList.tsx | 318 ++++-- .../JoWorkbench/newJobPickExecution.tsx | 165 +-- .../WorkbenchPickExecution.tsx | 80 +- .../ProductionProcess/JobOrderOpsTable.tsx | 750 ++++++++++++++ .../ProductionProcessJobOrderDetail.tsx | 81 +- .../ProductionProcessList.tsx | 944 ++++++++++++++---- .../ProductionProcessPage.tsx | 94 +- src/config/reportConfig.ts | 49 +- src/i18n/en/productionProcess.json | 29 +- src/i18n/zh/itemTracing.json | 2 +- src/i18n/zh/productionProcess.json | 29 +- 18 files changed, 2191 insertions(+), 582 deletions(-) create mode 100644 src/app/(main)/report/shopOrderReplenishmentReportApi.ts create mode 100644 src/components/ProductionProcess/JobOrderOpsTable.tsx diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 83bb62b..266020a 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -31,6 +31,7 @@ import { } from './semiFGProductionAnalysisApi'; import { generateGrnReportExcel } from './grnReportApi'; import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; +import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi'; import { FEATURE_USAGE, FEATURE_USAGE_ACTION, @@ -42,6 +43,7 @@ interface ItemCodeWithName { name: string; } +/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const includeGrnFinancialColumns = @@ -267,12 +269,24 @@ export default function ReportPage() { ); } else if (currentReport.id === 'rep-015') { await generateBomShopSyncReportExcel(criteria, currentReport.title); + } else if (currentReport.id === 'rep-017') { + await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. - const queryParams = + let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : new URLSearchParams(criteria).toString(); + // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. + if (currentReport.id === 'rep-016') { + const p = new URLSearchParams(criteria); + const day = (criteria.dateStart || '').trim(); + if (day) { + p.set('dateStart', day); + p.set('dateEnd', day); + } + queryParams = p.toString(); + } const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; const response = await clientAuthFetch(excelUrl, { diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index 7c6cced..1c86308 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#b3d4f0", bodyBg: "#eef5fc", accent: "#1565c0", - reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013"], + reportIds: ["rep-004", "rep-014", "rep-008", "rep-009", "rep-013", "rep-016", "rep-017"], }, { id: "production", diff --git a/src/app/(main)/report/shopOrderReplenishmentReportApi.ts b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts new file mode 100644 index 0000000..bca78f7 --- /dev/null +++ b/src/app/(main)/report/shopOrderReplenishmentReportApi.ts @@ -0,0 +1,161 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; + +export interface ShopOrderReplenishmentReportRow { + shopNo?: string; + shopName?: string; + shopOrderDate?: string; + shopOrderNo?: string; + itemNo?: string; + itemName?: string; + firstOrderQty?: number | string; + firstOrderActualPickQty?: number | string; + reorderQty?: number | string; + reorderDate?: string; + reason?: string; + actualDeliveredQty?: number | string; + deliveredDate?: string; + [key: string]: unknown; +} + +export interface ShopOrderReplenishmentReportResponse { + rows: ShopOrderReplenishmentReportRow[]; +} + +const SHEET_NAME = "店鋪訂單補貨記錄"; + +const NO_DATA_NOTE = + "(篩選範圍內無資料 / No records in the selected range)"; + +function emptySheetRow(note: string = NO_DATA_NOTE): Record { + return { + "Shop No. / 店鋪編號": note, + "Shop Name / 店鋪名稱": "", + "Shop Order Date / 店鋪訂單日期": "", + "Shop Order No. / 店鋪訂單編號": "", + "Item No. / 貨品編號": "", + "Item Name / 貨品名稱": "", + "First Order Qty / 原訂單數量": "", + "First Order Actual Pick Qty / 原單實際提料數量": "", + "Reorder Qty / 補貨數量": "", + "Reorder Date / 補貨日期": "", + "Reason / 補貨原因": "", + "Actual Delivered Qty / 實際補貨數量": "", + "Delivered Date / 送貨日期": "", + }; +} + +function formatReason(reason: string | undefined): string { + switch ((reason ?? "").trim()) { + case "quality_issue": + return "質素問題"; + case "out_of_stock": + return "缺貨"; + case "other": + return "其他"; + default: + return reason ?? ""; + } +} + +function formatDateCell(value: unknown): string { + if (value == null || value === "") return ""; + if (typeof value === "number" && Number.isFinite(value)) { + // Jackson may serialize java.sql.Date as epoch millis or seconds + const ms = value > 1e12 ? value : value * 1000; + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return String(value); + return d.toISOString().slice(0, 10); + } + const s = String(value).trim(); + if (/^\d{10,13}$/.test(s)) { + const n = Number(s); + const ms = s.length >= 13 ? n : n * 1000; + const d = new Date(ms); + if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); + } + // JDBC / string date: "2026-08-01" or "2026-08-01 00:00:00.0" + return s.length >= 10 ? s.slice(0, 10) : s; +} + +function formatQty(value: unknown): string | number { + if (value === null || value === undefined || value === "") return ""; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return String(value); + return n; +} + +function toExcelRow(r: ShopOrderReplenishmentReportRow): Record { + const base = emptySheetRow(""); + return { + ...base, + "Shop No. / 店鋪編號": r.shopNo ?? "", + "Shop Name / 店鋪名稱": r.shopName ?? "", + "Shop Order Date / 店鋪訂單日期": formatDateCell(r.shopOrderDate), + "Shop Order No. / 店鋪訂單編號": r.shopOrderNo ?? "", + "Item No. / 貨品編號": r.itemNo ?? "", + "Item Name / 貨品名稱": r.itemName ?? "", + "First Order Qty / 原訂單數量": formatQty(r.firstOrderQty), + "First Order Actual Pick Qty / 原單實際提料數量": formatQty(r.firstOrderActualPickQty), + "Reorder Qty / 補貨數量": formatQty(r.reorderQty), + "Reorder Date / 補貨日期": formatDateCell(r.reorderDate), + "Reason / 補貨原因": formatReason(r.reason), + "Actual Delivered Qty / 實際補貨數量": formatQty(r.actualDeliveredQty), + "Delivered Date / 送貨日期": formatDateCell(r.deliveredDate), + }; +} + +export async function fetchShopOrderReplenishmentReportData( + criteria: Record, +): Promise { + const queryParams = new URLSearchParams(criteria).toString(); + const url = `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment?${queryParams}`; + + const response = await clientAuthFetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (response.status === 401 || response.status === 403) + throw new Error("Unauthorized"); + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`); + + const data = (await response.json()) as + | ShopOrderReplenishmentReportResponse + | ShopOrderReplenishmentReportRow[]; + if (Array.isArray(data)) return data; + return data.rows ?? []; +} + +/** + * Generate and download Shop Orders Replenishment Records as Excel. + */ +export async function generateShopOrderReplenishmentReportExcel( + criteria: Record, + reportTitle: string = "店鋪訂單補貨記錄", +): Promise { + const rows = await fetchShopOrderReplenishmentReportData(criteria); + const excelRows = + rows.length > 0 ? rows.map(toExcelRow) : [emptySheetRow()]; + + const dateCandidates = [ + criteria.reorderDateStart, + criteria.reorderDateEnd, + criteria.shopOrderDateStart, + criteria.shopOrderDateEnd, + criteria.deliveredDateStart, + criteria.deliveredDateEnd, + ].filter((v) => (v ?? "").trim().length > 0); + const datePart = + dateCandidates.length > 0 + ? dateCandidates.join("_") + : new Date().toISOString().slice(0, 10); + const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); + const filename = `${reportTitle}_${safeDatePart}`; + + exportChartToXlsx(excelRows, filename, SHEET_NAME); +} diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index ad5ce91..65af3d6 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = { excludeWarehouseCodes?: string[] | null; /** Optional decimal string or number serialized by JSON */ qty?: number | string | null; + /** Just Complete button (no QR) — DO user-audit flag */ + justComplete?: boolean | null; userId: number; }; @@ -144,6 +146,7 @@ export async function workbenchScanPick( ...(storeId !== undefined ? { storeId } : {}), ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), ...(qty !== undefined ? { qty } : {}), + ...(body.justComplete === true ? { justComplete: true } : {}), userId: body.userId, }), headers: { "Content-Type": "application/json" }, diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 8d67ac1..00b634a 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse { itemCode: string; itemName: string; bomDescription?: string | null; + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + bomType?: string | null; lotNo: string; requiredQty: number; jobOrderId: number; @@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse { productProcessLineCount: number; FinishedProductProcessLineCount: number; lines: ProductProcessInfoResponse[]; + isPicked?: boolean | null; + /** Fine-grained pick/process bucket from backend. */ + pickProcessBucket?: ProductionProcessFinePickBucket | null; +} + +/** Fine-grained buckets returned per row. */ +export type ProductionProcessFinePickBucket = + | "not_picked_not_started" + | "picked_not_started" + | "picked_started" + | "not_picked_started"; + +/** Merged tab filter: pending = not started; processing = started. */ +export type ProductionProcessPickBucket = "pending" | "processing" | ProductionProcessFinePickBucket; + +export interface JobOrderProductProcessBucketCounts { + notPickedNotStarted: number; + pickedNotStarted: number; + pickedStarted: number; + notPickedStarted: number; } export interface JobOrderProductProcessPageResponse { @@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse { totalJobOrders: number; page: number; size: number; + bucketCounts?: JobOrderProductProcessBucketCounts | null; + searchDate?: string | null; + carriedOverCount?: number | null; } export interface ProductProcessInfoResponse { id: number; @@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse { jobOrderType: string | null; itemId: number; itemName: string; + itemCode?: string | null; bomDescription?: string | null; + /** BOM.type (e.g. drink / Powder_Mixture / other). */ + bomType?: string | null; lotNo: string | null; planStart?: string | number[] | null; reqQty: number; @@ -877,8 +905,9 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n ); }); +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ export const fetchJoborderProductProcessesPage = cache(async (params: { - /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ + /** Job order / process date(YYYY-MM-DD) */ date?: string | null; itemCode?: string | null; jobOrderCode?: string | null; @@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { includePutaway?: boolean | null; /** all | completed | notCompleted */ putawayStatus?: string | null; + /** Production list carry-over window (days before date). */ + lookbackDays?: number | null; + /** Pick/process tab filter when lookbackDays is set. */ + bucket?: ProductionProcessPickBucket | "all" | null; page?: number; size?: number; }) => { @@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { includePutaway, putawayStatus, type, + lookbackDays, + bucket, page = 0, size = 50, } = params; @@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { queryParts.push(`includePutaway=${includePutaway}`); } if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`); + if (lookbackDays !== undefined && lookbackDays !== null) { + queryParts.push(`lookbackDays=${lookbackDays}`); + } + if (bucket) queryParts.push(`bucket=${encodeURIComponent(bucket)}`); queryParts.push(`page=${page}`); queryParts.push(`size=${size}`); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 4b6015d..2953659 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -551,7 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record) } } -/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.2 | 2026-08-03 */ const WorkbenchGoodPickExecutionDetail: React.FC = ({ filterArgs, onSwitchToRecordTab, @@ -3274,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe ? { stockInLineId: canonicalLotForSol.stockInLineId } : {}), qty: qtyToSend, + justComplete: true, storeId: fgPickOrders?.[0]?.storeId ?? null, userId: currentUserId ?? 1, }); diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index b1c2dd5..778e6d6 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -172,12 +172,12 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { pagingController: typeof defaultPagingController, lotNo: string, ) => { - console.log('%c Action Type 1.', 'color:red', actionType); + //console.log('%c Action Type 1.', 'color:red', actionType); // Avoid loading data again if (actionType === 'paging' && pagingController === defaultPagingController) { return; } - console.log('%c Action Type 2.', 'color:blue', actionType); + // console.log('%c Action Type 2.', 'color:blue', actionType); const params: SearchInventory = { code: query?.itemCode ?? '', @@ -415,7 +415,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { scanUiMode, ]); - console.log('', 'color: #666', inventoriesPagingController); + //console.log('', 'color: #666', inventoriesPagingController); const handleOpenOpeningInventoryModal = useCallback(() => { setOpeningSelectedItem(null); diff --git a/src/components/JoWorkbench/JoPickOrderList.tsx b/src/components/JoWorkbench/JoPickOrderList.tsx index f40abef..9901f55 100644 --- a/src/components/JoWorkbench/JoPickOrderList.tsx +++ b/src/components/JoWorkbench/JoPickOrderList.tsx @@ -1,11 +1,9 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { Box, Button, Card, - CardContent, - CardActions, Stack, Typography, Chip, @@ -26,6 +24,17 @@ interface Props { printerCombo: PrinterCombo[]; } +const chipSx = { + flexShrink: 0, + height: 28, + borderRadius: "14px", + "& .MuiChip-label": { + typography: "body2", + px: 1.25, + lineHeight: 1.2, + }, +} as const; + /** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */ const JoPickOrderList: React.FC = ({ printerCombo }) => { const { t } = useTranslation(["common", "jo"]); @@ -44,10 +53,9 @@ const JoPickOrderList: React.FC = ({ printerCombo }) => { { label: t("Item Name"), paramName: "itemName", type: "text" }, { label: t("Job Order Type"), - paramName: "BOM Description", + paramName: "bomDescription", type: "select-labelled", options: [ - //{ label: t("All"), value: "All" }, { label: t("FG"), value: "FG" }, { label: t("WIP"), value: "WIP" }, ], @@ -58,7 +66,6 @@ const JoPickOrderList: React.FC = ({ printerCombo }) => { paramName: "bomType", type: "select-labelled", options: [ - //{ label: t("All"), value: "All" }, { label: t("Drink"), value: "drink" }, { label: t("Powder Mixture"), value: "Powder_Mixture" }, { label: t("Other"), value: "other" }, @@ -69,7 +76,6 @@ const JoPickOrderList: React.FC = ({ printerCombo }) => { paramName: "floor", type: "select-labelled", options: [ - //{ label: t("All"), value: "ALL" }, { label: "2F", value: "2F" }, { label: "3F", value: "3F" }, { label: "4F", value: "4F" }, @@ -179,8 +185,8 @@ const JoPickOrderList: React.FC = ({ printerCombo }) => { {t("Total pick orders")}: {pickOrders.length} - - + + {pickOrders.map((pickOrder) => { const status = String(pickOrder.jobOrderStatus || ""); const statusLower = status.toLowerCase(); @@ -190,126 +196,216 @@ const JoPickOrderList: React.FC = ({ printerCombo }) => { : statusLower === "pending" || statusLower === "processing" ? "primary" : "default"; - + const finishedCount = pickOrder.finishedPickOLineCount ?? 0; - + const bomDescription = pickOrder.bomDescription + ? String(pickOrder.bomDescription).trim() + : ""; + const bomType = pickOrder.bomType + ? String(pickOrder.bomType).trim() + : ""; + return ( - + - - - - - {t("Job Order")}: {pickOrder.jobOrderCode || "-"} - - - - - - {t("Lot No")}: {pickOrder.lotNo || "-"} - - - {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} - - - {t("Item Name")}: {pickOrder.itemName} - {pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""} - - - {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) + + {[pickOrder.itemCode, pickOrder.itemName] + .filter(Boolean) + .join(" ") || "-"} - {selectedFloor === "ALL" ? ( - <> - {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( - - {floor}: {finishedCount}/{totalCount} - - ))} - {!!pickOrder.noLotPickCount && ( - - {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} - + + + {bomDescription ? ( + + ) : null} + {bomType ? ( + + ) : null} + + + + + + {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} + + + {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) + + + + {selectedFloor === "ALL" ? ( + <> + {pickOrder.floorPickCounts?.map( + ({ floor, finishedCount, totalCount }) => ( + + {floor}: {finishedCount}/{totalCount} + + ), + )} + {!!pickOrder.noLotPickCount && ( + + {t("No Lot")}:{" "} + {pickOrder.noLotPickCount.finishedCount}/ + {pickOrder.noLotPickCount.totalCount} + + )} + + ) : selectedFloor === "NO_LOT" ? ( + !!pickOrder.noLotPickCount && ( + + {t("No Lot")}:{" "} + {pickOrder.noLotPickCount.finishedCount}/ + {pickOrder.noLotPickCount.totalCount} + + ) + ) : ( + pickOrder.floorPickCounts + ?.filter((c) => c.floor === selectedFloor) + .map(({ floor, finishedCount, totalCount }) => ( + + {floor}: {finishedCount}/{totalCount} + + )) )} - - ) : selectedFloor === "NO_LOT" ? ( - !!pickOrder.noLotPickCount && ( - - {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} - - ) - ) : ( - pickOrder.floorPickCounts - ?.filter((c) => c.floor === selectedFloor) - .map(({ floor, finishedCount, totalCount }) => ( - - {floor}: {finishedCount}/{totalCount} + + + {typeof pickOrder.suggestedFailCount === "number" && + pickOrder.suggestedFailCount > 0 && ( + + {t("Suggested Fail")}: {pickOrder.suggestedFailCount} - )) - )} - {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( - - {t("Suggested Fail")}: {pickOrder.suggestedFailCount} - - )} - {statusLower !== "pending" && finishedCount > 0 && ( - - + )} + + {statusLower !== "pending" && finishedCount > 0 && ( + {t("Finished lines")}: {finishedCount} - - )} - - - - + + + - {t("View Details")} - - - + {pickOrder.jobOrderCode || "-"} + {" · "} + {t("Lot No")}: {pickOrder.lotNo || "-"} + + ); diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx index 3458235..10a4c66 100644 --- a/src/components/JoWorkbench/newJobPickExecution.tsx +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -618,6 +618,7 @@ const QrCodeModal: React.FC<{ ); }; +/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.0 | 2026-08-03 */ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); @@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; let switchedToUnavailable = false; - // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo - if (!originalSuggestedPickLotId) { - if (!selectedLotForQr?.stockOutLineId) { - throw new Error("Missing stockOutLineId for noLot line"); - } - console.log( - "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", - ); - const res = await updateStockOutLineStatusByQRCodeAndLotNo({ - pickOrderLineId: selectedLotForQr.pickOrderLineId, - inventoryLotNo: effectiveScannedLot.lotNo || "", - stockInLineId: effectiveScannedLot?.stockInLineId ?? null, - stockOutLineId: selectedLotForQr.stockOutLineId, - itemId: selectedLotForQr.itemId, - status: "checked", - }); - console.log( - "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", - res, - ); - switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; - const ok = - res?.code === "checked" || - res?.code === "SUCCESS" || - switchedToUnavailable; - if (!ok) { - const errMsg = - res?.code === "LOT_UNAVAILABLE" - ? tPick( - "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", - ) - : res?.message || - tPick( - "Lot switch failed; pick line was not marked as checked.", - ); - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - return; - } - } else { - // Call confirmLotSubstitution to update the suggested lot - console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); - const substitutionResult = await confirmLotSubstitution({ - pickOrderLineId: selectedLotForQr.pickOrderLineId, - stockOutLineId: selectedLotForQr.stockOutLineId, - originalSuggestedPickLotId, - newInventoryLotNo: effectiveScannedLot.lotNo || "", - // ✅ required by LotSubstitutionConfirmRequest - newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, - }); - - console.log( - "✅ [LOT CONFIRM] Lot substitution result:", - substitutionResult, - ); + // Workbench no-hold: skip classic bind/switch (holdQty). Switch + pick via scan-pick only. + // Non-workbench: keep confirmLotSubstitution / QR bind (moves hold). + if (!workbenchMode) { + // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo + if (!originalSuggestedPickLotId) { + if (!selectedLotForQr?.stockOutLineId) { + throw new Error("Missing stockOutLineId for noLot line"); + } + console.log( + "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", + ); + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: selectedLotForQr.pickOrderLineId, + inventoryLotNo: effectiveScannedLot.lotNo || "", + stockInLineId: effectiveScannedLot?.stockInLineId ?? null, + stockOutLineId: selectedLotForQr.stockOutLineId, + itemId: selectedLotForQr.itemId, + status: "checked", + }); + console.log( + "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", + res, + ); + switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; + const ok = + res?.code === "checked" || + res?.code === "SUCCESS" || + switchedToUnavailable; + if (!ok) { + const errMsg = + res?.code === "LOT_UNAVAILABLE" + ? tPick( + "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", + ) + : res?.message || + tPick( + "Lot switch failed; pick line was not marked as checked.", + ); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } + } else { + // Call confirmLotSubstitution to update the suggested lot + console.log("🔄 [LOT CONFIRM] Calling confirmLotSubstitution..."); + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: selectedLotForQr.pickOrderLineId, + stockOutLineId: selectedLotForQr.stockOutLineId, + originalSuggestedPickLotId, + newInventoryLotNo: effectiveScannedLot.lotNo || "", + // ✅ required by LotSubstitutionConfirmRequest + newStockInLineId: effectiveScannedLot?.stockInLineId ?? null, + }); - // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. - // Keep modal open so user can cancel/rescan. - switchedToUnavailable = - substitutionResult?.code === "SUCCESS_UNAVAILABLE" || - substitutionResult?.code === "BOUND_UNAVAILABLE"; - if ( - !substitutionResult || - (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) - ) { - console.error( - "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", + console.log( + "✅ [LOT CONFIRM] Lot substitution result:", + substitutionResult, ); - const errMsg = - substitutionResult?.code === "LOT_UNAVAILABLE" - ? tPick( - "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", - ) - : substitutionResult?.message || - `换批失败:stockInLineId ${ - effectiveScannedLot?.stockInLineId ?? "" - } 不存在或无法匹配`; - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(errMsg); - return; + + // ✅ CRITICAL: substitution failed => DO NOT mark original stockOutLine as checked. + // Keep modal open so user can cancel/rescan. + switchedToUnavailable = + substitutionResult?.code === "SUCCESS_UNAVAILABLE" || + substitutionResult?.code === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionResult.code !== "SUCCESS" && !switchedToUnavailable) + ) { + console.error( + "❌ [LOT CONFIRM] Lot substitution failed. Will NOT update stockOutLine status.", + ); + const errMsg = + substitutionResult?.code === "LOT_UNAVAILABLE" + ? tPick( + "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", + ) + : substitutionResult?.message || + `换批失败:stockInLineId ${ + effectiveScannedLot?.stockInLineId ?? "" + } 不存在或无法匹配`; + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } } } diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 40843a7..98d3442 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -30,7 +30,6 @@ import { SessionWithTokens } from "@/config/authConfig"; import { fetchConsumableWorkbenchPickOrderLotsHierarchical, reloadConsumableWorkbenchPickOrderLotsHierarchical, - confirmLotSubstitution, suggestPickOrderWorkbenchV2, } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; @@ -229,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean => const isRejectedStatus = (status: string | undefined): boolean => String(status || "").toLowerCase() === "rejected"; -const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { - const c = String(code || "").toUpperCase(); - const m = String(message || ""); - if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true; - if (/^Reject switch lot:/i.test(m)) return true; - if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true; - return false; -}; - function safeDisplayTargetDate(targetDate: string | number[]): string { try { if (Array.isArray(targetDate) && targetDate.length >= 3) { @@ -356,7 +346,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { }); } -/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.2 | 2026-08-03 */ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { const { t } = useTranslation("pickOrder"); const { data: session } = useSession() as { data: SessionWithTokens | null }; @@ -962,6 +952,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { } }, []); + // Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only. const handleLotConfirmation = useCallback( async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { const expected = overrideExpected ?? expectedLotData; @@ -972,56 +963,25 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { setError(""); setMessage(""); try { - const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0); - let switchedToUnavailable = false; - if (originalSuggestedPickLotId > 0) { - const res = await confirmLotSubstitution({ - pickOrderLineId: expected.row.pickOrderLineId, - stockOutLineId: expected.row.stockOutLineId, - originalSuggestedPickLotId, - newInventoryLotNo: scanned.lotNo, - newStockInLineId: Number(scanned.stockInLineId ?? 0), - }); - switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE"; - const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message); - if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) { - const msg = (res.message as string) || t("Lot switch failed"); - setLotConfirmationError(msg); - setError(msg); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(msg); - }); - return; - } - if (nonBlockingReject && !switchedToUnavailable) { - const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick."); - setMessage(warnMsg); - } - } - - if (!switchedToUnavailable) { - const res = await workbenchScanPick({ - stockOutLineId: expected.row.stockOutLineId, - lotNo: scanned.lotNo, - ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 - ? { stockInLineId: Number(scanned.stockInLineId) } - : {}), - ...workbenchScanPickQtyFromLot(expected.row), - userId, + const res = await workbenchScanPick({ + stockOutLineId: expected.row.stockOutLineId, + lotNo: scanned.lotNo, + ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 + ? { stockInLineId: Number(scanned.stockInLineId) } + : {}), + ...workbenchScanPickQtyFromLot(expected.row), + userId, + }); + if (res.code !== "SUCCESS") { + const msg = (res.message as string) || t("Workbench scan-pick failed."); + setLotConfirmationError(msg); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); }); - if (res.code !== "SUCCESS") { - const msg = (res.message as string) || t("Workbench scan-pick failed."); - setLotConfirmationError(msg); - setError(msg); - startTransition(() => { - setQrScanError(true); - setQrScanSuccess(false); - setQrScanErrorMsg(msg); - }); - return; - } + return; } setMessage(t("Scan pick success")); startTransition(() => { diff --git a/src/components/ProductionProcess/JobOrderOpsTable.tsx b/src/components/ProductionProcess/JobOrderOpsTable.tsx new file mode 100644 index 0000000..bb27757 --- /dev/null +++ b/src/components/ProductionProcess/JobOrderOpsTable.tsx @@ -0,0 +1,750 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Tab, + Tabs, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useSession } from "next-auth/react"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import dayjs, { Dayjs } from "dayjs"; +import { SessionWithTokens } from "@/config/authConfig"; +import { AUTH } from "@/authorities"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { + AllJoborderProductProcessInfoResponse, + completeProductProcessLine, + fetchJoborderProductProcessesPage, + fetchJos, + fetchProductProcessesByJobOrderId, + setJobOrderHidden, +} from "@/app/api/jo/actions"; +import { JobOrder } from "@/app/api/jo"; +import QcStockInModal from "@/components/Qc/QcStockInModal"; +import { StockInLineInput } from "@/app/api/stockIn"; +import type { PrinterCombo } from "@/app/api/settings/printer"; + +type PrimaryTab = + | "all" + | "pending" + | "processing" + | "pending_qc" + | "putawayed" + | "issue"; +type PendingSubTab = "all" | "picked_not_started" | "not_picked_not_started"; +type ProcessingSubTab = "all" | "picked_started" | "not_picked_started"; +type IssueSubTab = "stop" | "cancel"; + +type OpsRow = { + key: string; + jobOrderId: number; + jobOrderCode: string; + itemCode: string; + itemName: string; + requiredQty: number; + uom: string; + productionDate: string; + statusLabel: string; + pickProcessBucket?: string | null; + isPaused: boolean; + isCancelled: boolean; + /** pending_qc | putawayed | production | cancelled */ + rowKind: "production" | "pending_qc" | "putawayed" | "cancelled"; + stockInLineId?: number | null; + /** Present for production rows; used by complete-JO. */ + sourceProcess?: AllJoborderProductProcessInfoResponse; +}; + +/** Cards / page size for ops table fetch. */ +const FETCH_SIZE = 200; + +function isPausedProcess(p: AllJoborderProductProcessInfoResponse): boolean { + return (p.lines ?? []).some( + (l) => String(l.status ?? "").trim().toLowerCase() === "paused", + ); +} + +function toProductionRow( + p: AllJoborderProductProcessInfoResponse, + rowKind: OpsRow["rowKind"] = "production", +): OpsRow | null { + if (!p.jobOrderId) return null; + return { + key: `pp-${rowKind}-${p.jobOrderId}-${p.id}`, + jobOrderId: p.jobOrderId, + jobOrderCode: p.jobOrderCode || "-", + itemCode: p.itemCode || "-", + itemName: p.itemName || "-", + requiredQty: p.requiredQty ?? 0, + uom: p.uom || "", + productionDate: p.date || "", + statusLabel: + rowKind === "pending_qc" + ? "pending_qc" + : rowKind === "putawayed" + ? "putawayed" + : p.pickProcessBucket || p.status || "-", + pickProcessBucket: p.pickProcessBucket, + isPaused: isPausedProcess(p), + isCancelled: false, + rowKind, + stockInLineId: p.stockInLineId ?? null, + sourceProcess: p, + }; +} + +function toCancelledRow(jo: JobOrder): OpsRow { + const planStart = Array.isArray(jo.planStart) + ? dayjs( + new Date( + jo.planStart[0], + (jo.planStart[1] ?? 1) - 1, + jo.planStart[2] ?? 1, + ), + ).format("YYYY-MM-DD") + : jo.planStart + ? dayjs(jo.planStart as unknown as string).format("YYYY-MM-DD") + : ""; + return { + key: `jo-${jo.id}`, + jobOrderId: jo.id, + jobOrderCode: jo.code || "-", + itemCode: jo.item?.code || "-", + itemName: jo.itemName || jo.item?.name || "-", + requiredQty: jo.reqQty ?? 0, + uom: "", + productionDate: planStart, + statusLabel: "cancelled", + pickProcessBucket: null, + isPaused: false, + isCancelled: true, + rowKind: "cancelled", + stockInLineId: null, + }; +} + +/** Deduplicate by jobOrderId (prefer paused / higher priority). */ +function dedupeByJobOrder(rows: OpsRow[]): OpsRow[] { + const map = new Map(); + for (const row of rows) { + const existing = map.get(row.jobOrderId); + if (!existing) { + map.set(row.jobOrderId, row); + continue; + } + if (!existing.isPaused && row.isPaused) { + map.set(row.jobOrderId, row); + } + } + return Array.from(map.values()); +} + +interface JobOrderOpsTableProps { + onSelectProcess?: (jobOrderId: number) => void; + printerCombo?: PrinterCombo[]; +} + +const JobOrderOpsTable: React.FC = ({ + onSelectProcess, + printerCombo = [], +}) => { + const { t } = useTranslation(["productionProcess", "common"]); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const sessionToken = session as SessionWithTokens | null; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canManage = abilities.some((a) => a.trim() === AUTH.ADMIN); + + const [queryDate, setQueryDate] = useState(() => dayjs()); + const [primaryTab, setPrimaryTab] = useState("all"); + const [pendingSub, setPendingSub] = useState("all"); + const [processingSub, setProcessingSub] = useState("all"); + const [issueSub, setIssueSub] = useState("stop"); + + const [productionRows, setProductionRows] = useState([]); + const [pendingQcRows, setPendingQcRows] = useState([]); + const [putawayedRows, setPutawayedRows] = useState([]); + const [cancelledRows, setCancelledRows] = useState([]); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmMessage, setConfirmMessage] = useState(""); + const [confirmLoading, setConfirmLoading] = useState(false); + const [pendingAction, setPendingAction] = useState Promise)>(null); + const actionInFlightRef = useRef(false); + const [rowBusyIds, setRowBusyIds] = useState>(new Set()); + const [qcModalOpen, setQcModalOpen] = useState(false); + const [qcModalInfo, setQcModalInfo] = useState(); + + const loadProduction = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + // lookbackDays=0 → exact search date only (still enables pickProcessBucket on backend) + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + lookbackDays: 0, + bucket: "all", + qcReady: false, + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "production")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setProductionRows(dedupeByJobOrder(rows)); + }, [queryDate]); + + const loadPendingQc = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + lookbackDays: 0, + qcReady: true, + includePutaway: true, + putawayStatus: "notCompleted", + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "pending_qc")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setPendingQcRows(dedupeByJobOrder(rows)); + } catch (e) { + console.error("loadPendingQc failed", e); + setPendingQcRows([]); + } + }, [queryDate]); + + const loadPutawayed = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const data = await fetchJoborderProductProcessesPage({ + date: dayStr, + qcReady: true, + includePutaway: true, + putawayStatus: "completed", + page: 0, + size: FETCH_SIZE, + }); + const rows = (data?.content ?? []) + .map((p) => toProductionRow(p, "putawayed")) + .filter((r): r is OpsRow => r != null) + .filter((r) => !r.productionDate || r.productionDate === dayStr); + setPutawayedRows(dedupeByJobOrder(rows)); + } catch (e) { + console.error("loadPutawayed failed", e); + setPutawayedRows([]); + } + }, [queryDate]); + + const loadCancelled = useCallback(async () => { + const dayStr = queryDate.format("YYYY-MM-DD"); + try { + const res = await fetchJos({ + code: "", + planStart: dayStr, + planStartTo: dayStr, + joSearchStatus: "cancel", + pageNum: 0, + pageSize: FETCH_SIZE, + }); + setCancelledRows((res?.records ?? []).map(toCancelledRow)); + } catch (e) { + console.error("loadCancelled failed", e); + setCancelledRows([]); + } + }, [queryDate]); + + const loadData = useCallback(async () => { + setLoading(true); + const results = await Promise.allSettled([ + loadProduction(), + loadPendingQc(), + loadPutawayed(), + loadCancelled(), + ]); + if (results[0].status === "rejected") { + console.error(results[0].reason); + setProductionRows([]); + } + setLoading(false); + }, [loadProduction, loadPendingQc, loadPutawayed, loadCancelled]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + setPage(0); + }, [primaryTab, pendingSub, processingSub, issueSub, queryDate]); + + const filteredRows = useMemo(() => { + if (primaryTab === "pending_qc") return pendingQcRows; + if (primaryTab === "putawayed") return putawayedRows; + if (primaryTab === "issue" && issueSub === "cancel") return cancelledRows; + + let list = productionRows; + if (primaryTab === "pending") { + list = list.filter((r) => { + const b = r.pickProcessBucket; + if (pendingSub === "picked_not_started") return b === "picked_not_started"; + if (pendingSub === "not_picked_not_started") return b === "not_picked_not_started"; + return b === "picked_not_started" || b === "not_picked_not_started"; + }); + } else if (primaryTab === "processing") { + list = list.filter((r) => { + const b = r.pickProcessBucket; + if (processingSub === "picked_started") return b === "picked_started"; + if (processingSub === "not_picked_started") return b === "not_picked_started"; + return b === "picked_started" || b === "not_picked_started"; + }); + } else if (primaryTab === "issue" && issueSub === "stop") { + list = list.filter((r) => r.isPaused); + } + return list; + }, [ + primaryTab, + pendingSub, + processingSub, + issueSub, + productionRows, + pendingQcRows, + putawayedRows, + cancelledRows, + ]); + + const counts = useMemo(() => { + const pending = productionRows.filter( + (r) => + r.pickProcessBucket === "picked_not_started" || + r.pickProcessBucket === "not_picked_not_started", + ).length; + const processing = productionRows.filter( + (r) => + r.pickProcessBucket === "picked_started" || + r.pickProcessBucket === "not_picked_started", + ).length; + const stop = productionRows.filter((r) => r.isPaused).length; + return { + all: productionRows.length, + pending, + processing, + pendingQc: pendingQcRows.length, + putawayed: putawayedRows.length, + stop, + cancel: cancelledRows.length, + }; + }, [productionRows, pendingQcRows, putawayedRows, cancelledRows]); + + const paginatedRows = useMemo(() => { + const start = page * pageSize; + return filteredRows.slice(start, start + pageSize); + }, [filteredRows, page, pageSize]); + + const openConfirm = useCallback((message: string, action: () => Promise) => { + setConfirmMessage(message); + setPendingAction(() => action); + setConfirmOpen(true); + }, []); + + const closeConfirm = useCallback(() => { + if (confirmLoading) return; + setConfirmOpen(false); + setPendingAction(null); + setConfirmMessage(""); + }, [confirmLoading]); + + const markBusy = (id: number, busy: boolean) => { + setRowBusyIds((prev) => { + const next = new Set(prev); + if (busy) next.add(id); + else next.delete(id); + return next; + }); + }; + + const handleOpenQcModal = useCallback( + (row: OpsRow) => { + if (!row.stockInLineId) { + alert(t("Invalid Stock In Line Id")); + return; + } + setQcModalInfo({ id: row.stockInLineId }); + setQcModalOpen(true); + }, + [t], + ); + + const handleComplete = useCallback( + (row: OpsRow) => { + if (!canManage || row.isCancelled) return; + openConfirm(t("Confirm to update this Job Order?"), async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + markBusy(row.jobOrderId, true); + try { + const processes = await fetchProductProcessesByJobOrderId(row.jobOrderId); + const lineIds = (processes ?? []) + .flatMap((p) => (p as { productProcessLines?: { id?: number }[] }).productProcessLines ?? []) + .map((l) => l.id) + .filter((id): id is number => !!id); + for (const lineId of lineIds) { + try { + await completeProductProcessLine(lineId); + } catch (e) { + console.error("completeProductProcessLine failed", lineId, e); + } + } + await loadData(); + } finally { + markBusy(row.jobOrderId, false); + actionInFlightRef.current = false; + } + }); + }, + [canManage, openConfirm, t, loadData], + ); + + const handleCancel = useCallback( + (row: OpsRow) => { + if (!canManage || row.isCancelled) return; + openConfirm(t("Cancel job order confirm message"), async () => { + if (actionInFlightRef.current) return; + actionInFlightRef.current = true; + markBusy(row.jobOrderId, true); + try { + await setJobOrderHidden(row.jobOrderId, true); + await loadData(); + } finally { + markBusy(row.jobOrderId, false); + actionInFlightRef.current = false; + } + }); + }, + [canManage, openConfirm, t, loadData], + ); + + const onConfirm = useCallback(async () => { + if (!pendingAction) return; + setConfirmLoading(true); + try { + await pendingAction(); + } catch (e) { + console.error(e); + } finally { + setConfirmLoading(false); + setConfirmOpen(false); + setPendingAction(null); + setConfirmMessage(""); + } + }, [pendingAction]); + + const bucketLabel = (row: OpsRow) => { + if (row.rowKind === "pending_qc") return t("Waiting QC Put Away"); + if (row.rowKind === "putawayed") return t("Put Awayed"); + if (row.isCancelled) return t("Cancelled"); + switch (row.pickProcessBucket) { + case "not_picked_not_started": + case "not_picked_started": + return t("Not picked"); + case "picked_not_started": + case "picked_started": + return t("Picked"); + default: + return row.statusLabel || "-"; + } + }; + + const showManageActions = + primaryTab !== "pending_qc" && + primaryTab !== "putawayed" && + !(primaryTab === "issue" && issueSub === "cancel"); + + return ( + + + + + {t("Job Order Ops Table")} + + + + v && setQueryDate(v)} + slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} + /> + + + + setPrimaryTab(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} + > + + + + + + + + + {primaryTab === "pending" && ( + setPendingSub(v)} + sx={{ mb: 2 }} + > + + + + + )} + + {primaryTab === "processing" && ( + setProcessingSub(v)} + sx={{ mb: 2 }} + > + + + + + )} + + {primaryTab === "issue" && ( + setIssueSub(v)} + sx={{ mb: 2 }} + > + + + + )} + + {loading ? ( + + + + ) : ( + <> + + + + + {t("Job Order")} + {t("Item")} + {t("Required Qty")} + {t("Production Date")} + {t("Status")} + + {t("Actions")} + + + + + {paginatedRows.length === 0 ? ( + + + {t("No data available")} + + + ) : ( + paginatedRows.map((row) => { + const busy = rowBusyIds.has(row.jobOrderId); + return ( + + {row.jobOrderCode} + + + {[row.itemCode, row.itemName].filter(Boolean).join(" ")} + + + + {row.requiredQty} + {row.uom ? ` ${row.uom}` : ""} + + + {row.productionDate && dayjs(row.productionDate).isValid() + ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) + : "-"} + + + + {row.isPaused && ( + + )} + + + + + + + {primaryTab === "pending_qc" && row.stockInLineId != null && ( + + )} + {primaryTab === "putawayed" && row.stockInLineId != null && ( + + )} + {showManageActions && !row.isCancelled && ( + + )} + {showManageActions && !row.isCancelled && ( + + )} + + + + ); + }) + )} + +
+
+ setPage(p)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[5, 10, 25, 50]} + labelRowsPerPage={t("Rows per page")} + /> + + )} +
+
+ + + {t("Confirm")} + + {confirmMessage} + + + + + + + + { + setQcModalOpen(false); + setQcModalInfo(undefined); + }} + inputDetail={qcModalInfo} + printerCombo={printerCombo} + warehouse={[]} + printSource="productionProcess" + uiMode="default" + /> +
+ ); +}; + +export default JobOrderOpsTable; diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 4859629..b763ff3 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useCallback, useEffect, useState, useMemo } from "react"; +import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { Box, Button, @@ -23,7 +23,7 @@ import { } from "@mui/material"; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { useTranslation } from "react-i18next"; -import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; +import { fetchProductProcessesByJobOrderId , setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; import ProductionProcessDetail from "./ProductionProcessDetail"; import { BomCombo } from "@/app/api/bom"; import { fetchBomCombo } from "@/app/api/bom/index"; @@ -36,7 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; -import { releaseJo, startJo } from "@/app/api/jo/actions"; +import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; import ProcessSummaryHeader from "./ProcessSummaryHeader"; import EditIcon from "@mui/icons-material/Edit"; @@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps { initialTabIndex?: number; } +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, @@ -276,25 +277,13 @@ const isPutAwayed = useMemo( () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", [processData?.jobOrderStatus] ); -const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); -const [deleteLoading, setDeleteLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false); - -const handleConfirmDeleteJobOrder = useCallback(async () => { - setDeleteLoading(true); - try { - const response = await deleteJobOrder(jobOrderId); - if (response) { - setDeleteConfirmOpen(false); - onBack(); - } - } finally { - setDeleteLoading(false); - } -}, [jobOrderId, onBack]); +const cancelInFlightRef = useRef(false); const handleConfirmCancelJobOrder = useCallback(async () => { + if (cancelInFlightRef.current) return; + cancelInFlightRef.current = true; setCancelLoading(true); try { await setJobOrderHidden(jobOrderId, true); @@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => { onBack(); } finally { setCancelLoading(false); + cancelInFlightRef.current = false; } }, [jobOrderId, onBack]); -const handleRelease = useCallback(async ( jobOrderId: number) => { - // TODO: 替换为实际的 release 调用 - console.log("Release clicked for jobOrderId:", jobOrderId); - const response = await releaseJo({ id: jobOrderId }) - if (response) { - //setProcessData(response.entity); - await fetchData(); +const releaseInFlightRef = useRef(false); +const [isReleasing, setIsReleasing] = useState(false); + +const handleRelease = useCallback(async (jobOrderId: number) => { + if (releaseInFlightRef.current) return; + releaseInFlightRef.current = true; + setIsReleasing(true); + try { + // Workbench no-hold release: defer SPL/SOL/hold until first pick assign + const response = await releaseJoForWorkbench({ id: jobOrderId }); + if (response) { + await fetchData(); + } + } finally { + setIsReleasing(false); + releaseInFlightRef.current = false; } -}, [jobOrderId]); +}, [fetchData]); const handleTabChange = useCallback>( (_e, newValue) => { setTabIndex(newValue); @@ -722,21 +721,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { {t("Lines with insufficient stock: ")}{stockCounts.insufficient} - {fromJosave && jobOrderPlanning && ( - - )} - {fromJosave && !jobOrderPlanning && ( + {fromJosave && ( @@ -746,8 +736,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { variant="contained" color="primary" onClick={() => handleRelease(jobOrderId)} - //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} - disabled={processData?.jobOrderStatus !== "planning"} + disabled={processData?.jobOrderStatus !== "planning" || isReleasing} + startIcon={isReleasing ? : undefined} > {t("Release")} @@ -984,19 +974,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { - !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> - {t("Confirm delete job order")} - - {t("Delete job order confirm message")} - - - - - - - !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> {t("Confirm cancel job order")} diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index f559001..7ec8e83 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -1,16 +1,13 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Card, - CardContent, - CardActions, Stack, Typography, Chip, CircularProgress, - TablePagination, Grid, FormControl, InputLabel, @@ -23,7 +20,15 @@ import { DialogTitle, DialogContent, DialogActions, + Tabs, + Tab, + Badge, + 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"; @@ -41,11 +46,19 @@ import { fetchProductProcessesByJobOrderId, completeProductProcessLine, assignJobOrderPickOrder, - fetchJoborderProductProcessesPage + 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" + | "pending_qc" + | "putawayed"; + export type ProductionProcessListPersistedState = { date: string; itemCode: string | null; @@ -53,13 +66,20 @@ export type ProductionProcessListPersistedState = { filter: "all" | "drink" | "Powder_Mixture" | "other"; page: number; selectedItemCodes: string[]; + /** + * Unified list tabs: + * needs_action (= pending+processing) | pending | processing | 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[]; - qcReady: boolean; + /** @deprecated Derived from pickBucket when unified tabs are used; kept for compatibility. */ + qcReady?: boolean; includePutaway?: boolean | null; /** all | completed | notCompleted */ putawayStatus?: string | null; @@ -71,7 +91,28 @@ interface ProductProcessListProps { } export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; -const PAGE_SIZE = 50; +/** 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() { @@ -89,16 +130,26 @@ export function createDefaultProductionProcessListPersistedState(): ProductionPr 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 === "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"; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, onSelectMatchingStock, - qcReady, - includePutaway, - putawayStatus, disableDateFilter = false, listPersistedState, onListPersistedStateChange, @@ -108,6 +159,11 @@ const ProductProcessList: React.FC = ({ const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); const [processes, setProcesses] = useState([]); + 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; @@ -115,7 +171,21 @@ const ProductProcessList: React.FC = ({ // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作 const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN); type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other"; - const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); + + const listTab = normalizeListTab(listPersistedState.pickBucket); + const isProductionTab = + listTab === "needs_action" || listTab === "pending" || listTab === "processing"; + 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: carry-over + pick buckets. Pending QC: carry-over only. */ + const enableCarryOver = + !disableDateFilter && (isProductionTab || listTab === "pending_qc"); const appliedSearch = useMemo( () => ({ @@ -133,6 +203,11 @@ const ProductProcessList: React.FC = ({ 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.) @@ -270,6 +345,7 @@ const ProductProcessList: React.FC = ({ jobOrderCode: null, selectedItemCodes: [], page: 0, + pickBucket: "needs_action", })); }, [disableDateFilter, onListPersistedStateChange]); @@ -277,33 +353,265 @@ const ProductProcessList: React.FC = ({ setLoading(true); try { const typeParam = filter === "all" ? undefined : filter; - + // Production tabs share one fetch (bucket=all); pending/processing filter client-side. const data = await fetchJoborderProductProcessesPage({ date: disableDateFilter ? undefined : appliedSearch.date, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, qcReady, - includePutaway: includePutaway ?? (qcReady ? true : null), + includePutaway, putawayStatus, type: typeParam, - page, - size: PAGE_SIZE, + lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined, + bucket: isProductionTab ? "all" : undefined, + page: 0, + size: FETCH_SIZE, }); setProcesses(data?.content || []); setTotalJobOrders(data?.totalJobOrders || 0); + if (isProductionTab && data?.bucketCounts) { + setBucketCounts(data.bucketCounts); + } + if (qcReady && putawayStatus === "notCompleted") { + 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, page]); + }, [ + appliedSearch, + disableDateFilter, + filter, + qcReady, + includePutaway, + putawayStatus, + enableCarryOver, + isProductionTab, + ]); 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: 1, + }; + + (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, + qcReady: true, + includePutaway: true, + putawayStatus: "completed", + }), + ]); + if (cancelled) return; + if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts); + setPendingQcCount(pendingQc?.totalJobOrders || 0); + setPutawayedCount(putawayed?.totalJobOrders || 0); + } 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 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 ?? "")), + ); + } + if (selectedItemCodes.length === 0) return list; + return list.filter((p) => selectedItemCodes.includes(p.itemCode)); + }, [processes, selectedItemCodes, listTab]); + + const displayTotalJobOrders = 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) => ( + 0 ? t(labelKey) + `: ${count}` : t(labelKey)}> + + 99 ? "99+" : count} + invisible={count === 0} + sx={{ + "& .MuiBadge-badge": { + fontWeight: 800, + fontSize: "0.7rem", + minWidth: 18, + height: 18, + lineHeight: "18px", + px: 0.5, + right: -8, + top: 2, + }, + }} + > + 0 ? 1 : 0 }} + > + {t(labelKey)} + + + + + ), + [t], + ); const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => { if (!canManageUpdateJo) return; if (!process.jobOrderId) { @@ -383,11 +691,6 @@ const ProductProcessList: React.FC = ({ [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 base: Criterion[] = [ @@ -489,175 +792,424 @@ const ProductProcessList: React.FC = ({ {" | "} )} - {t("Total job orders")}: {totalJobOrders} - {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${paged.length}` : ""} + {t("Total job orders")}: {displayTotalJobOrders} + {enableCarryOver && displayCarriedOverCount > 0 + ? ` | ${t("Including carried over")}: ${displayCarriedOverCount}` + : ""} + {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.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" || String(l.status ?? "").trim().toLowerCase() === "pass" - ).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} - {process.bomDescription ? ` (${t(process.bomDescription as string)})` : ""} - - - {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 || "-"} + + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + 0 ? 4 : 2 }} + /> + + + { + 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 chipLabel = qcReady + ? putawayStatus === "completed" + ? t("Put Awayed") + : t("Waiting QC Put Away") + : t(displayStatus); + const statusColor = qcReady + ? putawayStatus === "completed" + ? "success" + : "warning" + : statusLower === "completed" + ? "success" + : 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 = 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(" ") || "-"} - ))} - - )} - - )} - {statusLower == "pending" && ( - - - {t("Pending")} - - - - {""} - + {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 ?? "-"} + - - )} - -
- - - - - - {statusLower !== "completed" && ( - - )} - - {canQc && ( - - )} - - - -
-
- ); - })} -
+ +
+ ); + })} +
+ + ))} + + + = totalPages || filteredProcesses.length === 0 + } + sx={{ alignSelf: "center" }} + > + + + = ({ - {totalJobOrders > 0 && ( - - onListPersistedStateChange((prev) => ({ ...prev, page: p })) - } - rowsPerPageOptions={[PAGE_SIZE]} - /> + {filteredProcesses.length > 0 && ( + + {safePage + 1} / {totalPages} + )} )} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index d060c7b..3bc4f4c 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -8,13 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal"; import ProductionProcessList, { createDefaultProductionProcessListPersistedState, } from "@/components/ProductionProcess/ProductionProcessList"; -import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; +import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; import type { PrinterCombo } from "@/app/api/settings/printer"; import { useTranslation } from "react-i18next"; @@ -26,25 +26,18 @@ const STORAGE_KEY = 'productionProcess_selectedMatchingStock'; /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ const ProductionProcessPage: React.FC = ({ printerCombo }) => { - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "productionProcess"]); const [selectedProcessId, setSelectedProcessId] = useState(null); const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ jobOrderId: number; productProcessId: number; pickOrderId: number; } | null>(null); + /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ const [tabIndex, setTabIndex] = useState(0); - /** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ const [productionListState, setProductionListState] = useState(() => ({ ...createDefaultProductionProcessListPersistedState(), - // date: "", })); - const [waitingPutawayListState, setWaitingPutawayListState] = useState( - createDefaultProductionProcessListPersistedState, - ); - const [putawayedListState, setPutawayedListState] = useState( - createDefaultProductionProcessListPersistedState, - ); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const searchParams = useSearchParams(); @@ -53,22 +46,18 @@ const ProductionProcessPage: React.FC = ({ printerCo const [linkQcOpen, setLinkQcOpen] = useState(false); const [linkQcSilId, setLinkQcSilId] = useState(null); - // Add printer selection state const [selectedPrinter, setSelectedPrinter] = useState( printerCombo && printerCombo.length > 0 ? printerCombo[0] : null ); - // 从 sessionStorage 恢复状态(仅在客户端) useEffect(() => { if (typeof window !== 'undefined') { try { const saved = sessionStorage.getItem(STORAGE_KEY); if (saved) { const parsed = JSON.parse(saved); - // 验证数据有效性 if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { setSelectedMatchingStock(parsed); - console.log(" Restored selectedMatchingStock from sessionStorage:", parsed); } } } catch (error) { @@ -78,19 +67,16 @@ const ProductionProcessPage: React.FC = ({ printerCo } }, []); - // 保存状态到 sessionStorage useEffect(() => { if (typeof window !== 'undefined') { if (selectedMatchingStock) { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); - console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock); } else { sessionStorage.removeItem(STORAGE_KEY); } } }, [selectedMatchingStock]); - // 处理返回列表时清除存储 const handleBackFromSecondScan = useCallback(() => { setSelectedMatchingStock(null); if (typeof window !== 'undefined') { @@ -104,7 +90,7 @@ const ProductionProcessPage: React.FC = ({ printerCo const openStockInLineIdQ = searchParams.get("openStockInLineId"); - /** Deep link from nav alert: /productionProcess?openStockInLineId=… → 「完成QC工單」tab + FG QC modal */ + /** Deep link: /productionProcess?openStockInLineId=… → list tab pending_qc + FG QC modal */ useEffect(() => { if (!openStockInLineIdQ) { setLinkQcOpen(false); @@ -115,7 +101,12 @@ const ProductionProcessPage: React.FC = ({ printerCo if (!Number.isFinite(id) || id <= 0) return; setSelectedProcessId(null); setSelectedMatchingStock(null); - setTabIndex(1); + setTabIndex(0); + setProductionListState((prev) => ({ + ...prev, + pickBucket: "pending_qc", + page: 0, + })); setLinkQcSilId(id); setLinkQcOpen(true); }, [openStockInLineIdQ]); @@ -129,6 +120,9 @@ const ProductionProcessPage: React.FC = ({ printerCo router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); }, [pathname, router, searchParams]); + const listTab = String(productionListState.pickBucket || "needs_action"); + const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; + if (selectedMatchingStock) { return ( = ({ printerCo return ( <> - {/* Header section with printer selection */} - {tabIndex === 1 && ( + {showPrinterBar && ( = ({ printerCo - - + @@ -215,9 +207,7 @@ const ProductionProcessPage: React.FC = ({ printerCo {tabIndex === 0 && ( { @@ -237,62 +227,24 @@ 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, - }); + if (jobOrderId != null) setSelectedProcessId(jobOrderId); }} /> )} {tabIndex === 2 && ( - { - const id = jobOrderId ?? null; - if (id !== null) { - setSelectedProcessId(id); - } - }} - onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => { - setSelectedMatchingStock({ - jobOrderId: jobOrderId || 0, - productProcessId: productProcessId || 0, - pickOrderId: pickOrderId || 0, - }); - }} - /> - )} - {tabIndex === 3 && ( )} - {tabIndex === 4 && ( + {tabIndex === 3 && ( )} - {tabIndex === 5 && ( + {tabIndex === 4 && ( )} - {tabIndex === 6 && ( + {tabIndex === 5 && ( )} @@ -310,4 +262,4 @@ const ProductionProcessPage: React.FC = ({ printerCo ); }; -export default ProductionProcessPage; \ No newline at end of file +export default ProductionProcessPage; diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index 6a20247..47dd05f 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -1,3 +1,4 @@ +/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; import { NEXT_PUBLIC_API_URL } from "@/config/api"; @@ -224,7 +225,7 @@ export const REPORTS: ReportDefinition[] = [ { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, - { label: "提料人 Handler", name: "handler", type: "select", required: false, + { label: "提料員 Handler", name: "handler", type: "select", required: false, multiple: true, dynamicOptions: true, dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`, @@ -336,4 +337,50 @@ export const REPORTS: ReportDefinition[] = [ }, ], }, + { + id: "rep-016", + title: "成品出倉揀貨合規報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-do-user-pick-audit`, + responseType: "excel", + fields: [ + { label: "日期 Date", name: "dateStart", type: "date", required: true }, + { + label: "提料人 Handler", + name: "handler", + type: "select", + required: false, + multiple: true, + dynamicOptions: true, + dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/do-user-pick-audit-handlers`, + options: [], + }, + { label: "提票號碼", name: "ticketNo", type: "text", required: false }, + { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + { + label: "樓層", + name: "storeId", + type: "select", + required: false, + options: [ + { label: "2F", value: "2F" }, + { label: "4F", value: "4F" }, + ], + }, + ], + }, + { + id: "rep-017", + title: "店鋪訂單補貨記錄", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/shop-order-replenishment`, + responseType: "excel", + fields: [ + // { label: "補貨日期:由 Reorder Date Start", name: "reorderDateStart", type: "date", required: false }, + //{ label: "補貨日期:至 Reorder Date End", name: "reorderDateEnd", type: "date", required: false }, + { label: "店鋪訂單日期:由 Shop Order Date Start", name: "shopOrderDateStart", type: "date", required: false }, + { label: "店鋪訂單日期:至 Shop Order Date End", name: "shopOrderDateEnd", type: "date", required: false }, + //{ label: "送貨日期:由 Delivered Date Start", name: "deliveredDateStart", type: "date", required: false }, + //{ label: "送貨日期:至 Delivered Date End", name: "deliveredDateEnd", type: "date", required: false }, + { label: "店鋪編號 Shop Code", name: "shopCode", type: "text", required: false, placeholder: "e.g. S001" }, + ], + }, ] \ No newline at end of file diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index c0cac69..8d40ea8 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -62,7 +62,7 @@ "Not Started": "Not Started", "cancelled": "Cancelled", "in_progress": "In Progress", - "pending": "Pending", + "pending": "Awaiting production", "stopped": "Stopped", "Invalid Job Order Id": "Invalid Job Order Id", "Invalid Stock In Line Id": "Invalid Stock In Line Id", @@ -76,6 +76,20 @@ "Job Order Info": "Job Order Info", "Job Order No.": "Job Order No.", "Job Order and Product": "Job Order and Product", + "Issue": "Issue", + "Job Order Ops Table": "Job Order Ops Table", + "Pending (picked)": "Picked", + "Pending (not picked)": "Not picked", + "Processing (picked)": "Picked", + "Processing (not picked)": "Not picked", + "Picked": "Picked", + "Not picked": "Not picked", + "Stop (paused)": "Stop (paused)", + "Cancelled": "Cancelled", + "Reload data": "Reload data", + "Rows per page": "Rows per page", + "Processing...": "Processing...", + "Actions": "Actions", "Job Order Production Process": "Job Order Production Process", "Job Process Status Dashboard": "Job Process Status Dashboard", "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", @@ -198,6 +212,16 @@ "Total Time": "Total Time", "Total finished QC job orders": "Total finished QC job orders", "Total job orders": "Total job orders", + "Including carried over": "Including carried over", + "Carried over from past day": "Carried over from past day", + "All unfinished": "Needs action", + "Needs action": "Needs action", + "Not picked · Not started": "Not picked · Not started", + "Picked · Not started": "Picked · Not started", + "Picked · In production": "Picked · In production", + "Not picked · In production": "Not picked · In production", + "Powder_Mixture": "Powder Mixture", + "Processing": "Processing", "Total lines: ": "Total lines: ", "Type": "Type", "Unable to get user ID": "Unable to get user ID", @@ -212,6 +236,9 @@ "View Details": "View Details", "Wait Time": "Wait Time", "Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders", + "Waiting QC Put Away": "Waiting QC Put Away", + "Put Awayed": "Put Awayed", + "Put Away Detail": "Put Away Detail", "all": "All", "drink": "Drink", "id": "ID", diff --git a/src/i18n/zh/itemTracing.json b/src/i18n/zh/itemTracing.json index 055f932..5c3d1ba 100644 --- a/src/i18n/zh/itemTracing.json +++ b/src/i18n/zh/itemTracing.json @@ -190,7 +190,7 @@ "jobOrder": "工單", "deliveryOrder": "送貨單", "deliveryNoteCode": "送貨單據號 (DN)", - "ticketNo": "票號", + "ticketNo": "提票號碼", "variance": "差異", "before": "帳面數量", "after": "核准數量", diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index d5e2e11..3f378f6 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -62,7 +62,7 @@ "Not Started": "未開始", "cancelled": "已取消", "in_progress": "進行中", - "pending": "待處理", + "pending": "待生產", "stopped": "已停止", "Invalid Job Order Id": "無效工單編號", "Invalid Stock In Line Id": "無效庫存行ID", @@ -76,6 +76,20 @@ "Job Order Info": "工單信息", "Job Order No.": "工單編號", "Job Order and Product": "工單及貨品", + "Issue": "異常", + "Job Order Ops Table": "查看工單流程情況", + "Pending (picked)": "已提料", + "Pending (not picked)": "未提料", + "Processing (picked)": "已提料", + "Processing (not picked)": "未提料", + "Picked": "已提料", + "Not picked": "未提料", + "Stop (paused)": "暫停中", + "Cancelled": "已取消", + "Reload data": "重新載入", + "Rows per page": "每頁列數", + "Processing...": "處理中...", + "Actions": "操作", "Job Order Production Process": "工單生產流程", "Job Process Status Dashboard": "儀表板 - 工單狀態", "Drink Production Qty Dashboard": "儀表板 - 飲料生產量數", @@ -198,6 +212,16 @@ "Total Time": "總時間", "Total finished QC job orders": "總完成QC工單數量", "Total job orders": "總工單數量", + "Including carried over": "含過去轉來", + "Carried over from past day": "過去轉來的工單", + "All unfinished": "需處理", + "Needs action": "需處理", + "Not picked · Not started": "未提料 · 未開工", + "Picked · Not started": "已提料 · 未開工", + "Picked · In production": "已提料 · 未完成生產", + "Not picked · In production": "未提料 · 未完成生產", + "Powder_Mixture": "箱料粉", + "Processing": "生產中", "Total lines: ": "總數量:", "Type": "類型", "Unable to get user ID": "無法獲取用戶ID", @@ -212,6 +236,9 @@ "View Details": "查看詳情", "Wait Time": "等待時間", "Waiting QC Put Away Job Orders": "待QC上架工單", + "Waiting QC Put Away": "待QC上架", + "Put Awayed": "已上架", + "Put Away Detail": "上架詳情", "all": "全部", "drink": "飲料", "id": "ID",