ledger lotQtyBefore/After + inventoryLotLineId 成品出倉員工問題報告 rep-016 Truck X 2F/4F suggestion Just Complete / isIssueJustComplete Workbench 略過 classic confirmLotSubstitution 停 JO planStart/renumber 生產流程 lookback + tabs/QC chip/Release→Cancel FP-MTMS code comments Report 店鋪訂單補貨記錄 工單生產流程 UI 再重構 工單提料列表顯示加強production
| @@ -31,6 +31,7 @@ import { | |||||
| } from './semiFGProductionAnalysisApi'; | } from './semiFGProductionAnalysisApi'; | ||||
| import { generateGrnReportExcel } from './grnReportApi'; | import { generateGrnReportExcel } from './grnReportApi'; | ||||
| import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; | import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; | ||||
| import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi'; | |||||
| import { | import { | ||||
| FEATURE_USAGE, | FEATURE_USAGE, | ||||
| FEATURE_USAGE_ACTION, | FEATURE_USAGE_ACTION, | ||||
| @@ -42,6 +43,7 @@ interface ItemCodeWithName { | |||||
| name: string; | name: string; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const includeGrnFinancialColumns = | const includeGrnFinancialColumns = | ||||
| @@ -267,12 +269,24 @@ export default function ReportPage() { | |||||
| ); | ); | ||||
| } else if (currentReport.id === 'rep-015') { | } else if (currentReport.id === 'rep-015') { | ||||
| await generateBomShopSyncReportExcel(criteria, currentReport.title); | await generateBomShopSyncReportExcel(criteria, currentReport.title); | ||||
| } else if (currentReport.id === 'rep-017') { | |||||
| await generateShopOrderReplenishmentReportExcel(criteria, currentReport.title); | |||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // Backend returns actual .xlsx bytes for this Excel endpoint. | ||||
| const queryParams = | |||||
| let queryParams = | |||||
| currentReport.id === 'rep-012' | currentReport.id === 'rep-012' | ||||
| ? buildRep012QueryString() | ? buildRep012QueryString() | ||||
| : new URLSearchParams(criteria).toString(); | : 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 excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | ||||
| const response = await clientAuthFetch(excelUrl, { | const response = await clientAuthFetch(excelUrl, { | ||||
| @@ -25,7 +25,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | |||||
| headerBg: "#b3d4f0", | headerBg: "#b3d4f0", | ||||
| bodyBg: "#eef5fc", | bodyBg: "#eef5fc", | ||||
| accent: "#1565c0", | 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", | id: "production", | ||||
| @@ -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<string, unknown> { | |||||
| 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<string, unknown> { | |||||
| 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<string, string>, | |||||
| ): Promise<ShopOrderReplenishmentReportRow[]> { | |||||
| 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<string, string>, | |||||
| reportTitle: string = "店鋪訂單補貨記錄", | |||||
| ): Promise<void> { | |||||
| 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); | |||||
| } | |||||
| @@ -94,6 +94,8 @@ export type WorkbenchScanPickBody = { | |||||
| excludeWarehouseCodes?: string[] | null; | excludeWarehouseCodes?: string[] | null; | ||||
| /** Optional decimal string or number serialized by JSON */ | /** Optional decimal string or number serialized by JSON */ | ||||
| qty?: number | string | null; | qty?: number | string | null; | ||||
| /** Just Complete button (no QR) — DO user-audit flag */ | |||||
| justComplete?: boolean | null; | |||||
| userId: number; | userId: number; | ||||
| }; | }; | ||||
| @@ -144,6 +146,7 @@ export async function workbenchScanPick( | |||||
| ...(storeId !== undefined ? { storeId } : {}), | ...(storeId !== undefined ? { storeId } : {}), | ||||
| ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), | ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), | ||||
| ...(qty !== undefined ? { qty } : {}), | ...(qty !== undefined ? { qty } : {}), | ||||
| ...(body.justComplete === true ? { justComplete: true } : {}), | |||||
| userId: body.userId, | userId: body.userId, | ||||
| }), | }), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| @@ -368,6 +368,8 @@ export interface AllJoborderProductProcessInfoResponse { | |||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| bomDescription?: string | null; | bomDescription?: string | null; | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| bomType?: string | null; | |||||
| lotNo: string; | lotNo: string; | ||||
| requiredQty: number; | requiredQty: number; | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| @@ -381,6 +383,26 @@ export interface AllJoborderProductProcessInfoResponse { | |||||
| productProcessLineCount: number; | productProcessLineCount: number; | ||||
| FinishedProductProcessLineCount: number; | FinishedProductProcessLineCount: number; | ||||
| lines: ProductProcessInfoResponse[]; | 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 { | export interface JobOrderProductProcessPageResponse { | ||||
| @@ -388,6 +410,9 @@ export interface JobOrderProductProcessPageResponse { | |||||
| totalJobOrders: number; | totalJobOrders: number; | ||||
| page: number; | page: number; | ||||
| size: number; | size: number; | ||||
| bucketCounts?: JobOrderProductProcessBucketCounts | null; | |||||
| searchDate?: string | null; | |||||
| carriedOverCount?: number | null; | |||||
| } | } | ||||
| export interface ProductProcessInfoResponse { | export interface ProductProcessInfoResponse { | ||||
| id: number; | id: number; | ||||
| @@ -558,7 +583,10 @@ export interface AllJoPickOrderResponse { | |||||
| jobOrderType: string | null; | jobOrderType: string | null; | ||||
| itemId: number; | itemId: number; | ||||
| itemName: string; | itemName: string; | ||||
| itemCode?: string | null; | |||||
| bomDescription?: string | null; | bomDescription?: string | null; | ||||
| /** BOM.type (e.g. drink / Powder_Mixture / other). */ | |||||
| bomType?: string | null; | |||||
| lotNo: string | null; | lotNo: string | null; | ||||
| planStart?: string | number[] | null; | planStart?: string | number[] | null; | ||||
| reqQty: number; | 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: { | export const fetchJoborderProductProcessesPage = cache(async (params: { | ||||
| /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ | |||||
| /** Job order / process date(YYYY-MM-DD) */ | |||||
| date?: string | null; | date?: string | null; | ||||
| itemCode?: string | null; | itemCode?: string | null; | ||||
| jobOrderCode?: string | null; | jobOrderCode?: string | null; | ||||
| @@ -888,6 +917,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| includePutaway?: boolean | null; | includePutaway?: boolean | null; | ||||
| /** all | completed | notCompleted */ | /** all | completed | notCompleted */ | ||||
| putawayStatus?: string | null; | 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; | page?: number; | ||||
| size?: number; | size?: number; | ||||
| }) => { | }) => { | ||||
| @@ -900,6 +933,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| includePutaway, | includePutaway, | ||||
| putawayStatus, | putawayStatus, | ||||
| type, | type, | ||||
| lookbackDays, | |||||
| bucket, | |||||
| page = 0, | page = 0, | ||||
| size = 50, | size = 50, | ||||
| } = params; | } = params; | ||||
| @@ -917,6 +952,10 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { | |||||
| queryParts.push(`includePutaway=${includePutaway}`); | queryParts.push(`includePutaway=${includePutaway}`); | ||||
| } | } | ||||
| if (putawayStatus) queryParts.push(`putawayStatus=${encodeURIComponent(putawayStatus)}`); | 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(`page=${page}`); | ||||
| queryParts.push(`size=${size}`); | queryParts.push(`size=${size}`); | ||||
| @@ -551,7 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) | |||||
| } | } | ||||
| } | } | ||||
| /** 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<Props> = ({ | const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | ||||
| filterArgs, | filterArgs, | ||||
| onSwitchToRecordTab, | onSwitchToRecordTab, | ||||
| @@ -3274,6 +3274,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||||
| ? { stockInLineId: canonicalLotForSol.stockInLineId } | ? { stockInLineId: canonicalLotForSol.stockInLineId } | ||||
| : {}), | : {}), | ||||
| qty: qtyToSend, | qty: qtyToSend, | ||||
| justComplete: true, | |||||
| storeId: fgPickOrders?.[0]?.storeId ?? null, | storeId: fgPickOrders?.[0]?.storeId ?? null, | ||||
| userId: currentUserId ?? 1, | userId: currentUserId ?? 1, | ||||
| }); | }); | ||||
| @@ -172,12 +172,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| lotNo: string, | lotNo: string, | ||||
| ) => { | ) => { | ||||
| console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| // Avoid loading data again | // Avoid loading data again | ||||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | if (actionType === 'paging' && pagingController === defaultPagingController) { | ||||
| return; | return; | ||||
| } | } | ||||
| console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| // console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| const params: SearchInventory = { | const params: SearchInventory = { | ||||
| code: query?.itemCode ?? '', | code: query?.itemCode ?? '', | ||||
| @@ -415,7 +415,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| scanUiMode, | scanUiMode, | ||||
| ]); | ]); | ||||
| console.log('', 'color: #666', inventoriesPagingController); | |||||
| //console.log('', 'color: #666', inventoriesPagingController); | |||||
| const handleOpenOpeningInventoryModal = useCallback(() => { | const handleOpenOpeningInventoryModal = useCallback(() => { | ||||
| setOpeningSelectedItem(null); | setOpeningSelectedItem(null); | ||||
| @@ -1,11 +1,9 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useState } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Card, | Card, | ||||
| CardContent, | |||||
| CardActions, | |||||
| Stack, | Stack, | ||||
| Typography, | Typography, | ||||
| Chip, | Chip, | ||||
| @@ -26,6 +24,17 @@ interface Props { | |||||
| printerCombo: PrinterCombo[]; | 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`. */ | /** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */ | ||||
| const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common", "jo"]); | const { t } = useTranslation(["common", "jo"]); | ||||
| @@ -44,10 +53,9 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| { label: t("Item Name"), paramName: "itemName", type: "text" }, | { label: t("Item Name"), paramName: "itemName", type: "text" }, | ||||
| { | { | ||||
| label: t("Job Order Type"), | label: t("Job Order Type"), | ||||
| paramName: "BOM Description", | |||||
| paramName: "bomDescription", | |||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "All" }, | |||||
| { label: t("FG"), value: "FG" }, | { label: t("FG"), value: "FG" }, | ||||
| { label: t("WIP"), value: "WIP" }, | { label: t("WIP"), value: "WIP" }, | ||||
| ], | ], | ||||
| @@ -58,7 +66,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| paramName: "bomType", | paramName: "bomType", | ||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "All" }, | |||||
| { label: t("Drink"), value: "drink" }, | { label: t("Drink"), value: "drink" }, | ||||
| { label: t("Powder Mixture"), value: "Powder_Mixture" }, | { label: t("Powder Mixture"), value: "Powder_Mixture" }, | ||||
| { label: t("Other"), value: "other" }, | { label: t("Other"), value: "other" }, | ||||
| @@ -69,7 +76,6 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| paramName: "floor", | paramName: "floor", | ||||
| type: "select-labelled", | type: "select-labelled", | ||||
| options: [ | options: [ | ||||
| //{ label: t("All"), value: "ALL" }, | |||||
| { label: "2F", value: "2F" }, | { label: "2F", value: "2F" }, | ||||
| { label: "3F", value: "3F" }, | { label: "3F", value: "3F" }, | ||||
| { label: "4F", value: "4F" }, | { label: "4F", value: "4F" }, | ||||
| @@ -179,8 +185,8 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| {t("Total pick orders")}: {pickOrders.length} | {t("Total pick orders")}: {pickOrders.length} | ||||
| </Typography> | </Typography> | ||||
| <Grid container spacing={2}> | |||||
| <Grid container spacing={2} alignItems="stretch"> | |||||
| {pickOrders.map((pickOrder) => { | {pickOrders.map((pickOrder) => { | ||||
| const status = String(pickOrder.jobOrderStatus || ""); | const status = String(pickOrder.jobOrderStatus || ""); | ||||
| const statusLower = status.toLowerCase(); | const statusLower = status.toLowerCase(); | ||||
| @@ -190,126 +196,216 @@ const JoPickOrderList: React.FC<Props> = ({ printerCombo }) => { | |||||
| : statusLower === "pending" || statusLower === "processing" | : statusLower === "pending" || statusLower === "processing" | ||||
| ? "primary" | ? "primary" | ||||
| : "default"; | : "default"; | ||||
| const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | const finishedCount = pickOrder.finishedPickOLineCount ?? 0; | ||||
| const bomDescription = pickOrder.bomDescription | |||||
| ? String(pickOrder.bomDescription).trim() | |||||
| : ""; | |||||
| const bomType = pickOrder.bomType | |||||
| ? String(pickOrder.bomType).trim() | |||||
| : ""; | |||||
| return ( | return ( | ||||
| <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | |||||
| <Grid | |||||
| key={pickOrder.id} | |||||
| item | |||||
| xs={12} | |||||
| sm={6} | |||||
| md={4} | |||||
| sx={{ display: "flex" }} | |||||
| > | |||||
| <Card | <Card | ||||
| sx={{ | sx={{ | ||||
| minHeight: 180, | |||||
| maxHeight: 280, | |||||
| width: "100%", | |||||
| height: "100%", | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| border: "1px solid", | |||||
| borderColor: "divider", | |||||
| borderRadius: 2, | |||||
| boxShadow: "none", | |||||
| }} | }} | ||||
| > | > | ||||
| <CardContent | |||||
| <Box | |||||
| sx={{ | sx={{ | ||||
| pb: 1, | |||||
| p: 2, | |||||
| flexGrow: 1, | flexGrow: 1, | ||||
| overflow: "auto", | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| minHeight: 0, | |||||
| }} | }} | ||||
| > | > | ||||
| <Stack direction="row" justifyContent="space-between" alignItems="center"> | |||||
| <Box sx={{ minWidth: 0 }}> | |||||
| <Typography variant="subtitle1"> | |||||
| {t("Job Order")}: {pickOrder.jobOrderCode || "-"} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Lot No")}: {pickOrder.lotNo || "-"} | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Item Name")}: {pickOrder.itemName} | |||||
| {pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""} | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||||
| <Typography | |||||
| variant="body1" | |||||
| color="text.primary" | |||||
| fontWeight={600} | |||||
| title={ | |||||
| [pickOrder.itemCode, pickOrder.itemName] | |||||
| .filter(Boolean) | |||||
| .join(" ") || undefined | |||||
| } | |||||
| sx={{ | |||||
| display: "-webkit-box", | |||||
| WebkitLineClamp: 2, | |||||
| WebkitBoxOrient: "vertical", | |||||
| overflow: "hidden", | |||||
| lineHeight: 1.35, | |||||
| }} | |||||
| > | |||||
| {[pickOrder.itemCode, pickOrder.itemName] | |||||
| .filter(Boolean) | |||||
| .join(" ") || "-"} | |||||
| </Typography> | </Typography> | ||||
| {selectedFloor === "ALL" ? ( | |||||
| <> | |||||
| {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| ))} | |||||
| {!!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| <Stack | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| spacing={0.75} | |||||
| flexWrap="nowrap" | |||||
| useFlexGap | |||||
| sx={{ | |||||
| mt: 0.75, | |||||
| minWidth: 0, | |||||
| overflowX: "auto", | |||||
| scrollbarWidth: "none", | |||||
| "&::-webkit-scrollbar": { display: "none" }, | |||||
| }} | |||||
| > | |||||
| {bomDescription ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(bomDescription)} | |||||
| variant="outlined" | |||||
| sx={chipSx} | |||||
| /> | |||||
| ) : null} | |||||
| {bomType ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(bomType)} | |||||
| color="info" | |||||
| variant="outlined" | |||||
| sx={chipSx} | |||||
| /> | |||||
| ) : null} | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(status)} | |||||
| color={statusColor as any} | |||||
| sx={chipSx} | |||||
| /> | |||||
| </Stack> | |||||
| <Stack | |||||
| spacing={0.35} | |||||
| sx={{ mt: 0.75, color: "text.secondary" }} | |||||
| > | |||||
| <Typography variant="body2"> | |||||
| {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} | |||||
| </Typography> | |||||
| <Typography variant="body2"> | |||||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||||
| </Typography> | |||||
| <Box> | |||||
| {selectedFloor === "ALL" ? ( | |||||
| <> | |||||
| {pickOrder.floorPickCounts?.map( | |||||
| ({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| ), | |||||
| )} | |||||
| {!!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}:{" "} | |||||
| {pickOrder.noLotPickCount.finishedCount}/ | |||||
| {pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| )} | |||||
| </> | |||||
| ) : selectedFloor === "NO_LOT" ? ( | |||||
| !!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}:{" "} | |||||
| {pickOrder.noLotPickCount.finishedCount}/ | |||||
| {pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| ) | |||||
| ) : ( | |||||
| pickOrder.floorPickCounts | |||||
| ?.filter((c) => c.floor === selectedFloor) | |||||
| .map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| )) | |||||
| )} | )} | ||||
| </> | |||||
| ) : selectedFloor === "NO_LOT" ? ( | |||||
| !!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| ) | |||||
| ) : ( | |||||
| pickOrder.floorPickCounts | |||||
| ?.filter((c) => c.floor === selectedFloor) | |||||
| .map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Box> | |||||
| {typeof pickOrder.suggestedFailCount === "number" && | |||||
| pickOrder.suggestedFailCount > 0 && ( | |||||
| <Typography variant="body2" color="error"> | |||||
| {t("Suggested Fail")}: {pickOrder.suggestedFailCount} | |||||
| </Typography> | </Typography> | ||||
| )) | |||||
| )} | |||||
| {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( | |||||
| <Typography variant="body2" color="error" sx={{ mt: 0.5 }}> | |||||
| {t("Suggested Fail")}: {pickOrder.suggestedFailCount} | |||||
| </Typography> | |||||
| )} | |||||
| {statusLower !== "pending" && finishedCount > 0 && ( | |||||
| <Box sx={{ mt: 1 }}> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| )} | |||||
| {statusLower !== "pending" && finishedCount > 0 && ( | |||||
| <Typography variant="body2" fontWeight={600} color="text.primary"> | |||||
| {t("Finished lines")}: {finishedCount} | {t("Finished lines")}: {finishedCount} | ||||
| </Typography> | </Typography> | ||||
| </Box> | |||||
| )} | |||||
| </CardContent> | |||||
| <CardActions sx={{ pt: 0.5 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined); | |||||
| setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined); | |||||
| }} | |||||
| )} | |||||
| </Stack> | |||||
| <Stack sx={{ mt: "auto", pt: 1.5 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| sx={{ alignSelf: "flex-start" }} | |||||
| onClick={() => { | |||||
| setSelectedPickOrderId(pickOrder.pickOrderId ?? undefined); | |||||
| setSelectedJobOrderId(pickOrder.jobOrderId ?? undefined); | |||||
| }} | |||||
| > | |||||
| {t("View Details")} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| sx={{ mt: 1.25 }} | |||||
| > | > | ||||
| {t("View Details")} | |||||
| </Button> | |||||
| <Box sx={{ flex: 1 }} /> | |||||
| </CardActions> | |||||
| {pickOrder.jobOrderCode || "-"} | |||||
| {" · "} | |||||
| {t("Lot No")}: {pickOrder.lotNo || "-"} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Card> | </Card> | ||||
| </Grid> | </Grid> | ||||
| ); | ); | ||||
| @@ -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<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | ||||
| const workbenchMode = true; | const workbenchMode = true; | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| @@ -1799,88 +1800,92 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; | selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; | ||||
| let switchedToUnavailable = false; | 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; | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -30,7 +30,6 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { | import { | ||||
| fetchConsumableWorkbenchPickOrderLotsHierarchical, | fetchConsumableWorkbenchPickOrderLotsHierarchical, | ||||
| reloadConsumableWorkbenchPickOrderLotsHierarchical, | reloadConsumableWorkbenchPickOrderLotsHierarchical, | ||||
| confirmLotSubstitution, | |||||
| suggestPickOrderWorkbenchV2, | suggestPickOrderWorkbenchV2, | ||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | ||||
| @@ -229,15 +228,6 @@ const isCheckedStatus = (status: string | undefined): boolean => | |||||
| const isRejectedStatus = (status: string | undefined): boolean => | const isRejectedStatus = (status: string | undefined): boolean => | ||||
| String(status || "").toLowerCase() === "rejected"; | 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 { | function safeDisplayTargetDate(targetDate: string | number[]): string { | ||||
| try { | try { | ||||
| if (Array.isArray(targetDate) && targetDate.length >= 3) { | 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<Props> = ({ filterArgs }) => { | const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -962,6 +952,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| // Workbench no-hold: skip confirmLotSubstitution (classic hold move). Switch + pick via scan-pick only. | |||||
| const handleLotConfirmation = useCallback( | const handleLotConfirmation = useCallback( | ||||
| async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { | async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { | ||||
| const expected = overrideExpected ?? expectedLotData; | const expected = overrideExpected ?? expectedLotData; | ||||
| @@ -972,56 +963,25 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| setError(""); | setError(""); | ||||
| setMessage(""); | setMessage(""); | ||||
| try { | 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")); | setMessage(t("Scan pick success")); | ||||
| startTransition(() => { | startTransition(() => { | ||||
| @@ -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<number, OpsRow>(); | |||||
| 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<JobOrderOpsTableProps> = ({ | |||||
| 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>(() => dayjs()); | |||||
| const [primaryTab, setPrimaryTab] = useState<PrimaryTab>("all"); | |||||
| const [pendingSub, setPendingSub] = useState<PendingSubTab>("all"); | |||||
| const [processingSub, setProcessingSub] = useState<ProcessingSubTab>("all"); | |||||
| const [issueSub, setIssueSub] = useState<IssueSubTab>("stop"); | |||||
| const [productionRows, setProductionRows] = useState<OpsRow[]>([]); | |||||
| const [pendingQcRows, setPendingQcRows] = useState<OpsRow[]>([]); | |||||
| const [putawayedRows, setPutawayedRows] = useState<OpsRow[]>([]); | |||||
| const [cancelledRows, setCancelledRows] = useState<OpsRow[]>([]); | |||||
| 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<null | (() => Promise<void>)>(null); | |||||
| const actionInFlightRef = useRef(false); | |||||
| const [rowBusyIds, setRowBusyIds] = useState<Set<number>>(new Set()); | |||||
| const [qcModalOpen, setQcModalOpen] = useState(false); | |||||
| const [qcModalInfo, setQcModalInfo] = useState<StockInLineInput | undefined>(); | |||||
| 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<void>) => { | |||||
| 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 ( | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||||
| <Card sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}> | |||||
| {t("Job Order Ops Table")} | |||||
| </Typography> | |||||
| <Stack | |||||
| direction="row" | |||||
| spacing={2} | |||||
| sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} | |||||
| > | |||||
| <DatePicker | |||||
| label={t("Search date")} | |||||
| value={queryDate} | |||||
| onChange={(v) => v && setQueryDate(v)} | |||||
| slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} | |||||
| /> | |||||
| <Button variant="outlined" size="small" onClick={() => void loadData()}> | |||||
| {t("Reload data")} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Tabs | |||||
| value={primaryTab} | |||||
| onChange={(_, v: PrimaryTab) => setPrimaryTab(v)} | |||||
| variant="scrollable" | |||||
| scrollButtons="auto" | |||||
| sx={{ mb: 1, borderBottom: 1, borderColor: "divider" }} | |||||
| > | |||||
| <Tab value="all" label={`${t("All")} (${counts.all})`} /> | |||||
| <Tab value="pending" label={`${t("pending")} (${counts.pending})`} /> | |||||
| <Tab value="processing" label={`${t("Processing")} (${counts.processing})`} /> | |||||
| <Tab | |||||
| value="pending_qc" | |||||
| label={`${t("Waiting QC Put Away")} (${counts.pendingQc})`} | |||||
| /> | |||||
| <Tab value="putawayed" label={`${t("Put Awayed")} (${counts.putawayed})`} /> | |||||
| <Tab | |||||
| value="issue" | |||||
| label={`${t("Issue")} (${counts.stop + counts.cancel})`} | |||||
| /> | |||||
| </Tabs> | |||||
| {primaryTab === "pending" && ( | |||||
| <Tabs | |||||
| value={pendingSub} | |||||
| onChange={(_, v: PendingSubTab) => setPendingSub(v)} | |||||
| sx={{ mb: 2 }} | |||||
| > | |||||
| <Tab value="all" label={t("All")} /> | |||||
| <Tab value="picked_not_started" label={t("Picked")} /> | |||||
| <Tab value="not_picked_not_started" label={t("Not picked")} /> | |||||
| </Tabs> | |||||
| )} | |||||
| {primaryTab === "processing" && ( | |||||
| <Tabs | |||||
| value={processingSub} | |||||
| onChange={(_, v: ProcessingSubTab) => setProcessingSub(v)} | |||||
| sx={{ mb: 2 }} | |||||
| > | |||||
| <Tab value="all" label={t("All")} /> | |||||
| <Tab value="picked_started" label={t("Picked")} /> | |||||
| <Tab value="not_picked_started" label={t("Not picked")} /> | |||||
| </Tabs> | |||||
| )} | |||||
| {primaryTab === "issue" && ( | |||||
| <Tabs | |||||
| value={issueSub} | |||||
| onChange={(_, v: IssueSubTab) => setIssueSub(v)} | |||||
| sx={{ mb: 2 }} | |||||
| > | |||||
| <Tab value="stop" label={`${t("Stop (paused)")} (${counts.stop})`} /> | |||||
| <Tab value="cancel" label={`${t("Cancelled")} (${counts.cancel})`} /> | |||||
| </Tabs> | |||||
| )} | |||||
| {loading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <> | |||||
| <TableContainer component={Paper} sx={{ maxHeight: 480, overflow: "auto" }}> | |||||
| <Table size="small" stickyHeader sx={{ minWidth: 800 }}> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Job Order")}</TableCell> | |||||
| <TableCell>{t("Item")}</TableCell> | |||||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||||
| <TableCell>{t("Production Date")}</TableCell> | |||||
| <TableCell>{t("Status")}</TableCell> | |||||
| <TableCell align="center" sx={{ minWidth: 280 }}> | |||||
| {t("Actions")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedRows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={6} align="center"> | |||||
| {t("No data available")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedRows.map((row) => { | |||||
| const busy = rowBusyIds.has(row.jobOrderId); | |||||
| return ( | |||||
| <TableRow key={row.key} hover> | |||||
| <TableCell>{row.jobOrderCode}</TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {[row.itemCode, row.itemName].filter(Boolean).join(" ")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| {row.requiredQty} | |||||
| {row.uom ? ` ${row.uom}` : ""} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.productionDate && dayjs(row.productionDate).isValid() | |||||
| ? dayjs(row.productionDate).format(OUTPUT_DATE_FORMAT) | |||||
| : "-"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> | |||||
| {row.isPaused && ( | |||||
| <Chip size="small" color="warning" label={t("Stop (paused)")} /> | |||||
| )} | |||||
| <Chip | |||||
| size="small" | |||||
| variant="outlined" | |||||
| label={bucketLabel(row)} | |||||
| /> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Stack | |||||
| direction="row" | |||||
| spacing={1} | |||||
| justifyContent="center" | |||||
| flexWrap="wrap" | |||||
| useFlexGap | |||||
| > | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => onSelectProcess?.(row.jobOrderId)} | |||||
| > | |||||
| {t("View Details")} | |||||
| </Button> | |||||
| {primaryTab === "pending_qc" && row.stockInLineId != null && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleOpenQcModal(row)} | |||||
| > | |||||
| {t("view stockin")} | |||||
| </Button> | |||||
| )} | |||||
| {primaryTab === "putawayed" && row.stockInLineId != null && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleOpenQcModal(row)} | |||||
| > | |||||
| {t("Put Away Detail")} | |||||
| </Button> | |||||
| )} | |||||
| {showManageActions && !row.isCancelled && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| disabled={!canManage || busy} | |||||
| onClick={() => handleComplete(row)} | |||||
| startIcon={ | |||||
| busy ? ( | |||||
| <CircularProgress size={14} color="inherit" /> | |||||
| ) : undefined | |||||
| } | |||||
| > | |||||
| {t("Update Job Order")} | |||||
| </Button> | |||||
| )} | |||||
| {showManageActions && !row.isCancelled && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| color="warning" | |||||
| disabled={!canManage || busy} | |||||
| onClick={() => handleCancel(row)} | |||||
| > | |||||
| {t("Cancel Job Order")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={filteredRows.length} | |||||
| page={page} | |||||
| onPageChange={(_, p) => setPage(p)} | |||||
| rowsPerPage={pageSize} | |||||
| onRowsPerPageChange={(e) => { | |||||
| setPageSize(parseInt(e.target.value, 10)); | |||||
| setPage(0); | |||||
| }} | |||||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth> | |||||
| <DialogTitle>{t("Confirm")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Typography variant="body2">{confirmMessage}</Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={closeConfirm} disabled={confirmLoading}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={() => void onConfirm()} | |||||
| disabled={confirmLoading || !pendingAction} | |||||
| > | |||||
| {confirmLoading ? t("Processing...") : t("Confirm")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <QcStockInModal | |||||
| session={sessionToken} | |||||
| open={qcModalOpen} | |||||
| onClose={() => { | |||||
| setQcModalOpen(false); | |||||
| setQcModalInfo(undefined); | |||||
| }} | |||||
| inputDetail={qcModalInfo} | |||||
| printerCombo={printerCombo} | |||||
| warehouse={[]} | |||||
| printSource="productionProcess" | |||||
| uiMode="default" | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| ); | |||||
| }; | |||||
| export default JobOrderOpsTable; | |||||
| @@ -1,5 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useState, useMemo } from "react"; | |||||
| import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -23,7 +23,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import { useTranslation } from "react-i18next"; | 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 ProductionProcessDetail from "./ProductionProcessDetail"; | ||||
| import { BomCombo } from "@/app/api/bom"; | import { BomCombo } from "@/app/api/bom"; | ||||
| import { fetchBomCombo } from "@/app/api/bom/index"; | 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 DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; | ||||
| import { fetchInventories } from "@/app/api/inventory/actions"; | import { fetchInventories } from "@/app/api/inventory/actions"; | ||||
| import { InventoryResult } from "@/app/api/inventory"; | 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 JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; | ||||
| import ProcessSummaryHeader from "./ProcessSummaryHeader"; | import ProcessSummaryHeader from "./ProcessSummaryHeader"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| @@ -53,6 +53,7 @@ interface ProductProcessJobOrderDetailProps { | |||||
| initialTabIndex?: number; | initialTabIndex?: number; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.0 | 2026-08-03 */ | |||||
| const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({ | ||||
| jobOrderId, | jobOrderId, | ||||
| onBack, | onBack, | ||||
| @@ -276,25 +277,13 @@ const isPutAwayed = useMemo( | |||||
| () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | ||||
| [processData?.jobOrderStatus] | [processData?.jobOrderStatus] | ||||
| ); | ); | ||||
| const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |||||
| const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | ||||
| const [deleteLoading, setDeleteLoading] = useState(false); | |||||
| const [cancelLoading, setCancelLoading] = 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 () => { | const handleConfirmCancelJobOrder = useCallback(async () => { | ||||
| if (cancelInFlightRef.current) return; | |||||
| cancelInFlightRef.current = true; | |||||
| setCancelLoading(true); | setCancelLoading(true); | ||||
| try { | try { | ||||
| await setJobOrderHidden(jobOrderId, true); | await setJobOrderHidden(jobOrderId, true); | ||||
| @@ -302,17 +291,27 @@ const handleConfirmCancelJobOrder = useCallback(async () => { | |||||
| onBack(); | onBack(); | ||||
| } finally { | } finally { | ||||
| setCancelLoading(false); | setCancelLoading(false); | ||||
| cancelInFlightRef.current = false; | |||||
| } | } | ||||
| }, [jobOrderId, onBack]); | }, [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<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| (_e, newValue) => { | (_e, newValue) => { | ||||
| setTabIndex(newValue); | setTabIndex(newValue); | ||||
| @@ -722,21 +721,12 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | ||||
| {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | ||||
| </Typography> | </Typography> | ||||
| {fromJosave && jobOrderPlanning && ( | |||||
| <Button | |||||
| variant="contained" | |||||
| color="error" | |||||
| onClick={() => setDeleteConfirmOpen(true)} | |||||
| > | |||||
| {t("Delete Job Order")} | |||||
| </Button> | |||||
| )} | |||||
| {fromJosave && !jobOrderPlanning && ( | |||||
| {fromJosave && ( | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="warning" | color="warning" | ||||
| onClick={() => setCancelConfirmOpen(true)} | onClick={() => setCancelConfirmOpen(true)} | ||||
| disabled={isPutAwayed} | |||||
| disabled={isPutAwayed || cancelLoading} | |||||
| > | > | ||||
| {t("Cancel Job Order")} | {t("Cancel Job Order")} | ||||
| </Button> | </Button> | ||||
| @@ -746,8 +736,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={() => handleRelease(jobOrderId)} | onClick={() => handleRelease(jobOrderId)} | ||||
| //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||||
| disabled={processData?.jobOrderStatus !== "planning"} | |||||
| disabled={processData?.jobOrderStatus !== "planning" || isReleasing} | |||||
| startIcon={isReleasing ? <CircularProgress size={16} color="inherit" /> : undefined} | |||||
| > | > | ||||
| {t("Release")} | {t("Release")} | ||||
| </Button> | </Button> | ||||
| @@ -984,19 +974,6 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| <Dialog open={deleteConfirmOpen} onClose={() => !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> | |||||
| <DialogTitle>{t("Confirm delete job order")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Typography variant="body2">{t("Delete job order confirm message")}</Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setDeleteConfirmOpen(false)} disabled={deleteLoading}>{t("Cancel")}</Button> | |||||
| <Button variant="contained" color="error" onClick={() => void handleConfirmDeleteJobOrder()} disabled={deleteLoading}> | |||||
| {deleteLoading ? <CircularProgress size={20} /> : t("Delete Job Order")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | ||||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| @@ -8,13 +8,13 @@ import QcStockInModal from "@/components/Qc/QcStockInModal"; | |||||
| import ProductionProcessList, { | import ProductionProcessList, { | ||||
| createDefaultProductionProcessListPersistedState, | createDefaultProductionProcessListPersistedState, | ||||
| } from "@/components/ProductionProcess/ProductionProcessList"; | } from "@/components/ProductionProcess/ProductionProcessList"; | ||||
| import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; | |||||
| import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | ||||
| import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | ||||
| import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | ||||
| import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard"; | ||||
| import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; | import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard"; | ||||
| import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; | import DrinkProductionQtyDashboard from "@/components/ProductionProcess/DrinkProductionQtyDashboard"; | ||||
| import JobOrderOpsTable from "@/components/ProductionProcess/JobOrderOpsTable"; | |||||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | import type { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { useTranslation } from "react-i18next"; | 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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 26 | v1.0.0 | 2026-07-20 */ | ||||
| const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation(["common"]); | |||||
| const { t } = useTranslation(["common", "productionProcess"]); | |||||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | ||||
| const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ | const [selectedMatchingStock, setSelectedMatchingStock] = useState<{ | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| productProcessId: number; | productProcessId: number; | ||||
| pickOrderId: number; | pickOrderId: number; | ||||
| } | null>(null); | } | null>(null); | ||||
| /** 0 = Production Process list; 1 = JO ops table; 2..5 = dashboards */ | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| /** 列表搜索/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ | |||||
| const [productionListState, setProductionListState] = useState(() => ({ | const [productionListState, setProductionListState] = useState(() => ({ | ||||
| ...createDefaultProductionProcessListPersistedState(), | ...createDefaultProductionProcessListPersistedState(), | ||||
| // date: "", | |||||
| })); | })); | ||||
| const [waitingPutawayListState, setWaitingPutawayListState] = useState( | |||||
| createDefaultProductionProcessListPersistedState, | |||||
| ); | |||||
| const [putawayedListState, setPutawayedListState] = useState( | |||||
| createDefaultProductionProcessListPersistedState, | |||||
| ); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| @@ -53,22 +46,18 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| const [linkQcOpen, setLinkQcOpen] = useState(false); | const [linkQcOpen, setLinkQcOpen] = useState(false); | ||||
| const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | const [linkQcSilId, setLinkQcSilId] = useState<number | null>(null); | ||||
| // Add printer selection state | |||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | null>( | ||||
| printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | printerCombo && printerCombo.length > 0 ? printerCombo[0] : null | ||||
| ); | ); | ||||
| // 从 sessionStorage 恢复状态(仅在客户端) | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| try { | try { | ||||
| const saved = sessionStorage.getItem(STORAGE_KEY); | const saved = sessionStorage.getItem(STORAGE_KEY); | ||||
| if (saved) { | if (saved) { | ||||
| const parsed = JSON.parse(saved); | const parsed = JSON.parse(saved); | ||||
| // 验证数据有效性 | |||||
| if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { | if (parsed && typeof parsed.jobOrderId === 'number' && typeof parsed.productProcessId === 'number') { | ||||
| setSelectedMatchingStock(parsed); | setSelectedMatchingStock(parsed); | ||||
| console.log(" Restored selectedMatchingStock from sessionStorage:", parsed); | |||||
| } | } | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| @@ -78,19 +67,16 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| // 保存状态到 sessionStorage | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| if (selectedMatchingStock) { | if (selectedMatchingStock) { | ||||
| sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); | sessionStorage.setItem(STORAGE_KEY, JSON.stringify(selectedMatchingStock)); | ||||
| console.log(" Saved selectedMatchingStock to sessionStorage:", selectedMatchingStock); | |||||
| } else { | } else { | ||||
| sessionStorage.removeItem(STORAGE_KEY); | sessionStorage.removeItem(STORAGE_KEY); | ||||
| } | } | ||||
| } | } | ||||
| }, [selectedMatchingStock]); | }, [selectedMatchingStock]); | ||||
| // 处理返回列表时清除存储 | |||||
| const handleBackFromSecondScan = useCallback(() => { | const handleBackFromSecondScan = useCallback(() => { | ||||
| setSelectedMatchingStock(null); | setSelectedMatchingStock(null); | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| @@ -104,7 +90,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| const openStockInLineIdQ = searchParams.get("openStockInLineId"); | 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(() => { | useEffect(() => { | ||||
| if (!openStockInLineIdQ) { | if (!openStockInLineIdQ) { | ||||
| setLinkQcOpen(false); | setLinkQcOpen(false); | ||||
| @@ -115,7 +101,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| if (!Number.isFinite(id) || id <= 0) return; | if (!Number.isFinite(id) || id <= 0) return; | ||||
| setSelectedProcessId(null); | setSelectedProcessId(null); | ||||
| setSelectedMatchingStock(null); | setSelectedMatchingStock(null); | ||||
| setTabIndex(1); | |||||
| setTabIndex(0); | |||||
| setProductionListState((prev) => ({ | |||||
| ...prev, | |||||
| pickBucket: "pending_qc", | |||||
| page: 0, | |||||
| })); | |||||
| setLinkQcSilId(id); | setLinkQcSilId(id); | ||||
| setLinkQcOpen(true); | setLinkQcOpen(true); | ||||
| }, [openStockInLineIdQ]); | }, [openStockInLineIdQ]); | ||||
| @@ -129,6 +120,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); | ||||
| }, [pathname, router, searchParams]); | }, [pathname, router, searchParams]); | ||||
| const listTab = String(productionListState.pickBucket || "needs_action"); | |||||
| const showPrinterBar = tabIndex === 0 && listTab === "pending_qc"; | |||||
| if (selectedMatchingStock) { | if (selectedMatchingStock) { | ||||
| return ( | return ( | ||||
| <JobPickExecutionsecondscan | <JobPickExecutionsecondscan | ||||
| @@ -154,8 +148,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Box> | <Box> | ||||
| {/* Header section with printer selection */} | |||||
| {tabIndex === 1 && ( | |||||
| {showPrinterBar && ( | |||||
| <Box sx={{ | <Box sx={{ | ||||
| p: 1, | p: 1, | ||||
| borderBottom: '1px solid #e0e0e0', | borderBottom: '1px solid #e0e0e0', | ||||
| @@ -205,8 +198,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | ||||
| <Tab label={t("Production Process")} /> | <Tab label={t("Production Process")} /> | ||||
| <Tab label={t("Waiting QC Put Away Job Orders")} /> | |||||
| <Tab label={t("Put Awayed Job Orders")} /> | |||||
| <Tab label={t("Job Order Ops Table")} /> | |||||
| <Tab label={t("Job Process Status Dashboard")} /> | <Tab label={t("Job Process Status Dashboard")} /> | ||||
| <Tab label={t("Operator KPI Dashboard")} /> | <Tab label={t("Operator KPI Dashboard")} /> | ||||
| <Tab label={t("Production Equipment Status Dashboard")} /> | <Tab label={t("Production Equipment Status Dashboard")} /> | ||||
| @@ -215,9 +207,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| {tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
| <ProductionProcessList | <ProductionProcessList | ||||
| printerCombo={printerCombo} | |||||
| qcReady={false} | |||||
| disableDateFilter={false} | |||||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||||
| listPersistedState={productionListState} | listPersistedState={productionListState} | ||||
| onListPersistedStateChange={setProductionListState} | onListPersistedStateChange={setProductionListState} | ||||
| onSelectProcess={(jobOrderId) => { | onSelectProcess={(jobOrderId) => { | ||||
| @@ -237,62 +227,24 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| )} | )} | ||||
| {tabIndex === 1 && ( | {tabIndex === 1 && ( | ||||
| <ProductionProcessList | |||||
| printerCombo={printerCombo} | |||||
| qcReady={true} | |||||
| includePutaway={true} | |||||
| putawayStatus="notCompleted" | |||||
| listPersistedState={waitingPutawayListState} | |||||
| onListPersistedStateChange={setWaitingPutawayListState} | |||||
| <JobOrderOpsTable | |||||
| printerCombo={selectedPrinter ? [selectedPrinter] : printerCombo} | |||||
| onSelectProcess={(jobOrderId) => { | onSelectProcess={(jobOrderId) => { | ||||
| 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 && ( | {tabIndex === 2 && ( | ||||
| <ProductionProcessList | |||||
| printerCombo={printerCombo} | |||||
| qcReady={true} | |||||
| includePutaway={true} | |||||
| putawayStatus="completed" | |||||
| listPersistedState={putawayedListState} | |||||
| onListPersistedStateChange={setPutawayedListState} | |||||
| onSelectProcess={(jobOrderId) => { | |||||
| 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 && ( | |||||
| <JobProcessStatus /> | <JobProcessStatus /> | ||||
| )} | )} | ||||
| {tabIndex === 4 && ( | |||||
| {tabIndex === 3 && ( | |||||
| <OperatorKpiDashboard /> | <OperatorKpiDashboard /> | ||||
| )} | )} | ||||
| {tabIndex === 5 && ( | |||||
| {tabIndex === 4 && ( | |||||
| <EquipmentStatusDashboard /> | <EquipmentStatusDashboard /> | ||||
| )} | )} | ||||
| {tabIndex === 6 && ( | |||||
| {tabIndex === 5 && ( | |||||
| <DrinkProductionQtyDashboard /> | <DrinkProductionQtyDashboard /> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -310,4 +262,4 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| ); | ); | ||||
| }; | }; | ||||
| export default ProductionProcessPage; | |||||
| export default ProductionProcessPage; | |||||
| @@ -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'; | export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | 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 Start", name: "lastOutDateStart", type: "date", required: false }, | ||||
| { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", 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: "貨品編號 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, | multiple: true, | ||||
| dynamicOptions: true, | dynamicOptions: true, | ||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/fg-stock-out-traceability-handlers`, | 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" }, | |||||
| ], | |||||
| }, | |||||
| ] | ] | ||||
| @@ -62,7 +62,7 @@ | |||||
| "Not Started": "Not Started", | "Not Started": "Not Started", | ||||
| "cancelled": "Cancelled", | "cancelled": "Cancelled", | ||||
| "in_progress": "In Progress", | "in_progress": "In Progress", | ||||
| "pending": "Pending", | |||||
| "pending": "Awaiting production", | |||||
| "stopped": "Stopped", | "stopped": "Stopped", | ||||
| "Invalid Job Order Id": "Invalid Job Order Id", | "Invalid Job Order Id": "Invalid Job Order Id", | ||||
| "Invalid Stock In Line Id": "Invalid Stock In Line Id", | "Invalid Stock In Line Id": "Invalid Stock In Line Id", | ||||
| @@ -76,6 +76,20 @@ | |||||
| "Job Order Info": "Job Order Info", | "Job Order Info": "Job Order Info", | ||||
| "Job Order No.": "Job Order No.", | "Job Order No.": "Job Order No.", | ||||
| "Job Order and Product": "Job Order and Product", | "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 Order Production Process": "Job Order Production Process", | ||||
| "Job Process Status Dashboard": "Job Process Status Dashboard", | "Job Process Status Dashboard": "Job Process Status Dashboard", | ||||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | ||||
| @@ -198,6 +212,16 @@ | |||||
| "Total Time": "Total Time", | "Total Time": "Total Time", | ||||
| "Total finished QC job orders": "Total finished QC job orders", | "Total finished QC job orders": "Total finished QC job orders", | ||||
| "Total job orders": "Total 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: ", | "Total lines: ": "Total lines: ", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "Unable to get user ID": "Unable to get user ID", | "Unable to get user ID": "Unable to get user ID", | ||||
| @@ -212,6 +236,9 @@ | |||||
| "View Details": "View Details", | "View Details": "View Details", | ||||
| "Wait Time": "Wait Time", | "Wait Time": "Wait Time", | ||||
| "Waiting QC Put Away Job Orders": "Waiting QC Put Away Job Orders", | "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", | "all": "All", | ||||
| "drink": "Drink", | "drink": "Drink", | ||||
| "id": "ID", | "id": "ID", | ||||
| @@ -190,7 +190,7 @@ | |||||
| "jobOrder": "工單", | "jobOrder": "工單", | ||||
| "deliveryOrder": "送貨單", | "deliveryOrder": "送貨單", | ||||
| "deliveryNoteCode": "送貨單據號 (DN)", | "deliveryNoteCode": "送貨單據號 (DN)", | ||||
| "ticketNo": "票號", | |||||
| "ticketNo": "提票號碼", | |||||
| "variance": "差異", | "variance": "差異", | ||||
| "before": "帳面數量", | "before": "帳面數量", | ||||
| "after": "核准數量", | "after": "核准數量", | ||||
| @@ -62,7 +62,7 @@ | |||||
| "Not Started": "未開始", | "Not Started": "未開始", | ||||
| "cancelled": "已取消", | "cancelled": "已取消", | ||||
| "in_progress": "進行中", | "in_progress": "進行中", | ||||
| "pending": "待處理", | |||||
| "pending": "待生產", | |||||
| "stopped": "已停止", | "stopped": "已停止", | ||||
| "Invalid Job Order Id": "無效工單編號", | "Invalid Job Order Id": "無效工單編號", | ||||
| "Invalid Stock In Line Id": "無效庫存行ID", | "Invalid Stock In Line Id": "無效庫存行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": "異常", | |||||
| "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 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": "總完成QC工單數量", | "Total finished QC job orders": "總完成QC工單數量", | ||||
| "Total job orders": "總工單數量", | "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: ": "總數量:", | "Total lines: ": "總數量:", | ||||
| "Type": "類型", | "Type": "類型", | ||||
| "Unable to get user ID": "無法獲取用戶ID", | "Unable to get user ID": "無法獲取用戶ID", | ||||
| @@ -212,6 +236,9 @@ | |||||
| "View Details": "查看詳情", | "View Details": "查看詳情", | ||||
| "Wait Time": "等待時間", | "Wait Time": "等待時間", | ||||
| "Waiting QC Put Away Job Orders": "待QC上架工單", | "Waiting QC Put Away Job Orders": "待QC上架工單", | ||||
| "Waiting QC Put Away": "待QC上架", | |||||
| "Put Awayed": "已上架", | |||||
| "Put Away Detail": "上架詳情", | |||||
| "all": "全部", | "all": "全部", | ||||
| "drink": "飲料", | "drink": "飲料", | ||||
| "id": "ID", | "id": "ID", | ||||