diff --git a/src/app/(main)/do copy 2/edit/page.tsx b/src/app/(main)/do copy 2/edit/page.tsx new file mode 100644 index 0000000..a3200a5 --- /dev/null +++ b/src/app/(main)/do copy 2/edit/page.tsx @@ -0,0 +1,36 @@ +import { SearchParams } from "@/app/utils/fetchUtil"; +import DoDetail from "@/components/DoDetail/DoDetailWrapper"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { isArray } from "lodash"; +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Edit Delivery Order Detail", +}; + +type Props = SearchParams; + +const DoEdit: React.FC = async ({ searchParams }) => { + const { t } = await getServerI18n("do"); + const id = searchParams["id"]; + + if (!id || isArray(id) || !isFinite(parseInt(id))) { + notFound(); + } + + return ( + <> + + + }> + + + + + ); +}; + +export default DoEdit; \ No newline at end of file diff --git a/src/app/(main)/do copy 2/page.tsx b/src/app/(main)/do copy 2/page.tsx new file mode 100644 index 0000000..5489385 --- /dev/null +++ b/src/app/(main)/do copy 2/page.tsx @@ -0,0 +1,35 @@ +import DoSearchWorkbench from "@/components/DoSearchWorkbench/DoSearchWorkbench"; +import { getServerI18n } from "@/i18n"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider } from "@/i18n"; +import { Metadata } from "next"; +import { Suspense } from "react"; +import GeneralLoading from "@/components/General/GeneralLoading"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "DO Workbench (copy)", +}; + +/** Dev alias — prefer canonical route `/doworkbench`. */ +const Page: React.FC = async () => { + const { t } = await getServerI18n("do"); + + return ( + <> + +

+ + /doworkbench + +

+ + }> + + + + + ); +}; + +export default Page; diff --git a/src/app/(main)/do copy/edit/page.tsx b/src/app/(main)/do copy/edit/page.tsx new file mode 100644 index 0000000..a3200a5 --- /dev/null +++ b/src/app/(main)/do copy/edit/page.tsx @@ -0,0 +1,36 @@ +import { SearchParams } from "@/app/utils/fetchUtil"; +import DoDetail from "@/components/DoDetail/DoDetailWrapper"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { isArray } from "lodash"; +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Edit Delivery Order Detail", +}; + +type Props = SearchParams; + +const DoEdit: React.FC = async ({ searchParams }) => { + const { t } = await getServerI18n("do"); + const id = searchParams["id"]; + + if (!id || isArray(id) || !isFinite(parseInt(id))) { + notFound(); + } + + return ( + <> + + + }> + + + + + ); +}; + +export default DoEdit; \ No newline at end of file diff --git a/src/app/(main)/do copy/page.tsx b/src/app/(main)/do copy/page.tsx new file mode 100644 index 0000000..e1ef75d --- /dev/null +++ b/src/app/(main)/do copy/page.tsx @@ -0,0 +1,29 @@ +// import DoSearch from "@/components/DoSearch"; +// import { getServerI18n } from "@/i18n" +import DoSearch from "../../../components/DoSearch"; +import { getServerI18n } from "../../../i18n"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider } from "@/i18n"; +import { Metadata } from "next"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Delivery Order", +}; + +const DeliveryOrder: React.FC = async () => { + const { t } = await getServerI18n("do"); + + return ( + <> + + + }> + + + + + ); +}; + +export default DeliveryOrder; diff --git a/src/app/(main)/doworkbench/edit/page.tsx b/src/app/(main)/doworkbench/edit/page.tsx new file mode 100644 index 0000000..1341280 --- /dev/null +++ b/src/app/(main)/doworkbench/edit/page.tsx @@ -0,0 +1,46 @@ +import { SearchParams } from "@/app/utils/fetchUtil"; +import DoDetail from "@/components/DoDetail/DoDetailWrapper"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { isArray } from "lodash"; +import { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "DO Workbench — Delivery Order Detail", +}; + +type Props = SearchParams; + +const Page: React.FC = async ({ searchParams }) => { + const { t } = await getServerI18n("do"); + const id = searchParams["id"]; + + if (!id || isArray(id) || !isFinite(parseInt(id))) { + notFound(); + } + + return ( + <> + +

+ + {t("DO Workbench", { defaultValue: "DO Workbench" })} + + {" · "} + + {t("DO Workbench Search", { defaultValue: "DO Workbench Search" })} + +

+ + }> + + + + + ); +}; + +export default Page; diff --git a/src/app/(main)/doworkbench/page.tsx b/src/app/(main)/doworkbench/page.tsx new file mode 100644 index 0000000..6322a89 --- /dev/null +++ b/src/app/(main)/doworkbench/page.tsx @@ -0,0 +1,25 @@ +import DoWorkbenchTabs from "@/components/DoWorkbench/DoWorkbenchTabs"; +import PageTitleBar from "@/components/PageTitleBar"; +import { getServerI18n, I18nProvider } from "@/i18n"; +import { Metadata } from "next"; +import { fetchPrinterCombo } from "@/app/api/settings/printer"; + +export const metadata: Metadata = { + title: "DO Workbench", +}; + +const DoWorkbenchPage: React.FC = async () => { + const { t } = await getServerI18n("do"); + const printerCombo = await fetchPrinterCombo(); + + return ( + <> + + + + + + ); +}; + +export default DoWorkbenchPage; diff --git a/src/app/(main)/doworkbench/pick/page.tsx b/src/app/(main)/doworkbench/pick/page.tsx new file mode 100644 index 0000000..eee0a6f --- /dev/null +++ b/src/app/(main)/doworkbench/pick/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** 揀貨工作台已合併至 `/doworkbench`,保留此路徑以利舊連結。 */ +export default function DoWorkbenchPickLegacyRedirect() { + redirect("/doworkbench"); +} diff --git a/src/app/(main)/doworkbenchsearch/page.tsx b/src/app/(main)/doworkbenchsearch/page.tsx new file mode 100644 index 0000000..602cc26 --- /dev/null +++ b/src/app/(main)/doworkbenchsearch/page.tsx @@ -0,0 +1,31 @@ +import DoSearchWorkbench from "@/components/DoSearchWorkbench"; +import { getServerI18n } from "@/i18n"; +import PageTitleBar from "@/components/PageTitleBar"; +import { I18nProvider } from "@/i18n"; +import { Metadata } from "next"; +import { Suspense } from "react"; +import GeneralLoading from "@/components/General/GeneralLoading"; + +export const metadata: Metadata = { + title: "DO Workbench Search", +}; + +const DoWorkbenchSearchPage: React.FC = async () => { + const { t } = await getServerI18n("do"); + + return ( + <> + + + }> + + + + + ); +}; + +export default DoWorkbenchSearchPage; diff --git a/src/app/(main)/jo/page.tsx b/src/app/(main)/jo/page.tsx index d794c57..7cd53e4 100644 --- a/src/app/(main)/jo/page.tsx +++ b/src/app/(main)/jo/page.tsx @@ -1,6 +1,9 @@ -import { preloadBomCombo } from "@/app/api/bom"; -import JoSearch from "@/components/JoSearch"; +import { fetchBomCombo } from "@/app/api/bom"; +import { fetchPrinterCombo } from "@/app/api/settings/printer"; +import { fetchAllJobTypes, type SearchJoResultRequest } from "@/app/api/jo/actions"; +import GeneralLoading from "@/components/General/GeneralLoading"; import PageTitleBar from "@/components/PageTitleBar"; +import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch"; import { I18nProvider, getServerI18n } from "@/i18n"; import { Metadata } from "next"; import React, { Suspense } from "react"; @@ -11,15 +14,32 @@ export const metadata: Metadata = { const Jo: React.FC = async () => { const { t } = await getServerI18n("jo"); - - preloadBomCombo(); + const today = new Date(); + const todayStr = today.toISOString().split("T")[0]; + const defaultInputs: SearchJoResultRequest = { + code: "", + itemName: "", + planStart: `${todayStr}T00:00`, + planStartTo: `${todayStr}T23:59:59`, + joSearchStatus: "all", + }; + const [bomCombo, printerCombo, jobTypes] = await Promise.all([ + fetchBomCombo(), + fetchPrinterCombo(), + fetchAllJobTypes(), + ]); return ( <> - }> - + }> + diff --git a/src/app/(main)/jo/workbench/page.tsx b/src/app/(main)/jo/workbench/page.tsx new file mode 100644 index 0000000..2140658 --- /dev/null +++ b/src/app/(main)/jo/workbench/page.tsx @@ -0,0 +1,27 @@ +import GeneralLoading from "@/components/General/GeneralLoading"; +import PageTitleBar from "@/components/PageTitleBar"; +import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { Metadata } from "next"; +import React, { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Job Order Pick List", +}; + +const JoWorkbenchPage = async () => { + const { t } = await getServerI18n("jo"); + + return ( + <> + + + }> + + + + + ); +}; + +export default JoWorkbenchPage; \ No newline at end of file diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index d9f0af6..5d8114c 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -148,6 +148,35 @@ export interface getTicketReleaseTable { isActiveDoPickOrder?: boolean; } +export interface WorkbenchTicketReleaseTable { + deliveryOrderPickOrderId: number; + storeId: string | null; + ticketNo: string | null; + loadingSequence: number | null; + ticketStatus: string | null; + truckDepartureTime: string | null; + handledBy: number | null; + ticketReleaseTime: string | null; + ticketCompleteDateTime: string | null; + truckLanceCode: string | null; + shopCode: string | null; + shopName: string | null; + requiredDeliveryDate: string | null; + handlerName: string | null; + numberOfFGItems: number; + isActiveWorkbenchTicket?: boolean; +} + +export interface WorkbenchTicketOpResponse { + id: number | null; + name: string | null; + code: string; + type: string | null; + message: string | null; + errorPosition: string | null; + entity?: any; +} + export interface TruckScheduleDashboardItem { storeId: string | null; truckId: number | null; @@ -213,6 +242,39 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: ); }); +export const fetchWorkbenchTicketReleaseTable = cache(async (startDate: string, endDate: string)=> { + return await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/ticket-release-table/${startDate}&${endDate}`, + { + method: "GET", + } + ); +}); + +export async function forceCompleteWorkbenchTicket( + deliveryOrderPickOrderId: number, +): Promise { + return await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/force-complete/${deliveryOrderPickOrderId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); +} + +export async function revertWorkbenchTicketAssignment( + deliveryOrderPickOrderId: number, +): Promise { + return await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/revert-assignment/${deliveryOrderPickOrderId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); +} + export const fetchTruckScheduleDashboard = cache(async (date?: string) => { const url = date ? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}` @@ -451,7 +513,7 @@ export async function printDNLabelsReprint(request: PrintDNLabelsReprintRequest) return { success: true, message: "Print job sent successfully (reprint labels)"} as PrintDeliveryNoteResponse } -/* + export interface PrintWorkbenchDeliveryNoteRequest{ deliveryOrderPickOrderId: number; printerId: number; @@ -466,6 +528,14 @@ export interface PrintWorkbenchDNLabelsRequest{ printQty: number; numOfCarton: number; } +export interface PrintWorkbenchDNLabelsReprintRequest{ + deliveryOrderPickOrderId: number; + printerId: number; + printQty: number; + fromCarton: number; + toCarton: number; + totalCartonsOnShipment: number; +} export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteRequest){ const params = new URLSearchParams(); params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString()); @@ -477,7 +547,7 @@ export async function printDNWorkbench(request: PrintWorkbenchDeliveryNoteReques params.append("isDraft", request.isDraft.toString()); try { - const response = await serverFetch(`${BASE_API_URL}/do/workbench/print-DN?${params.toString()}`, { + const response = await serverFetch(`${BASE_API_URL}/doPickOrder/workbench/print-DN?${params.toString()}`, { method: "GET", }); if (response.ok) { @@ -507,13 +577,31 @@ export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequ } params.append("numOfCarton", request.numOfCarton.toString()); - await serverFetchWithNoContent(`${BASE_API_URL}/do/workbench/print-DNLabels?${params.toString()}`,{ + await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels?${params.toString()}`,{ method: "GET" }); return { success: true, message: "Print job sent successfully (workbench labels)"} as PrintDeliveryNoteResponse } -*/ + +export async function printDNLabelsReprintWorkbench(request: PrintWorkbenchDNLabelsReprintRequest){ + const params = new URLSearchParams(); + params.append("doPickOrderId", request.deliveryOrderPickOrderId.toString()); + params.append("printerId", request.printerId.toString()); + if (request.printQty !== null && request.printQty !== undefined) { + params.append("printQty", request.printQty.toString()); + } + params.append("fromCarton", request.fromCarton.toString()); + params.append("toCarton", request.toCarton.toString()); + params.append("totalCartonsOnShipment", request.totalCartonsOnShipment.toString()); + + await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels-reprint?${params.toString()}`,{ + method: "GET" + }); + + return { success: true, message: "Print job sent successfully (workbench reprint labels)"} as PrintDeliveryNoteResponse +} + export interface Check4FTruckBatchResponse { hasProblem: boolean; problems: ProblemDoDto[]; diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts new file mode 100644 index 0000000..4c04edc --- /dev/null +++ b/src/app/api/doworkbench/actions.ts @@ -0,0 +1,372 @@ +"use server"; + +import { revalidateTag } from "next/cache"; +import { BASE_API_URL } from "@/config/api"; +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import type { + PostPickOrderResponse, + ReleasedDoPickOrderListItem, + StoreLaneSummary, +} from "@/app/api/pickOrder/actions"; +import dayjs from "dayjs"; + +/** Aligns with backend MessageResponse for workbench endpoints */ +export type WorkbenchMessageResponse = { + id?: number | null; + code?: string | null; + name?: string | null; + type?: string | null; + message?: string | null; + errorPosition?: string | null; + entity?: unknown; +}; + +export async function startWorkbenchBatchReleaseAsync(data: { + ids: number[]; + userId: number; +}): Promise { + const { ids, userId } = data; + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/batch-release/async?userId=${userId}`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { "Content-Type": "application/json" }, + } + ); +} + +/** V2: no SPL/stock out at batch release; created when assigning delivery_order_pick_order. */ +export async function startWorkbenchBatchReleaseAsyncV2(data: { + ids: number[]; + userId: number; +}): Promise { + const { ids, userId } = data; + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/batch-release/async-v2?userId=${userId}`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { "Content-Type": "application/json" }, + } + ); +} + +export async function workbenchBatchReleaseSyncV2(data: { + ids: number[]; + userId: number; +}): Promise { + const { ids, userId } = data; + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/batch-release/sync-v1?userId=${userId}`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { "Content-Type": "application/json" }, + } + ); +} + +export async function getWorkbenchBatchReleaseProgress( + jobId: string +): Promise { + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/batch-release/progress/${encodeURIComponent(jobId)}`, + { method: "GET" } + ); +} + +export type WorkbenchScanPickBody = { + stockOutLineId: number; + lotNo: string; + /** From QR: ties to a single `inventory_lot` when lotNo is reused across stock-ins */ + stockInLineId?: number | null; + /** + * When set (e.g. label-print modal row), backend resolves this exact inventory lot line + * instead of stockInLineId / newest-by-lotNo. + */ + inventoryLotLineId?: number | null; + /** Optional store scope (e.g. 2/F). When set, split re-suggestions must stay within the same store. */ + storeId?: string | null; + /** Optional: exclude these warehouse codes from resuggest logic */ + excludeWarehouseCodes?: string[] | null; + /** Optional decimal string or number serialized by JSON */ + qty?: number | string | null; + userId: number; +}; + +function serializeWorkbenchQty( + qty: number | string | null | undefined +): number | undefined { + if (qty === null || qty === undefined || qty === "") return undefined; + const n = typeof qty === "string" ? Number(qty) : qty; + if (typeof n !== "number" || Number.isNaN(n) || !Number.isFinite(n)) return undefined; + // 0 is a valid explicit workbench short submit (must be sent, not omitted) + return n; +} + +/** + * DO workbench scan-pick. Omit `qty` for full remaining on this SOL chunk (backend may split if lot runs out). + * Pass `qty` less than remaining for short submit (POL/SOL completed without `partially_completed` on POL). + * Pass `qty` greater than remaining to overscan: backend posts up to lot availability, then rebuild/ensure SOL. + */ +export async function workbenchScanPick( + body: WorkbenchScanPickBody +): Promise { + const qty = serializeWorkbenchQty(body.qty); + const sil = body.stockInLineId; + const stockInLineId = + typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined; + const storeId = + typeof body.storeId === "string" && body.storeId.trim() !== "" + ? body.storeId.trim() + : undefined; + const excludeWarehouseCodes = + Array.isArray(body.excludeWarehouseCodes) && body.excludeWarehouseCodes.length > 0 + ? body.excludeWarehouseCodes + .map((c) => (typeof c === "string" ? c.trim() : "")) + .filter((c) => c !== "") + : undefined; + const ill = body.inventoryLotLineId; + const inventoryLotLineId = + typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined; + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/scan-pick`, + { + method: "POST", + body: JSON.stringify({ + stockOutLineId: body.stockOutLineId, + lotNo: body.lotNo, + ...(stockInLineId !== undefined ? { stockInLineId } : {}), + ...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}), + ...(storeId !== undefined ? { storeId } : {}), + ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), + ...(qty !== undefined ? { qty } : {}), + userId: body.userId, + }), + headers: { "Content-Type": "application/json" }, + } + ); +} + +export type WorkbenchBatchScanPickBody = { + lines: WorkbenchScanPickBody[]; +}; + +/** + * DO workbench batch scan-pick. + * Intended for batch-submit style flows where we close multiple SOLs (commonly qty=0 for noLot/expired/unavailable). + */ +export async function workbenchBatchScanPick( + body: WorkbenchBatchScanPickBody, +): Promise { + const lines = Array.isArray(body.lines) ? body.lines : []; + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/scan-pick/batch`, + { + method: "POST", + body: JSON.stringify({ + lines: lines.map((l) => { + const qty = serializeWorkbenchQty(l.qty); + const sil = l.stockInLineId; + const stockInLineId = + typeof sil === "number" && Number.isFinite(sil) && sil > 0 ? sil : undefined; + const storeId = + typeof l.storeId === "string" && l.storeId.trim() !== "" ? l.storeId.trim() : undefined; + const excludeWarehouseCodes = + Array.isArray(l.excludeWarehouseCodes) && l.excludeWarehouseCodes.length > 0 + ? l.excludeWarehouseCodes + .map((c) => (typeof c === "string" ? c.trim() : "")) + .filter((c) => c !== "") + : undefined; + const ill = l.inventoryLotLineId; + const inventoryLotLineId = + typeof ill === "number" && Number.isFinite(ill) && ill > 0 ? ill : undefined; + return { + stockOutLineId: l.stockOutLineId, + lotNo: l.lotNo ?? "", + ...(stockInLineId !== undefined ? { stockInLineId } : {}), + ...(inventoryLotLineId !== undefined ? { inventoryLotLineId } : {}), + ...(storeId !== undefined ? { storeId } : {}), + ...(excludeWarehouseCodes !== undefined ? { excludeWarehouseCodes } : {}), + ...(qty !== undefined ? { qty } : {}), + userId: l.userId, + }; + }), + }), + headers: { "Content-Type": "application/json" }, + }, + ); +} + +/** Store lane grid backed by `delivery_order_pick_order` + `pick_order.deliveryOrderPickOrderId`. */ +export async function fetchWorkbenchStoreLaneSummary( + storeId: string, + requiredDate?: string, + releaseType?: string +): Promise { + const dateToUse = requiredDate || dayjs().format("YYYY-MM-DD"); + const rt = releaseType || "all"; + const url = `${BASE_API_URL}/doPickOrder/workbench/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${encodeURIComponent(dateToUse)}&releaseType=${encodeURIComponent(rt)}`; + return serverFetchJson(url, { + method: "GET", + cache: "no-store", + next: { revalidate: 0 }, + }); +} + +/** Past-date `delivery_order_pick_order` tickets (same shape as `/doPickOrder/released`). */ +export async function fetchWorkbenchReleasedDoPickOrdersForSelection( + shopName?: string, + storeId?: string, + truck?: string +): Promise { + const params = new URLSearchParams(); + if (shopName?.trim()) params.append("shopName", shopName.trim()); + if (storeId?.trim()) params.append("storeId", storeId.trim()); + if (truck?.trim()) params.append("truck", truck.trim()); + const query = params.toString(); + const url = `${BASE_API_URL}/doPickOrder/workbench/released${query ? `?${query}` : ""}`; + const response = await serverFetchJson(url, { method: "GET" }); + return response ?? []; +} + +export async function fetchWorkbenchReleasedDoPickOrdersForSelectionToday( + shopName?: string, + storeId?: string, + truck?: string +): Promise { + const params = new URLSearchParams(); + if (shopName?.trim()) params.append("shopName", shopName.trim()); + if (storeId?.trim()) params.append("storeId", storeId.trim()); + if (truck?.trim()) params.append("truck", truck.trim()); + const query = params.toString(); + const url = `${BASE_API_URL}/doPickOrder/workbench/released-today${query ? `?${query}` : ""}`; + const response = await serverFetchJson(url, { method: "GET" }); + return response ?? []; +} + +/** Same body as `/doPickOrder/assign-by-lane` but resolves `delivery_order_pick_order`. */ +export async function assignWorkbenchByLane(data: { + userId: number; + storeId: string; + truckLanceCode: string; + truckDepartureTime?: string; + requiredDate?: string; +}): Promise { + const res = await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/assign-by-lane`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ); + revalidateTag("pickorder"); + return res; +} + +/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */ +export async function assignWorkbenchByLaneV1(data: { + userId: number; + storeId: string; + truckLanceCode: string; + truckDepartureTime?: string; + requiredDate?: string; +}): Promise { + const res = await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/assign-by-lane-v1`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ); + revalidateTag("pickorder"); + return res; +} + +export async function assignByDeliveryOrderPickOrderId( + userId: number, + deliveryOrderPickOrderId: number +): Promise { + const res = await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, deliveryOrderPickOrderId }), + } + ); + revalidateTag("pickorder"); + return res; +} + +/** Assign V1 (legacy): old FG-style, no atomic conflict guard. */ +export async function assignByDeliveryOrderPickOrderIdV1( + userId: number, + deliveryOrderPickOrderId: number +): Promise { + const res = await serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/assign-by-delivery-order-pick-order-id-v1`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, deliveryOrderPickOrderId }), + } + ); + revalidateTag("pickorder"); + return res; +} + +export async function fetchWorkbenchCompletedLotDetails( + deliveryOrderPickOrderId: number, +): Promise { + return serverFetchJson( + `${BASE_API_URL}/doPickOrder/workbench/completed-lot-details/${deliveryOrderPickOrderId}`, + { method: "GET" }, + ); +} +export type WorkbenchScanPayload = { + itemId: number; + stockInLineId: number; +}; +export async function fetchWorkbenchPrinters() { + return serverFetchJson(`${BASE_API_URL}/printers`, { + method: "GET", + cache: "no-store", + }); +} +export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) { + return serverFetchJson(`${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + cache: "no-store", + }); +} + +export async function fetchWorkbenchAvailableLotsByItem(itemId: number) { + return serverFetchJson( + `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`, + { + method: "GET", + cache: "no-store", + }, + ); +} + +export async function printWorkbenchLotLabel(params: { + inventoryLotLineId: number; + printerId: number; + printQty: number; +}) { + const searchParams = new URLSearchParams(); + searchParams.set("inventoryLotLineId", String(params.inventoryLotLineId)); + searchParams.set("printerId", String(params.printerId)); + searchParams.set("printQty", String(params.printQty)); + return serverFetchJson( + `${BASE_API_URL}/inventoryLotLine/workbench/print-label?${searchParams.toString()}`, + { method: "GET", cache: "no-store" }, + ); +} \ No newline at end of file diff --git a/src/app/api/doworkbench/client.ts b/src/app/api/doworkbench/client.ts new file mode 100644 index 0000000..d96fbac --- /dev/null +++ b/src/app/api/doworkbench/client.ts @@ -0,0 +1,5 @@ +/** Server actions live in ./actions — import them directly in client components. */ +export type { + WorkbenchMessageResponse, + WorkbenchScanPickBody, +} from "./actions"; diff --git a/src/app/api/doworkbench/index.tsx b/src/app/api/doworkbench/index.tsx new file mode 100644 index 0000000..9605668 --- /dev/null +++ b/src/app/api/doworkbench/index.tsx @@ -0,0 +1,9 @@ +export { + startWorkbenchBatchReleaseAsync, + startWorkbenchBatchReleaseAsyncV2, + workbenchBatchReleaseSyncV2, + getWorkbenchBatchReleaseProgress, + workbenchScanPick, + type WorkbenchMessageResponse, + type WorkbenchScanPickBody, +} from "./actions"; diff --git a/src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts b/src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts new file mode 100644 index 0000000..a38e857 --- /dev/null +++ b/src/app/api/doworkbench/truckRoutingSummaryWorkbenchApi.ts @@ -0,0 +1,74 @@ +"use client"; + +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + +export interface WorkbenchReportOption { + label: string; + value: string; +} + +export interface WorkbenchTruckRoutingSummaryPrecheck { + unpickedOrderCount: number; + hasUnpickedOrders: boolean; +} + +export async function fetchWorkbenchTruckRoutingStoreOptions(): Promise { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/store-options`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + if (!response.ok) throw new Error(`Failed to fetch workbench store options: ${response.status}`); + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((item: any) => ({ + label: item?.label ?? item?.value ?? "", + value: item?.value ?? "", + })); +} + +export async function fetchWorkbenchTruckRoutingLaneOptions(storeId?: string): Promise { + const qs = storeId ? `?storeId=${encodeURIComponent(storeId)}` : ""; + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/lane-options${qs}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + if (!response.ok) throw new Error(`Failed to fetch workbench lane options: ${response.status}`); + const data = await response.json(); + if (!Array.isArray(data)) return []; + return data.map((item: any) => ({ + label: item?.label ?? item?.value ?? "", + value: item?.value ?? "", + })); +} + +export async function fetchWorkbenchTruckRoutingSummaryPrecheck(params: { + storeId: string; + truckLanceCode: string; + date: string; +}): Promise { + const qs = new URLSearchParams({ + storeId: params.storeId, + truckLanceCode: params.truckLanceCode, + date: params.date, + }).toString(); + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/precheck?${qs}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + } + ); + if (!response.ok) throw new Error(`Failed to precheck workbench routing summary: ${response.status}`); + const data = await response.json(); + return { + unpickedOrderCount: Number(data?.unpickedOrderCount ?? 0), + hasUnpickedOrders: Boolean(data?.hasUnpickedOrders), + }; +} diff --git a/src/app/api/doworkbench/workbenchScanPickUtils.ts b/src/app/api/doworkbench/workbenchScanPickUtils.ts new file mode 100644 index 0000000..f59d05a --- /dev/null +++ b/src/app/api/doworkbench/workbenchScanPickUtils.ts @@ -0,0 +1,14 @@ +/** + * Pure helpers for workbench scan-pick (not server actions — keep out of `actions.ts` with "use server"). + */ + +/** + * When true, the server created/reshaped lines (e.g. split pick); UI should reload hierarchical workbench data. + * Normal scans only need to patch the row from `entity`. + */ +export function workbenchScanPickResponseNeedsFullRefresh(res: { + message?: string | null; +}): boolean { + const m = (res.message ?? "").toLowerCase(); + return m.includes("next stock-out line") || m.includes("remaining quantity allocated"); +} diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 3160ef3..8624175 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -71,16 +71,26 @@ export interface SameItemLotInfo { availableQty: number; uom: string; } -export const analyzeQrCode = async (data: { - itemId: number; - stockInLineId: number; -}) => { +/** FG / inventory label modal: same-item lots use in − out − hold. */ +export const analyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => { return serverFetchJson(`${BASE_API_URL}/inventoryLotLine/analyze-qr-code`, { method: 'POST', body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); }; + +/** DO workbench label modal only: same-item lots use in − out (no hold). */ +export const workbenchAnalyzeQrCode = async (data: { itemId: number; stockInLineId: number }) => { + return serverFetchJson( + `${BASE_API_URL}/inventoryLotLine/workbench/analyze-qr-code`, + { + method: 'POST', + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; export const updateInventoryStatus = async (data: { itemId: number; lotId: number; diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index a724c42..e2805fc 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -346,6 +346,7 @@ export interface AllJoborderProductProcessInfoResponse { pickOrderStatus: string; itemCode: string; itemName: string; + bomDescription?: string | null; lotNo: string; requiredQty: number; jobOrderId: number; @@ -536,7 +537,9 @@ export interface AllJoPickOrderResponse { jobOrderType: string | null; itemId: number; itemName: string; + bomDescription?: string | null; lotNo: string | null; + planStart?: string | number[] | null; reqQty: number; uomId: number; uomName: string; @@ -606,8 +609,8 @@ export interface StockOutLineDetailResponse { availableQty: number | null; noLot: boolean; /** Workbench API: matched suggest_pick_lot qty for this SOL lot line */ - // suggestedPickQty?: number | null; - //suggestedPickLotId?: number | null; + suggestedPickQty?: number | null; + suggestedPickLotId?: number | null; } export interface LotDetailResponse { @@ -718,7 +721,7 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder }); /** JO Workbench: in−out available (matches scan-pick); stockouts include suggestedPickQty / suggestedPickLotId when SPL matches SOL lot line */ -/* + export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache( async (pickOrderId: number) => { return serverFetchJson( @@ -730,13 +733,30 @@ export const fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench = cache( ); }, ); -*/ + // NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines // immediately when navigating back from JobPickExecution. -export const fetchAllJoPickOrders = async (type?: string | null, floor?: string | null) => { +export interface FetchAllJoPickOrdersFilters { + jobOrderCode?: string | null; + pickOrderCode?: string | null; + itemName?: string | null; + bomDescription?: string | null; + planStart?: string | null; +} + +export const fetchAllJoPickOrders = async ( + type?: string | null, + floor?: string | null, + filters?: FetchAllJoPickOrdersFilters, +) => { const params = new URLSearchParams(); if (type) params.set("type", type); if (floor) params.set("floor", floor); + if (filters?.jobOrderCode) params.set("jobOrderCode", filters.jobOrderCode); + if (filters?.pickOrderCode) params.set("pickOrderCode", filters.pickOrderCode); + if (filters?.itemName) params.set("itemName", filters.itemName); + if (filters?.bomDescription) params.set("bomDescription", filters.bomDescription); + if (filters?.planStart) params.set("planStart", filters.planStart); const query = params.toString() ? `?${params.toString()}` : ""; return serverFetchJson( `${BASE_API_URL}/jo/AllJoPickOrder${query}`, diff --git a/src/app/api/jo/workbenchActions.ts b/src/app/api/jo/workbenchActions.ts new file mode 100644 index 0000000..22b7a33 --- /dev/null +++ b/src/app/api/jo/workbenchActions.ts @@ -0,0 +1,57 @@ +"use server"; + +import { cache } from "react"; +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { revalidateTag } from "next/cache"; +import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; +import type { + AssignJobOrderResponse, + CommonActionJoResponse, + SearchJoResultRequest, + SearchJoResultResponse, +} from "@/app/api/jo/actions"; + +/** Workbench-only release body (no flags — endpoint defines behavior). */ +export interface WorkbenchReleaseJoRequest { + id: number; +} + +/** Job Order Workbench search — separate URL from `/jo/getRecordByPage`. */ +export const fetchJosForWorkbench = cache(async (data?: SearchJoResultRequest) => { + const queryStr = convertObjToURLSearchParams(data); + return serverFetchJson( + `${BASE_API_URL}/jo/workbench/getRecordByPage?${queryStr}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + next: { + tags: ["jos-workbench"], + }, + }, + ); +}); + +/** Job Order Workbench release — defers stock out / SPL / SOL to first assign. */ +export const releaseJoForWorkbench = cache(async (data: WorkbenchReleaseJoRequest) => { + const response = await serverFetchJson(`${BASE_API_URL}/jo/workbench/release`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("jo"); + revalidateTag("jos-workbench"); + revalidateTag("jos"); + return response; +}); + +/** Workbench assign — primes SPL/SOL when release was deferred; use only from Jo Workbench UI. */ +export const assignJobOrderPickOrderForWorkbench = async (pickOrderId: number, userId: number) => { + return serverFetchJson( + `${BASE_API_URL}/jo/workbench/assign-job-order-pick-order/${pickOrderId}/${userId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); +}; diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index 46d63ac..01ebdcc 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -462,7 +462,7 @@ export interface LaneBtn { loadingSequence?: number | null; unassigned: number; total: number; - handlerName: string; + handlerName?: string | null; } export interface QrPickBatchSubmitRequest { @@ -694,7 +694,7 @@ export const fetchCompletedDoPickOrders = async ( /** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed`. **/ -/* + export const fetchCompletedDoPickOrdersWorkbench = async ( userId: number, searchParams?: CompletedDoPickOrderSearchParams, @@ -723,7 +723,36 @@ export const fetchCompletedDoPickOrdersWorkbench = async ( method: "GET", }); }; -*/ + +/** DO workbench: completed tickets from `delivery_order_pick_order.ticketStatus = completed` (all users). */ +export const fetchCompletedDoPickOrdersWorkbenchAll = async ( + searchParams?: CompletedDoPickOrderSearchParams, +): Promise => { + const params = new URLSearchParams(); + + if (searchParams?.deliveryNoteCode) { + params.append("deliveryNoteCode", searchParams.deliveryNoteCode); + } + if (searchParams?.shopName) { + params.append("shopName", searchParams.shopName); + } + if (searchParams?.targetDate) { + params.append("targetDate", searchParams.targetDate); + } + if (searchParams?.truckLanceCode) { + params.append("truckLanceCode", searchParams.truckLanceCode); + } + + const queryString = params.toString(); + const url = `${BASE_API_URL}/pickOrder/completed-do-pick-orders-workbench-all${ + queryString ? `?${queryString}` : "" + }`; + + return serverFetchJson(url, { + method: "GET", + }); +}; + /** 全部已完成 DO 提貨記錄(不限經手人),需後端 `/completed-do-pick-orders-all` */ export const fetchCompletedDoPickOrdersAll = async ( searchParams?: CompletedDoPickOrderSearchParams @@ -806,7 +835,7 @@ export const fetchFGPickOrdersByUserId = async (userId: number) => { /** DO workbench: FG headers from `delivery_order_pick_order`, not `do_pick_order_line`. */ -/* + export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { return serverFetchJson( `${BASE_API_URL}/pickOrder/fg-pick-orders-workbench/${userId}`, @@ -818,7 +847,7 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { }, ); }; -*/ + export const updateSuggestedLotLineId = async (suggestedPickLotId: number, newLotLineId: number) => { const response = await serverFetchJson>( `${BASE_API_URL}/suggestedPickLot/update-suggested-lot/${suggestedPickLotId}`, @@ -893,6 +922,83 @@ export const resuggestPickOrder = async (pickOrderId: number) => { return result; }; +/** + * Workbench suggest (no-hold path target). + * Current backend route is shared with legacy resuggest, but we expose a dedicated + * API name so PickOrder workbench pages can migrate independently. + */ +export const suggestPickOrderWorkbenchV2 = async (pickOrderId: number, userId: number) => { + const result = await serverFetchJson( + `${BASE_API_URL}/pickOrder/workbench/suggest-v2/${pickOrderId}`, + { + method: "POST", + body: JSON.stringify({ userId }), + headers: { "Content-Type": "application/json" }, + }, + ); + revalidateTag("pickorder"); + return result; +}; + +/** + * Workbench release V2 (no-hold): do not create stock_out at release time. + * Downstream suggestion/stock_out_line are created when assigning workbench ticket. + */ +export const releasePickOrderWorkbenchV2 = async (data: { + pickOrderIds: number[]; + assignTo: number; +}) => { + const response = await serverFetchJson( + `${BASE_API_URL}/pickOrder/workbench/release-v2`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + revalidateTag("pickorder"); + return response; +}; + +/** Consumable workbench hierarchical lots (not DO workbench). */ +export const fetchConsumableWorkbenchPickOrderLotsHierarchical = cache(async (userId: number): Promise => { + try { + const data = await serverFetchJson( + `${BASE_API_URL}/pickOrder/workbench/all-lots-hierarchical/${userId}`, + { + method: "GET", + next: { tags: ["pickorder"] }, + }, + ); + return data; + } catch (error) { + console.error("❌ Error fetching consumable workbench hierarchical lot details:", error); + return { + fgInfo: null, + pickOrders: [], + }; + } +}); + +/** + * Workbench assign: assign by delivery_order_pick_order id. + */ +export const assignPickOrderWorkbenchV2 = async (data: { + pickOrderIds: number[]; + assignTo: number; +}) => { + const response = await serverFetchJson( + `${BASE_API_URL}/pickOrder/workbench/assign-v2`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); + revalidateTag("pickorder"); + return response; +}; + export const updateStockOutLineStatus = async (data: { id: number; status: string; @@ -938,6 +1044,7 @@ export const releaseAssignedPickOrders = async (data: AssignPickOrderInputs) => revalidateTag("pickorder"); return response; }; + // Get latest group name and create it automatically export const getLatestGroupNameAndCreate = async () => { return serverFetchJson( @@ -996,9 +1103,11 @@ export const fetchPickOrderDetails = cache(async (ids: string) => { }); export interface PickOrderLotDetailResponse { lotId: number | null; // ✅ 改为可空 + stockInLineId?: number | null; lotNo: string | null; // ✅ 改为可空 expiryDate: string | null; // ✅ 改为可空 location: string | null; // ✅ 改为可空 + itemId: number | null; stockUnit: string | null; inQty: number | null; availableQty: number | null; // ✅ 改为可空 @@ -1190,15 +1299,14 @@ export const fetchAllPickOrderLotsHierarchical = cache(async (userId: number): P }; } }); - /** DO workbench: hierarchical lots where header is `delivery_order_pick_order`. */ -/* + export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: number): Promise => { try { const data = await serverFetchJson( `${BASE_API_URL}/pickOrder/all-lots-hierarchical-workbench/${userId}`, { - method: "GET", + method: 'GET', next: { tags: ["pickorder"] }, }, ); @@ -1211,7 +1319,8 @@ export const fetchAllPickOrderLotsHierarchicalWorkbench = cache(async (userId: n }; } }); -*/ + + export const fetchLotDetailsByDoPickOrderRecordId = async (doPickOrderRecordId: number): Promise<{ fgInfo: any; pickOrders: any[]; @@ -1272,7 +1381,15 @@ export const fetchPickOrderLineLotDetails = cache(async (pickOrderLineId: number ); }); - +export const fetchWorkbenchPickOrderLineDetailV2 = cache(async (pickOrderLineId: number) => { + return serverFetchJson( + `${BASE_API_URL}/pickOrder/workbench/line-detail-v2/${pickOrderLineId}`, + { + method: "GET", + next: { tags: ["pickorder"] }, + }, + ); +}); diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index d655829..22399d7 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -394,6 +394,11 @@ export interface BatchSaveApproverStockTakeAllRequest { sectionDescription?: string | null; stockTakeSections?: string | null; // 逗號字串 } +export interface BatchSaveApproverStockTakeByIdsRequest { + stockTakeId: number; + approverId: number; + recordIds: number[]; +} export const saveApproverStockTakeRecord = async ( request: SaveApproverStockTakeRecordRequest, stockTakeId: number @@ -451,6 +456,18 @@ export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSave return r }) +export const batchSaveApproverStockTakeRecordsByIds = cache(async (data: BatchSaveApproverStockTakeByIdsRequest) => { + const r = await serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsByIds`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + } + ) + return r +}) + export const updateStockTakeRecordStatusToNotMatch = async ( stockTakeRecordId: number ) => { diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index 6ec388f..1c1fa6e 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -93,8 +93,11 @@ export const serverFetch: typeof fetch = async (input, init) => { type FetchParams = Parameters; export async function serverFetchJson(...args: FetchParams) { + const url = String(args[0]); + const t0 = performance.now(); const response = await serverFetch(...args); - console.log("serverFetchJson - Status:", response.status, "URL:", args[0]); + const t1 = performance.now(); + console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); if (response.ok) { if (response.status === 204) { return response.status as T; @@ -117,7 +120,11 @@ export async function serverFetchJson(...args: FetchParams) { } export async function serverFetchString(...args: FetchParams) { - const response = await serverFetch(...args); + const url = String(args[0]); +const t0 = performance.now(); +const response = await serverFetch(...args); +const t1 = performance.now(); +console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); if (response.ok) { return response.text() as T; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index ad0aba7..4d856cd 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -37,9 +37,10 @@ const pathToLabelMap: { [path: string]: string } = { "/inventory": "Inventory", "/settings/importTesting": "Import Testing", "/do": "Delivery Order", - //"/doworkbench": "DO Workbench", - // "/doworkbench/pick": "DO Workbench pick", - // "/doworkbench/edit": "DO Workbench detail", + "/doworkbench": "DO Workbench", + "/doworkbenchsearch": "DO Workbench Search", + "/doworkbench/pick": "DO Workbench pick", + "/doworkbench/edit": "DO Workbench detail", "/pickOrder": "Pick Order", "/po": "Purchase Order", "/po/workbench": "PO Workbench", @@ -47,6 +48,7 @@ const pathToLabelMap: { [path: string]: string } = { "/jo": "Job Order", "/jo/edit": "Edit Job Order", "/jo/testing": "Job order testing", + "/jo/workbench": "Job Order Workbench", "/putAway": "Put Away", "/stockIssue": "Stock Issue", "/report": "Report", diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index ddfde5a..18a0a79 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -2,6 +2,10 @@ import { DoResult } from "@/app/api/do"; import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; +import { + startWorkbenchBatchReleaseAsyncV2, + getWorkbenchBatchReleaseProgress, +} from "@/app/api/doworkbench/actions"; import { useRouter } from "next/navigation"; import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; @@ -74,7 +78,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const [searchAllDos, setSearchAllDos] = useState([]); const [totalCount, setTotalCount] = useState(0); - + const [isWorkbench, setIsWorkbench] = useState(false); const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10, @@ -485,7 +489,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { } }, [hasSearched, currentSearchParams]); - const handleBatchRelease = useCallback(async () => { + const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { try { // 根据当前搜索条件获取所有匹配的记录(不分页) let estArrStartDate = currentSearchParams.estimatedArrivalDate; @@ -575,7 +579,14 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { if (result.isConfirmed) { try { - const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); + let startRes ; + if(isWorkbench){ + startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 }); + } + else{ + startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); + } + //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); const jobId = startRes?.entity?.jobId; if (!jobId) { @@ -596,7 +607,9 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { const timer = setInterval(async () => { try { - const p = await getBatchReleaseProgress(jobId); + const p = isWorkbench + ? await getWorkbenchBatchReleaseProgress(jobId) + : await getBatchReleaseProgress(jobId); const e = p?.entity || {}; const total = e.total ?? 0; @@ -659,14 +672,23 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} > {hasSearched && hasResults && ( - + + + {/* + */} )} diff --git a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx new file mode 100644 index 0000000..4e2301b --- /dev/null +++ b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx @@ -0,0 +1,737 @@ +"use client"; + +import { DoResult } from "@/app/api/do"; +import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo } from "@/app/api/do/actions"; +import { + startWorkbenchBatchReleaseAsyncV2, + getWorkbenchBatchReleaseProgress, +} from "@/app/api/doworkbench/actions"; + +import { useRouter } from "next/navigation"; +import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Criterion } from "../SearchBox"; +import { isEmpty, sortBy, uniqBy, upperFirst } from "lodash"; +import { arrayToDateString, arrayToDayjs } from "@/app/utils/formatUtil"; +import SearchBox from "../SearchBox/SearchBox"; +import { EditNote } from "@mui/icons-material"; +import InputDataGrid from "../InputDataGrid"; +import { CreateConsoDoInput } from "@/app/api/do/actions"; +import { TableRow } from "../InputDataGrid/InputDataGrid"; +import { + FooterPropsOverrides, + GridColDef, + GridRowModel, + GridToolbarContainer, + useGridApiRef, +} from "@mui/x-data-grid"; +import { + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, +} from "react-hook-form"; +import { Box, Button, Paper, Stack, Typography, TablePagination } from "@mui/material"; +import StyledDataGrid from "../StyledDataGrid"; +import { GridRowSelectionModel } from "@mui/x-data-grid"; +import Swal from "sweetalert2"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; + +type Props = { + filterArgs?: Record; + searchQuery?: Record; + onDeliveryOrderSearch?: () => void; + /** 明細頁路由前綴,預設 `/doworkbench`;在 `/do copy 2` 等別名頁面請傳對應 base */ + workbenchHrefBase?: string; +}; +type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; +type SearchParamNames = keyof SearchBoxInputs; + +// put all this into a new component +// ConsoDoForm +type EntryError = + | { + [field in keyof DoResult]?: string; + } + | undefined; +type DoRow = TableRow, EntryError>; + + +const DoSearchWorkbench: React.FC = ({ + filterArgs, + searchQuery, + onDeliveryOrderSearch, + workbenchHrefBase = "/doworkbench", +}) => { + const apiRef = useGridApiRef(); + + const formProps = useForm({ + defaultValues: {}, + }); + const { setValue } = formProps; + const errors = formProps.formState.errors; + + + const { t } = useTranslation("do"); + const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const currentUserId = session?.id ? parseInt(session.id) : undefined; + console.log("🔍 DoSearch - session:", session); + console.log("🔍 DoSearch - currentUserId:", currentUserId); + const [searchTimeout, setSearchTimeout] = useState(null); + /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ + const [excludedRowIds, setExcludedRowIds] = useState([]); + + const [searchAllDos, setSearchAllDos] = useState([]); + const [totalCount, setTotalCount] = useState(0); + + const [pagingController, setPagingController] = useState({ + pageNum: 1, + pageSize: 10, + }); + + const [currentSearchParams, setCurrentSearchParams] = useState({ + code: "", + status: "", + estimatedArrivalDate: "", + orderDate: "", + supplierName: "", + shopName: "", + deliveryOrderLines: "", + truckLanceCode: "", // 添加这个字段 + codeTo: "", + statusTo: "", + estimatedArrivalDateTo: "", + orderDateTo: "", + supplierNameTo: "", + shopNameTo: "", + deliveryOrderLinesTo: "", + truckLanceCodeTo: "" // 这个字段已经存在,但需要确保在类型定义中 + }); + + const [hasSearched, setHasSearched] = useState(false); + const [hasResults, setHasResults] = useState(false); + + const excludedIdSet = useMemo(() => new Set(excludedRowIds), [excludedRowIds]); + + const rowSelectionModel = useMemo(() => { + return searchAllDos + .map((r) => r.id) + .filter((id) => !excludedIdSet.has(id)); + }, [searchAllDos, excludedIdSet]); + + const applyRowSelectionChange = useCallback( + (newModel: GridRowSelectionModel) => { + const pageIds = searchAllDos.map((r) => r.id); + const selectedSet = new Set( + newModel.map((id) => (typeof id === "string" ? Number(id) : id)), + ); + setExcludedRowIds((prev) => { + const next = new Set(prev); + for (const id of pageIds) { + next.delete(id); + } + for (const id of pageIds) { + if (!selectedSet.has(id)) { + next.add(id); + } + } + return Array.from(next); + }); + setValue("ids", newModel); + }, + [searchAllDos, setValue], + ); + + // 当搜索条件变化时,重置到第一页 + useEffect(() => { + setPagingController(p => ({ + ...p, + pageNum: 1, + })); + }, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]); + + + const searchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Code"), paramName: "code", type: "text" }, + { label: t("Shop Name"), paramName: "shopName", type: "text" }, + { label: t("Truck Lance Code"), paramName: "truckLanceCode", type: "text" }, + { + label: t("Estimated Arrival"), + paramName: "estimatedArrivalDate", + type: "date", + }, + { + label: t("Status"), + paramName: "status", + type: "autocomplete", + options:[ + {label: t('Pending'), value: 'pending'}, + {label: t('Receiving'), value: 'receiving'}, + {label: t('Completed'), value: 'completed'} + ] + } + ], + [t], + ); + + const onReset = useCallback(async () => { + try { + setSearchAllDos([]); + setTotalCount(0); + setHasSearched(false); + setHasResults(false); + setExcludedRowIds([]); + setPagingController({ pageNum: 1, pageSize: 10 }); + } + catch (error) { + console.error("Error: ", error); + setSearchAllDos([]); + setTotalCount(0); + } + }, []); + + const onDetailClick = useCallback( + (doResult: DoResult) => { + if (typeof window !== 'undefined') { + sessionStorage.setItem('doSearchParams', JSON.stringify(currentSearchParams)); + } + const base = workbenchHrefBase.replace(/\/$/, ""); + router.push(`${base}/edit?id=${doResult.id}`); + }, + [router, currentSearchParams, workbenchHrefBase], + ); + + const validationTest = useCallback( + ( + newRow: GridRowModel, + ): EntryError => { + const error: EntryError = {}; + console.log(newRow); + return Object.keys(error).length > 0 ? error : undefined; + }, + [], + ); + + const columns = useMemo( + () => [ + { + field: "id", + headerName: t("Details"), + width: 100, + renderCell: (params) => ( + + ), + }, + { + field: "code", + headerName: t("code"), + flex: 1.5, + }, + { + field: "shopName", + headerName: t("Shop Name"), + flex: 1, + }, + { + field: "supplierName", + headerName: t("Supplier Name"), + flex: 1, + }, + { + field: "truckLanceCode", + headerName: t("Truck Lance Code"), + flex: 1, + }, + { + field: "orderDate", + headerName: t("Order Date"), + flex: 1, + renderCell: (params) => { + return params.row.orderDate + ? arrayToDateString(params.row.orderDate) + : "N/A"; + }, + }, + { + field: "estimatedArrivalDate", + headerName: t("Estimated Arrival"), + flex: 1, + renderCell: (params) => { + return params.row.estimatedArrivalDate + ? arrayToDateString(params.row.estimatedArrivalDate) + : "N/A"; + }, + }, + { + field: "status", + headerName: t("Status"), + flex: 1, + renderCell: (params) => { + return t(upperFirst(params.row.status)); + }, + }, + ], + [t, arrayToDateString, onDetailClick], + ); + + const onSubmit = useCallback>( + async (data, event) => { + const hasErrors = false; + console.log(errors); + }, + [errors], + ); + const onSubmitError = useCallback>( + (errors) => {}, + [], + ); + + //SEARCH FUNCTION +const handleSearch = useCallback(async (query: SearchBoxInputs) => { + try { + setCurrentSearchParams(query); + + let estArrStartDate = query.estimatedArrivalDate; + const time = "T00:00:00"; + + if(estArrStartDate != ""){ + estArrStartDate = query.estimatedArrivalDate + time; + } + + let status = ""; + if(query.status == "All"){ + status = ""; + } + else{ + status = query.status; + } + + // 调用新的 API,传入分页参数和 truckLanceCode + const response = await fetchDoSearch( + query.code || "", + query.shopName || "", + status, + "", // orderStartDate - 不再使用 + "", // orderEndDate - 不再使用 + estArrStartDate, + "", // estArrEndDate - 不再使用 + pagingController.pageNum, // 传入当前页码 + pagingController.pageSize, // 传入每页大小 + query.truckLanceCode || "" // 添加 truckLanceCode 参数 + ); + + setSearchAllDos(response.records); + setTotalCount(response.total); // 设置总记录数 + setHasSearched(true); + setHasResults(response.records.length > 0); + setExcludedRowIds([]); + + } catch (error) { + console.error("Error: ", error); + setSearchAllDos([]); + setTotalCount(0); + setHasSearched(true); + setHasResults(false); + setExcludedRowIds([]); + } +}, [pagingController]); + + useEffect(() => { + if (typeof window !== 'undefined') { + const savedSearchParams = sessionStorage.getItem('doSearchParams'); + + if (savedSearchParams) { + try { + const params = JSON.parse(savedSearchParams); + setCurrentSearchParams(params); + + // 自动使用保存的搜索条件重新搜索,获取最新数据 + const timer = setTimeout(async () => { + await handleSearch(params); + // 搜索完成后,清除 sessionStorage + if (typeof window !== 'undefined') { + sessionStorage.removeItem('doSearchParams'); + sessionStorage.removeItem('doSearchResults'); + sessionStorage.removeItem('doSearchHasSearched'); + } + }, 100); + + return () => clearTimeout(timer); + } catch (e) { + console.error('Error restoring search state:', e); + // 如果出错,也清除 sessionStorage + if (typeof window !== 'undefined') { + sessionStorage.removeItem('doSearchParams'); + sessionStorage.removeItem('doSearchResults'); + sessionStorage.removeItem('doSearchHasSearched'); + } + } + } + } + }, [handleSearch]); + + const debouncedSearch = useCallback((query: SearchBoxInputs) => { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + + const timeout = setTimeout(() => { + handleSearch(query); + }, 300); + + setSearchTimeout(timeout); + }, [handleSearch, searchTimeout]); + + // 分页变化时重新搜索 + const handlePageChange = useCallback((event: unknown, newPage: number) => { + const newPagingController = { + ...pagingController, + pageNum: newPage + 1, + }; + setPagingController(newPagingController); + // 如果已经搜索过,重新搜索 + if (hasSearched && currentSearchParams) { + // 使用新的分页参数重新搜索 + const searchWithNewPage = async () => { + try { + let estArrStartDate = currentSearchParams.estimatedArrivalDate; + const time = "T00:00:00"; + + if(estArrStartDate != ""){ + estArrStartDate = currentSearchParams.estimatedArrivalDate + time; + } + + let status = ""; + if(currentSearchParams.status == "All"){ + status = ""; + } + else{ + status = currentSearchParams.status; + } + + const response = await fetchDoSearch( + currentSearchParams.code || "", + currentSearchParams.shopName || "", + status, + "", + "", + estArrStartDate, + "", + newPagingController.pageNum, + newPagingController.pageSize, + currentSearchParams.truckLanceCode || "" // 添加这个参数 + ); + + setSearchAllDos(response.records); + setTotalCount(response.total); + } catch (error) { + console.error("Error: ", error); + } + }; + searchWithNewPage(); + } + }, [pagingController, hasSearched, currentSearchParams]); + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + const newPagingController = { + pageNum: 1, // 改变每页大小时重置到第一页 + pageSize: newPageSize, + }; + setPagingController(newPagingController); + // 如果已经搜索过,重新搜索 + if (hasSearched && currentSearchParams) { + const searchWithNewPageSize = async () => { + try { + let estArrStartDate = currentSearchParams.estimatedArrivalDate; + const time = "T00:00:00"; + + if(estArrStartDate != ""){ + estArrStartDate = currentSearchParams.estimatedArrivalDate + time; + } + + let status = ""; + if(currentSearchParams.status == "All"){ + status = ""; + } + else{ + status = currentSearchParams.status; + } + + const response = await fetchDoSearch( + currentSearchParams.code || "", + currentSearchParams.shopName || "", + status, + "", + "", + estArrStartDate, + "", + 1, // 重置到第一页 + newPageSize, + currentSearchParams.truckLanceCode || "" // 添加这个参数 + ); + + setSearchAllDos(response.records); + setTotalCount(response.total); + } catch (error) { + console.error("Error: ", error); + } + }; + searchWithNewPageSize(); + } + }, [hasSearched, currentSearchParams]); + + const handleBatchRelease = useCallback(async () => { + try { + // 根据当前搜索条件获取所有匹配的记录(不分页) + let estArrStartDate = currentSearchParams.estimatedArrivalDate; + const time = "T00:00:00"; + + if(estArrStartDate != ""){ + estArrStartDate = currentSearchParams.estimatedArrivalDate + time; + } + + let status = ""; + if(currentSearchParams.status == "All"){ + status = ""; + } + else{ + status = currentSearchParams.status; + } + + // 显示加载提示 + const loadingSwal = Swal.fire({ + title: t("Loading"), + text: t("Fetching all matching records..."), + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: () => { + Swal.showLoading(); + } + }); + + // 获取所有匹配的记录 + const allMatchingDos = await fetchAllDoSearch( + currentSearchParams.code || "", + currentSearchParams.shopName || "", + status, + estArrStartDate, + currentSearchParams.truckLanceCode || "" + ); + + Swal.close(); + + if (allMatchingDos.length === 0) { + await Swal.fire({ + icon: "warning", + title: t("No Records"), + text: t("No matching records found for batch release."), + confirmButtonText: t("OK") + }); + return; + } + + const idsToRelease = allMatchingDos + .map((d) => d.id) + .filter((id) => !excludedIdSet.has(id)); + + if (idsToRelease.length === 0) { + await Swal.fire({ + icon: "warning", + title: t("No Records"), + text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."), + confirmButtonText: t("OK"), + }); + return; + } + + // 显示确认对话框 + const result = await Swal.fire({ + icon: "question", + title: t("Batch Release"), + html: ` +
+

${t("Selected Shop(s): ")}${idsToRelease.length}

+

+ + ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} + ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} + ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} + ${status ? `${t("Status")}: ${status} ` : ""} +

+
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438" + }); + + if (result.isConfirmed) { + try { + const startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, userId: currentUserId ?? 1 }); + const startEntity = startRes?.entity as { jobId?: string } | undefined; + const jobId = startEntity?.jobId; + + if (!jobId) { + await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") }); + return; + } + + const progressSwal = Swal.fire({ + title: t("Releasing"), + text: "0% (0 / 0)", + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: () => { + Swal.showLoading(); + } + }); + + const timer = setInterval(async () => { + try { + const p = await getWorkbenchBatchReleaseProgress(jobId); + + const e = (p?.entity || {}) as { + total?: number; + finished?: number; + running?: boolean; + }; + const total = e.total ?? 0; + const finished = e.finished ?? 0; + const percentage = total > 0 ? Math.round((finished / total) * 100) : 0; + + const textContent = document.querySelector('.swal2-html-container'); + if (textContent) { + textContent.textContent = `${percentage}% (${finished} / ${total})`; + } + + if (p.code === "FINISHED" || e.running === false) { + clearInterval(timer); + await new Promise(resolve => setTimeout(resolve, 500)); + Swal.close(); + + await Swal.fire({ + icon: "success", + title: t("Completed"), + text: t("Batch release completed successfully."), + confirmButtonText: t("Confirm"), + confirmButtonColor: "#8dba00" + }); + + if (currentSearchParams && Object.keys(currentSearchParams).length > 0) { + await handleSearch(currentSearchParams); + } + } + } catch (err) { + console.error("progress poll error:", err); + } + }, 800); + } catch (error) { + console.error("Batch release error:", error); + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("An error occurred during batch release"), + confirmButtonText: t("OK") + }); + } + } + } catch (error) { + console.error("Error fetching all matching records:", error); + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("Failed to fetch matching records"), + confirmButtonText: t("OK") + }); + } + }, [t, currentUserId, currentSearchParams, handleSearch, excludedIdSet]); + + return ( + <> + + + {hasSearched && hasResults && ( + + + + )} + + + + + + + + + + + + ); +}; + + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some entries!")} + + ); +}; + +export default DoSearchWorkbench; \ No newline at end of file diff --git a/src/components/DoSearchWorkbench/index.ts b/src/components/DoSearchWorkbench/index.ts new file mode 100644 index 0000000..92cc1c0 --- /dev/null +++ b/src/components/DoSearchWorkbench/index.ts @@ -0,0 +1 @@ +export { default } from "./DoSearchWorkbench"; diff --git a/src/components/DoWorkbench/DoWorkbenchPickShell.tsx b/src/components/DoWorkbench/DoWorkbenchPickShell.tsx new file mode 100644 index 0000000..01bc58d --- /dev/null +++ b/src/components/DoWorkbench/DoWorkbenchPickShell.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Box, CircularProgress } from "@mui/material"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { + fetchAllPickOrderLotsHierarchicalWorkbench, +} from "@/app/api/pickOrder/actions"; +import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; +import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail"; + +/** + * FG workbench: 未指派顯示樓層/車線指派;已指派顯示揀貨明細(workbench API)。 + */ +const DoWorkbenchPickShell: React.FC = () => { + const { data: session, status } = useSession() as { + data: SessionWithTokens | null; + status: "loading" | "authenticated" | "unauthenticated"; + }; + const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; + const [showDetail, setShowDetail] = useState(false); + const [viewLoading, setViewLoading] = useState(true); + const filterArgs = useMemo(() => ({}), []); + + const refreshWorkbenchView = useCallback(async () => { + if (!currentUserId) { + setShowDetail(false); + setViewLoading(false); + return; + } + setViewLoading(true); + try { + const data = await fetchAllPickOrderLotsHierarchicalWorkbench(currentUserId); + const ticketStatus = String(data?.ticketStatus ?? "").trim().toLowerCase(); + const hasDetailData = Boolean(data?.fgInfo && Array.isArray(data?.pickOrders) && data.pickOrders.length > 0); + const shouldShowDetail = ticketStatus !== "completed" && (ticketStatus.length > 0 || hasDetailData); + setShowDetail(shouldShowDetail); + } catch { + setShowDetail(false); + } finally { + setViewLoading(false); + } + }, [currentUserId]); + + useEffect(() => { + if (status === "loading") return; + if (status !== "authenticated" || !currentUserId) { + setViewLoading(false); + setShowDetail(false); + return; + } + void refreshWorkbenchView(); + }, [status, currentUserId, refreshWorkbenchView]); + + useEffect(() => { + const onAssigned = () => { + void refreshWorkbenchView(); + }; + window.addEventListener("pickOrderAssigned", onAssigned); + return () => window.removeEventListener("pickOrderAssigned", onAssigned); + }, [refreshWorkbenchView]); + + const onWorkbenchHierarchyEmpty = useCallback(() => { + void refreshWorkbenchView(); + }, [refreshWorkbenchView]); + + if (status === "loading") { + return ( + + + + ); + } + + const showAssignmentSpinner = viewLoading; + + return ( + + {showAssignmentSpinner ? ( + + + + ) : !showDetail ? ( + void refreshWorkbenchView()} /> + ) : ( + + )} + + ); +}; + +export default DoWorkbenchPickShell; diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx new file mode 100644 index 0000000..83ef702 --- /dev/null +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { Autocomplete, Box, Tab, Tabs, TextField, Typography } from "@mui/material"; +import React from "react"; +import DoWorkbenchPickShell from "./DoWorkbenchPickShell"; +import type { PrinterCombo } from "@/app/api/settings/printer"; +import GoodPickExecutionWorkbenchRecord from "./GoodPickExecutionWorkbenchRecord"; +import { useTranslation } from "react-i18next"; +import WorkbenchTicketReleaseTableTab from "./WorkbenchTicketReleaseTable"; +import { Stack } from "@mui/system"; +import Swal from "sweetalert2"; +import { printDNWorkbench } from "@/app/api/do/actions"; +import { fetchWorkbenchReleasedDoPickOrdersForSelectionToday } from "@/app/api/doworkbench/actions"; +import { Button } from "@mui/material"; +import FinishedGoodCartonDashboardTab from "../FinishedGoodSearch/FinishedGoodCartonDashboardTab"; +import TruckRoutingSummaryTabWorkbench from "./TruckRoutingSummaryTabWorkbench"; +type Props = { + defaultTabIndex?: 0 | 1; + printerCombo?: PrinterCombo[]; +}; + +function TabPanel(props: { value: number; index: number; children: React.ReactNode }) { + const { value, index, children } = props; + if (value !== index) return null; + return {children}; +} + +const DoWorkbenchTabs: React.FC = ({ defaultTabIndex = 0, printerCombo = [] }) => { + const [tab, setTab] = React.useState(defaultTabIndex); + const [a4Printer, setA4Printer] = React.useState(null); + const [labelPrinter, setLabelPrinter] = React.useState(null); + const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); + const { t } = useTranslation( ); + const a4Printers = React.useMemo( + () => (printerCombo || []).filter((printer) => printer.type === "A4"), + [printerCombo], + ); + const labelPrinters = React.useMemo( + () => (printerCombo || []).filter((printer) => printer.type === "Label"), + [printerCombo], + ); + + const fetchReleasedOrderCount = React.useCallback(async () => { + try { + const releasedOrders = await fetchWorkbenchReleasedDoPickOrdersForSelectionToday(); + setReleasedOrderCount(releasedOrders.length); + } catch (error) { + console.error("Error fetching workbench released order count:", error); + setReleasedOrderCount(0); + } + }, []); + + React.useEffect(() => { + void fetchReleasedOrderCount(); + }, [fetchReleasedOrderCount]); + + const handleAllDraft = React.useCallback(async () => { + try { + if (!a4Printer) { + await Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a printer first"), + showConfirmButton: false, + timer: 1500, + }); + return; + } + + const releasedOrders = await fetchWorkbenchReleasedDoPickOrdersForSelectionToday(); + if (releasedOrders.length === 0) { + await Swal.fire({ + title: "", + text: t("No released pick order records found."), + icon: "info", + }); + return; + } + + const confirmResult = await Swal.fire({ + title: t("Batch Print"), + text: `${t("Confirm print: (")}${releasedOrders.length}${t("piece(s))")}`, + icon: "question", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + }); + if (!confirmResult.isConfirmed) return; + + Swal.fire({ + title: t("Printing..."), + text: t("Please wait..."), + allowOutsideClick: false, + allowEscapeKey: false, + didOpen: () => Swal.showLoading(), + }); + + for (const order of releasedOrders) { + const printRequest = { + printerId: a4Printer.id, + printQty: 1, + isDraft: true, + numOfCarton: 0, + deliveryOrderPickOrderId: order.id, + }; + const response = await printDNWorkbench(printRequest); + if (!response.success) { + console.error(`Workbench print draft failed for deliveryOrderPickOrderId ${order.id}:`, response.message); + } + } + + Swal.close(); + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + await fetchReleasedOrderCount(); + } catch (error) { + Swal.close(); + console.error("Error in workbench handleAllDraft:", error); + await Swal.fire({ + icon: "error", + text: t("An error occurred during batch print"), + }); + } + }, [a4Printer, t, fetchReleasedOrderCount]); + + return ( + + + + {t("A4 Printer")}: + + option.name || option.label || option.code || `Printer ${option.id}`} + value={a4Printer} + onChange={(_, newValue) => setA4Printer(newValue)} + sx={{ minWidth: 200 }} + size="small" + renderInput={(params) => ( + + )} + /> + + {t("Label Printer")}: + + option.name || option.label || option.code || `Printer ${option.id}`} + value={labelPrinter} + onChange={(_, newValue) => setLabelPrinter(newValue)} + sx={{ minWidth: 200 }} + size="small" + renderInput={(params) => ( + + )} + /> + + + setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default DoWorkbenchTabs; + diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx new file mode 100644 index 0000000..7c2e9fd --- /dev/null +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -0,0 +1,659 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + Card, + CardActions, + CardContent, + Chip, + CircularProgress, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { PrinterCombo } from "@/app/api/settings/printer"; +import { useTranslation } from "react-i18next"; +import Swal from "sweetalert2"; +import dayjs from "dayjs"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { + CompletedDoPickOrderResponse, + fetchCompletedDoPickOrdersWorkbench, + fetchCompletedDoPickOrdersWorkbenchAll, +} from "@/app/api/pickOrder/actions"; +import { printDNWorkbench, printDNLabelsWorkbench, printDNLabelsReprintWorkbench } from "@/app/api/do/actions"; +import { fetchWorkbenchCompletedLotDetails } from "@/app/api/doworkbench/actions"; +import SearchBox, { Criterion } from "../SearchBox"; + +type Props = { + printerCombo: PrinterCombo[]; + listScope?: "mine" | "all"; + a4Printer: PrinterCombo | null; + labelPrinter: PrinterCombo | null; +}; + +const GoodPickExecutionWorkbenchRecord: React.FC = ({ + printerCombo, + listScope = "mine", + a4Printer, + labelPrinter, +}) => { + const { t } = useTranslation("pickOrder"); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; + + const [loading, setLoading] = useState(false); + const [records, setRecords] = useState([]); + const [searchQuery, setSearchQuery] = useState>({ + targetDate: dayjs().format("YYYY-MM-DD"), + }); + const [showDetailView, setShowDetailView] = useState(false); + const [selectedRecord, setSelectedRecord] = useState(null); + const [detailLotData, setDetailLotData] = useState([]); + + const loadData = useCallback(async (searchParams?: { + targetDate?: string; + shopName?: string; + deliveryNoteCode?: string; + truckLanceCode?: string; + }) => { + setLoading(true); + try { + const data = + listScope === "all" + ? await fetchCompletedDoPickOrdersWorkbenchAll(searchParams) + : currentUserId + ? await fetchCompletedDoPickOrdersWorkbench(currentUserId, searchParams) + : []; + setRecords(data || []); + } catch (e) { + console.error("Failed to load workbench completed records:", e); + setRecords([]); + } finally { + setLoading(false); + } + }, [currentUserId, listScope]); + + useEffect(() => { + void loadData({ targetDate: dayjs().format("YYYY-MM-DD") }); + }, [loadData]); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { + label: t("Delivery Note Code"), + paramName: "deliveryNoteCode", + type: "text", + }, + { + label: t("Shop Name"), + paramName: "shopName", + type: "text", + }, + { + label: t("Truck Lance Code"), + paramName: "truckLanceCode", + type: "text", + }, + { + label: t("Target Date"), + paramName: "targetDate", + type: "date", + defaultValue: dayjs().format("YYYY-MM-DD"), + }, + ], + [t], + ); + + const handleSearch = useCallback((query: Record) => { + setSearchQuery({ ...query }); + void loadData({ + targetDate: query.targetDate || undefined, + shopName: query.shopName || undefined, + deliveryNoteCode: query.deliveryNoteCode || undefined, + truckLanceCode: query.truckLanceCode || undefined, + }); + }, [loadData]); + + const handleSearchReset = useCallback(() => { + const today = dayjs().format("YYYY-MM-DD"); + const resetQuery = { targetDate: today }; + setSearchQuery(resetQuery); + void loadData({ targetDate: today }); + }, [loadData]); + + const searchDateDisplay = useMemo(() => { + const raw = searchQuery.targetDate; + if (raw && String(raw).trim() !== "") { + const d = dayjs(raw); + return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : t("All dates"); + } + return t("All dates"); + }, [searchQuery.targetDate, t]); + + const askNumOfCarton = useCallback(async () => { + const result = await Swal.fire({ + title: t("Enter the number of cartons: "), + icon: "info", + input: "number", + inputPlaceholder: t("Number of cartons"), + inputAttributes: { + min: "1", + step: "1", + }, + inputValidator: (value) => { + if (!value) return t("You need to enter a number"); + if (parseInt(value, 10) < 1) return t("Number must be at least 1"); + return null; + }, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + }); + if (!result.isConfirmed) return null; + return parseInt(result.value, 10); + }, [t]); + + const handlePrintDN = useCallback( + async (recordId: number) => { + if (!a4Printer) { + await Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a printer first"), + showConfirmButton: false, + timer: 1500, + }); + return; + } + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const response = await printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }); + if (response.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + await loadData(); + } + }, + [a4Printer, askNumOfCarton, loadData, t], + ); + + const handlePrintLabel = useCallback( + async (recordId: number) => { + if (!labelPrinter) { + await Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a label printer first"), + showConfirmButton: false, + timer: 1500, + }); + return; + } + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const response = await printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }); + if (response.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + await loadData(); + } + }, + [askNumOfCarton, labelPrinter, loadData, t], + ); + + const handlePrintBoth = useCallback( + async (recordId: number) => { + if (!a4Printer || !labelPrinter) { + await Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a printer first"), + showConfirmButton: false, + timer: 1500, + }); + return; + } + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const [labelRes, dnRes] = await Promise.all([ + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ]); + if (labelRes.success && dnRes.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + await loadData(); + } + }, + [a4Printer, askNumOfCarton, labelPrinter, loadData, t], + ); + const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { + if (!labelPrinter) { + Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a label printer first"), + showConfirmButton: false, + timer: 1500 + }); + return; + } + + const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); + const result = await Swal.fire({ + title: t("Reprint DN Label"), + html: ` +
+
+ + +
+
+ + +
+
+ + +
+
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + focusConfirm: false, + preConfirm: () => { + const fromCarton = Number((document.getElementById("swal-from-carton") as HTMLInputElement | null)?.value || "0"); + const toCarton = Number((document.getElementById("swal-to-carton") as HTMLInputElement | null)?.value || "0"); + const totalCartonsOnShipment = Number((document.getElementById("swal-total-carton") as HTMLInputElement | null)?.value || "0"); + + if (!Number.isInteger(fromCarton) || fromCarton < 1) { + Swal.showValidationMessage(t("From carton must be at least 1")); + return null; + } + if (!Number.isInteger(toCarton) || toCarton < fromCarton) { + Swal.showValidationMessage(t("To carton must be greater than or equal to from carton")); + return null; + } + if (!Number.isInteger(totalCartonsOnShipment) || totalCartonsOnShipment < 1) { + Swal.showValidationMessage(t("Total cartons on shipment must be at least 1")); + return null; + } + if (toCarton > totalCartonsOnShipment) { + Swal.showValidationMessage(t("To carton cannot be greater than total cartons on shipment")); + return null; + } + + return { + fromCarton, + toCarton, + totalCartonsOnShipment, + }; + } + }); + + if (!result.isConfirmed || !result.value) { + return; + } + + try { + const response = await printDNLabelsReprintWorkbench({ + deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, + printerId: labelPrinter.id, + printQty: 1, + fromCarton: result.value.fromCarton, + toCarton: result.value.toCarton, + totalCartonsOnShipment: result.value.totalCartonsOnShipment, + }); + + if (response.success) { + Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500 + }); + } else { + console.error("Reprint failed:", response.message); + } + } catch (error) { + console.error("reprint error: ", error); + } + }, [labelPrinter, t]); + + const handleDetailClick = useCallback( + async (record: CompletedDoPickOrderResponse) => { + setSelectedRecord(record); + setShowDetailView(true); + try { + const hierarchicalData = await fetchWorkbenchCompletedLotDetails(record.doPickOrderRecordId); + const flatLotData: any[] = []; + + if (hierarchicalData?.pickOrders?.length > 0) { + const toProc = (s?: string) => { + if (!s) return "pending"; + const v = s.toLowerCase(); + if (v === "completed" || v === "complete") return "completed"; + if (v === "rejected") return "rejected"; + if (v === "partially_completed") return "pending"; + return "pending"; + }; + + hierarchicalData.pickOrders.forEach((po: any) => { + po.pickOrderLines?.forEach((line: any) => { + const lineRequiredQty = Number(line.requiredQty ?? line.qty ?? 0); + const lineStockouts = line.stockouts || []; + const lots = line.lots || []; + + if (lots.length > 0) { + lots.forEach((lot: any) => { + const sos = lineStockouts.filter((so: any) => (so.lotId ?? null) === (lot.id ?? null)); + if (sos.length > 0) { + sos.forEach((so: any) => { + flatLotData.push({ + pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + itemCode: line.item?.code, + itemName: line.item?.name, + lotNo: so.lotNo || lot.lotNo, + location: so.location || lot.location, + deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, + requiredQty: lineRequiredQty, + actualPickQty: so.qty ?? lot.actualPickQty ?? 0, + processingStatus: toProc(so.status), + stockOutLineStatus: so.status, + noLot: so.noLot === true, + }); + }); + } else { + flatLotData.push({ + pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + itemCode: line.item?.code, + itemName: line.item?.name, + lotNo: lot.lotNo, + location: lot.location, + deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, + requiredQty: lot.requiredQty, + actualPickQty: lot.actualPickQty ?? 0, + processingStatus: lot.processingStatus || "pending", + stockOutLineStatus: lot.stockOutLineStatus || "pending", + noLot: false, + }); + } + }); + } else if (lineStockouts.length > 0) { + lineStockouts.forEach((so: any) => { + flatLotData.push({ + pickOrderCode: po.pickOrderCodes?.[0] || po.pickOrderCode, + itemCode: line.item?.code, + itemName: line.item?.name, + lotNo: so.lotNo || "", + location: so.location || "", + deliveryOrderCode: po.deliveryOrderCodes?.[0] || po.deliveryOrderCode, + requiredQty: line.requiredQty ?? 0, + actualPickQty: so.qty ?? 0, + processingStatus: toProc(so.status), + stockOutLineStatus: so.status, + noLot: so.noLot === true, + }); + }); + } + }); + }); + } + setDetailLotData(flatLotData); + } catch (e) { + console.error("Failed to load completed lot details:", e); + setDetailLotData([]); + } + }, + [], + ); + + const handleBackToList = useCallback(() => { + setShowDetailView(false); + setSelectedRecord(null); + setDetailLotData([]); + }, []); + + if (showDetailView && selectedRecord) { + return ( + + + + + {t("Pick Order Details")}: {selectedRecord.ticketNo} + + + + + + + {t("Shop Name")}: {selectedRecord.shopName} + + + {t("Store ID")}: {selectedRecord.storeId} + + + {t("Ticket No.")}: {selectedRecord.ticketNo} + + + {t("Handler")}:{" "} + {selectedRecord.handlerName?.trim() ? selectedRecord.handlerName : "—"} + + + {t("Truck Lance Code")}: {selectedRecord.truckLanceCode} + + + {t("Completed Date")}:{" "} + {selectedRecord.completedDate ? dayjs(selectedRecord.completedDate).format(OUTPUT_DATE_FORMAT) : "-"} + + + + + {detailLotData.length === 0 ? ( + + + {t("No lot details found for this order")} + + + ) : ( + + {Object.entries( + detailLotData.reduce((acc: any, lot: any) => { + const key = lot.pickOrderCode || "Unknown"; + if (!acc[key]) acc[key] = { lots: [], deliveryOrderCode: lot.deliveryOrderCode || "N/A" }; + acc[key].lots.push(lot); + return acc; + }, {}), + ).map(([pickOrderCode, data]: [string, any]) => ( + + }> + + {t("Pick Order")}: {pickOrderCode} ({data.lots.length} {t("items")}){" | "} + {t("Delivery Order")}: {data.deliveryOrderCode} + + + + + + + + {t("Index")} + {t("Item Code")} + {t("Item Name")} + {t("Lot No")} + {t("Location")} + {t("Required Qty")} + {t("Actual Pick Qty")} + {t("Status")} + + + + {data.lots.map((lot: any, index: number) => ( + + {index + 1} + {lot.itemCode || "N/A"} + {lot.itemName || "N/A"} + {lot.lotNo || "N/A"} + {lot.location || "N/A"} + {lot.requiredQty || 0} + {lot.actualPickQty || 0} + + + + + ))} + +
+
+
+
+ ))} +
+ )} +
+ ); + } + + return ( + + + + + + + {t("Search date")}: {searchDateDisplay} | {t("Completed DO pick orders: ")} {records.length} + + + + {loading ? ( + + + + ) : records.length === 0 ? ( + + + {t("No completed DO pick orders found")} + + + ) : ( + + {records.map((row) => ( + + + + + {row.deliveryNoteCode || "-"} + + {row.shopName} + + + {t("Completed")}:{" "} + {row.completedDate ? dayjs(row.completedDate).format(OUTPUT_DATE_FORMAT) : "-"} + + + {t("Ticket No.")}: {row.ticketNo || "-"} + + + + + + + + + + + + + + ))} + + )} + + ); +}; + +export default GoodPickExecutionWorkbenchRecord; diff --git a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx new file mode 100644 index 0000000..3325a53 --- /dev/null +++ b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { + WorkbenchReportOption, + fetchWorkbenchTruckRoutingLaneOptions, + fetchWorkbenchTruckRoutingStoreOptions, + fetchWorkbenchTruckRoutingSummaryPrecheck, +} from "@/app/api/doworkbench/truckRoutingSummaryWorkbenchApi"; +import { + FEATURE_USAGE, + FEATURE_USAGE_ACTION, + logFeatureUsage, +} from "@/lib/featureUsageLog"; + +const TruckRoutingSummaryTabWorkbench: React.FC = () => { + const [storeOptions, setStoreOptions] = useState([]); + const [laneOptions, setLaneOptions] = useState([]); + const [storeId, setStoreId] = useState(""); + const [truckLanceCode, setTruckLanceCode] = useState(""); + const [date, setDate] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + fetchWorkbenchTruckRoutingStoreOptions() + .then(setStoreOptions) + .catch((err) => console.error("Failed to load workbench store options", err)); + }, []); + + const onStoreChange = async (value: string) => { + setStoreId(value); + setTruckLanceCode(""); + try { + const lanes = await fetchWorkbenchTruckRoutingLaneOptions(value); + setLaneOptions(lanes); + } catch (error) { + console.error("Failed to load workbench lane options", error); + setLaneOptions([]); + } + }; + + const canDownload = storeId && truckLanceCode && date && !loading; + + const onDownload = async () => { + if (!canDownload) return; + try { + const precheck = await fetchWorkbenchTruckRoutingSummaryPrecheck({ + storeId, + truckLanceCode, + date, + }); + if (precheck.hasUnpickedOrders) { + const confirmed = window.confirm( + `此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?` + ); + if (!confirmed) return; + } + + setLoading(true); + const qs = new URLSearchParams({ + storeId, + truckLanceCode, + date, + }).toString(); + const url = `${NEXT_PUBLIC_API_URL}/doPickOrder/workbench/truck-routing-summary/print?${qs}`; + const response = await clientAuthFetch(url, { + method: "GET", + headers: { Accept: "application/pdf" }, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + link.setAttribute("download", "TruckRoutingSummary.pdf"); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(downloadUrl); + logFeatureUsage( + FEATURE_USAGE.TRUCK_ROUTING_SUMMARY, + FEATURE_USAGE_ACTION.DOWNLOAD, + `workbench-pdf:${storeId}:${truckLanceCode}:${date}`, + ); + } catch (error) { + console.error("Failed to download Workbench Truck Routing Summary", error); + alert("下載 Workbench 送貨路線摘要失敗,請稍後再試。"); + } finally { + setLoading(false); + } + }; + + return ( + + + 送貨路線摘要 (Workbench) + + + onStoreChange(e.target.value)} + > + {storeOptions.map((opt) => ( + + {opt.label} + + ))} + + setTruckLanceCode(e.target.value)} + disabled={!storeId} + > + {laneOptions.map((opt) => ( + + {opt.label} + + ))} + + setDate(e.target.value)} + /> + + + + ); +}; + +export default TruckRoutingSummaryTabWorkbench; diff --git a/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx new file mode 100644 index 0000000..5356b20 --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import type { StoreLaneSummary, LaneRow, LaneBtn } from "@/app/api/pickOrder/actions"; +import { + assignByDeliveryOrderPickOrderId, + assignWorkbenchByLane, + fetchWorkbenchReleasedDoPickOrdersForSelection, + fetchWorkbenchReleasedDoPickOrdersForSelectionToday, + fetchWorkbenchStoreLaneSummary, +} from "@/app/api/doworkbench/actions"; +import Swal from "sweetalert2"; +import dayjs from "dayjs"; +import ReleasedDoPickOrderSelectModal from "@/components/FinishedGoodSearch/ReleasedDoPickOrderSelectModal"; + +interface Props { + onPickOrderAssigned?: () => void; + onSwitchToDetailTab?: () => void; + initialReleaseType?: string; +} + +type LaneSlot4F = { truckDepartureTime: string; lane: LaneBtn }; +type TruckGroup4F = { truckLanceCode: string; slots: (LaneSlot4F & { sequenceIndex: number })[] }; + +const WorkbenchFloorLanePanel: React.FC = ({ onPickOrderAssigned, onSwitchToDetailTab, initialReleaseType = "batch" }) => { + const { t } = useTranslation("pickOrder"); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const currentUserId = session?.id ? parseInt(session.id) : undefined; + const [selectedStore, setSelectedStore] = useState("2/F"); + const [selectedTruck, setSelectedTruck] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [truckCounts2F, setTruckCounts2F] = useState<{ truck: string; count: number }[]>([]); + const [truckCounts4F, setTruckCounts4F] = useState<{ truck: string; count: number }[]>([]); + const [summary2F, setSummary2F] = useState(null); + const [summary4F, setSummary4F] = useState(null); + const [defaultDateScope, setDefaultDateScope] = useState<"today" | "before">("today"); + const [isLoadingSummary, setIsLoadingSummary] = useState(false); + const [isAssigning, setIsAssigning] = useState(false); + const [isDefaultTruck, setIsDefaultTruck] = useState(false); + const [beforeTodayTruckXCount, setBeforeTodayTruckXCount] = useState(0); + const [selectedDate, setSelectedDate] = useState("today"); + const [releaseType, setReleaseType] = useState(initialReleaseType); + const [ticketFloor, setTicketFloor] = useState<"2/F" | "4/F">("2/F"); + const defaultTruckCount = summary4F?.defaultTruckCount ?? 0; + + const hasLoggedRef = useRef(false); + const fullReadyLoggedRef = useRef(false); + const pendingRef = useRef(0); + + const workbenchReleasedListBridge = useMemo( + () => ({ + loadBeforeToday: fetchWorkbenchReleasedDoPickOrdersForSelection, + loadToday: fetchWorkbenchReleasedDoPickOrdersForSelectionToday, + assignByListItemId: assignByDeliveryOrderPickOrderId, + }), + [], + ); + + const startFullTimer = () => { + if (typeof window === "undefined") return; + const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const; + if (!(window as any)[key]) { + (window as any)[key] = true; + console.time("[FG] FloorLanePanel full ready"); + } + }; + const tryEndFullTimer = () => { + if (typeof window === "undefined") return; + const key = "__FG_FLOOR_FULL_TIMER_STARTED__" as const; + if ((window as any)[key] && !fullReadyLoggedRef.current && pendingRef.current === 0) { + fullReadyLoggedRef.current = true; + console.timeEnd("[FG] FloorLanePanel full ready"); + delete (window as any)[key]; + } + }; + + const loadSummaries = useCallback(async () => { + setIsLoadingSummary(true); + pendingRef.current += 1; + startFullTimer(); + try { + let dateParam: string | undefined; + if (selectedDate === "today") dateParam = dayjs().format("YYYY-MM-DD"); + else if (selectedDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD"); + else if (selectedDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD"); + const [s2, s4] = await Promise.all([ + fetchWorkbenchStoreLaneSummary("2/F", dateParam, releaseType), + fetchWorkbenchStoreLaneSummary("4/F", dateParam, releaseType), + ]); + setSummary2F(s2); + setSummary4F(s4); + } catch (error) { + console.error("Error loading summaries:", error); + } finally { + setIsLoadingSummary(false); + pendingRef.current -= 1; + tryEndFullTimer(); + if (!hasLoggedRef.current) { + hasLoggedRef.current = true; + } + } + }, [selectedDate, releaseType]); + + useEffect(() => { + void loadSummaries(); + }, [loadSummaries]); + + useEffect(() => { + const loadCounts = async () => { + pendingRef.current += 1; + startFullTimer(); + try { + const [list2F, list4F] = await Promise.all([ + fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "2/F"), + fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, "4/F"), + ]); + const groupByTruck = (list: { truckLanceCode?: string | null }[]) => { + const map: Record = {}; + list.forEach((item) => { + const lane = item.truckLanceCode || "-"; + map[lane] = (map[lane] || 0) + 1; + }); + return Object.entries(map) + .map(([truck, count]) => ({ truck, count })) + .sort((a, b) => a.truck.localeCompare(b.truck)); + }; + setTruckCounts2F(groupByTruck(list2F)); + setTruckCounts4F(groupByTruck(list4F)); + } catch (e) { + console.error("Error loading counts:", e); + setTruckCounts2F([]); + setTruckCounts4F([]); + } finally { + pendingRef.current -= 1; + tryEndFullTimer(); + } + }; + void loadCounts(); + }, [loadSummaries]); + + useEffect(() => { + const loadBeforeTodayTruckX = async () => { + pendingRef.current += 1; + startFullTimer(); + try { + const list = await fetchWorkbenchReleasedDoPickOrdersForSelection(undefined, undefined, "車線-X"); + setBeforeTodayTruckXCount(list.length); + } catch { + setBeforeTodayTruckXCount(0); + } finally { + pendingRef.current -= 1; + tryEndFullTimer(); + } + }; + void loadBeforeTodayTruckX(); + }, []); + + const handleAssignByLane = useCallback( + async (storeId: string, truckDepartureTime: string, truckLanceCode: string, requiredDate: string) => { + if (!currentUserId) return; + let dateParam: string | undefined; + if (requiredDate === "today") dateParam = dayjs().format("YYYY-MM-DD"); + else if (requiredDate === "tomorrow") dateParam = dayjs().add(1, "day").format("YYYY-MM-DD"); + else if (requiredDate === "dayAfterTomorrow") dateParam = dayjs().add(2, "day").format("YYYY-MM-DD"); + setIsAssigning(true); + try { + const res = await assignWorkbenchByLane({ + userId: currentUserId, + storeId, + truckLanceCode, + truckDepartureTime, + requiredDate: dateParam, + }); + console.log("assignByLane result:", res); + if (res.code === "SUCCESS") { + window.dispatchEvent(new CustomEvent("pickOrderAssigned")); + void loadSummaries(); + onPickOrderAssigned?.(); + onSwitchToDetailTab?.(); + } + } catch { + await Swal.fire({ icon: "error", title: t("Error"), text: t("Error occurred during assignment."), confirmButtonText: t("Confirm"), confirmButtonColor: "#8dba00" }); + } finally { + setIsAssigning(false); + } + }, + [currentUserId, loadSummaries, onPickOrderAssigned, onSwitchToDetailTab, t], + ); + + const handleLaneButtonClick = useCallback( + async ( + storeId: string, + truckDepartureTime: string, + truckLanceCode: string, + loadingSequence: number | null | undefined, + requiredDate: string, + unassigned: number, + total: number, + ) => { + let dateDisplay = requiredDate; + if (requiredDate === "today") dateDisplay = dayjs().format("YYYY-MM-DD"); + else if (requiredDate === "tomorrow") dateDisplay = dayjs().add(1, "day").format("YYYY-MM-DD"); + else if (requiredDate === "dayAfterTomorrow") dateDisplay = dayjs().add(2, "day").format("YYYY-MM-DD"); + const result = await Swal.fire({ + title: t("Confirm Assignment"), + html: `
+

${t("Store")}: ${storeId}

+

${t("Lane Code")}: ${truckLanceCode}

+ ${loadingSequence != null ? `

${t("Loading Sequence")}: ${loadingSequence}

` : ""} +

${t("Departure Time")}: ${truckDepartureTime}

+

${t("Required Date")}: ${dateDisplay}

+

${t("Available Orders")}: ${unassigned}/${total}

+
`, + icon: "question", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + }); + if (result.isConfirmed) { + await handleAssignByLane(storeId, truckDepartureTime, truckLanceCode, requiredDate); + } + }, + [handleAssignByLane, t], + ); + + const getDateLabel = (offset: number) => dayjs().add(offset, "day").format("YYYY-MM-DD"); + + const truckGroups4F = useMemo((): TruckGroup4F[] => { + const rows = summary4F?.rows as LaneRow[] | undefined; + if (!rows?.length) return []; + const map = new Map(); + for (const row of rows) { + for (const lane of row.lanes) { + const list = map.get(lane.truckLanceCode); + const slot: LaneSlot4F = { truckDepartureTime: row.truckDepartureTime, lane }; + if (list) list.push(slot); + else map.set(lane.truckLanceCode, [slot]); + } + } + return Array.from(map.entries()).map(([truckLanceCode, slots]) => ({ + truckLanceCode, + slots: slots + .slice() + .sort((a, b) => (a.lane.loadingSequence ?? 999) - (b.lane.loadingSequence ?? 999)) + .map((s, i) => ({ ...s, sequenceIndex: i + 1 })), + })); + }, [summary4F?.rows]); + + const renderNoEntry = () => ( + + {t("No entries available")} + + ); + + return ( + + + + + {t("Select Date")} + + + + + + {t("Release Type")} + + + + + + {t("Floor ticket")} + + + + + + + {ticketFloor === "2/F" && ( + + + 2/F + + {isLoadingSummary ? {t("Loading...")} : !summary2F?.rows?.length ? renderNoEntry() : ( + + {summary2F.rows.map((row) => ( + + + {row.truckDepartureTime} + + {row.lanes.map((lane) => ( + + ))} + + + + ))} + + )} + + + + )} + + {ticketFloor === "4/F" && ( + + + 4/F + + {isLoadingSummary ? {t("Loading...")} : !truckGroups4F.length ? renderNoEntry() : ( + + {truckGroups4F.map(({ truckLanceCode, slots }) => ( + + + {truckLanceCode} + + {slots.map((slot) => { + const handlerName = (slot.lane.handlerName ?? "").trim(); + return ( + + ); + })} + + + + ))} + + )} + + + + )} + + + + + {t("Not yet finished released do pick orders")} + + + {t("Released orders not yet completed - click lane to select and assign")} + + + + + {ticketFloor === "2/F" && ( + + + 2/F + + {truckCounts2F.length === 0 ? renderNoEntry() : ( + + {truckCounts2F.map(({ truck, count }) => ( + + + + ))} + + )} + + + + )} + + {ticketFloor === "4/F" && ( + + + 4/F + + {truckCounts4F.length === 0 ? renderNoEntry() : ( + + {truckCounts4F.map(({ truck, count }) => ( + + + + ))} + + )} + + + + )} + + + + {t("Truck X")} + + {beforeTodayTruckXCount === 0 && defaultTruckCount === 0 ? renderNoEntry() : ( + + {defaultTruckCount > 0 && ( + + )} + {beforeTodayTruckXCount > 0 && ( + + )} + + )} + + + + + setModalOpen(false)} + onAssigned={() => { + void loadSummaries(); + onPickOrderAssigned?.(); + onSwitchToDetailTab?.(); + }} + /> + + + ); +}; + +export default WorkbenchFloorLanePanel; \ No newline at end of file diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecution.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecution.tsx new file mode 100644 index 0000000..bed75e0 --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecution.tsx @@ -0,0 +1,1221 @@ +"use client"; + +import { + Box, + Button, + Stack, + TextField, + Typography, + Alert, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + TablePagination, + Modal, +} from "@mui/material"; +import React, { useCallback, useEffect, useState, useRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "next/navigation"; +import { + fetchAllPickOrderLotsHierarchicalWorkbench, + updateStockOutLineStatus, + createStockOutLine, + recordPickExecutionIssue, + fetchFGPickOrdersByUserIdWorkbench, + FGPickOrderResponse, + autoAssignAndReleasePickOrder, + AutoAssignReleaseResponse, + checkPickOrderCompletion, + PickOrderCompletionResponse, + checkAndCompletePickOrderByConsoCode, + fetchDoPickOrderDetail, + DoPickOrderDetail, + batchQrSubmit, +} from "@/app/api/pickOrder/actions"; +import { fetchNameList, NameList } from "@/app/api/user/actions"; +import { + FormProvider, + useForm, +} from "react-hook-form"; +import SearchBox, { Criterion } from "@/components/SearchBox"; +import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; +import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; +import QrCodeIcon from '@mui/icons-material/QrCode'; +import { useQrCodeScannerContext } from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm"; +import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard"; +import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; +import FGPickOrderInfoCard from "@/components/FinishedGoodSearch/FGPickOrderInfoCard"; +export type GoodPickExecutionFloorPanelProps = { + onPickOrderAssigned?: () => void; + onSwitchToDetailTab?: () => void; + initialReleaseType?: string; +}; + +interface Props { + filterArgs: Record; + onFgPickOrdersChange?: (fgPickOrders: FGPickOrderResponse[]) => void; + onSwitchToDetailTab?: () => void; + onFirstLoadDone?: () => void; +} + +// QR Code Modal Component (from LotTable) +const QrCodeModal: React.FC<{ + open: boolean; + onClose: () => void; + lot: any | null; + onQrCodeSubmit: (lotNo: string) => void; + combinedLotData: any[]; // Add this prop +}> = ({ open, onClose, lot, onQrCodeSubmit, combinedLotData }) => { + const { t } = useTranslation("pickOrder"); + const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); + const [manualInput, setManualInput] = useState(''); + const floorPanelTimerStartedRef = useRef(false); + const [manualInputSubmitted, setManualInputSubmitted] = useState(false); + const [manualInputError, setManualInputError] = useState(false); + const [isProcessingQr, setIsProcessingQr] = useState(false); + const [qrScanFailed, setQrScanFailed] = useState(false); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + + const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); + const [scannedQrResult, setScannedQrResult] = useState(''); + const [fgPickOrder, setFgPickOrder] = useState(null); + // Process scanned QR codes + useEffect(() => { + if (qrValues.length > 0 && lot && !isProcessingQr && !qrScanSuccess) { + const latestQr = qrValues[qrValues.length - 1]; + + if (processedQrCodes.has(latestQr)) { + console.log("QR code already processed, skipping..."); + return; + } + + setProcessedQrCodes(prev => new Set(prev).add(latestQr)); + + try { + const qrData = JSON.parse(latestQr); + + if (qrData.stockInLineId && qrData.itemId) { + setIsProcessingQr(true); + setQrScanFailed(false); + + fetchStockInLineInfo(qrData.stockInLineId) + .then((stockInLineInfo) => { + console.log("Stock in line info:", stockInLineInfo); + setScannedQrResult(stockInLineInfo.lotNo || 'Unknown lot number'); + + if (stockInLineInfo.lotNo === lot.lotNo) { + console.log(` QR Code verified for lot: ${lot.lotNo}`); + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + console.log(`❌ QR Code mismatch. Expected: ${lot.lotNo}, Got: ${stockInLineInfo.lotNo}`); + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + }) + .catch((error) => { + console.error("Error fetching stock in line info:", error); + setScannedQrResult('Error fetching data'); + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + }) + .finally(() => { + setIsProcessingQr(false); + }); + } else { + const qrContent = latestQr.replace(/[{}]/g, ''); + setScannedQrResult(qrContent); + + if (qrContent === lot.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + } + } catch (error) { + console.log("QR code is not JSON format, trying direct comparison"); + const qrContent = latestQr.replace(/[{}]/g, ''); + setScannedQrResult(qrContent); + + if (qrContent === lot.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + } + } + }, [qrValues, lot, onQrCodeSubmit, onClose, resetScan, isProcessingQr, qrScanSuccess, processedQrCodes]); + + // Clear states when modal opens + useEffect(() => { + if (open) { + setManualInput(''); + setManualInputSubmitted(false); + setManualInputError(false); + setIsProcessingQr(false); + setQrScanFailed(false); + setQrScanSuccess(false); + setScannedQrResult(''); + setProcessedQrCodes(new Set()); + } + }, [open]); + + useEffect(() => { + if (lot) { + setManualInput(''); + setManualInputSubmitted(false); + setManualInputError(false); + setIsProcessingQr(false); + setQrScanFailed(false); + setQrScanSuccess(false); + setScannedQrResult(''); + setProcessedQrCodes(new Set()); + } + }, [lot]); + + // Auto-submit manual input when it matches + useEffect(() => { + if (manualInput.trim() === lot?.lotNo && manualInput.trim() !== '' && !qrScanFailed && !qrScanSuccess) { + console.log(' Auto-submitting manual input:', manualInput.trim()); + + const timer = setTimeout(() => { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + setManualInput(''); + setManualInputError(false); + setManualInputSubmitted(false); + }, 200); + + return () => clearTimeout(timer); + } + }, [manualInput, lot, onQrCodeSubmit, onClose, qrScanFailed, qrScanSuccess]); + + const handleManualSubmit = () => { + if (manualInput.trim() === lot?.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + setManualInput(''); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + }; + + useEffect(() => { + if (open) { + startScan(); + } + }, [open, startScan]); + + return ( + + + + {t("QR Code Scan for Lot")}: {lot?.lotNo} + + + {isProcessingQr && ( + + + {t("Processing QR code...")} + + + )} + + + + {t("Manual Input")}: + + { + setManualInput(e.target.value); + if (qrScanFailed || manualInputError) { + setQrScanFailed(false); + setManualInputError(false); + setManualInputSubmitted(false); + } + }} + sx={{ mb: 1 }} + error={manualInputSubmitted && manualInputError} + helperText={ + manualInputSubmitted && manualInputError + ? `${t("The input is not the same as the expected lot number.")}` + : '' + } + /> + + + + {qrValues.length > 0 && ( + + + {t("QR Scan Result:")} {scannedQrResult} + + + {qrScanSuccess && ( + + {t("Verified successfully!")} + + )} + + )} + + + + + + + ); +}; + +const WorkbenchGoodPickExecution: React.FC = ({ + filterArgs, + onFgPickOrdersChange, + onSwitchToDetailTab, + onFirstLoadDone, +}) => { + const FloorPanel = WorkbenchFloorLanePanel; + const { t } = useTranslation("pickOrder"); + const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + + const currentUserId = session?.id ? parseInt(session.id) : undefined; + + const [combinedLotData, setCombinedLotData] = useState([]); + const [combinedDataLoading, setCombinedDataLoading] = useState(false); + const [originalCombinedData, setOriginalCombinedData] = useState([]); + + const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); + const [doPickOrderDetail, setDoPickOrderDetail] = useState(null); +const [selectedPickOrderId, setSelectedPickOrderId] = useState(null); +const [pickOrderSwitching, setPickOrderSwitching] = useState(false); + const [qrScanInput, setQrScanInput] = useState(''); + const [qrScanError, setQrScanError] = useState(false); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + + const [pickQtyData, setPickQtyData] = useState>({}); + const [searchQuery, setSearchQuery] = useState>({}); + + const [paginationController, setPaginationController] = useState({ + pageNum: 0, + pageSize: 10, + }); + + const [usernameList, setUsernameList] = useState([]); + + const initializationRef = useRef(false); + const autoAssignRef = useRef(false); + + const formProps = useForm(); + const errors = formProps.formState.errors; + + // Add QR modal states + const [qrModalOpen, setQrModalOpen] = useState(false); + const [selectedLotForQr, setSelectedLotForQr] = useState(null); + const floorPanelTimerStartedRef = useRef(false); + // Add GoodPickExecutionForm states + const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); + const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState(null); + const [fgPickOrders, setFgPickOrders] = useState([]); + const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); +// 在 GoodPickExecutiondetail.tsx 中修改 fetchFgPickOrdersData +// 修改 fetchFgPickOrdersData 函数: +const fetchFgPickOrdersData = useCallback(async () => { + if (!currentUserId) return; + + setFgPickOrdersLoading(true); + try { + const fgPickOrders = await fetchFGPickOrdersByUserIdWorkbench(currentUserId); + + console.log("🔍 DEBUG: Fetched FG pick orders:", fgPickOrders); + console.log("🔍 DEBUG: First order numberOfPickOrders:", fgPickOrders[0]?.numberOfPickOrders); + + setFgPickOrders(fgPickOrders); + + if (onFgPickOrdersChange) { + onFgPickOrdersChange(fgPickOrders); + } + + + } catch (error) { + console.error("❌ Error fetching FG pick orders:", error); + setFgPickOrders([]); + + if (onFgPickOrdersChange) { + onFgPickOrdersChange([]); + } + + } finally { + setFgPickOrdersLoading(false); + } +}, [currentUserId, selectedPickOrderId]); + + // 简化:移除复杂的 useEffect 依赖 + useEffect(() => { + if (currentUserId) { + fetchFgPickOrdersData(); + } + }, [currentUserId, fetchFgPickOrdersData]); + + // Handle QR code button click + const handleQrCodeClick = (pickOrderId: number) => { + console.log(`QR Code clicked for pick order ID: ${pickOrderId}`); + // TODO: Implement QR code functionality + }; + + useEffect(() => { + startScan(); + return () => { + stopScan(); + resetScan(); + }; + }, [startScan, stopScan, resetScan]); + + const fetchAllCombinedLotData = useCallback(async (userId?: number) => { + setCombinedDataLoading(true); + try { + const userIdToUse = userId || currentUserId; + + console.log(" fetchAllCombinedLotData called with userId:", userIdToUse); + + if (!userIdToUse) { + console.warn("⚠️ No userId available, skipping API call"); + setCombinedLotData([]); + setOriginalCombinedData([]); + return; + } + + // ✅ Fix: fetchAllPickOrderLotsHierarchical returns hierarchical data, not a flat array + const hierarchicalData = await fetchAllPickOrderLotsHierarchicalWorkbench(userIdToUse); + console.log(" Hierarchical data:", hierarchicalData); + + // ✅ Fix: Ensure we always set an array + // If hierarchicalData is not in the expected format, default to empty array + let allLotDetails: any[] = []; + + if (hierarchicalData && Array.isArray(hierarchicalData)) { + // If it's already an array, use it directly + allLotDetails = hierarchicalData; + } else if (hierarchicalData?.pickOrders && Array.isArray(hierarchicalData.pickOrders)) { + // Process hierarchical data into flat array (similar to GoodPickExecutiondetail.tsx) + const mergedPickOrder = hierarchicalData.pickOrders[0]; + if (mergedPickOrder?.pickOrderLines) { + mergedPickOrder.pickOrderLines.forEach((line: any) => { + if (line.lots && line.lots.length > 0) { + line.lots.forEach((lot: any) => { + allLotDetails.push({ + pickOrderConsoCode: mergedPickOrder.consoCode, + pickOrderTargetDate: mergedPickOrder.targetDate, + pickOrderStatus: mergedPickOrder.status, + pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + pickOrderLineId: line.id, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + itemId: line.item?.id, + itemCode: line.item?.code, + itemName: line.item?.name, + uomDesc: line.item?.uomDesc, + uomShortDesc: line.item?.uomShortDesc, + lotId: lot.id, + lotNo: lot.lotNo, + expiryDate: lot.expiryDate, + location: lot.location, + stockUnit: lot.stockUnit, + availableQty: lot.availableQty, + requiredQty: lot.requiredQty, + actualPickQty: lot.actualPickQty, + lotStatus: lot.lotStatus, + lotAvailability: lot.lotAvailability, + processingStatus: lot.processingStatus, + stockOutLineId: lot.stockOutLineId, + stockOutLineStatus: lot.stockOutLineStatus, + stockOutLineQty: lot.stockOutLineQty, + routerId: lot.router?.id, + routerIndex: lot.router?.index, + routerRoute: lot.router?.route, + routerArea: lot.router?.area, + }); + }); + } + }); + } + } + + console.log(" All combined lot details:", allLotDetails); + setCombinedLotData(allLotDetails); + setOriginalCombinedData(allLotDetails); + + // ✅ Fix: Add safety check - ensure allLotDetails is an array before using .every() + const allCompleted = Array.isArray(allLotDetails) && allLotDetails.length > 0 && allLotDetails.every((lot: any) => + lot.processingStatus === 'completed' + ); + + // 发送完成状态事件,包含标签页信息 + window.dispatchEvent(new CustomEvent('pickOrderCompletionStatus', { + detail: { + allLotsCompleted: allCompleted, + tabIndex: 0 // 明确指定这是来自标签页 0 的事件 + } + })); + + } catch (error) { + console.error("❌ Error fetching combined lot data:", error); + setCombinedLotData([]); + setOriginalCombinedData([]); + + // 如果加载失败,禁用打印按钮 + window.dispatchEvent(new CustomEvent('pickOrderCompletionStatus', { + detail: { + allLotsCompleted: false, + tabIndex: 0 + } + })); + } finally { + setCombinedDataLoading(false); + } + }, [currentUserId, combinedLotData]); + + + // Only fetch existing data when session is ready, no auto-assignment + useEffect(() => { + if (session && currentUserId && !initializationRef.current) { + console.log(" Session loaded, initializing pick order..."); + initializationRef.current = true; + + (async () => { + try { + await fetchAllCombinedLotData(); // ✅ 等待数据加载完成 + } finally { + if (onFirstLoadDone) { + onFirstLoadDone(); // ✅ 这时候再结束 [FinishedGoodSearch] page ready + } + } + })(); + } + }, [session, currentUserId, fetchAllCombinedLotData, onFirstLoadDone]); + // Add event listener for manual assignment + useEffect(() => { + const handlePickOrderAssigned = () => { + console.log("🔄 Pick order assigned event received, refreshing data..."); + fetchAllCombinedLotData(); + }; + + window.addEventListener('pickOrderAssigned', handlePickOrderAssigned); + + return () => { + window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned); + }; + }, [fetchAllCombinedLotData]); + + // Handle QR code submission for matched lot (external scanning) + // Handle QR code submission for matched lot (external scanning) + /* + const handleQrCodeSubmit = useCallback(async (lotNo: string) => { + console.log(` Processing QR Code for lot: ${lotNo}`); + + // Use current data without refreshing to avoid infinite loop + const currentLotData = combinedLotData; + console.log(`🔍 Available lots:`, currentLotData.map(lot => lot.lotNo)); + + const matchingLots = currentLotData.filter(lot => + lot.lotNo === lotNo || + lot.lotNo?.toLowerCase() === lotNo.toLowerCase() + ); + + if (matchingLots.length === 0) { + console.error(`❌ Lot not found: ${lotNo}`); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + console.log(` Found ${matchingLots.length} matching lots:`, matchingLots); + setQrScanError(false); + + try { + let successCount = 0; + let existsCount = 0; + let errorCount = 0; + + for (const matchingLot of matchingLots) { + console.log(`🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`); + + if (matchingLot.stockOutLineId) { + console.log(` Stock out line already exists for line ${matchingLot.pickOrderLineId}`); + existsCount++; + } else { + const stockOutLineData: CreateStockOutLine = { + consoCode: matchingLot.pickOrderConsoCode, + pickOrderLineId: matchingLot.pickOrderLineId, + inventoryLotLineId: matchingLot.lotId, + qty: 0.0 + }; + + console.log(`Creating stock out line for pick order line ${matchingLot.pickOrderLineId}:`, stockOutLineData); + const result = await createStockOutLine(stockOutLineData); + console.log(`Create stock out line result for line ${matchingLot.pickOrderLineId}:`, result); + + if (result && result.code === "EXISTS") { + console.log(` Stock out line already exists for line ${matchingLot.pickOrderLineId}`); + existsCount++; + } else if (result && result.code === "SUCCESS") { + console.log(` Stock out line created successfully for line ${matchingLot.pickOrderLineId}`); + successCount++; + } else { + console.error(`❌ Failed to create stock out line for line ${matchingLot.pickOrderLineId}:`, result); + errorCount++; + } + } + } + + // Always refresh data after processing (success or failure) + console.log("🔄 Refreshing data after QR code processing..."); + await fetchAllCombinedLotData(); + + if (successCount > 0 || existsCount > 0) { + console.log(` QR Code processing completed: ${successCount} created, ${existsCount} already existed`); + setQrScanSuccess(true); + setQrScanInput(''); // Clear input after successful processing + + // Clear success state after a delay + setTimeout(() => { + setQrScanSuccess(false); + }, 2000); + } else { + console.error(`❌ QR Code processing failed: ${errorCount} errors`); + setQrScanError(true); + setQrScanSuccess(false); + + // Clear error state after a delay + setTimeout(() => { + setQrScanError(false); + }, 3000); + } + } catch (error) { + console.error("❌ Error processing QR code:", error); + setQrScanError(true); + setQrScanSuccess(false); + + // Still refresh data even on error + await fetchAllCombinedLotData(); + + // Clear error state after a delay + setTimeout(() => { + setQrScanError(false); + }, 3000); + } + }, [combinedLotData, fetchAllCombinedLotData]); + */ const handleQrCodeSubmit = useCallback(async (lotNo: string) => { + console.log(` Processing QR Code for lot: ${lotNo}`); + + // Use current data without refreshing to avoid infinite loop + const currentLotData = combinedLotData; + console.log(`🔍 Available lots:`, currentLotData.map(lot => lot.lotNo)); + + const matchingLots = currentLotData.filter(lot => + lot.lotNo === lotNo || + lot.lotNo?.toLowerCase() === lotNo.toLowerCase() + ); + + if (matchingLots.length === 0) { + console.error(`❌ Lot not found: ${lotNo}`); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + console.log(` Found ${matchingLots.length} matching lots:`, matchingLots); + setQrScanError(false); + + try { + let successCount = 0; + let existsCount = 0; + let errorCount = 0; + + for (const matchingLot of matchingLots) { + console.log(`🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`); + + if (matchingLot.stockOutLineId) { + console.log(` Stock out line already exists for line ${matchingLot.pickOrderLineId}`); + existsCount++; + } else { + const stockOutLineData: CreateStockOutLine = { + consoCode: matchingLot.pickOrderConsoCode, + pickOrderLineId: matchingLot.pickOrderLineId, + inventoryLotLineId: matchingLot.lotId, + qty: 0.0 + }; + + console.log(`Creating stock out line for pick order line ${matchingLot.pickOrderLineId}:`, stockOutLineData); + const result = await createStockOutLine(stockOutLineData); + console.log(`Create stock out line result for line ${matchingLot.pickOrderLineId}:`, result); + + if (result && result.code === "EXISTS") { + console.log(` Stock out line already exists for line ${matchingLot.pickOrderLineId}`); + existsCount++; + } else if (result && result.code === "SUCCESS") { + console.log(` Stock out line created successfully for line ${matchingLot.pickOrderLineId}`); + successCount++; + } else { + console.error(`❌ Failed to create stock out line for line ${matchingLot.pickOrderLineId}:`, result); + errorCount++; + } + } + } + + // Always refresh data after processing (success or failure) + console.log("🔄 Refreshing data after QR code processing..."); + await fetchAllCombinedLotData(); + + if (successCount > 0 || existsCount > 0) { + console.log(` QR Code processing completed: ${successCount} created, ${existsCount} already existed`); + setQrScanSuccess(true); + setQrScanInput(''); // Clear input after successful processing + + // Clear success state after a delay + setTimeout(() => { + setQrScanSuccess(false); + }, 2000); + } else { + console.error(`❌ QR Code processing failed: ${errorCount} errors`); + setQrScanError(true); + setQrScanSuccess(false); + + // Clear error state after a delay + setTimeout(() => { + setQrScanError(false); + }, 3000); + } + } catch (error) { + console.error("❌ Error processing QR code:", error); + setQrScanError(true); + setQrScanSuccess(false); + + // Still refresh data even on error + await fetchAllCombinedLotData(); + + // Clear error state after a delay + setTimeout(() => { + setQrScanError(false); + }, 3000); + } + }, [combinedLotData, fetchAllCombinedLotData]); + + const handleManualInputSubmit = useCallback(() => { + if (qrScanInput.trim() !== '') { + handleQrCodeSubmit(qrScanInput.trim()); + } + }, [qrScanInput, handleQrCodeSubmit]); + + // Handle QR code submission from modal (internal scanning) + const handleQrCodeSubmitFromModal = useCallback(async (lotNo: string) => { + if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { + console.log(` QR Code verified for lot: ${lotNo}`); + + const requiredQty = selectedLotForQr.requiredQty; + const lotId = selectedLotForQr.lotId; + + // Create stock out line + const stockOutLineData: CreateStockOutLine = { + consoCode: selectedLotForQr.pickOrderConsoCode, // Use pickOrderConsoCode instead of pickOrderCode + pickOrderLineId: selectedLotForQr.pickOrderLineId, + inventoryLotLineId: selectedLotForQr.lotId, + qty: 0.0 + }; + + try { + await createStockOutLine(stockOutLineData); + console.log("Stock out line created successfully!"); + + // Close modal + setQrModalOpen(false); + setSelectedLotForQr(null); + + // Set pick quantity + const lotKey = `${selectedLotForQr.pickOrderLineId}-${lotId}`; + setTimeout(() => { + setPickQtyData(prev => ({ + ...prev, + [lotKey]: requiredQty + })); + console.log(` Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`); + }, 500); + + // Refresh data + await fetchAllCombinedLotData(); + } catch (error) { + console.error("Error creating stock out line:", error); + } + } + }, [selectedLotForQr, fetchAllCombinedLotData]); + + // Outside QR scanning - process QR codes from outside the page automatically + useEffect(() => { + if (qrValues.length > 0 && combinedLotData.length > 0) { + const latestQr = qrValues[qrValues.length - 1]; + + // Extract lot number from QR code + let lotNo = ''; + try { + const qrData = JSON.parse(latestQr); + if (qrData.stockInLineId && qrData.itemId) { + // For JSON QR codes, we need to fetch the lot number + fetchStockInLineInfo(qrData.stockInLineId) + .then((stockInLineInfo) => { + console.log("Outside QR scan - Stock in line info:", stockInLineInfo); + const extractedLotNo = stockInLineInfo.lotNo; + if (extractedLotNo) { + console.log(`Outside QR scan detected (JSON): ${extractedLotNo}`); + handleQrCodeSubmit(extractedLotNo); + } + }) + .catch((error) => { + console.error("Outside QR scan - Error fetching stock in line info:", error); + }); + return; // Exit early for JSON QR codes + } + } catch (error) { + // Not JSON format, treat as direct lot number + lotNo = latestQr.replace(/[{}]/g, ''); + } + + // For direct lot number QR codes + if (lotNo) { + console.log(`Outside QR scan detected (direct): ${lotNo}`); + handleQrCodeSubmit(lotNo); + } + } + }, [qrValues, combinedLotData, handleQrCodeSubmit]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const key = "__FG_FLOOR_PANEL_TIMER_STARTED__" as const; + + // 只有当「没有 FG 订单」时才会显示 FinishedGoodFloorLanePanel + if (fgPickOrders.length === 0 && !fgPickOrdersLoading) { + if (!(window as any)[key]) { + (window as any)[key] = true; + console.time("[FG] FloorLanePanel initial load"); + } + } + + // 如果之后拿到 FG 订单,你可以选择在这里清掉标记(可选) + if (fgPickOrders.length > 0 && (window as any)[key]) { + // delete (window as any)[key]; + } + }, [fgPickOrders.length, fgPickOrdersLoading]); + const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { + if (value === '' || value === null || value === undefined) { + setPickQtyData(prev => ({ + ...prev, + [lotKey]: 0 + })); + return; + } + + const numericValue = typeof value === 'string' ? parseFloat(value) : value; + + if (isNaN(numericValue)) { + setPickQtyData(prev => ({ + ...prev, + [lotKey]: 0 + })); + return; + } + + setPickQtyData(prev => ({ + ...prev, + [lotKey]: numericValue + })); + }, []); + + const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle'); + const [autoAssignMessage, setAutoAssignMessage] = useState(''); + const [completionStatus, setCompletionStatus] = useState(null); + + const checkAndAutoAssignNext = useCallback(async () => { + if (!currentUserId) return; + + try { + const completionResponse = await checkPickOrderCompletion(currentUserId); + + if (completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders) { + console.log("Found completed pick orders, auto-assigning next..."); + // 移除前端的自动分配逻辑,因为后端已经处理了 + // await handleAutoAssignAndRelease(); // 删除这个函数 + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + }, [currentUserId]); + + // Handle submit pick quantity + const handleSubmitPickQty = useCallback(async (lot: any) => { + const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; + const newQty = pickQtyData[lotKey] || 0; + + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + + try { + const currentActualPickQty = lot.actualPickQty || 0; + const cumulativeQty = currentActualPickQty + newQty; + + let newStatus = 'partially_completed'; + + if (cumulativeQty >= lot.requiredQty) { + newStatus = 'completed'; + } + + console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Required Qty: ${lot.requiredQty}`); + console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); + console.log(`New Submitted Qty: ${newQty}`); + console.log(`Cumulative Qty: ${cumulativeQty}`); + console.log(`New Status: ${newStatus}`); + console.log(`=====================================`); + + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: newStatus, + qty: cumulativeQty + }); + + if (newQty > 0) { + await updateInventoryLotLineQuantities({ + inventoryLotLineId: lot.lotId, + qty: newQty, + status: 'available', + operation: 'pick' + }); + } + + // FIXED: Use the proper API function instead of direct fetch + if (newStatus === 'completed' && lot.pickOrderConsoCode) { + console.log(` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`); + + try { + // Use the imported API function instead of direct fetch + const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); + console.log(` Pick order completion check result:`, completionResponse); + + if (completionResponse.code === "SUCCESS") { + console.log(`�� Pick order ${lot.pickOrderConsoCode} completed successfully!`); + } else if (completionResponse.message === "not completed") { + console.log(`⏳ Pick order not completed yet, more lines remaining`); + } else { + console.error(`❌ Error checking completion: ${completionResponse.message}`); + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + } + + await fetchAllCombinedLotData(); + console.log("Pick quantity submitted successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + + } catch (error) { + console.error("Error submitting pick quantity:", error); + } + }, [pickQtyData, fetchAllCombinedLotData, checkAndAutoAssignNext]); + + // Handle reject lot + const handleRejectLot = useCallback(async (lot: any) => { + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + + try { + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: 'rejected', + qty: 0 + }); + + await fetchAllCombinedLotData(); + console.log("Lot rejected successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + + } catch (error) { + console.error("Error rejecting lot:", error); + } + }, [fetchAllCombinedLotData, checkAndAutoAssignNext]); + + // Handle pick execution form + const handlePickExecutionForm = useCallback((lot: any) => { + console.log("=== Pick Execution Form ==="); + console.log("Lot data:", lot); + + if (!lot) { + console.warn("No lot data provided for pick execution form"); + return; + } + + console.log("Opening pick execution form for lot:", lot.lotNo); + + setSelectedLotForExecutionForm(lot); + setPickExecutionFormOpen(true); + + console.log("Pick execution form opened for lot ID:", lot.lotId); + }, []); + + const handlePickExecutionFormSubmit = useCallback(async (data: any) => { + try { + console.log("Pick execution form submitted:", data); + const issueData = { + ...data, + type: "Do", // Delivery Order Record 类型 + }; + const result = await recordPickExecutionIssue(issueData); + console.log("Pick execution issue recorded:", result); + + if (result && result.code === "SUCCESS") { + console.log(" Pick execution issue recorded successfully"); + } else { + console.error("❌ Failed to record pick execution issue:", result); + } + + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + + await fetchAllCombinedLotData(); + } catch (error) { + console.error("Error submitting pick execution form:", error); + } + }, [fetchAllCombinedLotData]); + + // Calculate remaining required quantity + const calculateRemainingRequiredQty = useCallback((lot: any) => { + const requiredQty = lot.requiredQty || 0; + const stockOutLineQty = lot.stockOutLineQty || 0; + return Math.max(0, requiredQty - stockOutLineQty); + }, []); + + // Search criteria + const searchCriteria: Criterion[] = [ + { + label: t("Pick Order Code"), + paramName: "pickOrderCode", + type: "text", + }, + { + label: t("Item Code"), + paramName: "itemCode", + type: "text", + }, + { + label: t("Item Name"), + paramName: "itemName", + type: "text", + }, + { + label: t("Lot No"), + paramName: "lotNo", + type: "text", + }, + ]; + + const handleSearch = useCallback((query: Record) => { + setSearchQuery({ ...query }); + console.log("Search query:", query); + + if (!originalCombinedData) return; + + const filtered = originalCombinedData.filter((lot: any) => { + const pickOrderCodeMatch = !query.pickOrderCode || + lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); + + const itemCodeMatch = !query.itemCode || + lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); + + const itemNameMatch = !query.itemName || + lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); + + const lotNoMatch = !query.lotNo || + lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase()); + + return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch; + }); + + setCombinedLotData(filtered); + console.log("Filtered lots count:", filtered.length); + }, [originalCombinedData]); + + const handleReset = useCallback(() => { + setSearchQuery({}); + if (originalCombinedData) { + setCombinedLotData(originalCombinedData); + } + }, [originalCombinedData]); + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setPaginationController(prev => ({ + ...prev, + pageNum: newPage, + })); + }, []); + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + setPaginationController({ + pageNum: 0, + pageSize: newPageSize, + }); + }, []); + + const paginatedData = useMemo(() => { + // ✅ Fix: Add safety check to ensure combinedLotData is an array + if (!Array.isArray(combinedLotData)) { + console.warn(" combinedLotData is not an array:", combinedLotData); + return []; + } + + // Sort by routerIndex first, then by other criteria + const sortedData = [...combinedLotData].sort((a: any, b: any) => { + const aIndex = a.routerIndex || 0; + const bIndex = b.routerIndex || 0; + + // Primary sort: by routerIndex + if (aIndex !== bIndex) { + return aIndex - bIndex; + } + + // Secondary sort: by pickOrderCode if routerIndex is the same + if (a.pickOrderCode !== b.pickOrderCode) { + return a.pickOrderCode.localeCompare(b.pickOrderCode); + } + + // Tertiary sort: by lotNo if everything else is the same + return (a.lotNo || '').localeCompare(b.lotNo || ''); + }); + + const startIndex = paginationController.pageNum * paginationController.pageSize; + const endIndex = startIndex + paginationController.pageSize; + return sortedData.slice(startIndex, endIndex); + }, [combinedLotData, paginationController]); +return ( + + {/* 修复:改进条件渲染逻辑 */} + {combinedDataLoading || fgPickOrdersLoading ? ( + // 数据加载中,显示加载指示器 + + + + ) : fgPickOrders.length === 0 ? ( + // 没有活动订单,显示楼层选择面板 + { + if (currentUserId) { + fetchAllCombinedLotData(currentUserId); + fetchFgPickOrdersData(); + } + }} + onSwitchToDetailTab={onSwitchToDetailTab} + /> + ) : ( + // 有活动订单,显示 FG 订单信息 + + {fgPickOrders.map((fgOrder) => ( + + + + + + ))} + + )} + + {/* Modals */} + setQrModalOpen(false)} + lot={selectedLotForQr} + onQrCodeSubmit={handleQrCodeSubmit} + combinedLotData={combinedLotData} + /> + + { + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + }} + onSubmit={handlePickExecutionFormSubmit} + selectedLot={selectedLotForExecutionForm} + selectedPickOrderLine={null} + pickOrderId={selectedLotForExecutionForm?.pickOrderId} + pickOrderCreateDate={null} + /> + +); +}; + +export default WorkbenchGoodPickExecution; \ No newline at end of file diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx new file mode 100644 index 0000000..54abc51 --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -0,0 +1,4148 @@ +"use client"; + +import { + Box, + Button, + Stack, + TextField, + Typography, + Alert, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Checkbox, + TablePagination, + Modal, + Chip, +} from "@mui/material"; +import dayjs from 'dayjs'; +import TestQrCodeProvider from "@/components/QrCodeScannerProvider/TestQrCodeProvider"; +import { fetchLotDetail } from "@/app/api/inventory/actions"; +import React, { useCallback, useEffect, useState, useRef, useMemo, startTransition } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "next/navigation"; +import { + updateStockOutLineStatus, + createStockOutLine, + updateStockOutLine, + recordPickExecutionIssue, + fetchFGPickOrders, // Add this import + FGPickOrderResponse, + stockReponse, + PickExecutionIssueData, + checkPickOrderCompletion, + fetchAllPickOrderLotsHierarchicalWorkbench, + PickOrderCompletionResponse, + checkAndCompletePickOrderByConsoCode, + updateSuggestedLotLineId, + updateStockOutLineStatusByQRCodeAndLotNo, + confirmLotSubstitution, + fetchDoPickOrderDetail, // 必须添加 + DoPickOrderDetail, // 必须添加 + batchQrSubmit, + batchScan, + BatchScanRequest, + BatchScanLineRequest, +} from "@/app/api/pickOrder/actions"; +import { workbenchBatchScanPick, workbenchScanPick } from "@/app/api/doworkbench/actions"; +import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils"; + +import FGPickOrderInfoCard from "@/components/FinishedGoodSearch/FGPickOrderInfoCard"; +//import { fetchItem } from "@/app/api/settings/item"; +import { updateInventoryLotLineStatus, analyzeQrCode } from "@/app/api/inventory/actions"; +import { fetchNameList, NameList } from "@/app/api/user/actions"; +import { + FormProvider, + useForm, +} from "react-hook-form"; +import SearchBox, { Criterion } from "@/components/SearchBox"; +import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; +import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; +import QrCodeIcon from '@mui/icons-material/QrCode'; +import { useQrCodeScannerContext } from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import GoodPickExecutionForm from "@/components/FinishedGoodSearch/GoodPickExecutionForm"; +import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; +import FGPickOrderCard from "@/components/FinishedGoodSearch/FGPickOrderCard"; +import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel"; +import ScanStatusAlert from "@/components/common/ScanStatusAlert"; + +interface Props { + filterArgs: Record; + onSwitchToRecordTab?: () => void; + onRefreshReleasedOrderCount?: () => void; + /** 階層揀貨資料已無(例如訂單已完成/釋放)時通知外層,以便重新檢查是否仍為「已指派」狀態 */ + onWorkbenchHierarchyEmpty?: () => void; +} + +/** 同物料多行时,优先对「有建议批次号」的行做替换,避免误选「无批次/不足」行 */ +function pickExpectedLotForSubstitution(activeSuggestedLots: any[]): any | null { + if (!activeSuggestedLots?.length) return null; + const withLotNo = activeSuggestedLots.filter( + (l) => l.lotNo != null && String(l.lotNo).trim() !== "" + ); + if (withLotNo.length === 1) return withLotNo[0]; + if (withLotNo.length > 1) { + const pending = withLotNo.find( + (l) => (l.stockOutLineStatus || "").toLowerCase() === "pending" + ); + return pending || withLotNo[0]; + } + return activeSuggestedLots[0]; +} + +const ManualLotConfirmationModal: React.FC<{ + open: boolean; + onClose: () => void; + onConfirm: (expectedLotNo: string, scannedLotNo: string) => void; + expectedLot: { + lotNo: string; + itemCode: string; + itemName: string; + } | null; + scannedLot: { + lotNo: string; + itemCode: string; + itemName: string; + } | null; + isLoading?: boolean; +}> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => { + const { t } = useTranslation("pickOrder"); + const [expectedLotInput, setExpectedLotInput] = useState(''); + const [scannedLotInput, setScannedLotInput] = useState(''); + const [error, setError] = useState(''); + + // 当模态框打开时,预填充输入框 + useEffect(() => { + if (open) { + setExpectedLotInput(expectedLot?.lotNo || ''); + setScannedLotInput(scannedLot?.lotNo || ''); + setError(''); + } + }, [open, expectedLot, scannedLot]); + + const handleConfirm = () => { + if (!expectedLotInput.trim() || !scannedLotInput.trim()) { + setError(t("Please enter both expected and scanned lot numbers.")); + return; + } + + if (expectedLotInput.trim() === scannedLotInput.trim()) { + setError(t("Expected and scanned lot numbers cannot be the same.")); + return; + } + + onConfirm(expectedLotInput.trim(), scannedLotInput.trim()); + }; + + return ( + + + + {t("Manual Lot Confirmation")} + + + + + {t("Expected Lot Number")}: + + { + setExpectedLotInput(e.target.value); + setError(''); + }} + placeholder={expectedLot?.lotNo || t("Enter expected lot number")} + sx={{ mb: 2 }} + error={!!error && !expectedLotInput.trim()} + /> + + + + + {t("Scanned Lot Number")}: + + { + setScannedLotInput(e.target.value); + setError(''); + }} + placeholder={scannedLot?.lotNo || t("Enter scanned lot number")} + sx={{ mb: 2 }} + error={!!error && !scannedLotInput.trim()} + /> + + + {error && ( + + + {error} + + + )} + + + + + + + + ); +}; + +/** 過期批號(未換有效批前):與 noLot 類似——單筆/批量預設提交量為 0,除非 Issue 改數 */ +function isLotAvailabilityExpired(lot: any): boolean { + return String(lot?.lotAvailability || "").toLowerCase() === "expired"; +} + +/** inventory_lot_line.status = unavailable(API 可能用 lotAvailability 或 lotStatus) */ +function isInventoryLotLineUnavailable(lot: any): boolean { + if (!lot) return false; + const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); + if (solSt === "completed" || solSt === "partially_completed") return false; + if (lot.lotAvailability === "status_unavailable") return true; + return String(lot.lotStatus || "").toLowerCase() === "unavailable"; +} + +/** 提貨台「列印標籤」彈窗頂部:依目前表格列判斷可提貨/已用畢/已過期等 */ +function isWorkbenchSourceLotExpired(lot: any): boolean { + if (!lot) return false; + if (isLotAvailabilityExpired(lot)) return true; + if (String(lot.lotAvailability || "").toLowerCase() === "expired") return true; + if (lot.expiryDate) { + const d = dayjs(lot.expiryDate).startOf("day"); + if (d.isValid() && d.isBefore(dayjs().startOf("day"))) return true; + } + return false; +} + +function getWorkbenchSourceLotStatusSummary(lot: any): { + severity: "success" | "warning" | "error"; + text: string; +} { + if (!lot) { + return { severity: "warning", text: "無法判斷此批號狀態" }; + } + if (isWorkbenchSourceLotExpired(lot)) { + return { severity: "error", text: "此批號狀態:已過期" }; + } + const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); + if (solSt === "rejected") { + return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + } + if (solSt === "completed" || solSt === "partially_completed") { + return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + } + /** + * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。 + * 與表格紅字「請檢查周圍是否有 QR 碼…」一致。 + */ + const isNoLotRow = + lot.noLot === true || + !lot.lotNo || + String(lot.lotNo || "").trim() === ""; + if (isNoLotRow) { + return { + severity: "warning", + text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + }; + } + const av = String(lot.lotAvailability || "").toLowerCase(); + if (av === "insufficient_stock") { + return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + } + const avail = Number(lot.availableQty); + if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { + return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + } + if (isInventoryLotLineUnavailable(lot)) { + return { + severity: "warning", + text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + }; + } + return { severity: "success", text: "此批號狀態:可提貨" }; +} + +type PickOrderT = (key: string, options?: Record) => string; + +function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { + const msg = raw.trim(); + if (!msg) return msg; + + const expiredMatch = msg.match(/^Lot is expired \(expiry=([^)]+)\)\.?$/i); + if (expiredMatch) { + return t("Lot is expired (expiry={{expiry}})", { + expiry: expiredMatch[1], + }); + } + + return t(msg); +} + +/** + * 顯示後端拒絕原因:優先 workbench scan API 的 message(暫存於 scanRejectBySolId), + * 其次階層 API 若帶 stockOutLineRejectMessage,最後依 rejected + lotAvailability 推斷(與後端語意對齊)。 + */ +function buildLotRejectDisplayMessage( + lot: any, + scanRejectBySolId: Record, + t: PickOrderT, +): string | undefined { + const solId = Number(lot.stockOutLineId) || 0; + const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : ""; + if (fromScan) return translateWorkbenchRejectMessage(fromScan, t); + + const fromApi = + typeof lot.stockOutLineRejectMessage === "string" + ? lot.stockOutLineRejectMessage.trim() + : ""; + if (fromApi) return translateWorkbenchRejectMessage(fromApi, t); + + const st = String(lot.stockOutLineStatus || "").toLowerCase(); + const av = String(lot.lotAvailability || "").toLowerCase(); + const isRejected = st === "rejected" || av === "rejected"; + if (!isRejected) return undefined; + + if (isLotAvailabilityExpired(lot) || av === "expired") { + return t("Rejected: lot expired or no longer valid."); + } + if (av === "insufficient_stock") { + return t("Rejected: no remaining quantity / empty lot."); + } + if (isInventoryLotLineUnavailable(lot) || av === "status_unavailable") { + return t("Rejected: lot unavailable or not yet putaway."); + } + return t("Pick was rejected. Please scan another lot or check stock."); +} + +/** Issue「改數」未寫入 SOL,刷新/換頁後需靠 session 還原,否則 Qty will submit 會回到 req */ +const FG_ISSUE_PICKED_KEY = (doPickOrderId: number) => + `fpsms-fg-issuePickedQty:${doPickOrderId}`; + +function loadIssuePickedMap(doPickOrderId: number): Record { + if (typeof window === "undefined" || !doPickOrderId) return {}; + try { + const raw = sessionStorage.getItem(FG_ISSUE_PICKED_KEY(doPickOrderId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + Object.entries(parsed).forEach(([k, v]) => { + const n = Number(v); + if (!Number.isNaN(n)) out[Number(k)] = n; + }); + return out; + } catch { + return {}; + } +} + +function saveIssuePickedMap(doPickOrderId: number, map: Record) { + if (typeof window === "undefined" || !doPickOrderId) return; + try { + sessionStorage.setItem(FG_ISSUE_PICKED_KEY(doPickOrderId), JSON.stringify(map)); + } catch { + // quota / private mode + } +} + +const WorkbenchGoodPickExecutionDetail: React.FC = ({ + filterArgs, + onSwitchToRecordTab, + onRefreshReleasedOrderCount, + onWorkbenchHierarchyEmpty, +}) => { + const workbenchMode = true; + const { t } = useTranslation("pickOrder"); + const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const [doPickOrderDetail, setDoPickOrderDetail] = useState(null); +const [selectedPickOrderId, setSelectedPickOrderId] = useState(null); +const [pickOrderSwitching, setPickOrderSwitching] = useState(false); + const currentUserId = session?.id ? parseInt(session.id) : undefined; + const [allLotsCompleted, setAllLotsCompleted] = useState(false); + const [combinedLotData, setCombinedLotData] = useState([]); + const [combinedDataLoading, setCombinedDataLoading] = useState(false); + const [originalCombinedData, setOriginalCombinedData] = useState([]); + // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) + const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState>({}); + const applyLocalStockOutLineUpdate = useCallback(( + stockOutLineId: number, + status: string, + actualPickQty?: number + ) => { + setCombinedLotData(prev => prev.map((lot) => { + if (Number(lot.stockOutLineId) !== Number(stockOutLineId)) return lot; + return { + ...lot, + stockOutLineStatus: status, + ...(typeof actualPickQty === "number" + ? { actualPickQty, stockOutLineQty: actualPickQty } + : {}), + }; + })); + }, []); + // 防止重复点击(Submit / Just Completed / Issue) + const [actionBusyBySolId, setActionBusyBySolId] = useState>({}); + + const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); + + const [qrScanInput, setQrScanInput] = useState(''); + const [qrScanError, setQrScanError] = useState(false); + const [qrScanErrorMsg, setQrScanErrorMsg] = useState(''); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + /** Workbench scanPick 等非 SUCCESS 時後端 message,按 stockOutLineId 顯示在批號欄 */ + const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState>({}); + const rememberWorkbenchScanReject = useCallback((stockOutLineId: number, message: string | undefined | null) => { + const id = Number(stockOutLineId); + const m = String(message ?? "").trim(); + if (!id || !m) return; + setScanRejectMessageBySolId((prev) => ({ ...prev, [id]: m })); + }, []); + const clearWorkbenchScanReject = useCallback((stockOutLineId: number) => { + const id = Number(stockOutLineId); + if (!id) return; + setScanRejectMessageBySolId((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); + const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); + /** 使用者覆寫「可提交數量」:鍵不存在=未編輯;鍵存在且為 0=明確要送 0 */ + const [pickQtyData, setPickQtyData] = useState>({}); + /** Workbench row: false = qty TextField read-only; Edit toggles true to type qty (replaces opening issue form). */ + const [workbenchSubmitQtyFieldEnabledByLotKey, setWorkbenchSubmitQtyFieldEnabledByLotKey] = useState< + Record + >({}); + + const resolveSingleSubmitQty = useCallback( + (lot: any) => { + const required = Number(lot.requiredQty || lot.pickOrderLineRequiredQty || 0); + const solId = Number(lot.stockOutLineId) || 0; + const lotId = lot.lotId; + const lotKey = + Number.isFinite(solId) && solId > 0 ? `sol:${solId}` : `${lot.pickOrderLineId}-${lotId}`; + + const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined; + if (issuePicked !== undefined && !Number.isNaN(Number(issuePicked))) { + return Number(issuePicked); + } + + const st = String(lot.stockOutLineStatus || "").toLowerCase(); + if ( + st === "completed" || + st === "partially_completed" || + st === "partially_complete" + ) { + return Number(lot.stockOutLineQty ?? lot.actualPickQty ?? 0); + } + + if (Object.prototype.hasOwnProperty.call(pickQtyData, lotKey)) { + const fromPick = pickQtyData[lotKey]; + if (!Number.isNaN(Number(fromPick))) { + return Number(fromPick); + } + } + return required; + }, + [issuePickedQtyBySolId, pickQtyData], + ); + + const getWorkbenchQtyLotKey = useCallback((lot: any) => { + const solId = Number(lot?.stockOutLineId) || 0; + if (Number.isFinite(solId) && solId > 0) return `sol:${solId}`; + return `${lot?.pickOrderLineId}-${lot?.lotId}`; + }, []); + + /** Use table row from combinedLotData so pickQtyData key pickOrderLineId-lotId matches the row user edited (expectedLot from QR index may differ lotId). */ + const workbenchScanPickQtyFromLot = useCallback( + (lot: any) => { + const solId = Number(lot?.stockOutLineId); + const sourceLot = + Number.isFinite(solId) && solId > 0 + ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot + : lot; + const lotKey = getWorkbenchQtyLotKey(sourceLot); + const hasExplicitPickOverride = Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKey, + ); + const n = Number(resolveSingleSubmitQty(sourceLot)); + // Explicit 0 short submit must send qty=0; implicit 0 must omit qty (backend implicit fill path). + if (hasExplicitPickOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const; + if (!Number.isFinite(n) || n <= 0) return {}; + return { qty: n } as const; + }, + [resolveSingleSubmitQty, combinedLotData, pickQtyData, getWorkbenchQtyLotKey], + ); + + const [searchQuery, setSearchQuery] = useState>({}); + + const [paginationController, setPaginationController] = useState({ + pageNum: 0, + pageSize: -1, + }); + + const [usernameList, setUsernameList] = useState([]); + + const initializationRef = useRef(false); + const autoAssignRef = useRef(false); + /** 曾成功載入過 workbench 階層資料;避免「列表仍有單但階層暫空」時對外層重複觸發造成迴圈 */ + const workbenchHierarchicalReadyRef = useRef(false); + + const formProps = useForm(); + const errors = formProps.formState.errors; + + // Add GoodPickExecutionForm states + const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); + const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState(null); + const [fgPickOrders, setFgPickOrders] = useState([]); + + const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); + + const lotFloorPrefixFilter = useMemo(() => { + const storeId = String(fgPickOrders?.[0]?.storeId ?? "") + .trim() + .toUpperCase() + .replace(/\s/g, ""); + const floorKey = storeId.replace(/\//g, ""); + return floorKey ? `${floorKey}-` : ""; + }, [fgPickOrders]); + + const defaultLabelPrinterName = useMemo(() => { + const storeId = String(fgPickOrders?.[0]?.storeId ?? "") + .trim() + .toUpperCase() + .replace(/\s/g, ""); + const floorKey = storeId.replace(/\//g, ""); + if (floorKey === "2F") return "Label機 2F A+B"; + if (floorKey === "4F") return "Label機 4F 乾貨 C, D"; + return undefined; + }, [fgPickOrders]); + + const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = + useState(false); + const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = + useState<{ itemId: number; stockInLineId: number } | null>(null); + const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = + useState(null); + const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = + useState(null); + + useEffect(() => { + if (!qrScanSuccess || !workbenchLotLabelModalOpen) return; + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelInitialPayload(null); + setWorkbenchLotLabelReminderText(null); + setWorkbenchLotLabelContextLot(null); + }, [qrScanSuccess, workbenchLotLabelModalOpen]); + + // Add these missing state variables after line 352 + const [isManualScanning, setIsManualScanning] = useState(false); + // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling + const [processedQrCombinations, setProcessedQrCombinations] = useState>>(new Map()); + const [processedQrCodes, setProcessedQrCodes] = useState>(new Set()); + const [lastProcessedQr, setLastProcessedQr] = useState(''); + const [isRefreshingData, setIsRefreshingData] = useState(false); + const [isSubmittingAll, setIsSubmittingAll] = useState(false); + + // Cache for fetchStockInLineInfo API calls to avoid redundant requests + const stockInLineInfoCache = useRef>(new Map()); + const CACHE_TTL = 60000; // 60 seconds cache TTL + const abortControllerRef = useRef(null); + const qrProcessingTimeoutRef = useRef(null); + + // Use refs for processed QR tracking to avoid useEffect dependency issues and delays + const processedQrCodesRef = useRef>(new Set()); + const lastProcessedQrRef = useRef(''); + + // Store callbacks in refs to avoid useEffect dependency issues + const processOutsideQrCodeRef = useRef< + ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null + >(null); + const resetScanRef = useRef<(() => void) | null>(null); + + + + // Handle QR code button click + const handleQrCodeClick = (pickOrderId: number) => { + console.log(`QR Code clicked for pick order ID: ${pickOrderId}`); + // TODO: Implement QR code functionality + }; + const progress = useMemo(() => { + if (combinedLotData.length === 0) { + return { completed: 0, total: 0 }; + } + + // 與 allItemsReady 一致:noLot / 過期 / unavailable 的 pending 也算「已面對該行」可收尾 + const nonPendingCount = combinedLotData.filter((lot) => { + const status = lot.stockOutLineStatus?.toLowerCase(); + if (status !== "pending") return true; + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) return true; + return false; + }).length; + + return { + completed: nonPendingCount, + total: combinedLotData.length, + }; + }, [combinedLotData]); + + + // Cached version of fetchStockInLineInfo to avoid redundant API calls + const fetchStockInLineInfoCached = useCallback(async (stockInLineId: number): Promise<{ lotNo: string | null }> => { + const now = Date.now(); + const cached = stockInLineInfoCache.current.get(stockInLineId); + + // Return cached value if still valid + if (cached && (now - cached.timestamp) < CACHE_TTL) { + console.log(`✅ [CACHE HIT] Using cached stockInLineInfo for ${stockInLineId}`); + return { lotNo: cached.lotNo }; + } + + // Cancel previous request if exists + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller for this request + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + console.log(` [CACHE MISS] Fetching stockInLineInfo for ${stockInLineId}`); + const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); + + // Store in cache + stockInLineInfoCache.current.set(stockInLineId, { + lotNo: stockInLineInfo.lotNo || null, + timestamp: now + }); + + // Limit cache size to prevent memory leaks + if (stockInLineInfoCache.current.size > 100) { + const firstKey = stockInLineInfoCache.current.keys().next().value; + if (firstKey !== undefined) { + stockInLineInfoCache.current.delete(firstKey); + } + } + + return { lotNo: stockInLineInfo.lotNo || null }; + } catch (error: any) { + if (error.name === 'AbortError') { + console.log(` [CACHE] Request aborted for ${stockInLineId}`); + throw error; + } + console.error(`❌ [CACHE] Error fetching stockInLineInfo for ${stockInLineId}:`, error); + throw error; + } + }, []); + + const checkAllLotsCompleted = useCallback((lotData: any[]) => { + if (lotData.length === 0) { + setAllLotsCompleted(false); + return false; + } + + // Filter out rejected lots + const nonRejectedLots = lotData.filter(lot => + lot.lotAvailability !== 'rejected' && + lot.stockOutLineStatus !== 'rejected' + ); + + if (nonRejectedLots.length === 0) { + setAllLotsCompleted(false); + return false; + } + + // Check if all non-rejected lots are completed + const allCompleted = nonRejectedLots.every(lot => + lot.stockOutLineStatus === 'completed' + ); + + setAllLotsCompleted(allCompleted); + return allCompleted; + }, []); +// 在 fetchAllCombinedLotData 函数中(约 446-684 行) + +const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdOverride?: number) => { + setCombinedDataLoading(true); + try { + const userIdToUse = userId || currentUserId; + + console.log(" fetchAllCombinedLotData called with userId:", userIdToUse); + + if (!userIdToUse) { + console.warn("⚠️ No userId available, skipping API call"); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); + return; + } + + // 获取新结构的层级数据 + const hierarchicalData = await fetchAllPickOrderLotsHierarchicalWorkbench(userIdToUse); + console.log(" Hierarchical data (new structure):", hierarchicalData); + + // 检查数据结构 + if (!hierarchicalData?.fgInfo || !hierarchicalData.pickOrders?.length) { + console.warn("⚠️ No FG info or pick orders found"); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); + setFgPickOrders([]); + if (workbenchHierarchicalReadyRef.current) { + workbenchHierarchicalReadyRef.current = false; + onWorkbenchHierarchyEmpty?.(); + } + return; + } + + // 使用合并后的 pick order 对象(现在只有一个对象,包含所有数据) + const mergedPickOrder = hierarchicalData.pickOrders[0]; + + // 设置 FG info 到 fgPickOrders(用于显示 FG 信息卡片) + // 修改第 478-509 行的 fgOrder 构建逻辑: + + const fgOrder: FGPickOrderResponse = { + doPickOrderId: hierarchicalData.fgInfo.doPickOrderId, + ticketNo: hierarchicalData.fgInfo.ticketNo, + storeId: hierarchicalData.fgInfo.storeId, + shopCode: hierarchicalData.fgInfo.shopCode, + shopName: hierarchicalData.fgInfo.shopName, + truckLanceCode: hierarchicalData.fgInfo.truckLanceCode, + DepartureTime: hierarchicalData.fgInfo.departureTime, + shopAddress: "", + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + + // 兼容字段(注意 consoCodes 是数组) + pickOrderId: mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) + ? mergedPickOrder.consoCodes[0] || "" + : "", + pickOrderTargetDate: mergedPickOrder.targetDate || "", + pickOrderStatus: mergedPickOrder.status || "", + deliveryOrderId: mergedPickOrder.doOrderIds?.[0] || 0, + deliveryNo: mergedPickOrder.deliveryOrderCodes?.[0] || "", + deliveryDate: "", + shopId: 0, + shopPoNo: "", + numberOfCartons: mergedPickOrder.pickOrderLines?.length || 0, + qrCodeData: hierarchicalData.fgInfo.doPickOrderId, + + // 多个 pick orders 信息:全部保留为数组 + numberOfPickOrders: mergedPickOrder.pickOrderIds?.length || 0, + pickOrderIds: mergedPickOrder.pickOrderIds || [], + pickOrderCodes: Array.isArray(mergedPickOrder.pickOrderCodes) + ? mergedPickOrder.pickOrderCodes + : [], + deliveryOrderIds: mergedPickOrder.doOrderIds || [], + deliveryNos: Array.isArray(mergedPickOrder.deliveryOrderCodes) + ? mergedPickOrder.deliveryOrderCodes + : [], + lineCountsPerPickOrder: Array.isArray(mergedPickOrder.lineCountsPerPickOrder) + ? mergedPickOrder.lineCountsPerPickOrder + : [], + }; + + setFgPickOrders([fgOrder]); + workbenchHierarchicalReadyRef.current = true; + console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder); + console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes); + console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); + + // 直接使用合并后的 pickOrderLines + console.log("🎯 Displaying merged pick order lines"); + + // 将层级数据转换为平铺格式(用于表格显示) + const flatLotData: any[] = []; + + // 2/F 與後端 store_id 一致時需按 itemOrder;避免 API 未走 2F 分支時畫面仍亂序 + const doFloorKey = String(hierarchicalData.fgInfo.storeId ?? '') + .trim() + .toUpperCase() + .replace(/\//g, '') + .replace(/\s/g, ''); + const pickOrderLinesForDisplay = + doFloorKey === '2F' + ? [...(mergedPickOrder.pickOrderLines || [])].sort((a: any, b: any) => { + const ao = a.itemOrder != null ? Number(a.itemOrder) : 999999; + const bo = b.itemOrder != null ? Number(b.itemOrder) : 999999; + if (ao !== bo) return ao - bo; + return (Number(a.id) || 0) - (Number(b.id) || 0); + }) + : mergedPickOrder.pickOrderLines || []; + + pickOrderLinesForDisplay.forEach((line: any) => { + // 用来记录这一行已经通过 lots 出现过的 lotId + const lotIdSet = new Set(); + /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數) */ + let lotsAllocatedSumForLine = 0; + + // ✅ lots:按 lotId 去重并合并 requiredQty + if (line.lots && line.lots.length > 0) { + const lotMap = new Map(); + + line.lots.forEach((lot: any) => { + const lotId = lot.id; + if (lotMap.has(lotId)) { + const existingLot = lotMap.get(lotId); + existingLot.requiredQty = + (existingLot.requiredQty || 0) + (lot.requiredQty || 0); + } else { + lotMap.set(lotId, { ...lot }); + } + }); + + lotMap.forEach((lot: any) => { + lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; + if (lot.id != null) { + lotIdSet.add(lot.id); + } + + flatLotData.push({ + pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) + ? mergedPickOrder.consoCodes[0] || "" + : "", + pickOrderTargetDate: mergedPickOrder.targetDate, + pickOrderStatus: mergedPickOrder.status, + pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + pickOrderLineId: line.id, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + + itemId: line.item.id, + itemCode: line.item.code, + itemName: line.item.name, + uomDesc: line.item.uomDesc, + uomShortDesc: line.item.uomShortDesc, + + lotId: lot.id, + lotNo: lot.lotNo, + expiryDate: lot.expiryDate, + location: lot.location, + stockUnit: lot.stockUnit, + availableQty: lot.availableQty, + requiredQty: lot.requiredQty, + actualPickQty: lot.actualPickQty, + inQty: lot.inQty, + outQty: lot.outQty, + holdQty: lot.holdQty, + lotStatus: lot.lotStatus, + lotAvailability: lot.lotAvailability, + processingStatus: lot.processingStatus, + suggestedPickLotId: lot.suggestedPickLotId, + stockOutLineId: lot.stockOutLineId, + stockOutLineStatus: lot.stockOutLineStatus, + stockOutLineQty: lot.stockOutLineQty, + stockOutLineRejectMessage: lot.stockOutLineRejectMessage ?? null, + stockInLineId: lot.stockInLineId, + routerId: lot.router?.id, + routerIndex: lot.router?.index, + routerRoute: lot.router?.route, + routerArea: lot.router?.area, + noLot: false, + }); + }); + } + + // ✅ stockouts:只保留“真正无批次 / 未在 lots 出现过”的行 + if (line.stockouts && line.stockouts.length > 0) { + line.stockouts.forEach((stockout: any) => { + const hasLot = stockout.lotId != null; + const lotAlreadyInLots = + hasLot && lotIdSet.has(stockout.lotId as number); + + // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 + if (!stockout.noLot && lotAlreadyInLots) { + return; + } + + // 只渲染: + // - noLot === true 的 Null stock 行 + // - 或者 lotId 在 lots 中不存在的特殊情况 + flatLotData.push({ + pickOrderConsoCode: Array.isArray(mergedPickOrder.consoCodes) + ? mergedPickOrder.consoCodes[0] || "" + : "", + pickOrderTargetDate: mergedPickOrder.targetDate, + pickOrderStatus: mergedPickOrder.status, + pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, + pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", + pickOrderLineId: line.id, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + + itemId: line.item.id, + itemCode: line.item.code, + itemName: line.item.name, + uomDesc: line.item.uomDesc, + uomShortDesc: line.item.uomShortDesc, + + lotId: stockout.lotId || null, + lotNo: stockout.lotNo || null, + expiryDate: null, + location: stockout.location || null, + stockUnit: line.item.uomDesc, + availableQty: stockout.availableQty || 0, + // 無批次列對應 suggested_pick_lot 的缺口量(如 11),勿用整行 POL 需求(100)以免顯示成 89 / 100 + requiredQty: stockout.noLot + ? Math.max( + 0, + (Number(line.requiredQty) || 0) - lotsAllocatedSumForLine + ) + : Number(line.requiredQty) || 0, + actualPickQty: stockout.qty || 0, + inQty: 0, + outQty: 0, + holdQty: 0, + lotStatus: stockout.noLot ? "unavailable" : "available", + lotAvailability: stockout.noLot ? "insufficient_stock" : "available", + processingStatus: stockout.status || "pending", + suggestedPickLotId: null, + stockOutLineId: stockout.id || null, + stockOutLineStatus: stockout.status || null, + stockOutLineQty: stockout.qty || 0, + stockOutLineRejectMessage: stockout.rejectMessage ?? stockout.rejectReason ?? null, + + routerId: null, + routerIndex: stockout.noLot ? 999999 : null, + routerRoute: null, + routerArea: null, + noLot: !!stockout.noLot, + }); + }); + } + }); + + console.log(" Transformed flat lot data:", flatLotData); + console.log(" Total items (including null stock):", flatLotData.length); + + setCombinedLotData(flatLotData); + setOriginalCombinedData(flatLotData); + const doPid = hierarchicalData.fgInfo?.doPickOrderId; + if (doPid) { + setIssuePickedQtyBySolId(loadIssuePickedMap(doPid)); + } + checkAllLotsCompleted(flatLotData); + } catch (error) { + console.error(" Error fetching combined lot data:", error); + setCombinedLotData([]); + setOriginalCombinedData([]); + setAllLotsCompleted(false); + setIssuePickedQtyBySolId({}); + } finally { + setCombinedDataLoading(false); + } +}, [currentUserId, checkAllLotsCompleted, onWorkbenchHierarchyEmpty]); // 移除 selectedPickOrderId 依赖 + + /** After workbench scan-pick (incl. split → new stock_out_line), reload hierarchical rows. */ + const refreshWorkbenchAfterScanPick = useCallback(async () => { + setIsRefreshingData(true); + try { + await fetchAllCombinedLotData(); + } finally { + setIsRefreshingData(false); + } + }, [fetchAllCombinedLotData]); + + const openWorkbenchLotLabelModalForLot = useCallback( + (lot: any, reminderText?: string | null) => { + const itemId = Number(lot?.itemId); + const stockInLineId = Number(lot?.stockInLineId); + const solId = Number(lot?.stockOutLineId); + if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { + return; + } + setWorkbenchLotLabelContextLot(lot); + if (Number.isFinite(stockInLineId) && stockInLineId > 0) { + setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); + } else { + setWorkbenchLotLabelInitialPayload(null); + } + setWorkbenchLotLabelReminderText(reminderText ?? null); + setWorkbenchLotLabelModalOpen(true); + }, + [], + ); + + const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( + (code?: string | null, msg?: string | null): boolean => { + const normalizedCode = String(code || "").toUpperCase(); + if ( + normalizedCode.includes("UNAVAILABLE") || + normalizedCode.includes("EXPIRED") + ) { + return true; + } + + const normalizedMsg = String(msg || "").toLowerCase(); + if (!normalizedMsg) return false; + return ( + normalizedMsg.includes("unavailable") || + normalizedMsg.includes("not available") || + normalizedMsg.includes("expired") || + normalizedMsg.includes("不可用") || + normalizedMsg.includes("無法使用") || + normalizedMsg.includes("过期") + ); + }, + [], + ); + + const handleWorkbenchLotLabelScanPick = useCallback( + async ({ + inventoryLotLineId, + lotNo, + qty, + }: { + inventoryLotLineId: number; + lotNo: string; + qty?: number; + }) => { + const lot = workbenchLotLabelContextLot; + if (!lot?.stockOutLineId) { + throw new Error(t("Missing stock out line for this row.")); + } + const qtyPayload = Number.isFinite(Number(qty)) + ? { qty: Number(qty) } + : workbenchScanPickQtyFromLot(lot); + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lotNo || "").trim(), + inventoryLotLineId, + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + ...qtyPayload, + }); + if (res.code !== "SUCCESS") { + const errMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + rememberWorkbenchScanReject(Number(lot.stockOutLineId), errMsg); + throw new Error(errMsg); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + await refreshWorkbenchAfterScanPick(); + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + setWorkbenchLotLabelReminderText(null); + }, + [ + workbenchLotLabelContextLot, + fgPickOrders, + currentUserId, + workbenchScanPickQtyFromLot, + refreshWorkbenchAfterScanPick, + rememberWorkbenchScanReject, + clearWorkbenchScanReject, + t, + ], + ); + + const workbenchLotLabelStatusBanner = useMemo(() => { + if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) { + return { + text: undefined as string | undefined, + severity: undefined as "success" | "warning" | "error" | undefined, + }; + } + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + return { text: s.text, severity: s.severity }; + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + + const workbenchLotLabelSubmitQty = useMemo(() => { + if (!workbenchLotLabelContextLot) return 0; + return Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) || 0; + }, [workbenchLotLabelContextLot, resolveSingleSubmitQty]); + + const handleWorkbenchLotLabelSubmitQtyChange = useCallback( + (qty: number) => { + if (!workbenchLotLabelContextLot) return; + const lotKey = getWorkbenchQtyLotKey(workbenchLotLabelContextLot); + setPickQtyData((prev) => ({ ...prev, [lotKey]: qty })); + }, + [workbenchLotLabelContextLot, getWorkbenchQtyLotKey], + ); + + /** + * 與「掃描結果」欄綠勾一致:有批號且 SOL 非 pending、非 rejected 時視為已掃/已提貨,停用掃碼提貨。 + */ + const workbenchLotLabelScanPickDisabled = useMemo(() => { + if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) return true; + const lot = workbenchLotLabelContextLot; + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + const isNoLot = !lot.lotNo || String(lot.lotNo).trim() === ""; + if (isNoLot) return false; + if (status === "pending" || status === "rejected") return false; + return true; + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + + // Manual lot substitution is part of the legacy "checked then batch submit" flow. + // Workbench now uses scan-pick (immediate posting) + qty=0 zero-complete; disable this path to avoid writing `checked`. + const handleManualLotConfirmation = useCallback(async () => { + alert(t("Workbench does not support manual lot substitution in this flow. Please use scan-pick / Just Completed.")); + }, [t]); + useEffect(() => { + if (combinedLotData.length > 0) { + checkAllLotsCompleted(combinedLotData); + } + }, [combinedLotData, checkAllLotsCompleted]); + + // Add function to expose completion status to parent + const getCompletionStatus = useCallback(() => { + return allLotsCompleted; + }, [allLotsCompleted]); + + // Expose completion status to parent component + useEffect(() => { + // Dispatch custom event with completion status + const event = new CustomEvent('pickOrderCompletionStatus', { + detail: { + allLotsCompleted, + tabIndex: 1 // 明确指定这是来自标签页 1 的事件 + } + }); + window.dispatchEvent(event); + }, [allLotsCompleted]); + + const handleQrCodeSubmit = useCallback(async (lotNo: string) => { + console.log(` Processing QR Code for lot: ${lotNo}`); + + // 检查 lotNo 是否为 null 或 undefined(包括字符串 "null") + if (!lotNo || lotNo === 'null' || lotNo.trim() === '') { + console.error(" Invalid lotNo: null, undefined, or empty"); + return; + } + + // Use current data without refreshing to avoid infinite loop + const currentLotData = combinedLotData; + console.log(` Available lots:`, currentLotData.map(lot => lot.lotNo)); + + // 修复:在比较前确保 lotNo 不为 null + const lotNoLower = lotNo.toLowerCase(); + const matchingLots = currentLotData.filter(lot => { + if (!lot.lotNo) return false; // 跳过 null lotNo + return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower; + }); + + if (matchingLots.length === 0) { + console.error(` Lot not found: ${lotNo}`); + setQrScanError(true); + setQrScanSuccess(false); + const availableLotNos = currentLotData.map(lot => lot.lotNo).join(', '); + console.log(` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`); + return; + } + + const hasExpiredLot = matchingLots.some( + (lot: any) => String(lot.lotAvailability || '').toLowerCase() === 'expired' + ); + if (hasExpiredLot) { + console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + // Legacy QR flow marked SOL as `checked` (normal version). Workbench uses scan-pick (immediate posting). + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t("Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status."), + ); + }, [combinedLotData]); + const handleFastQrScan = useCallback(async (lotNo: string) => { + const startTime = performance.now(); + console.log(` [FAST SCAN START] Lot: ${lotNo}`); + console.log(` Start time: ${new Date().toISOString()}`); + + // 从 combinedLotData 中找到对应的 lot + const findStartTime = performance.now(); + const matchingLot = combinedLotData.find(lot => + lot.lotNo && lot.lotNo === lotNo + ); + const findTime = performance.now() - findStartTime; + console.log(` Find lot time: ${findTime.toFixed(2)}ms`); + + if (!matchingLot || !matchingLot.stockOutLineId) { + const totalTime = performance.now() - startTime; + console.warn(`⚠️ Fast scan: Lot ${lotNo} not found or no stockOutLineId`); + console.log(` Total time: ${totalTime.toFixed(2)}ms`); + return; + } + + try { + const apiStartTime = performance.now(); + const res = await workbenchScanPick({ + stockOutLineId: matchingLot.stockOutLineId, + lotNo, + ...(typeof matchingLot.stockInLineId === "number" && + Number.isFinite(matchingLot.stockInLineId) && + matchingLot.stockInLineId > 0 + ? { stockInLineId: matchingLot.stockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(matchingLot), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const apiTime = performance.now() - apiStartTime; + console.log(` API call time: ${apiTime.toFixed(2)}ms`); + + const ok = res.code === "SUCCESS"; + if (ok) { + clearWorkbenchScanReject(Number(matchingLot.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + const patchRow = (prev: any[]) => + prev.map((row) => { + if ( + row.stockOutLineId === matchingLot.stockOutLineId && + row.pickOrderLineId === matchingLot.pickOrderLineId + ) { + return { + ...row, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? row.stockOutLineQty, + actualPickQty: nextQty ?? row.actualPickQty, + }; + } + return row; + }); + setCombinedLotData(patchRow); + setOriginalCombinedData(patchRow); + + const totalTime = performance.now() - startTime; + console.log(`✅ [FAST SCAN END] Lot: ${lotNo}`); + console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); + console.log(` End time: ${new Date().toISOString()}`); + } else { + const totalTime = performance.now() - startTime; + console.warn(`⚠️ Fast scan failed for ${lotNo}:`, res.code); + console.log(` Total time: ${totalTime.toFixed(2)}ms`); + if (workbenchMode && matchingLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(matchingLot.stockOutLineId), + (res as { message?: string })?.message, + ); + } + } + } catch (error) { + const totalTime = performance.now() - startTime; + console.error(` Fast scan error for ${lotNo}:`, error); + console.log(` Total time: ${totalTime.toFixed(2)}ms`); + } + }, [combinedLotData, updateStockOutLineStatusByQRCodeAndLotNo, workbenchMode, currentUserId, clearWorkbenchScanReject, rememberWorkbenchScanReject, refreshWorkbenchAfterScanPick, workbenchScanPickQtyFromLot]); + // Enhanced lotDataIndexes with cached active lots for better performance + const lotDataIndexes = useMemo(() => { + const indexStartTime = performance.now(); + console.log(` [PERF] lotDataIndexes calculation START, data length: ${combinedLotData.length}`); + + const byItemId = new Map(); + const byItemCode = new Map(); + const byLotId = new Map(); + const byLotNo = new Map(); + const byStockInLineId = new Map(); + // Cache active lots separately to avoid filtering on every scan + const activeLotsByItemId = new Map(); + const rejectedStatuses = new Set(['rejected']); + + // ✅ Use for loop instead of forEach for better performance on tablets + for (let i = 0; i < combinedLotData.length; i++) { + const lot = combinedLotData[i]; + const solStatus = String(lot.stockOutLineStatus || "").toLowerCase(); + const lotAvailability = String(lot.lotAvailability || "").toLowerCase(); + const processingStatus = String(lot.processingStatus || "").toLowerCase(); + const isUnavailable = isInventoryLotLineUnavailable(lot); + const isExpired = isLotAvailabilityExpired(lot); + const isRejected = + rejectedStatuses.has(lotAvailability) || + rejectedStatuses.has(solStatus) || + rejectedStatuses.has(processingStatus); + const isEnded = solStatus === "checked" || solStatus === "completed"; + const isPartially = solStatus === "partially_completed" || solStatus === "partially_complete"; + const isPending = solStatus === "pending" || solStatus === ""; + const isActive = !isRejected && !isUnavailable && !isExpired && !isEnded && (isPending || isPartially); + + if (lot.itemId) { + if (!byItemId.has(lot.itemId)) { + byItemId.set(lot.itemId, []); + activeLotsByItemId.set(lot.itemId, []); + } + byItemId.get(lot.itemId)!.push(lot); + if (isActive) { + activeLotsByItemId.get(lot.itemId)!.push(lot); + } + } + + if (lot.itemCode) { + if (!byItemCode.has(lot.itemCode)) { + byItemCode.set(lot.itemCode, []); + } + byItemCode.get(lot.itemCode)!.push(lot); + } + + if (lot.lotId) { + byLotId.set(lot.lotId, lot); + } + + if (lot.lotNo) { + if (!byLotNo.has(lot.lotNo)) { + byLotNo.set(lot.lotNo, []); + } + byLotNo.get(lot.lotNo)!.push(lot); + } + + if (lot.stockInLineId) { + if (!byStockInLineId.has(lot.stockInLineId)) { + byStockInLineId.set(lot.stockInLineId, []); + } + byStockInLineId.get(lot.stockInLineId)!.push(lot); + } + } + + const indexTime = performance.now() - indexStartTime; + if (indexTime > 10) { + console.log(` [PERF] lotDataIndexes calculation END: ${indexTime.toFixed(2)}ms (${(indexTime / 1000).toFixed(3)}s)`); + } + + return { byItemId, byItemCode, byLotId, byLotNo, byStockInLineId, activeLotsByItemId }; + }, [combinedLotData.length, combinedLotData]); + + // Store resetScan in ref for immediate access (update on every render) + resetScanRef.current = resetScan; + + const processOutsideQrCode = useCallback(async (latestQr: string, qrScanCountAtInvoke?: number) => { + const totalStartTime = performance.now(); + console.log(` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`); + console.log(` Start time: ${new Date().toISOString()}`); + + // ✅ Measure index access time + const indexAccessStart = performance.now(); + const indexes = lotDataIndexes; // Access the memoized indexes + const indexAccessTime = performance.now() - indexAccessStart; + console.log(` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`); + + // 1) Parse JSON safely (parse once, reuse) + const parseStartTime = performance.now(); + let qrData: any = null; + let parseTime = 0; + try { + qrData = JSON.parse(latestQr); + parseTime = performance.now() - parseStartTime; + console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`); + } catch { + console.log("QR content is not JSON; skipping lotNo direct submit to avoid false matches."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + + try { + const validationStartTime = performance.now(); + if (!(qrData?.stockInLineId && qrData?.itemId)) { + console.log("QR JSON missing required fields (itemId, stockInLineId)."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const validationTime = performance.now() - validationStartTime; + console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`); + + const scannedItemId = qrData.itemId; + const scannedStockInLineId = qrData.stockInLineId; + + // ✅ Check if this combination was already processed + const duplicateCheckStartTime = performance.now(); + const itemProcessedSet = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet?.has(scannedStockInLineId)) { + const duplicateCheckTime = performance.now() - duplicateCheckStartTime; + console.log(` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed(2)}ms)`); + return; + } + const duplicateCheckTime = performance.now() - duplicateCheckStartTime; + console.log(` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`); + + // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) + const lookupStartTime = performance.now(); + const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; + // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected + const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; + const lookupTime = performance.now() - lookupStartTime; + console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); + + // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots + // This allows users to scan other lots even when all suggested lots are rejected + const scannedLot = allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId + ); + + if (scannedLot) { + const isRejected = + scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' || + scannedLot.lotAvailability === 'rejected'; + const isUnavailable = isInventoryLotLineUnavailable(scannedLot); + + if (isRejected) { + console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected`); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` + ); + }); + // Mark as processed to prevent re-processing + setProcessedQrCombinations(prev => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + return; + } + + if (isUnavailable) { + console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is unavailable; opening lot-label modal`); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(false); + }); + openWorkbenchLotLabelModalForLot( + scannedLot, + t("This lot is not available, please scan another lot."), + ); + return; + } + + const isExpired = + String(scannedLot.lotAvailability || '').toLowerCase() === 'expired'; + if (isExpired) { + console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired; opening lot-label modal`); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(false); + }); + openWorkbenchLotLabelModalForLot( + scannedLot, + `Lot is expired (expiry=${scannedLot.expiryDate || "-"})`, + ); + return; + } + } + + // ✅ If no active suggested lots, auto-switch to scanned lot (no modal) + if (activeSuggestedLots.length === 0) { + // Check if there are any lots for this item (even if all are rejected) + if (allLotsForItem.length === 0) { + console.error("No lots found for this item"); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg("当前订单中没有此物品的批次信息"); + }); + return; + } + + console.log(`⚠️ [QR PROCESS] No active suggested lots, auto-switching to scanned lot.`); + + // Find a rejected lot as expected lot (the one that was rejected) + const rejectedLot = allLotsForItem.find((lot: any) => + lot.stockOutLineStatus?.toLowerCase() === 'rejected' || + lot.lotAvailability === 'rejected' || + isInventoryLotLineUnavailable(lot) + ); + const expectedLot = + rejectedLot || + pickExpectedLotForSubstitution( + allLotsForItem.filter( + (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "" + ) + ) || + allLotsForItem[0]; + + let scannedLotNo: string | null = scannedLot?.lotNo || null; + if (!scannedLotNo) { + try { + const info = await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e); + } + } + if (!scannedLotNo) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.") + ); + }); + return; + } + + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; + if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) { + const errMsg = + substitutionResult?.message || + t("Lot switch failed; pick line was not updated."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + }); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg); + } + return; + } + } + + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const ok = res.code === "SUCCESS"; + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + + return; + } + + // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) + const matchStartTime = performance.now(); + let exactMatch: any = null; + const stockInLineLots = indexes.byStockInLineId.get(scannedStockInLineId) || []; + // Find exact match from stockInLineId index, then verify it's in active lots + for (let i = 0; i < stockInLineLots.length; i++) { + const lot = stockInLineLots[i]; + if (lot.itemId === scannedItemId && activeSuggestedLots.includes(lot)) { + exactMatch = lot; + break; + } + } + const matchTime = performance.now() - matchStartTime; + console.log(` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); + + // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots + // This handles the case where Lot A is rejected and user scans Lot B + // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) + if (!exactMatch) { + const expectedLot = + pickExpectedLotForSubstitution(activeSuggestedLots) || allLotsForItem[0]; + if (expectedLot) { + const shouldAutoSwitch = + !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); + if (shouldAutoSwitch) { + console.log( + `⚠️ [QR PROCESS] Auto-switching (scanned lot ${scannedLot?.lotNo || 'not in data'} is not in active suggested lots)`, + ); + + let scannedLotNo: string | null = scannedLot?.lotNo || null; + if (!scannedLotNo) { + try { + const info = await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e); + } + } + if (!scannedLotNo) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + t("Cannot resolve lot number from QR. Please rescan or use manual confirmation.") + ); + }); + return; + } + + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; + if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) { + const errMsg = + substitutionResult?.message || + t("Lot switch failed; pick line was not updated."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + }); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg); + } + return; + } + } + + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const ok = res.code === "SUCCESS"; + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + return; + } + } + } + + if (exactMatch) { + // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 + console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`); + + if (!exactMatch.stockOutLineId) { + console.warn("No stockOutLineId on exactMatch, cannot update status by QR."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + + try { + const apiStartTime = performance.now(); + console.log( + workbenchMode + ? ` [API CALL START] workbenchScanPick` + : ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo` + ); + console.log(` [API CALL] API start time: ${new Date().toISOString()}`); + const res = await workbenchScanPick({ + stockOutLineId: exactMatch.stockOutLineId, + lotNo: exactMatch.lotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : typeof exactMatch.stockInLineId === "number" && + Number.isFinite(exactMatch.stockInLineId) && + exactMatch.stockInLineId > 0 + ? { stockInLineId: exactMatch.stockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(exactMatch), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const apiTime = performance.now() - apiStartTime; + console.log(` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${(apiTime / 1000).toFixed(3)}s)`); + console.log(` [API CALL] API end time: ${new Date().toISOString()}`); + + const ok = res.code === "SUCCESS"; + if (ok) { + clearWorkbenchScanReject(Number(exactMatch.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = + entity?.qty != null ? Number(entity.qty) : undefined; + + // ✅ Batch state updates using startTransition + const stateUpdateStartTime = performance.now(); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + + setCombinedLotData(prev => prev.map(lot => { + if (lot.stockOutLineId === exactMatch.stockOutLineId && + lot.pickOrderLineId === exactMatch.pickOrderLineId) { + return { + ...lot, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + })); + + setOriginalCombinedData(prev => prev.map(lot => { + if (lot.stockOutLineId === exactMatch.stockOutLineId && + lot.pickOrderLineId === exactMatch.pickOrderLineId) { + return { + ...lot, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + })); + }); + const stateUpdateTime = performance.now() - stateUpdateStartTime; + console.log(` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`); + + // Mark this combination as processed + const markProcessedStartTime = performance.now(); + setProcessedQrCombinations(prev => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) { + newMap.set(scannedItemId, new Set()); + } + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + const markProcessedTime = performance.now() - markProcessedStartTime; + console.log(` [PERF] Mark processed time: ${markProcessedTime.toFixed(2)}ms`); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + + const totalTime = performance.now() - totalStartTime; + console.log(`✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); + console.log(` End time: ${new Date().toISOString()}`); + console.log(`📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed(2)}ms, stateUpdate=${stateUpdateTime.toFixed(2)}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`); + console.log( + workbenchMode + ? "✅ Workbench scan-pick: list refreshed from server" + : "✅ Status updated locally, no full data refresh needed", + ); + } else { + console.warn("Unexpected response code from backend:", res.code); + const failMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + exactMatch + ) { + openWorkbenchLotLabelModalForLot(exactMatch, failMsg); + return; + } + if (workbenchMode && exactMatch.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(exactMatch.stockOutLineId), failMsg); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + } + } catch (e) { + const totalTime = performance.now() - totalStartTime; + console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`); + console.error("Error calling updateStockOutLineStatusByQRCodeAndLotNo:", e); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + } + + return; // ✅ 直接返回,不需要确认表单 + } + + // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 + // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 + const mismatchCheckStartTime = performance.now(); + const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet2?.has(scannedStockInLineId)) { + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log( + ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed(2)}ms)`, + ); + return; + } + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log(` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`); + + const expectedLotStartTime = performance.now(); + const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); + if (!expectedLot) { + console.error("Could not determine expected lot for auto-switch"); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const expectedLotTime = performance.now() - expectedLotStartTime; + console.log(` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`); + + console.log( + `⚠️ Lot mismatch (auto): Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`, + ); + + // 1) 先把掃到的 stockInLineId 轉成 lotNo(workbenchScanPick 需要 lotNo) + let scannedLotNo: string | null = null; + try { + const info = await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn("Failed to fetch lotNo for stockInLineId:", scannedStockInLineId, e); + } + if (!scannedLotNo) { + const msg = t("Cannot resolve lot number from QR. Please rescan or use manual confirmation."); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + return; + } + + // 2) 非 workbench:先 confirmLotSubstitution;workbench 僅依 scan-pick 規則與錯誤訊息 + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || substitutionCode === "BOUND_UNAVAILABLE"; + if (!substitutionResult || (substitutionCode !== "SUCCESS" && !switchedToUnavailable)) { + const errMsg = + substitutionResult?.message || + t("Lot switch failed; pick line was not updated."); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), errMsg); + } + return; + } + } + + // 3) 提交掃描(workbench:直接 workbenchScanPick) + try { + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + + const ok = res.code === "SUCCESS"; + + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject(Number(expectedLot.stockOutLineId), failMsg); + } + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + + setCombinedLotData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + + setOriginalCombinedData((prev) => + prev.map((lot) => { + if ( + lot.stockOutLineId === expectedLot.stockOutLineId && + lot.pickOrderLineId === expectedLot.pickOrderLineId + ) { + return { + ...lot, + lotNo: scannedLotNo, + stockOutLineStatus: nextStatus, + stockOutLineQty: nextQty ?? lot.stockOutLineQty, + }; + } + return lot; + }), + ); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + } catch (e) { + console.error("Auto-switch scanPick failed:", e); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + const totalTime = performance.now() - totalStartTime; + console.log( + `✅ [PROCESS OUTSIDE QR AUTO-SWITCH] Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + console.log( + `📊 Breakdown: parse=${parseTime.toFixed(2)}ms, validation=${validationTime.toFixed(2)}ms, duplicateCheck=${duplicateCheckTime.toFixed(2)}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed(2)}ms, mismatchCheck=${mismatchCheckTime.toFixed(2)}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`, + ); + } catch (error) { + const totalTime = performance.now() - totalStartTime; + console.error(`❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`); + console.error("Error during QR code processing:", error); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + }, [ + lotDataIndexes, + processedQrCombinations, + combinedLotData, + fetchStockInLineInfoCached, + workbenchMode, + currentUserId, + clearWorkbenchScanReject, + rememberWorkbenchScanReject, + refreshWorkbenchAfterScanPick, + workbenchScanPickQtyFromLot, + openWorkbenchLotLabelModalForLot, + shouldOpenWorkbenchLotLabelModalForFailure, + t, + ]); + // Store processOutsideQrCode in ref for immediate access (update on every render) + processOutsideQrCodeRef.current = processOutsideQrCode; + + useEffect(() => { + // Skip if scanner is not active or no data available + if (!isManualScanning || qrValues.length === 0 || combinedLotData.length === 0 || isRefreshingData) { + return; + } + + const qrValuesChangeStartTime = performance.now(); + console.log(` [QR VALUES EFFECT] Triggered at: ${new Date().toISOString()}`); + console.log(` [QR VALUES EFFECT] qrValues.length: ${qrValues.length}`); + console.log(` [QR VALUES EFFECT] qrValues:`, qrValues); + + const latestQr = qrValues[qrValues.length - 1]; + console.log(` [QR VALUES EFFECT] Latest QR: ${latestQr}`); + console.log(` [QR VALUES EFFECT] Latest QR detected at: ${new Date().toISOString()}`); + + // ✅ FIXED: Handle test shortcut {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId + // Support both formats: {2fitest (2 t's) and {2fittest (3 t's) + if ((latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && latestQr.endsWith("}")) { + // Extract content: remove "{2fitest" or "{2fittest" and "}" + let content = ''; + if (latestQr.startsWith("{2fittest")) { + content = latestQr.substring(9, latestQr.length - 1); // Remove "{2fittest" and "}" + } else if (latestQr.startsWith("{2fitest")) { + content = latestQr.substring(8, latestQr.length - 1); // Remove "{2fitest" and "}" + } + + const parts = content.split(','); + + if (parts.length === 2) { + const itemId = parseInt(parts[0].trim(), 10); + const stockInLineId = parseInt(parts[1].trim(), 10); + + if (!isNaN(itemId) && !isNaN(stockInLineId)) { + console.log( + `%c TEST QR: Detected ${latestQr.substring(0, 9)}... - Simulating QR input (itemId=${itemId}, stockInLineId=${stockInLineId})`, + "color: purple; font-weight: bold" + ); + + // ✅ Simulate QR code JSON format + const simulatedQr = JSON.stringify({ + itemId: itemId, + stockInLineId: stockInLineId + }); + + console.log(` [TEST QR] Simulated QR content: ${simulatedQr}`); + console.log(` [TEST QR] Start time: ${new Date().toISOString()}`); + const testStartTime = performance.now(); + + // ✅ Mark as processed FIRST to avoid duplicate processing + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) { + processedQrCodesRef.current.delete(firstValue); + } + } + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + + // ✅ Process immediately (bypass QR scanner delay) + if (processOutsideQrCodeRef.current) { + processOutsideQrCodeRef.current(simulatedQr, qrValues.length).then(() => { + const testTime = performance.now() - testStartTime; + console.log(` [TEST QR] Total processing time: ${testTime.toFixed(2)}ms (${(testTime / 1000).toFixed(3)}s)`); + console.log(` [TEST QR] End time: ${new Date().toISOString()}`); + }).catch((error) => { + const testTime = performance.now() - testStartTime; + console.error(`❌ [TEST QR] Error after ${testTime.toFixed(2)}ms:`, error); + }); + } + + // Reset scan + if (resetScanRef.current) { + resetScanRef.current(); + } + + const qrValuesChangeTime = performance.now() - qrValuesChangeStartTime; + console.log(` [QR VALUES EFFECT] Test QR handling time: ${qrValuesChangeTime.toFixed(2)}ms`); + return; // ✅ IMPORTANT: Return early to prevent normal processing + } else { + console.warn(` [TEST QR] Invalid itemId or stockInLineId: itemId=${parts[0]}, stockInLineId=${parts[1]}`); + } + } else { + console.warn(` [TEST QR] Invalid format. Expected {2fitestx,y} or {2fittestx,y}, got: ${latestQr}`); + } + } + + // Skip processing if manual confirmation modal is open + if (manualLotConfirmationOpen) { + // Check if this is a different QR code than what triggered the modal + const modalTriggerQr = lastProcessedQrRef.current; + if (latestQr === modalTriggerQr) { + console.log(` [QR PROCESS] Skipping - manual modal open for same QR`); + return; + } + // If it's a different QR, allow processing + console.log(` [QR PROCESS] Different QR detected while manual modal open, allowing processing`); + } + + const qrDetectionStartTime = performance.now(); + console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); + console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); + console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); + + // Skip if already processed (use refs to avoid dependency issues and delays) + const checkProcessedStartTime = performance.now(); + if (processedQrCodesRef.current.has(latestQr) || lastProcessedQrRef.current === latestQr) { + const checkTime = performance.now() - checkProcessedStartTime; + console.log(` [QR PROCESS] Already processed check time: ${checkTime.toFixed(2)}ms`); + return; + } + const checkTime = performance.now() - checkProcessedStartTime; + console.log(` [QR PROCESS] Not processed check time: ${checkTime.toFixed(2)}ms`); + + // Handle special shortcut + if (latestQr === "{2fic}") { + console.log(" Detected {2fic} shortcut - opening manual lot confirmation form"); + setManualLotConfirmationOpen(true); + if (resetScanRef.current) { + resetScanRef.current(); + } + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) { + processedQrCodesRef.current.delete(firstValue); + } + } + setLastProcessedQr(latestQr); + setProcessedQrCodes(prev => { + const newSet = new Set(prev); + newSet.add(latestQr); + if (newSet.size > 100) { + const firstValue = newSet.values().next().value; + if (firstValue !== undefined) { + newSet.delete(firstValue); + } + } + return newSet; + }); + return; + } + + // Process new QR code immediately (background mode - no modal) + // Check against refs to avoid state update delays + if (latestQr && latestQr !== lastProcessedQrRef.current) { + const processingStartTime = performance.now(); + console.log(` [QR PROCESS] Starting processing at: ${new Date().toISOString()}`); + console.log(` [QR PROCESS] Time since detection: ${(processingStartTime - qrDetectionStartTime).toFixed(2)}ms`); + + // ✅ Process immediately for better responsiveness + // Clear any pending debounced processing + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + + // Log immediately (console.log is synchronous) + console.log(` [QR PROCESS] Processing new QR code with enhanced validation: ${latestQr}`); + + // Update refs immediately (no state update delay) - do this FIRST + const refUpdateStartTime = performance.now(); + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) { + processedQrCodesRef.current.delete(firstValue); + } + } + const refUpdateTime = performance.now() - refUpdateStartTime; + console.log(` [QR PROCESS] Ref update time: ${refUpdateTime.toFixed(2)}ms`); + + // Process immediately in background - no modal/form needed, no delays + // Use ref to avoid dependency issues + const processCallStartTime = performance.now(); + if (processOutsideQrCodeRef.current) { + processOutsideQrCodeRef.current(latestQr, qrValues.length).then(() => { + const processCallTime = performance.now() - processCallStartTime; + const totalProcessingTime = performance.now() - processingStartTime; + console.log(` [QR PROCESS] processOutsideQrCode call time: ${processCallTime.toFixed(2)}ms`); + console.log(` [QR PROCESS] Total processing time: ${totalProcessingTime.toFixed(2)}ms (${(totalProcessingTime / 1000).toFixed(3)}s)`); + }).catch((error) => { + const processCallTime = performance.now() - processCallStartTime; + const totalProcessingTime = performance.now() - processingStartTime; + console.error(`❌ [QR PROCESS] processOutsideQrCode error after ${processCallTime.toFixed(2)}ms:`, error); + console.error(`❌ [QR PROCESS] Total processing time before error: ${totalProcessingTime.toFixed(2)}ms`); + }); + } + + // Update state for UI (but don't block on it) + const stateUpdateStartTime = performance.now(); + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + const stateUpdateTime = performance.now() - stateUpdateStartTime; + console.log(` [QR PROCESS] State update time: ${stateUpdateTime.toFixed(2)}ms`); + + const detectionTime = performance.now() - qrDetectionStartTime; + const totalEffectTime = performance.now() - qrValuesChangeStartTime; + console.log(` [QR DETECTION] Total detection time: ${detectionTime.toFixed(2)}ms`); + console.log(` [QR VALUES EFFECT] Total effect time: ${totalEffectTime.toFixed(2)}ms`); + } + + return () => { + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + }; + }, [qrValues, isManualScanning, isRefreshingData, combinedLotData.length, manualLotConfirmationOpen]); +const renderCountRef = useRef(0); +const renderStartTimeRef = useRef(null); + +// Track render performance +useEffect(() => { + renderCountRef.current++; + const now = performance.now(); + + if (renderStartTimeRef.current !== null) { + const renderTime = now - renderStartTimeRef.current; + if (renderTime > 100) { // Only log slow renders (>100ms) + console.log(` [PERF] Render #${renderCountRef.current} took ${renderTime.toFixed(2)}ms, combinedLotData length: ${combinedLotData.length}`); + } + renderStartTimeRef.current = null; + } +}, [combinedLotData.length]); + // Auto-start scanner only once on mount + const scannerInitializedRef = useRef(false); + + useEffect(() => { + if (session && currentUserId && !initializationRef.current) { + console.log(" Session loaded, initializing pick order..."); + initializationRef.current = true; + + // Only fetch existing data, no auto-assignment + fetchAllCombinedLotData(); + } + }, [session, currentUserId, fetchAllCombinedLotData]); + + // Separate effect for auto-starting scanner (only once, prevents multiple resets) + useEffect(() => { + if (session && currentUserId && !scannerInitializedRef.current) { + scannerInitializedRef.current = true; + // ✅ Auto-start scanner on mount for tablet use (background mode - no modal) + console.log("✅ Auto-starting QR scanner in background mode"); + setIsManualScanning(true); + startScan(); + } + }, [session, currentUserId, startScan]); + + // Add event listener for manual assignment + useEffect(() => { + const handlePickOrderAssigned = () => { + console.log("🔄 Pick order assigned event received, refreshing data..."); + fetchAllCombinedLotData(); + }; + + window.addEventListener('pickOrderAssigned', handlePickOrderAssigned); + + return () => { + window.removeEventListener('pickOrderAssigned', handlePickOrderAssigned); + }; + }, [fetchAllCombinedLotData]); + + + + const handleManualInputSubmit = useCallback(() => { + if (qrScanInput.trim() !== '') { + handleQrCodeSubmit(qrScanInput.trim()); + } + }, [qrScanInput, handleQrCodeSubmit]); + + // Handle QR code submission from modal (internal scanning) + const handleQrCodeSubmitFromModal = useCallback(async () => { + // Legacy path: marked SOL as `checked` (normal version). Disabled for workbench. + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Workbench uses scan-pick; this QR modal flow is not supported.")); + }, [t]); + + + const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { + if (value === '' || value === null || value === undefined) { + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev; + const next = { ...prev }; + delete next[lotKey]; + return next; + }); + return; + } + + const numericValue = typeof value === "string" ? parseFloat(value) : value; + + if (Number.isNaN(numericValue) || numericValue < 0) { + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, lotKey)) return prev; + const next = { ...prev }; + delete next[lotKey]; + return next; + }); + return; + } + + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: numericValue, + })); + }, []); + + const [autoAssignStatus, setAutoAssignStatus] = useState<'idle' | 'checking' | 'assigned' | 'no_orders'>('idle'); + const [autoAssignMessage, setAutoAssignMessage] = useState(''); + const [completionStatus, setCompletionStatus] = useState(null); + + const checkAndAutoAssignNext = useCallback(async () => { + if (!currentUserId) return; + + try { + const completionResponse = await checkPickOrderCompletion(currentUserId); + + if (completionResponse.code === "SUCCESS" && completionResponse.entity?.hasCompletedOrders) { + console.log("Found completed pick orders, auto-assigning next..."); + // 移除前端的自动分配逻辑,因为后端已经处理了 + // await handleAutoAssignAndRelease(); // 删除这个函数 + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + }, [currentUserId]); + + // Handle reject lot + // Handle pick execution form + const handlePickExecutionForm = useCallback((lot: any) => { + console.log("=== Pick Execution Form ==="); + console.log("Lot data:", lot); + + if (!lot) { + console.warn("No lot data provided for pick execution form"); + return; + } + + console.log("Opening pick execution form for lot:", lot.lotNo); + + setSelectedLotForExecutionForm(lot); + setPickExecutionFormOpen(true); + + console.log("Pick execution form opened for lot ID:", lot.lotId); + }, []); + + const handlePickExecutionFormSubmit = useCallback(async (data: any) => { + try { + console.log("Pick execution form submitted:", data); + const issueData = { + ...data, + type: "Do", // Delivery Order Record 类型 + pickerName: session?.user?.name || '', + }; + + const result = await recordPickExecutionIssue(issueData); + console.log("Pick execution issue recorded:", result); + + if (result && result.code === "SUCCESS") { + console.log(" Pick execution issue recorded successfully"); + // 关键:issue form 只记录问题,不会更新 SOL.qty + // 但 batch submit 需要知道“实际拣到多少”,否则会按 requiredQty 补拣到满 + const solId = Number(issueData.stockOutLineId || issueData.stockOutLineId === 0 ? issueData.stockOutLineId : data?.stockOutLineId); + if (solId > 0) { + const picked = Number(issueData.actualPickQty || 0); + setIssuePickedQtyBySolId((prev) => { + const next = { ...prev, [solId]: picked }; + const doId = fgPickOrders[0]?.doPickOrderId; + if (doId) saveIssuePickedMap(doId, next); + return next; + }); + setCombinedLotData(prev => prev.map(lot => { + if (Number(lot.stockOutLineId) === solId) { + return { ...lot, actualPickQty: picked, stockOutLineQty: picked }; + } + return lot; + })); + } + } else { + console.error(" Failed to record pick execution issue:", result); + } + + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + setQrScanError(false); + setQrScanSuccess(false); + setQrScanInput(''); + // ✅ Keep scanner active after form submission - don't stop scanning + // Only clear processed QR codes for the specific lot, not all + // setIsManualScanning(false); // Removed - keep scanner active + // stopScan(); // Removed - keep scanner active + // resetScan(); // Removed - keep scanner active + // Don't clear all processed codes - only clear for this specific lot if needed + await fetchAllCombinedLotData(); + } catch (error) { + console.error("Error submitting pick execution form:", error); + } + }, [fetchAllCombinedLotData, session, fgPickOrders]); + + // Calculate remaining required quantity + const calculateRemainingRequiredQty = useCallback((lot: any) => { + const requiredQty = lot.requiredQty || 0; + const stockOutLineQty = lot.stockOutLineQty || 0; + return Math.max(0, requiredQty - stockOutLineQty); + }, []); + + // Search criteria + const searchCriteria: Criterion[] = [ + { + label: t("Pick Order Code"), + paramName: "pickOrderCode", + type: "text", + }, + { + label: t("Item Code"), + paramName: "itemCode", + type: "text", + }, + { + label: t("Item Name"), + paramName: "itemName", + type: "text", + }, + { + label: t("Lot No"), + paramName: "lotNo", + type: "text", + }, + ]; + + const handleSearch = useCallback((query: Record) => { + setSearchQuery({ ...query }); + console.log("Search query:", query); + + if (!originalCombinedData) return; + + const filtered = originalCombinedData.filter((lot: any) => { + const pickOrderCodeMatch = !query.pickOrderCode || + lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); + + const itemCodeMatch = !query.itemCode || + lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); + + const itemNameMatch = !query.itemName || + lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); + + const lotNoMatch = !query.lotNo || + lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase()); + + return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch; + }); + + setCombinedLotData(filtered); + console.log("Filtered lots count:", filtered.length); + }, [originalCombinedData]); + + const handleReset = useCallback(() => { + setSearchQuery({}); + if (originalCombinedData) { + setCombinedLotData(originalCombinedData); + } + }, [originalCombinedData]); + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setPaginationController(prev => ({ + ...prev, + pageNum: newPage, + })); + }, []); + + const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + setPaginationController({ + pageNum: 0, + pageSize: newPageSize === -1 ? -1 : newPageSize, + }); + }, []); + + // ✅ Workbench list: group same item (within same route), sort completed lots first, + // and suppress repeated item fields on subsequent rows. + const paginatedData = useMemo(() => { + type RowMeta = { + lot: any; + isGroupFirst: boolean; + groupDisplayIndex: number; + }; + + const isCompletedStatus = (lot: any) => { + const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); + return ( + st === "completed" || + st === "partially_completed" || + st === "partially_complete" + ); + }; + + const isCheckedStatus = (lot: any) => { + const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); + return st === "checked"; + }; + + const statusRank = (lot: any) => { + // Desired order within same item: + // completed -> checked -> pending -> rejected -> others + const st = String(lot?.stockOutLineStatus ?? "").toLowerCase(); + if (isCompletedStatus(lot)) return 0; + if (isCheckedStatus(lot)) return 1; + if (st === "pending") return 2; + if (st === "rejected") return 3; + return 9; + }; + + // Keep stable group ordering by first appearance. + const groups = new Map< + string, + { firstIndex: number; items: { lot: any; originalIndex: number }[] } + >(); + combinedLotData.forEach((lot: any, originalIndex: number) => { + const routeKey = String(lot?.routerRoute ?? "").trim(); + const itemKey = + lot?.itemId != null + ? `itemId:${String(lot.itemId)}` + : `itemCode:${String(lot?.itemCode ?? "").trim()}`; + // Group only within same route to avoid collapsing different routes visually. + const key = `${routeKey}__${itemKey}`; + const g = groups.get(key); + if (!g) { + groups.set(key, { firstIndex: originalIndex, items: [{ lot, originalIndex }] }); + } else { + g.items.push({ lot, originalIndex }); + } + }); + + const groupEntries = Array.from(groups.values()).sort( + (a, b) => a.firstIndex - b.firstIndex, + ); + + const flattened: RowMeta[] = []; + for (let gi = 0; gi < groupEntries.length; gi += 1) { + const g = groupEntries[gi]; + // Re-number groups contiguously (avoid gaps after grouping) + const groupDisplayIndex = gi + 1; + const sortedWithin = [...g.items].sort((a, b) => { + const ra = statusRank(a.lot); + const rb = statusRank(b.lot); + if (ra !== rb) return ra - rb; + return a.originalIndex - b.originalIndex; // stable fallback + }); + sortedWithin.forEach((it, idx) => { + flattened.push({ + lot: it.lot, + isGroupFirst: idx === 0, + groupDisplayIndex, + }); + }); + } + + if (paginationController.pageSize === -1) return flattened; + const startIndex = + paginationController.pageNum * paginationController.pageSize; + const endIndex = startIndex + paginationController.pageSize; + return flattened.slice(startIndex, endIndex); + }, [combinedLotData, paginationController.pageNum, paginationController.pageSize]); +const allItemsReady = useMemo(() => { + if (combinedLotData.length === 0) return false; + + return combinedLotData.every((lot: any) => { + const status = lot.stockOutLineStatus?.toLowerCase(); + const isRejected = + status === 'rejected' || lot.lotAvailability === 'rejected'; + const isCompleted = + status === 'completed' || status === 'partially_completed' || status === 'partially_complete'; + const isChecked = status === 'checked'; + const isPending = status === 'pending'; + + // ✅ FIXED: 无库存(noLot)行:pending 状态也应该被视为 ready(可以提交) + // ✅ 過期批號(未換批):與 noLot 相同,視為可收尾 + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { + return isChecked || isCompleted || isRejected || isPending; + } + + // 正常 lot:必须已扫描/提交或者被拒收 + return isChecked || isCompleted || isRejected; + }); +}, [combinedLotData]); +const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: number, source: 'justComplete' | 'singleSubmit') => { + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + const solId = Number(lot.stockOutLineId); + if (solId > 0 && actionBusyBySolId[solId]) { + console.warn("Action already in progress for stockOutLineId:", solId); + return; + } + + try { + if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); + const targetUnavailable = isInventoryLotLineUnavailable(lot); + const effectiveSubmitQty = targetUnavailable && submitQty > 0 ? 0 : submitQty; + + const canonicalLotForSol = + solId > 0 + ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? lot + : lot; + + // Workbench「Just Completed」:不掃 QR,直接用列上的 lotNo + stockInLineId 走 scan-pick 完成庫存扣帳 + if (workbenchMode && source === "justComplete") { + const solIdForOverride = Number(canonicalLotForSol.stockOutLineId) || 0; + const lotIdForOverride = canonicalLotForSol.lotId; + const lotKeyForOverride = + Number.isFinite(solIdForOverride) && solIdForOverride > 0 + ? `sol:${solIdForOverride}` + : `${canonicalLotForSol.pickOrderLineId}-${lotIdForOverride}`; + const hasExplicitSubmitOverride = Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKeyForOverride, + ); + const explicitSubmitOverride = hasExplicitSubmitOverride + ? Number(pickQtyData[lotKeyForOverride]) + : NaN; + + const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); + const wbJustQty = qtyPayload.qty; + const isUnavailableForJustComplete = isInventoryLotLineUnavailable(canonicalLotForSol); + const canPostScanPick = + // unavailable lot: Just Completed must always submit qty=0, even without lotNo + isUnavailableForJustComplete || ( + canonicalLotForSol.lotNo && String(canonicalLotForSol.lotNo).trim() !== "" && ( + // explicit short submit: user typed 0 (must send qty=0 to backend) + (hasExplicitSubmitOverride && + Number.isFinite(explicitSubmitOverride) && + explicitSubmitOverride === 0) || + // normal pick: positive qty + (wbJustQty != null && wbJustQty > 0) + ) + ); + + if (canPostScanPick) { + const qtyToSend = isUnavailableForJustComplete + ? 0 + : hasExplicitSubmitOverride && explicitSubmitOverride === 0 + ? 0 + : Number(wbJustQty); + const res = await workbenchScanPick({ + stockOutLineId: Number(canonicalLotForSol.stockOutLineId), + lotNo: String(canonicalLotForSol.lotNo).trim(), + ...(typeof canonicalLotForSol.stockInLineId === "number" && + Number.isFinite(canonicalLotForSol.stockInLineId) && + canonicalLotForSol.stockInLineId > 0 + ? { stockInLineId: canonicalLotForSol.stockInLineId } + : {}), + qty: qtyToSend, + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(canonicalLotForSol.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || "Workbench scan-pick failed", + ); + } + clearWorkbenchScanReject(Number(canonicalLotForSol.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, lotKeyForOverride)) return prev; + const next = { ...prev }; + delete next[lotKeyForOverride]; + return next; + }); + await refreshWorkbenchAfterScanPick(); + if (canonicalLotForSol.pickOrderConsoCode) { + void checkAndCompletePickOrderByConsoCode(canonicalLotForSol.pickOrderConsoCode) + .then((completionResponse) => { + console.log(` Pick order completion check (workbench just complete):`, completionResponse); + }) + .catch((error) => { + console.error("Error checking pick order completion:", error); + }); + } + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + console.log("Just Completed (workbench): workbenchScanPick posted without QR."); + return; + } + const justCompleteErr = t( + "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", + ); + if (solId > 0) { + rememberWorkbenchScanReject(solId, justCompleteErr); + } + setQrScanErrorMsg(justCompleteErr); + throw new Error(justCompleteErr); + } + + if (effectiveSubmitQty === 0 && source === 'singleSubmit') { + console.log(`=== SUBMITTING ALL ZEROS CASE ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); + + // ✅ Workbench: route qty=0 through scan-pick as well (backend supports zero-complete). + // Allow empty lotNo for noLot/expired/unavailable rows. + if (workbenchMode) { + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lot.lotNo ?? "").trim(), + ...(typeof lot.stockInLineId === "number" && + Number.isFinite(lot.stockInLineId) && + lot.stockInLineId > 0 + ? { stockInLineId: lot.stockInLineId } + : {}), + qty: 0, + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(lot.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || "Workbench scan-pick failed (qty=0)", + ); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + await refreshWorkbenchAfterScanPick(); + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + return; + } + + // Legacy non-workbench path used `checked` as an intermediate state. + // Workbench mode is always true in this page; keep this branch unreachable. + throw new Error("Unsupported legacy checked flow on workbench page"); + } + + // DO Workbench: inventory posting + SOL/POL rules live in /doPickOrder/workbench/scan-pick + if ( + workbenchMode && + effectiveSubmitQty > 0 && + lot.lotNo && + String(lot.lotNo).trim() !== "" && + !isLotAvailabilityExpired(lot) && + !isInventoryLotLineUnavailable(lot) + ) { + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lot.lotNo).trim(), + ...(typeof lot.stockInLineId === "number" && + Number.isFinite(lot.stockInLineId) && + lot.stockInLineId > 0 + ? { stockInLineId: lot.stockInLineId } + : {}), + qty: Number(effectiveSubmitQty), + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(lot.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || "Workbench scan-pick failed", + ); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + const entity = res.entity as any; + const nextStatus = String(entity?.status ?? "completed").toLowerCase(); + const nextQty = entity?.qty != null ? Number(entity.qty) : undefined; + const successLotKey = getWorkbenchQtyLotKey(lot); + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, successLotKey)) return prev; + const next = { ...prev }; + delete next[successLotKey]; + return next; + }); + await refreshWorkbenchAfterScanPick(); + if (lot.pickOrderConsoCode) { + void checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode) + .then((completionResponse) => { + console.log(` Pick order completion check (workbench submit):`, completionResponse); + }) + .catch((error) => { + console.error("Error checking pick order completion:", error); + }); + } + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + return; + } + + // FIXED: Calculate cumulative quantity correctly + const currentActualPickQty = lot.actualPickQty || 0; + const cumulativeQty = currentActualPickQty + effectiveSubmitQty; + + // FIXED: Determine status based on cumulative quantity vs required quantity + let newStatus = 'partially_completed'; + + if (cumulativeQty >= lot.requiredQty) { + newStatus = 'completed'; + } else if (cumulativeQty > 0) { + newStatus = 'partially_completed'; + } else { + // Legacy non-workbench path used `checked` as an intermediate state. + // Workbench posts immediately via scan-pick. + newStatus = 'pending'; + } + + console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Required Qty: ${lot.requiredQty}`); + console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); + console.log(`New Submitted Qty: ${effectiveSubmitQty}`); + console.log(`Cumulative Qty: ${cumulativeQty}`); + console.log(`New Status: ${newStatus}`); + console.log(`=====================================`); + + if (!workbenchMode) { + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: newStatus, + // 后端 updateStatus 的 qty 是“增量 delta”,不能传 cumulativeQty(否则会重复累加导致 out/hold 大幅偏移) + qty: effectiveSubmitQty + }); + applyLocalStockOutLineUpdate(Number(lot.stockOutLineId), newStatus, cumulativeQty); + } + // 注意:库存过账(hold->out)与 ledger 由后端 updateStatus 内部统一处理; + // 前端不再额外调用 updateInventoryLotLineQuantities(operation='pick'),避免 double posting。 + + // Check if pick order is completed when lot status becomes 'completed' + if (newStatus === 'completed' && lot.pickOrderConsoCode) { + console.log(` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`); + + try { + const completionResponse = await checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode); + console.log(` Pick order completion check result:`, completionResponse); + + if (completionResponse.code === "SUCCESS") { + console.log(` Pick order ${lot.pickOrderConsoCode} completed successfully!`); + } else if (completionResponse.message === "not completed") { + console.log(`⏳ Pick order not completed yet, more lines remaining`); + } else { + console.error(` Error checking completion: ${completionResponse.message}`); + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + } + + void fetchAllCombinedLotData(); + console.log("Pick quantity submitted successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + + } catch (error) { + console.error("Error submitting pick quantity:", error); + setQrScanError(true); + setQrScanSuccess(false); + } finally { + if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); + } +}, [ + fetchAllCombinedLotData, + checkAndAutoAssignNext, + actionBusyBySolId, + applyLocalStockOutLineUpdate, + workbenchMode, + currentUserId, + rememberWorkbenchScanReject, + clearWorkbenchScanReject, + refreshWorkbenchAfterScanPick, + combinedLotData, + workbenchScanPickQtyFromLot, + checkAndCompletePickOrderByConsoCode, + t, +]); + +const handleSkip = useCallback(async (lot: any) => { + try { + console.log("Just Complete clicked (workbench: scan-pick without QR when possible):", lot.lotNo); + await handleSubmitPickQtyWithQty(lot, 0, 'justComplete'); + } catch (err) { + console.error("Error in Skip:", err); + } +}, [handleSubmitPickQtyWithQty]); +const hasPendingBatchSubmit = useMemo(() => { + return combinedLotData.some((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return status === "pending" || status === "partially_completed" || status === "partially_complete"; + }); +}, [combinedLotData]); +useEffect(() => { + if (!hasPendingBatchSubmit) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); +}, [hasPendingBatchSubmit]); +const handleStartScan = useCallback(() => { + const startTime = performance.now(); + console.log(` [START SCAN] Called at: ${new Date().toISOString()}`); + console.log(` [START SCAN] Starting manual QR scan...`); + + setIsManualScanning(true); + const setManualScanningTime = performance.now() - startTime; + console.log(` [START SCAN] setManualScanning time: ${setManualScanningTime.toFixed(2)}ms`); + + setProcessedQrCodes(new Set()); + setLastProcessedQr(''); + setQrScanError(false); + setQrScanSuccess(false); + + const beforeStartScanTime = performance.now(); + startScan(); + const startScanTime = performance.now() - beforeStartScanTime; + console.log(` [START SCAN] startScan() call time: ${startScanTime.toFixed(2)}ms`); + + const totalTime = performance.now() - startTime; + console.log(` [START SCAN] Total start scan time: ${totalTime.toFixed(2)}ms`); + console.log(` [START SCAN] Start scan completed at: ${new Date().toISOString()}`); +}, [startScan]); + const handlePickOrderSwitch = useCallback(async (pickOrderId: number) => { + if (pickOrderSwitching) return; + + setPickOrderSwitching(true); + try { + console.log(" Switching to pick order:", pickOrderId); + setSelectedPickOrderId(pickOrderId); + + // 强制刷新数据,确保显示正确的 pick order 数据 + await fetchAllCombinedLotData(currentUserId, pickOrderId); + } catch (error) { + console.error("Error switching pick order:", error); + } finally { + setPickOrderSwitching(false); + } + }, [pickOrderSwitching, currentUserId, fetchAllCombinedLotData]); + + const handleStopScan = useCallback(() => { + console.log("⏸️ Pausing QR scanner..."); + setIsManualScanning(false); + setQrScanError(false); + setQrScanSuccess(false); + stopScan(); + resetScan(); + }, [stopScan, resetScan]); + // ... existing code around line 1469 ... + const handlelotnull = useCallback(async (lot: any) => { + // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId + const stockOutLineId = lot.stockOutLineId; + + if (!stockOutLineId) { + console.error(" No stockOutLineId found for lot:", lot); + return; + } + const solId = Number(stockOutLineId); + if (solId > 0 && actionBusyBySolId[solId]) { + console.warn("Action already in progress for stockOutLineId:", solId); + return; + } + + try { + if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); + // Step 1: Update stock out line status + await updateStockOutLineStatus({ + id: stockOutLineId, + status: 'completed', + qty: 0 + }); + + // Step 2: Create pick execution issue for no-lot case + // Get pick order ID from fgPickOrders or use 0 if not available + const pickOrderId = lot.pickOrderId || fgPickOrders[0]?.pickOrderId || 0; + const pickOrderCode = lot.pickOrderCode || fgPickOrders[0]?.pickOrderCode || lot.pickOrderConsoCode || ''; + + const issueData: PickExecutionIssueData = { + type: "Do", // Delivery Order type + pickOrderId: pickOrderId, + pickOrderCode: pickOrderCode, + pickOrderCreateDate: dayjs().format('YYYY-MM-DD'), // Use dayjs format + pickExecutionDate: dayjs().format('YYYY-MM-DD'), + pickOrderLineId: lot.pickOrderLineId, + itemId: lot.itemId, + itemCode: lot.itemCode || '', + itemDescription: lot.itemName || '', + lotId: null, // No lot available + lotNo: null, // No lot number + storeLocation: lot.location || '', + requiredQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, + actualPickQty: 0, // No items picked (no lot available) + missQty: lot.requiredQty || lot.pickOrderLineRequiredQty || 0, // All quantity is missing + badItemQty: 0, + issueRemark: `No lot available for this item. Handled via handlelotnull.`, + pickerName: session?.user?.name || '', + + }; + + const result = await recordPickExecutionIssue(issueData); + console.log(" Pick execution issue created for no-lot item:", result); + + if (result && result.code === "SUCCESS") { + console.log(" No-lot item handled and issue recorded successfully"); + } else { + console.error(" Failed to record pick execution issue:", result); + } + + // Step 3: Refresh data + await fetchAllCombinedLotData(); + } catch (error) { + console.error(" Error in handlelotnull:", error); + } finally { + if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); + } + }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]); + const handleBatchScan = useCallback(async () => { + const startTime = performance.now(); + console.log(` [BATCH SCAN START]`); + console.log(` Start time: ${new Date().toISOString()}`); + + // 获取所有活跃批次(未扫描的) + const activeLots = combinedLotData.filter(lot => { + return ( + lot.lotAvailability !== 'rejected' && + lot.stockOutLineStatus !== 'rejected' && + lot.stockOutLineStatus !== 'completed' && + lot.stockOutLineStatus !== 'checked' && // ✅ 只处理未扫描的 + lot.processingStatus !== 'completed' && + lot.noLot !== true && + lot.lotNo // ✅ 必须有 lotNo + ); + }); + + if (activeLots.length === 0) { + console.log("No active lots to scan"); + return; + } + + console.log(`📦 Batch scanning ${activeLots.length} active lots using batch API...`); + + try { + // ✅ 转换为批量扫描 API 所需的格式 + const lines: BatchScanLineRequest[] = activeLots.map((lot) => ({ + pickOrderLineId: Number(lot.pickOrderLineId), + inventoryLotLineId: lot.lotId ? Number(lot.lotId) : null, + pickOrderConsoCode: String(lot.pickOrderConsoCode || ''), + lotNo: lot.lotNo || null, + itemId: Number(lot.itemId), + itemCode: String(lot.itemCode || ''), + stockOutLineId: lot.stockOutLineId ? Number(lot.stockOutLineId) : null, // ✅ 新增 + })); + + const request: BatchScanRequest = { + userId: currentUserId || 0, + lines: lines + }; + + console.log(`📤 Sending batch scan request with ${lines.length} lines`); + console.log(`📋 Request data:`, JSON.stringify(request, null, 2)); + + const scanStartTime = performance.now(); + + // ✅ 使用新的批量扫描 API(一次性处理所有请求) + const result = await batchScan(request); + + const scanTime = performance.now() - scanStartTime; + console.log(` Batch scan API call completed in ${scanTime.toFixed(2)}ms (${(scanTime / 1000).toFixed(3)}s)`); + console.log(`📥 Batch scan result:`, result); + + // ✅ 刷新数据以获取最新的状态 + const refreshStartTime = performance.now(); + await fetchAllCombinedLotData(); + const refreshTime = performance.now() - refreshStartTime; + console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`); + + const totalTime = performance.now() - startTime; + console.log(` [BATCH SCAN END]`); + console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); + console.log(` End time: ${new Date().toISOString()}`); + + if (result && result.code === "SUCCESS") { + setQrScanSuccess(true); + setQrScanError(false); + } else { + console.error("❌ Batch scan failed:", result); + setQrScanError(true); + setQrScanSuccess(false); + } + + } catch (error) { + console.error("❌ Error in batch scan:", error); + setQrScanError(true); + setQrScanSuccess(false); + } + }, [combinedLotData, fetchAllCombinedLotData, currentUserId]); +const handleSubmitAllScanned = useCallback(async () => { + const startTime = performance.now(); + console.log(` [BATCH SUBMIT START]`); + console.log(` Start time: ${new Date().toISOString()}`); + + const scannedLots = combinedLotData.filter(lot => { + const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } + // Workbench batch submit is now dedicated to closing noLot / expired / unavailable rows (qty=0 via workbench scan-pick batch). + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { + return true; + } + return false; + }); + + if (scannedLots.length === 0) { + console.log("No scanned items to submit"); + return; + } + + setIsSubmittingAll(true); + console.log(`📦 Submitting ${scannedLots.length} items using workbench batch scan-pick (qty=0)...`); + + try { + const submitStartTime = performance.now(); + + const result = await workbenchBatchScanPick({ + lines: scannedLots.map((lot) => ({ + stockOutLineId: Number(lot.stockOutLineId) || 0, + lotNo: "", // qty=0 path allows empty lotNo (workbench zero-complete) + qty: 0, + storeId: fgPickOrders?.[0]?.storeId ?? null, + userId: currentUserId ?? 1, + })), + }); + + const submitTime = performance.now() - submitStartTime; + console.log(` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${(submitTime / 1000).toFixed(3)}s)`); + console.log(`📥 Batch submit result:`, result); + + // Refresh data once after batch submission + const refreshStartTime = performance.now(); + await fetchAllCombinedLotData(); + const refreshTime = performance.now() - refreshStartTime; + console.log(` Data refresh time: ${refreshTime.toFixed(2)}ms (${(refreshTime / 1000).toFixed(3)}s)`); + + const totalTime = performance.now() - startTime; + console.log(` [BATCH SUBMIT END]`); + console.log(` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed(3)}s)`); + console.log(` End time: ${new Date().toISOString()}`); + + if (result && result.code === "SUCCESS") { + setQrScanSuccess(true); + setTimeout(() => { + setQrScanSuccess(false); + checkAndAutoAssignNext(); + if (onSwitchToRecordTab) { + onSwitchToRecordTab(); + } + if (onRefreshReleasedOrderCount) { + onRefreshReleasedOrderCount(); + } + }, 2000); + } else { + console.error("Batch submit failed:", result); + setQrScanError(true); + } + + } catch (error) { + console.error("Error submitting all scanned items:", error); + setQrScanError(true); + } finally { + setIsSubmittingAll(false); + } +}, [combinedLotData, fetchAllCombinedLotData, checkAndAutoAssignNext, currentUserId, onSwitchToRecordTab, onRefreshReleasedOrderCount, fgPickOrders]); + + // Calculate scanned items count + // Calculate scanned items count (should match handleSubmitAllScanned filter logic) + const scannedItemsCount = useMemo(() => { + const filtered = combinedLotData.filter(lot => { + const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } + // Keep consistent with handleSubmitAllScanned: batch submit is only for noLot/expired/unavailable. + if (lot.noLot === true || isLotAvailabilityExpired(lot) || isInventoryLotLineUnavailable(lot)) { + return true; + } + return false; + }); + + // 添加调试日志 + const noLotCount = filtered.filter(l => l.noLot === true).length; + const normalCount = filtered.filter(l => l.noLot !== true).length; + console.log(`📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`); + console.log(`📊 All items breakdown:`, { + total: combinedLotData.length, + noLot: combinedLotData.filter(l => l.noLot === true).length, + normal: combinedLotData.filter(l => l.noLot !== true).length + }); + + return filtered.length; + }, [combinedLotData]); +/* + // ADD THIS: Auto-stop scan when no data available + useEffect(() => { + if (isManualScanning && combinedLotData.length === 0) { + console.log("⏹️ No data available, auto-stopping QR scan..."); + handleStopScan(); + } + }, [combinedLotData.length, isManualScanning, handleStopScan]); + */ + + // Cleanup effect + useEffect(() => { + return () => { + // Cleanup when component unmounts (e.g., when switching tabs) + if (isManualScanning) { + console.log("🧹 Pick execution component unmounting, stopping QR scanner..."); + stopScan(); + resetScan(); + } + }; + }, [isManualScanning, stopScan, resetScan]); + + const getStatusMessage = useCallback((lot: any) => { + switch (lot.stockOutLineStatus?.toLowerCase()) { + case 'pending': + return t("Please finish QR code scan and pick order."); + case 'partially_completed': + return t("Partial quantity submitted. Please submit more or complete the order."); + case 'completed': + return t("Pick order completed successfully!"); + case 'rejected': + return t("Lot has been rejected and marked as unavailable."); + case 'unavailable': + return t("This order is insufficient, please pick another lot."); + default: + return t("Please finish QR code scan and pick order."); + } + }, [t]); + return ( + ( + lot.lotAvailability !== 'rejected' && + lot.stockOutLineStatus !== 'rejected' && + lot.stockOutLineStatus !== 'completed' + )} + > + + + + + + + {/* DO Header */} + + + + + {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */} + + + + {t("All Pick Order Lots")} + + + + {/* Scanner status indicator (always visible) */} + {/* + + + + {isManualScanning ? t("Scanner Active") : t("Scanner Inactive")} + + + */} + + {/* Pause/Resume button instead of Start/Stop */} + {isManualScanning ? ( + + ) : ( + + )} + + {/* 保留:Submit All Scanned Button */} + + + + + +{fgPickOrders.length > 0 && ( + + + {/* 基本信息 */} + + + {t("Shop Name")}: {fgPickOrders[0].shopName || '-'} + + + {t("Store ID")}: {fgPickOrders[0].storeId || '-'} + + + {t("Ticket No.")}: {fgPickOrders[0].ticketNo || '-'} + + + {t("Departure Time")}: {fgPickOrders[0].DepartureTime || '-'} + + + + {/* 改进:三个字段显示在一起,使用表格式布局 */} + {/* 改进:三个字段合并显示 */} +{/* 改进:表格式显示每个 pick order */} + + + {t("Pick Orders Details")}: + + + {(() => { + const pickOrderCodes = fgPickOrders[0].pickOrderCodes as string[] | string | undefined; + const deliveryNos = fgPickOrders[0].deliveryNos as string[] | string | undefined; + const lineCounts = fgPickOrders[0].lineCountsPerPickOrder; + + const pickOrderCodesArray = Array.isArray(pickOrderCodes) + ? pickOrderCodes + : (typeof pickOrderCodes === 'string' ? pickOrderCodes.split(', ') : []); + + const deliveryNosArray = Array.isArray(deliveryNos) + ? deliveryNos + : (typeof deliveryNos === 'string' ? deliveryNos.split(', ') : []); + + const lineCountsArray = Array.isArray(lineCounts) ? lineCounts : []; + + const maxLength = Math.max( + pickOrderCodesArray.length, + deliveryNosArray.length, + lineCountsArray.length + ); + + if (maxLength === 0) { + return -; + } + + // 使用与外部基本信息相同的样式 + return Array.from({ length: maxLength }, (_, idx) => ( + + + {t("Delivery Order")}: {deliveryNosArray[idx] || '-'} + + + {t("Pick Order")}: {pickOrderCodesArray[idx] || '-'} + + + {t("Finsihed good items")}: {lineCountsArray[idx] || '-'}{t("kinds")} + + + )); + })()} + + + + )} + + + + + + + {t("Index")} + + {t("Item Code")} + {t("Item Name")} + {t("Route")} + {t("Suggest Lot No.")} + {t("Lot Required Pick Qty")} + {t("Scan Result")} + {/*{t("Qty will submit")}*/} + {t("Submit Required Pick Qty")} + + + + {paginatedData.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( +// 在第 1797-1938 行之间,将整个 map 函数修改为: +paginatedData.map((row, index) => { + const lot = row.lot; + const solIdForKey = Number(lot.stockOutLineId) || 0; + const lotKeyForSubmitQty = + Number.isFinite(solIdForKey) && solIdForKey > 0 ? `sol:${solIdForKey}` : `${lot.pickOrderLineId}-${lot.lotId}`; + const lockedSubmitQtyDisplay = isInventoryLotLineUnavailable(lot) ? 0 : resolveSingleSubmitQty(lot); + const hasPickOverride = Object.prototype.hasOwnProperty.call(pickQtyData, lotKeyForSubmitQty); + const fromPickRow = hasPickOverride ? pickQtyData[lotKeyForSubmitQty] : undefined; + const workbenchSubmitQtyDisplay = + hasPickOverride && fromPickRow !== undefined && fromPickRow !== null && !Number.isNaN(Number(fromPickRow)) + ? Number(fromPickRow) + : lockedSubmitQtyDisplay; + + // 检查是否是 issue lot + const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; + const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); + const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); + const isSolRejected = + solSt === "rejected" || String(lot.lotAvailability || "").toLowerCase() === "rejected"; + + return ( + + + + {row.isGroupFirst ? row.groupDisplayIndex : ""} + + + + {row.isGroupFirst ? lot.itemCode : ""} + + {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} + + + + {lot.routerRoute || '-'} + + + + + + + {lot.lotNo ? ( + rejectDisplay ? ( + <> + {lot.lotNo} + + {rejectDisplay} + + + ) : + + lot.lotAvailability === 'expired' ? ( + <> + {lot.lotNo}{' '} + {t('is expired. Please check around have available QR code or not.')} + + ) : isInventoryLotLineUnavailable(lot) ? ( + <> + {lot.lotNo}{' '} + {t('is unavable. Please check around have available QR code or not.')} + + ) : ( + lot.lotNo + ) + ) : ( + + {rejectDisplay || + t( + "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", + )} + + )} + + + {Number(lot.stockOutLineId) > 0 && Number(lot.itemId) > 0 ? ( + + ) : null} + + + + {(() => { + const requiredQty = lot.requiredQty || 0; + return requiredQty.toLocaleString() + '(' + lot.uomShortDesc + ')'; + })()} + + + + {(() => { + const status = lot.stockOutLineStatus?.toLowerCase(); + const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; + const isNoLot = !lot.lotNo; + + // rejected lot:显示红色勾选(已扫描但被拒绝) + if (isRejected && !isNoLot) { + return ( + + + + ); + } + + // 過期批號:與 noLot 同類——視為已掃到/可處理(含 pending),顯示警示色勾選 + if (isLotAvailabilityExpired(lot) && status !== "rejected") { + return ( + + + + ); + } + + // 正常 lot:已扫描(checked/partially_completed/completed) + if (!isNoLot && status !== 'pending' && status !== 'rejected') { + return ( + + + + ); + } + + // noLot 且已完成/部分完成:显示红色勾选 + if (isNoLot && (status === 'partially_completed' || status === 'completed')) { + return ( + + + + ); + } + + return null; + })()} + +{/* + + {workbenchSubmitQtyDisplay} + +*/} + + + {(() => { + const status = lot.stockOutLineStatus?.toLowerCase(); + const isRejected = status === 'rejected' || lot.lotAvailability === 'rejected'; + const isNoLot = !lot.lotNo; + const isUnavailableRow = isInventoryLotLineUnavailable(lot); + + // ✅ rejected lot:显示提示文本(换行显示) + if (isRejected && !isNoLot) { + const rejectHint = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); + return ( + + {rejectHint || t("This lot is rejected, please scan another lot.")} + + ); + } + + // noLot 且非 unavailable:保留舊行為(Issue) + if (isNoLot && !isUnavailableRow) { + return ( + + ); + } + + // 正常 lot:Submit + 可編輯數量(Edit 解鎖輸入,不再開 issue form) + { + const lotKey = lotKeyForSubmitQty; + const qtyFieldEnabled = workbenchSubmitQtyFieldEnabledByLotKey[lotKey] === true; + const displayedSubmitQty = workbenchSubmitQtyDisplay; + const hasPickOverrideRow = Object.prototype.hasOwnProperty.call(pickQtyData, lotKey); + const textFieldValue = qtyFieldEnabled + ? hasPickOverrideRow + ? String(pickQtyData[lotKey]) + : String(displayedSubmitQty) + : String(displayedSubmitQty); + + return ( + + {/* + + */} + + { + if (!qtyFieldEnabled) return; + if (e.key !== "{") return; + e.preventDefault(); + setWorkbenchSubmitQtyFieldEnabledByLotKey((prev) => ({ + ...prev, + [lotKey]: false, + })); + (e.currentTarget as HTMLInputElement).blur(); + }} + onChange={(e) => { + if (!qtyFieldEnabled) return; + const n = Number(e.target.value); + if (Number.isFinite(n) && n < 0) return; + handlePickQtyChange(lotKey, e.target.value); + }} + inputProps={{ min: 0, step: 1 }} + sx={{ + width: 96, + '& .MuiInputBase-input': { fontSize: '0.75rem', py: 0.5, textAlign: 'center' }, + }} + /> + + + + + ); + } + })()} + + + + ); +}) + )} + +
+
+ + + `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` + } + /> +
+
+ + {/* QR Code Scanner works in background - no modal needed */} + { + setManualLotConfirmationOpen(false); + }} + onConfirm={handleManualLotConfirmation} + expectedLot={null} + scannedLot={null} + isLoading={false} + /> + + {/* 保留:Good Pick Execution Form Modal */} + {pickExecutionFormOpen && selectedLotForExecutionForm && ( + { + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + }} + onSubmit={handlePickExecutionFormSubmit} + selectedLot={selectedLotForExecutionForm} + selectedPickOrderLine={{ + id: selectedLotForExecutionForm.pickOrderLineId, + itemId: selectedLotForExecutionForm.itemId, + itemCode: selectedLotForExecutionForm.itemCode, + itemName: selectedLotForExecutionForm.itemName, + pickOrderCode: selectedLotForExecutionForm.pickOrderCode, + availableQty: selectedLotForExecutionForm.availableQty || 0, + requiredQty: selectedLotForExecutionForm.requiredQty || 0, + // uomCode: selectedLotForExecutionForm.uomCode || '', + uomDesc: selectedLotForExecutionForm.uomDesc || '', + pickedQty: selectedLotForExecutionForm.actualPickQty || 0, + uomShortDesc: selectedLotForExecutionForm.uomShortDesc || '', + suggestedList: [], + noLotLines: [], + }} + pickOrderId={selectedLotForExecutionForm.pickOrderId} + pickOrderCreateDate={new Date()} + /> + )} + + { + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelReminderText(null); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + }} + initialPayload={workbenchLotLabelInitialPayload} + initialItemId={ + workbenchLotLabelContextLot != null + ? Number(workbenchLotLabelContextLot.itemId) + : null + } + defaultPrinterName={defaultLabelPrinterName} + hideScanSection={ + workbenchLotLabelInitialPayload != null || + workbenchLotLabelContextLot != null + } + reminderText={workbenchLotLabelReminderText ?? undefined} + statusTitleText={workbenchLotLabelStatusBanner.text} + statusTitleSeverity={workbenchLotLabelStatusBanner.severity} + warehouseCodePrefixFilter={lotFloorPrefixFilter} + triggerLotAvailableQty={ + workbenchLotLabelContextLot != null + ? Number(workbenchLotLabelContextLot.availableQty) + : null + } + triggerLotUom={ + workbenchLotLabelContextLot != null + ? String( + workbenchLotLabelContextLot.uomShortDesc ?? + workbenchLotLabelContextLot.stockUnit ?? + "", + ).trim() || null + : null + } + disableScanPick={workbenchLotLabelScanPickDisabled} + onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} + submitQty={workbenchLotLabelSubmitQty} + onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange} + /> +
+ + + + +
+ ); +}; + +export default WorkbenchGoodPickExecutionDetail; diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx new file mode 100644 index 0000000..e6abd6d --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -0,0 +1,792 @@ +"use client"; + +/** + * Workbench copy of `LotLabelPrintModal`: same label-print flow, plus optional + * 「掃碼提貨」 per listed lot row (parent calls `workbenchScanPick` with `inventoryLotLineId`). + */ + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + Snackbar, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { + analyzeWorkbenchQrCode, + fetchWorkbenchAvailableLotsByItem, + fetchWorkbenchPrinters, + printWorkbenchLotLabel, +} from "@/app/api/doworkbench/actions"; +import { QRCodeSVG } from "qrcode.react"; + +type ScanPayload = { + itemId: number; + stockInLineId: number; +}; + +type Printer = { + id: number; + name?: string; + description?: string; + ip?: string; + port?: number; + type?: string; + brand?: string; +}; + +type QrCodeAnalysisResponse = { + itemId: number; + itemCode: string; + itemName: string; + scanned?: { + stockInLineId: number; + lotNo: string; + inventoryLotLineId: number; + warehouseCode?: string | null; + warehouseName?: string | null; + } | null; + sameItemLots: Array<{ + lotNo: string; + inventoryLotLineId: number; + stockInLineId?: number | null; + availableQty: number; + uom: string; + warehouseCode?: string | null; + warehouseName?: string | null; + }>; +}; + +export interface WorkbenchLotLabelPrintModalProps { + open: boolean; + onClose: () => void; + initialPayload?: ScanPayload | null; + initialItemId?: number | null; + defaultPrinterName?: string; + hideScanSection?: boolean; + reminderText?: string; + statusTitleText?: string; + /** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */ + statusTitleSeverity?: "success" | "warning" | "error"; + warehouseCodePrefixFilter?: string; + /** + * When true, omit the API 「scanned」 lot from the merged list (legacy FG-style). + * Workbench should leave false so the current row’s lot appears for label print / scan-pick. + */ + hideTriggeredLot?: boolean; + /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ + triggerLotAvailableQty?: number | null; + triggerLotUom?: string | null; + /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ + disableScanPick?: boolean; + /** + * When set, each lot row shows 「掃碼提貨」. Parent should call `workbenchScanPick` + * with `inventoryLotLineId` and throw on failure. + */ + onWorkbenchScanPick?: (args: { + inventoryLotLineId: number; + lotNo: string; + qty?: number; + }) => Promise; + /** Global submit qty shared with outer "Qty will submit". */ + submitQty?: number | null; + onSubmitQtyChange?: (qty: number) => void; +} + +function safeParseScanPayload(raw: string): ScanPayload | null { + try { + const obj = JSON.parse(raw); + const itemId = Number(obj?.itemId); + const stockInLineId = Number(obj?.stockInLineId); + if (!Number.isFinite(itemId) || !Number.isFinite(stockInLineId)) + return null; + return { itemId, stockInLineId }; + } catch { + return null; + } +} + +function formatPrinterLabel(p: Printer): string { + const name = (p.name || "").trim(); + if (name) return name; + const desc = (p.description || "").trim(); + if (desc) return desc; + const code = (p as { code?: string }).code?.trim?.() ?? ""; + if (code) return code; + return `#${p.id}`; +} + +function isLabelPrinter(p: Printer): boolean { + const s = `${p.name ?? ""} ${p.description ?? ""} ${ + (p as { code?: string }).code ?? "" + } ${p.type ?? ""} ${p.brand ?? ""}`.toLowerCase(); + return s.includes("label") && !s.includes("a4"); +} + +const WorkbenchLotLabelPrintModal: React.FC = ({ + open, + onClose, + initialPayload = null, + initialItemId = null, + defaultPrinterName, + hideScanSection, + reminderText, + statusTitleText, + statusTitleSeverity = "error", + warehouseCodePrefixFilter, + hideTriggeredLot = false, + triggerLotAvailableQty = null, + triggerLotUom = null, + disableScanPick = false, + onWorkbenchScanPick, + submitQty = null, + onSubmitQtyChange, +}) => { + const scanInputRef = useRef(null); + const [scanInput, setScanInput] = useState(""); + const [scanError, setScanError] = useState(null); + + const [printers, setPrinters] = useState([]); + const [printersLoading, setPrintersLoading] = useState(false); + const [selectedPrinterId, setSelectedPrinterId] = useState(""); + + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysis, setAnalysis] = useState(null); + const [lastPayload, setLastPayload] = useState(null); + const [lastItemId, setLastItemId] = useState(null); + + const [printQty, setPrintQty] = useState(1); + const [printingLotLineId, setPrintingLotLineId] = useState( + null, + ); + const [qrVisibleLotLineId, setQrVisibleLotLineId] = useState( + null, + ); + + const [snackbar, setSnackbar] = useState<{ + open: boolean; + message: string; + severity?: "success" | "info" | "error"; + }>({ + open: false, + message: "", + severity: "info", + }); + + const resetAll = useCallback(() => { + setScanInput(""); + setScanError(null); + setAnalysis(null); + setPrintQty(1); + setPrintingLotLineId(null); + setQrVisibleLotLineId(null); + }, []); + + useEffect(() => { + if (!open) return; + resetAll(); + const t = setTimeout(() => scanInputRef.current?.focus(), 50); + return () => clearTimeout(t); + }, [open, resetAll]); + + const loadPrinters = useCallback(async () => { + setPrintersLoading(true); + try { + const data = (await fetchWorkbenchPrinters()) as Printer[]; + const list = Array.isArray(data) ? data : []; + setPrinters(list.filter(isLabelPrinter)); + } catch (e) { + setPrinters([]); + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "載入印表機清單失敗", + severity: "error", + }); + } finally { + setPrintersLoading(false); + } + }, []); + + useEffect(() => { + if (!open) return; + void loadPrinters(); + }, [open, loadPrinters]); + + const effectiveHideScanSection = hideScanSection ?? initialPayload != null; + + const pickDefaultPrinterId = useCallback( + (list: Printer[]): number | null => { + if (!defaultPrinterName) return null; + const target = defaultPrinterName.trim().toLowerCase(); + if (!target) return null; + const byExact = list.find( + (p) => formatPrinterLabel(p).trim().toLowerCase() === target, + ); + if (byExact) return byExact.id; + const byIncludes = list.find((p) => + formatPrinterLabel(p).trim().toLowerCase().includes(target), + ); + return byIncludes?.id ?? null; + }, + [defaultPrinterName], + ); + + useEffect(() => { + if (!open) return; + if (selectedPrinterId !== "") return; + if (printers.length === 0) return; + const id = pickDefaultPrinterId(printers); + if (id != null) setSelectedPrinterId(id); + }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); + + const analyzePayload = useCallback( + async (payload: ScanPayload) => { + setLastPayload(payload); + setScanError(null); + setAnalysisLoading(true); + try { + const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; + setAnalysis(data); + setSnackbar({ + open: true, + message: "已載入同品可用批號清單", + severity: "success", + }); + } catch (e) { + setAnalysis(null); + setScanError(e instanceof Error ? e.message : "分析失敗"); + } finally { + setAnalysisLoading(false); + } + }, + [], + ); + + const analyzeByItem = useCallback( + async (itemId: number) => { + if (!Number.isFinite(itemId) || itemId <= 0) { + setScanError("無效 itemId,無法載入批號清單。"); + return; + } + setLastItemId(itemId); + setScanError(null); + setAnalysisLoading(true); + try { + const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { + itemId: number; + itemCode: string; + itemName: string; + sameItemLots: QrCodeAnalysisResponse["sameItemLots"]; + }; + setAnalysis({ + itemId: data.itemId, + itemCode: data.itemCode, + itemName: data.itemName, + scanned: null, + sameItemLots: data.sameItemLots ?? [], + }); + setSnackbar({ + open: true, + message: "已載入同品可用批號清單", + severity: "success", + }); + } catch (e) { + setAnalysis(null); + setScanError(e instanceof Error ? e.message : "分析失敗"); + } finally { + setAnalysisLoading(false); + } + }, + [], + ); + + const handleAnalyze = useCallback(async () => { + const raw = scanInput.trim(); + const payload = safeParseScanPayload(raw); + if (!payload) { + setScanError( + '掃碼內容格式錯誤,請重新掃碼', + ); + setAnalysis(null); + return; + } + await analyzePayload(payload); + }, [scanInput, analyzePayload]); + + const handleRefreshLots = useCallback(async () => { + const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); + if (payload) { + await analyzePayload(payload); + return; + } + const candidateItemId = + (Number.isFinite(lastItemId ?? NaN) && (lastItemId ?? 0) > 0 + ? (lastItemId as number) + : Number(initialItemId)); + if (Number.isFinite(candidateItemId) && candidateItemId > 0) { + await analyzeByItem(candidateItemId); + return; + } + if (!payload) { + setSnackbar({ + open: true, + message: "請先掃碼或查詢一次,才可刷新批號清單。", + severity: "info", + }); + return; + } + }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]); + + useEffect(() => { + if (!open) return; + if (initialPayload) { + setScanInput(JSON.stringify(initialPayload)); + void analyzePayload(initialPayload); + return; + } + if (Number.isFinite(Number(initialItemId)) && Number(initialItemId) > 0) { + void analyzeByItem(Number(initialItemId)); + } + }, [open, initialPayload, initialItemId, analyzePayload, analyzeByItem]); + + const availableLots = useMemo(() => { + if (!analysis) return []; + const list = (analysis.sameItemLots ?? []).filter( + (x) => Number(x.availableQty) > 0 && !!String(x.lotNo || "").trim(), + ); + const scannedLotLineId = analysis.scanned?.inventoryLotLineId; + const scannedRow = scannedLotLineId + ? list.find((x) => x.inventoryLotLineId === scannedLotLineId) + : undefined; + const tableQty = Number(triggerLotAvailableQty); + const fromTable = + Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; + const fromApi = Number(scannedRow?.availableQty ?? 0); + const scanned = analysis.scanned; + const scannedLot = scannedLotLineId + ? { + lotNo: scanned?.lotNo ?? "", + inventoryLotLineId: scannedLotLineId, + stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, + availableQty: Math.max(fromApi, fromTable) as number, + uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, + warehouseCode: + scanned?.warehouseCode ?? scannedRow?.warehouseCode, + warehouseName: + scanned?.warehouseName ?? scannedRow?.warehouseName, + _scanned: true as const, + } + : null; + + const merged = [ + ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), + ...list + .filter((x) => x.inventoryLotLineId !== scannedLotLineId) + .map((x) => ({ ...x, _scanned: false as const })), + ]; + + return merged; + }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); + + const filteredLots = useMemo(() => { + const prefix = String(warehouseCodePrefixFilter ?? "").trim(); + if (!prefix) return availableLots; + return availableLots.filter((lot) => { + // 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號 + if (lot._scanned) return true; + return String(lot.warehouseCode ?? "").startsWith(prefix); + }); + }, [availableLots, warehouseCodePrefixFilter]); + + const selectedPrinter = useMemo(() => { + if (selectedPrinterId === "") return null; + return printers.find((p) => p.id === selectedPrinterId) ?? null; + }, [printers, selectedPrinterId]); + + const canPrint = + !!analysis && selectedPrinterId !== "" && printQty >= 1 && !analysisLoading; + + const handlePrintOne = useCallback( + async (inventoryLotLineId: number, lotNo: string) => { + if (selectedPrinterId === "") { + setSnackbar({ + open: true, + message: "請先選擇印表機", + severity: "error", + }); + return; + } + if (printQty < 1 || !Number.isFinite(printQty)) { + setSnackbar({ + open: true, + message: "列印張數需為大於等於 1 的整數", + severity: "error", + }); + return; + } + + setPrintingLotLineId(inventoryLotLineId); + try { + await printWorkbenchLotLabel({ + inventoryLotLineId, + printerId: selectedPrinterId, + printQty: Math.floor(printQty), + }); + setSnackbar({ + open: true, + message: `已送出列印:Lot ${lotNo}`, + severity: "success", + }); + } catch (e) { + setSnackbar({ + open: true, + message: e instanceof Error ? e.message : "列印失敗", + severity: "error", + }); + } finally { + setPrintingLotLineId(null); + } + }, + [selectedPrinterId, printQty], + ); + + return ( + + 批號標籤列印(提貨台) + + + {statusTitleText ? ( + + {statusTitleText} + + ) : null} + {reminderText ? ( + {reminderText} + ) : null} + {effectiveHideScanSection ? null : ( + <> + {/* + + 請掃描條碼(JSON 格式),例如{" "} + {'{"itemId":16431,"stockInLineId":10381'}。 + + */} + + setScanInput(e.target.value)} + fullWidth + size="small" + error={!!scanError} + helperText={scanError || "掃描後按 Enter 或點「查詢」"} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleAnalyze(); + } + }} + disabled={analysisLoading} + /> + + + + + )} + + + + 印表機 + + + + setPrintQty(Number(e.target.value))} + sx={{ width: 140 }} + disabled={analysisLoading} + /> + + {onWorkbenchScanPick ? ( + { + const n = Number(e.target.value); + if (!Number.isFinite(n) || n < 0) return; + onSubmitQtyChange?.(n); + }} + sx={{ width: 140 }} + disabled={analysisLoading} + /> + ) : null} + + + + {selectedPrinter && ( + + 已選:{formatPrinterLabel(selectedPrinter)} + + )} + + + {analysis && ( + + + 品號:{analysis.itemCode} {analysis.itemName} + + + {filteredLots.length === 0 ? ( + + 找不到該樓層有可用批號(availableQty > 0)。 + + ) : ( + + {filteredLots.map((lot) => { + const isPrinting = + printingLotLineId === lot.inventoryLotLineId; + const loc = String(lot.warehouseCode ?? "").trim(); + const canShowLotQr = + !!onWorkbenchScanPick && + !!analysis && + !analysisLoading && + !disableScanPick; + const lotQrPayload = + Number.isFinite(Number(analysis?.itemId)) && + Number.isFinite(Number(lot.stockInLineId)) + ? { + itemId: Number(analysis?.itemId), + stockInLineId: Number(lot.stockInLineId), + } + : null; + return ( + + + + Lot:{lot.lotNo} + {lot._scanned ? "(當前批次)" : ""} + + + 位置:{loc || "—"} + + + 可用量:{Number(lot.availableQty).toLocaleString()}{" "} + 單位:{lot.uom || ""} + + + + + {onWorkbenchScanPick ? ( + + ) : null} + + {qrVisibleLotLineId === lot.inventoryLotLineId && + lotQrPayload ? ( + + + + ) : null} + + ); + })} + + )} + + )} + + {!analysis && !analysisLoading && ( + + + {onWorkbenchScanPick + ? "沒有任何批號可列印標籤" + : ""} + + )} + + + + + + + setSnackbar((s) => ({ ...s, open: false }))} + message={snackbar.message} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +}; + +export default WorkbenchLotLabelPrintModal; diff --git a/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx new file mode 100644 index 0000000..7418579 --- /dev/null +++ b/src/components/DoWorkbench/WorkbenchTicketReleaseTable.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Card, + CardContent, + Chip, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + Typography, + Button, + Tooltip, +} 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 { arrayToDayjs } from "@/app/utils/formatUtil"; +import { + fetchWorkbenchTicketReleaseTable, + forceCompleteWorkbenchTicket, + revertWorkbenchTicketAssignment, + WorkbenchTicketReleaseTable, +} from "@/app/api/do/actions"; +import Swal from "sweetalert2"; +import { AUTH } from "@/authorities"; +import { SessionWithTokens } from "@/config/authConfig"; + +function requiredDeliveryDateToDayString(value: unknown): string { + if (value == null) return ""; + if (Array.isArray(value) && value.length >= 3 && value.every((x) => typeof x === "number")) { + return arrayToDayjs(value as number[]).format("YYYY-MM-DD"); + } + return dayjs(value as string | number | Date).format("YYYY-MM-DD"); +} + +function isCompletedStatus(status: string | null | undefined): boolean { + return (status ?? "").toLowerCase() === "completed"; +} + +function showDoPickOpsButtons(row: WorkbenchTicketReleaseTable): boolean { + return ( + row.isActiveWorkbenchTicket === true && + !isCompletedStatus(row.ticketStatus) && + row.handledBy != null + ); +} + +const WorkbenchTicketReleaseTableTab: React.FC = () => { + const { t } = useTranslation("ticketReleaseTable"); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canManageDoPickOps = abilities.some((a) => a.trim() === AUTH.ADMIN); + const [queryDate, setQueryDate] = useState(() => dayjs()); + const [selectedFloor, setSelectedFloor] = useState(""); + const [selectedStatus, setSelectedStatus] = useState("released"); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [paginationController, setPaginationController] = useState({ + pageNum: 0, + pageSize: 5, + }); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const dayStr = queryDate.format("YYYY-MM-DD"); + const result = await fetchWorkbenchTicketReleaseTable(dayStr, dayStr); + setData(result); + } catch (error) { + console.error("Error fetching workbench ticket release table:", error); + setData([]); + } finally { + setLoading(false); + } + }, [queryDate]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const dayStr = queryDate.format("YYYY-MM-DD"); + const filteredData = useMemo(() => { + return data.filter((item) => { + if (selectedFloor && item.storeId !== selectedFloor) return false; + if (item.requiredDeliveryDate) { + const itemDate = requiredDeliveryDateToDayString(item.requiredDeliveryDate); + if (itemDate !== dayStr) return false; + } + if (selectedStatus && item.ticketStatus?.toLowerCase() !== selectedStatus.toLowerCase()) return false; + return true; + }); + }, [data, dayStr, selectedFloor, selectedStatus]); + + const paginatedData = useMemo(() => { + const startIndex = paginationController.pageNum * paginationController.pageSize; + const endIndex = startIndex + paginationController.pageSize; + return filteredData.slice(startIndex, endIndex); + }, [filteredData, paginationController]); + + const handleRevert = useCallback( + async (row: WorkbenchTicketReleaseTable) => { + if (!canManageDoPickOps) return; + const r = await Swal.fire({ + title: t("Confirm revert assignment"), + text: t("Revert assignment hint"), + icon: "warning", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + }); + if (!r.isConfirmed) return; + try { + const res = await revertWorkbenchTicketAssignment(row.deliveryOrderPickOrderId); + if (res.code === "SUCCESS") { + await Swal.fire({ + icon: "success", + text: t("Operation succeeded"), + timer: 1500, + showConfirmButton: false, + }); + await loadData(); + } else { + await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" }); + } + } catch (e) { + console.error(e); + await Swal.fire({ icon: "error", text: String(e) }); + } + }, + [canManageDoPickOps, loadData, t], + ); + + const handleForceComplete = useCallback( + async (row: WorkbenchTicketReleaseTable) => { + if (!canManageDoPickOps) return; + const r = await Swal.fire({ + title: t("Confirm force complete"), + text: t("Force complete hint"), + icon: "warning", + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + }); + if (!r.isConfirmed) return; + try { + const res = await forceCompleteWorkbenchTicket(row.deliveryOrderPickOrderId); + if (res.code === "SUCCESS") { + await Swal.fire({ + icon: "success", + text: t("Operation succeeded"), + timer: 1500, + showConfirmButton: false, + }); + await loadData(); + } else { + await Swal.fire({ icon: "error", title: res.code ?? "", text: res.message ?? "" }); + } + } catch (e) { + console.error(e); + await Swal.fire({ icon: "error", text: String(e) }); + } + }, + [canManageDoPickOps, loadData, t], + ); + + const opsTooltip = !canManageDoPickOps ? t("Manager only hint") : ""; + + return ( + + + + + {t("Ticket Release Table")} + + + v && setQueryDate(v)} + slotProps={{ textField: { size: "small", sx: { minWidth: 180 } } }} + /> + + + + {t("Floor")} + + + + + + {t("Status")} + + + + + + {loading ? ( + + + + ) : ( + <> + + + + + {t("Store ID")} + {t("Required Delivery Date")} + {t("Truck Information")} + {t("Shop Name")} + {t("Loading Sequence")} + {t("Ticket Information")} + {t("Handler Name")} + {t("Number of FG Items (Order Item(s) Count)")} + + {t("Actions")} + + + + + {paginatedData.length === 0 ? ( + + + {t("No data available")} + + + ) : ( + paginatedData.map((row) => ( + + {row.storeId || "-"} + + {row.requiredDeliveryDate ? requiredDeliveryDateToDayString(row.requiredDeliveryDate) : "-"} + + + + {row.truckLanceCode && } + {row.truckDepartureTime && } + + + {row.shopName || "-"} + {row.loadingSequence ?? "-"} + + {row.ticketNo || "-"} ({row.ticketStatus ? t(row.ticketStatus.toLowerCase()) : "-"}) + + {row.handlerName ?? "-"} + {row.numberOfFGItems ?? 0} + + {showDoPickOpsButtons(row) ? ( + + + + + + + + + + + + + ) : ( + + — + + )} + + + )) + )} + +
+
+ {filteredData.length > 0 && ( + + setPaginationController((prev) => ({ ...prev, pageNum: page })) + } + onRowsPerPageChange={(event) => + setPaginationController({ pageNum: 0, pageSize: parseInt(event.target.value, 10) }) + } + rowsPerPageOptions={[5, 10, 15]} + labelRowsPerPage={t("Rows per page")} + /> + )} + + )} +
+
+
+ ); +}; + +export default WorkbenchTicketReleaseTableTab; diff --git a/src/components/DoWorkbench/index.ts b/src/components/DoWorkbench/index.ts new file mode 100644 index 0000000..81a2bff --- /dev/null +++ b/src/components/DoWorkbench/index.ts @@ -0,0 +1,4 @@ +export { default as DoWorkbenchPickShell } from "./DoWorkbenchPickShell"; +export { default as WorkbenchGoodPickExecution } from "./WorkbenchGoodPickExecution"; +export { default as WorkbenchGoodPickExecutionDetail } from "./WorkbenchGoodPickExecutionDetail"; +export { default as WorkbenchFloorLanePanel } from "./WorkbenchFloorLanePanel"; diff --git a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx index ce97906..f61d2a3 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx @@ -23,6 +23,7 @@ import dayjs from "dayjs"; import { CompletedDoPickOrderResponse, fetchCompletedDoPickOrdersAll, + fetchCompletedDoPickOrdersWorkbenchAll, } from "@/app/api/pickOrder/actions"; import SafeApexCharts from "@/components/charts/SafeApexCharts"; @@ -36,7 +37,11 @@ type DailySummaryRow = { total: number; }; -const FinishedGoodCartonDashboardTab: React.FC = () => { +type Props = { + mode?: "normal" | "workbench"; +}; + +const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => { const [floor, setFloor] = useState("all"); const [date, setDate] = useState(dayjs().format("YYYY-MM-DD")); const [loading, setLoading] = useState(false); @@ -47,9 +52,14 @@ const FinishedGoodCartonDashboardTab: React.FC = () => { setLoading(true); setError(""); try { - const data = await fetchCompletedDoPickOrdersAll( - date ? { targetDate: date } : undefined, - ); + const data = + mode === "workbench" + ? await fetchCompletedDoPickOrdersWorkbenchAll( + date ? { targetDate: date } : undefined, + ) + : await fetchCompletedDoPickOrdersAll( + date ? { targetDate: date } : undefined, + ); setRecords(data); } catch (err) { console.error("Failed to load finished good carton dashboard data", err); @@ -58,7 +68,7 @@ const FinishedGoodCartonDashboardTab: React.FC = () => { } finally { setLoading(false); } - }, [date]); + }, [date, mode]); useEffect(() => { loadData(); diff --git a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index 8a3a7e9..a4f75d3 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -3723,7 +3723,7 @@ const PickExecution: React.FC = ({ ); } else if (completionResponse.message === "not completed") { console.log( - `⏳ Pick order not completed yet, more lines remaining`, + `Pick order not completed yet, more lines remaining`, ); } else { console.error( diff --git a/src/components/JoWorkbench/JoPickOrderList.tsx b/src/components/JoWorkbench/JoPickOrderList.tsx new file mode 100644 index 0000000..01803ba --- /dev/null +++ b/src/components/JoWorkbench/JoPickOrderList.tsx @@ -0,0 +1,321 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Button, + Card, + CardContent, + CardActions, + Stack, + Typography, + Chip, + CircularProgress, + Grid, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { useTranslation } from "react-i18next"; +import { fetchAllJoPickOrders, AllJoPickOrderResponse } from "@/app/api/jo/actions"; +import JobPickExecution from "./newJobPickExecution"; +import SearchBox, { Criterion } from "../SearchBox"; +import dayjs from "dayjs"; + +interface Props { + /** Reserved for tabs parity with Jodetail; not used in workbench list yet. */ + onSwitchToRecordTab?: () => void; +} + +/** Jo workbench: same list + detail flow as Jodetail `JoPickOrderList`, detail uses `JoWorkbench/newJobPickExecution`. */ +const JoPickOrderList: React.FC = () => { + const { t } = useTranslation(["common", "jo"]); + const today = dayjs().format("YYYY-MM-DD"); + const [loading, setLoading] = useState(false); + const [pickOrders, setPickOrders] = useState([]); + const [selectedPickOrderId, setSelectedPickOrderId] = useState(undefined); + const [selectedJobOrderId, setSelectedJobOrderId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState>({ planStart: today }); + + type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT"; + + const searchCriteria: Criterion[] = [ + { label: t("Job Order Code"), paramName: "jobOrderCode", type: "text" }, + { label: t("Pick Order"), paramName: "pickOrderCode", type: "text" }, + { label: t("Item Name"), paramName: "itemName", type: "text" }, + { + label: t("Job Order Type"), + paramName: "BOM Description", + type: "select-labelled", + options: [ + { label: t("All"), value: "All" }, + { label: t("FG"), value: "FG" }, + { label: t("WIP"), value: "WIP" }, + ], + }, + { label: t("Plan Start"), paramName: "planStart", type: "date" }, + { + label: t("BOM Type"), + paramName: "bomType", + type: "select-labelled", + options: [ + { label: t("All"), value: "All" }, + { label: t("Drink"), value: "drink" }, + { label: t("Powder Mixture"), value: "Powder_Mixture" }, + { label: t("Other"), value: "other" }, + ], + }, + { + label: t("Floor"), + paramName: "floor", + type: "select-labelled", + options: [ + { label: t("All"), value: "ALL" }, + { label: "2F", value: "2F" }, + { label: "3F", value: "3F" }, + { label: "4F", value: "4F" }, + { label: t("No Lot"), value: "NO_LOT" }, + ], + }, + ]; + + const selectedFloor: FloorFilter = (() => { + const floor = String(searchQuery.floor || "ALL"); + if (floor === "2F" || floor === "3F" || floor === "4F" || floor === "NO_LOT") { + return floor; + } + return "ALL"; + })(); + + const fetchPickOrders = useCallback(async (query?: Record) => { + setLoading(true); + try { + const currentQuery = query ?? { planStart: today }; + const bomTypeValue = String(currentQuery.bomType || "All"); + const floorValue = String(currentQuery.floor || "ALL"); + const isAllOption = (value: string) => { + const normalized = value.trim().toLowerCase(); + return normalized === "" || normalized === "all"; + }; + const typeParam = isAllOption(bomTypeValue) ? undefined : bomTypeValue; + const floorParam = isAllOption(floorValue) ? undefined : floorValue; + const jobOrderCode = String(currentQuery.jobOrderCode || "").trim(); + const pickOrderCode = String(currentQuery.pickOrderCode || "").trim(); + const itemName = String(currentQuery.itemName || "").trim(); + const bomDescription = String(currentQuery.bomDescription || "").trim(); + const planStart = String(currentQuery.planStart || "").trim(); + + const data = await fetchAllJoPickOrders(typeParam, floorParam, { + jobOrderCode: jobOrderCode || undefined, + pickOrderCode: pickOrderCode || undefined, + itemName: itemName || undefined, + bomDescription: isAllOption(bomDescription) ? undefined : bomDescription, + planStart: planStart || undefined, + }); + setPickOrders(Array.isArray(data) ? data : []); + } catch (e) { + console.error(e); + setPickOrders([]); + } finally { + setLoading(false); + } + }, [today]); + + const handleSearch = useCallback((query: Record) => { + const nextQuery = { ...query }; + setSearchQuery(nextQuery); + void fetchPickOrders(nextQuery); + }, [fetchPickOrders]); + + const handleSearchReset = useCallback(() => { + const resetQuery = {}; + setSearchQuery(resetQuery); + void fetchPickOrders(resetQuery); + }, [fetchPickOrders]); + + useEffect(() => { + void fetchPickOrders({ planStart: today }); + }, [fetchPickOrders, today]); + const handleBackToList = useCallback(() => { + setSelectedPickOrderId(undefined); + setSelectedJobOrderId(undefined); + void fetchPickOrders(searchQuery); + }, [fetchPickOrders, searchQuery]); + if (selectedPickOrderId !== undefined) { + return ( + + + + + + + ); + } + + return ( + + + + + {loading ? ( + + + + ) : ( + + + {t("Total pick orders")}: {pickOrders.length} + + + + {pickOrders.map((pickOrder) => { + const status = String(pickOrder.jobOrderStatus || ""); + const statusLower = status.toLowerCase(); + const statusColor = + statusLower === "completed" + ? "success" + : statusLower === "pending" || statusLower === "processing" + ? "primary" + : "default"; + + const finishedCount = pickOrder.finishedPickOLineCount ?? 0; + + return ( + + + + + + + {t("Job Order")}: {pickOrder.jobOrderCode || "-"} + + + + + + {t("Lot No")}: {pickOrder.lotNo || "-"} + + + {t("Pick Order")}: {pickOrder.pickOrderCode || "-"} + + + {t("Item Name")}: {pickOrder.itemName} + {pickOrder.bomDescription ? ` (${t(pickOrder.bomDescription)})` : ""} + + + {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) + + {selectedFloor === "ALL" ? ( + <> + {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( + + {floor}: {finishedCount}/{totalCount} + + ))} + {!!pickOrder.noLotPickCount && ( + + {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} + + )} + + ) : selectedFloor === "NO_LOT" ? ( + !!pickOrder.noLotPickCount && ( + + {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} + + ) + ) : ( + pickOrder.floorPickCounts + ?.filter((c) => c.floor === selectedFloor) + .map(({ floor, finishedCount, totalCount }) => ( + + {floor}: {finishedCount}/{totalCount} + + )) + )} + {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( + + {t("Suggested Fail")}: {pickOrder.suggestedFailCount} + + )} + {statusLower !== "pending" && finishedCount > 0 && ( + + + {t("Finished lines")}: {finishedCount} + + + )} + + + + + + + + + ); + })} + + + )} + + ); +}; + +export default JoPickOrderList; diff --git a/src/components/JoWorkbench/JoWorkbenchSearch.tsx b/src/components/JoWorkbench/JoWorkbenchSearch.tsx new file mode 100644 index 0000000..e3b188b --- /dev/null +++ b/src/components/JoWorkbench/JoWorkbenchSearch.tsx @@ -0,0 +1,923 @@ +"use client" +import { SearchJoResultRequest, setJobOrderHidden, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; +import { fetchJosForWorkbench, releaseJoForWorkbench } from "@/app/api/jo/workbenchActions"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Criterion } from "../SearchBox"; +import SearchResults, { Column, defaultPagingController } from "../SearchResults/SearchResults"; +import { EditNote } from "@mui/icons-material"; +import { arrayToDateString, arrayToDateTimeString, integerFormatter, dayjsToDateString } from "@/app/utils/formatUtil"; +import { orderBy, uniqBy, upperFirst } from "lodash"; +import SearchBox from "../SearchBox/SearchBox"; +import { useRouter } from "next/navigation"; +import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; +import { StockInLineInput } from "@/app/api/stockIn"; +import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo"; +import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment, Typography, Box, CircularProgress } from "@mui/material"; +import { BomCombo } from "@/app/api/bom"; +import JoCreateFormModal from "@/components/JoSearch/JoCreateFormModal"; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import QcStockInModal from "../Qc/QcStockInModal"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { createStockInLine } from "@/app/api/stockIn/actions"; +import { msg } from "../Swal/CustomAlerts"; +import dayjs from "dayjs"; +//import { fetchInventories } from "@/app/api/inventory/actions"; +import { InventoryResult } from "@/app/api/inventory"; +import { PrinterCombo } from "@/app/api/settings/printer"; +import { JobTypeResponse } from "@/app/api/jo/actions"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { updateJoPlanStart } from "@/app/api/jo/actions"; +import { arrayToDayjs } from "@/app/utils/formatUtil"; + +interface Props { + defaultInputs: SearchJoResultRequest, + bomCombo: BomCombo[] + printerCombo: PrinterCombo[]; + jobTypes: JobTypeResponse[]; +} + +type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; + +const JoWorkbenchSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobTypes = [] }) => { + const { t } = useTranslation("jo"); + const router = useRouter() + const [filteredJos, setFilteredJos] = useState([]); + const [inputs, setInputs] = useState(defaultInputs); + const [pagingController, setPagingController] = useState( + defaultPagingController + ) + const [totalCount, setTotalCount] = useState(0) + const [isCreateJoModalOpen, setIsCreateJoModalOpen] = useState(false) + + const [inventoryData, setInventoryData] = useState([]); + const [detailedJos, setDetailedJos] = useState>(new Map()); + const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); + const [releasingJoIds, setReleasingJoIds] = useState>(new Set()); + const [isBatchReleasing, setIsBatchReleasing] = useState(false); + const [cancelConfirmJoId, setCancelConfirmJoId] = useState(null); + const [cancelSubmitting, setCancelSubmitting] = useState(false); + const [cancelingJoIds, setCancelingJoIds] = useState>(new Set()); + + // 合并后的统一编辑 Dialog 状态 + const [openEditDialog, setOpenEditDialog] = useState(false); + const [selectedJoForEdit, setSelectedJoForEdit] = useState(null); + const [editPlanStartDate, setEditPlanStartDate] = useState(null); + const [editReqQtyMultiplier, setEditReqQtyMultiplier] = useState(1); + const [editBomForReqQty, setEditBomForReqQty] = useState(null); + const [editProductionPriority, setEditProductionPriority] = useState(50); + const [editProductProcessId, setEditProductProcessId] = useState(null); + + const fetchJoDetailClient = async (id: number): Promise => { + const response = await fetch(`/api/jo/detail?id=${id}`); + if (!response.ok) { + throw new Error('Failed to fetch JO detail'); + } + return response.json(); + }; +/* + useEffect(() => { + const fetchDetailedJos = async () => { + const detailedMap = new Map(); + try { + const results = await Promise.all( + filteredJos.map((jo) => + fetchJoDetailClient(jo.id).then((detail) => ({ id: jo.id, detail })).catch((error) => { + console.error(`Error fetching detail for JO ${jo.id}:`, error); + return null; + }) + ) + ); + results.forEach((r) => { + if (r) detailedMap.set(r.id, r.detail); + }); + } catch (error) { + console.error("Error fetching JO details:", error); + } + setDetailedJos(detailedMap); + }; + + if (filteredJos.length > 0) { + fetchDetailedJos(); + } + }, [filteredJos]); + */ +/* + useEffect(() => { + const fetchInventoryData = async () => { + try { + const inventoryResponse = await fetchInventories({ + code: "", + name: "", + type: "", + pageNum: 0, + pageSize: 200, + }); + setInventoryData(inventoryResponse.records ?? []); + } catch (error) { + console.error("Error fetching inventory data:", error); + } + }; + + fetchInventoryData(); + }, []); + */ + + const getStockAvailable = (pickLine: JoDetailPickLine) => { + const inventory = inventoryData.find(inventory => + inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name + ); + + if (inventory) { + return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); + } + + return 0; + }; + + const isStockSufficient = (pickLine: JoDetailPickLine) => { + const stockAvailable = getStockAvailable(pickLine); + return stockAvailable >= pickLine.reqQty; + }; + + const getStockCounts = (jo: JobOrder) => { + return { + sufficient: jo.sufficientCount, + insufficient: jo.insufficientCount + }; + }; + + const searchCriteria: Criterion[] = useMemo(() => [ + { label: t("Code"), paramName: "code", type: "text" }, + { label: t("Item Name"), paramName: "itemName", type: "text" }, + { label: t("Plan Start"), label2: t("Plan Start To"), paramName: "planStart", type: "dateRange", preFilledValue: { + from: dayjsToDateString(dayjs(), "input"), + to: dayjsToDateString(dayjs(), "input") + } }, + { + label: t("Job Type"), + paramName: "jobTypeName", + type: "select", + options: (jobTypes ?? []).map(jt => jt.name) + }, + { + label: t("Status"), + paramName: "joSearchStatus", + type: "select-labelled", + options: [ + { label: t("Pending"), value: "pending" }, + { label: t("Packaging"), value: "packaging" }, + { label: t("Processing"), value: "processing" }, + { label: t("Storing"), value: "storing" }, + { label: t("Put Awayed"), value: "putAwayed" }, + { label: t("cancel"), value: "cancel" }, + ], + }, + ], [t, jobTypes]) + + const fetchBomForJo = useCallback(async (jo: JobOrder): Promise => { + try { + // 若列表的 jo 已有 bomId(之後若 API 有回傳),可直接用 + const bomId = (jo as { bomId?: number }).bomId; + if (bomId != null) { + const matchingBom = bomCombo.find(bom => bom.id === bomId); + if (matchingBom) return matchingBom; + } + // 否則打明細 API,明細會帶 bomId + const detailedJo = detailedJos.get(jo.id) ?? await fetchJoDetailClient(jo.id); + const detailBomId = (detailedJo as { bomId?: number }).bomId; + if (detailBomId != null) { + const matchingBom = bomCombo.find(bom => bom.id === detailBomId); + if (matchingBom) return matchingBom; + } + return null; + } catch (error) { + console.error("Error fetching BOM for JO:", error); + return null; + } + }, [bomCombo, detailedJos]); + + // 统一的打开编辑对话框函数 + const handleOpenEditDialog = useCallback(async (jo: JobOrder) => { + setSelectedJoForEdit(jo); + + // 设置 Plan Start Date + if (jo.planStart && Array.isArray(jo.planStart)) { + setEditPlanStartDate(arrayToDayjs(jo.planStart)); + } else { + setEditPlanStartDate(dayjs()); + } + + // 设置 Production Priority + setEditProductionPriority(jo.productionPriority ?? 50); + + // 获取 productProcessId + try { + const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions"); + const processes = await fetchProductProcessesByJobOrderId(jo.id); + if (processes && processes.length > 0) { + setEditProductProcessId(processes[0].id); + } + } catch (error) { + console.error("Error fetching product process:", error); + } + + // 设置 ReqQty + const bom = await fetchBomForJo(jo); + if (bom) { + setEditBomForReqQty(bom); + const currentMultiplier = bom.outputQty > 0 + ? Math.round(jo.reqQty / bom.outputQty) + : 1; + setEditReqQtyMultiplier(currentMultiplier); + } + + setOpenEditDialog(true); + }, [fetchBomForJo]); + + // 统一的关闭函数 + const handleCloseEditDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { + setOpenEditDialog(false); + setSelectedJoForEdit(null); + setEditPlanStartDate(null); + setEditReqQtyMultiplier(1); + setEditBomForReqQty(null); + setEditProductionPriority(50); + setEditProductProcessId(null); + }, []); + + const newPageFetch = useCallback( + async ( + pagingController: { pageNum: number; pageSize: number }, + filterArgs: SearchJoResultRequest, + ) => { + const params: SearchJoResultRequest = { + ...filterArgs, + pageNum: pagingController.pageNum - 1, + pageSize: pagingController.pageSize, + }; + const response = await fetchJosForWorkbench(params); + console.log("newPageFetch params:", params) + console.log("newPageFetch response:", response) + if (response && response.records) { + console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); + setTotalCount(response.total); + setFilteredJos(response.records); + console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); + } else { + console.warn("newPageFetch - no response or no records"); + setFilteredJos([]); + } + }, + [], + ); + + const isPlanningJo = useCallback((jo: JobOrder) => { + return String(jo.status ?? "").toLowerCase() === "planning"; + }, []); + + const handleReleaseJo = useCallback(async (joId: number) => { + if (!joId) return; + setReleasingJoIds((prev) => { + const next = new Set(prev); + next.add(joId); + return next; + }); + try { + const response = await releaseJoForWorkbench({ id: joId }); + if (response) { + msg(t("update success")); + setCheckboxIds((prev) => prev.filter((id) => Number(id) !== joId)); + await newPageFetch(pagingController, inputs); + } + } catch (error) { + console.error("Error releasing JO:", error); + msg(t("update failed")); + } finally { + setReleasingJoIds((prev) => { + const next = new Set(prev); + next.delete(joId); + return next; + }); + } + }, [inputs, pagingController, t, newPageFetch]); + + const handleConfirmCancelJobOrder = useCallback(async () => { + if (cancelConfirmJoId == null) return; + const id = cancelConfirmJoId; + setCancelSubmitting(true); + setCancelingJoIds((prev) => new Set(prev).add(id)); + try { + await setJobOrderHidden(id, true); + msg(t("update success")); + setCancelConfirmJoId(null); + await newPageFetch(pagingController, inputs); + } catch (error) { + console.error("Error cancelling job order:", error); + msg(t("update failed")); + } finally { + setCancelSubmitting(false); + setCancelingJoIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, [cancelConfirmJoId, newPageFetch, pagingController, inputs, t]); + + const selectedPlanningJoIds = useMemo(() => { + const selectedIds = new Set(checkboxIds.map((id) => Number(id))); + return filteredJos + .filter((jo) => selectedIds.has(jo.id)) + .filter((jo) => isPlanningJo(jo)) + .map((jo) => jo.id); + }, [checkboxIds, filteredJos, isPlanningJo]); + + const handleBatchRelease = useCallback(async () => { + if (selectedPlanningJoIds.length === 0) return; + setIsBatchReleasing(true); + try { + const results = await Promise.allSettled( + selectedPlanningJoIds.map((id) => releaseJoForWorkbench({ id })), + ); + const successCount = results.filter((r) => r.status === "fulfilled").length; + const failedCount = results.length - successCount; + if (successCount > 0 && failedCount === 0) { + msg(t("update success")); + } else if (successCount > 0) { + msg(`${t("update success")} (${successCount}), ${t("update failed")} (${failedCount})`); + } else { + msg(t("update failed")); + } + setCheckboxIds((prev) => prev.filter((id) => !selectedPlanningJoIds.includes(Number(id)))); + await newPageFetch(pagingController, inputs); + } catch (error) { + console.error("Error batch releasing JOs:", error); + msg(t("update failed")); + } finally { + setIsBatchReleasing(false); + } + }, [inputs, pagingController, selectedPlanningJoIds, t, newPageFetch]); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: "", + type: "checkbox", + disabled: (row) => { + const id = row.id; + return !isPlanningJo(row) || releasingJoIds.has(id) || isBatchReleasing; + } + }, + + { + name: "planStart", + label: t("Estimated Production Date"), + align: "left", + headerAlign: "left", + renderCell: (row) => { + return ( + + {row.status == "planning" && ( + { + e.stopPropagation(); + handleOpenEditDialog(row); + }} + sx={{ padding: '4px' }} + > + + + )} + {row.planStart ? arrayToDateString(row.planStart) : '-'} + + + ); + } + }, + { + name: "productionPriority", + label: t("Production Priority"), + renderCell: (row) => { + return ( + + {integerFormatter.format(row.productionPriority)} + + + ); + } + }, + { + name: "code", + label: t("Code / Lot No"), + flex: 2, + renderCell: (row) => ( + + {row.code} +
+ {row.lotNo ?? "-"} +
+ ), + }, + { + name: "item", + label: `${t("Item Name")}`, + renderCell: (row) => { + return row.item ? <>{t(row.jobTypeName)} {t(row.item.code)} {t(row.item.name)} : '-' + } + }, + { + name: "reqQty", + label: t("Req. Qty"), + align: "right", + headerAlign: "right", + renderCell: (row) => { + return ( + + {integerFormatter.format(row.reqQty)} + + + ); + } + }, + { + name: "item", + label: t("UoM"), + align: "left", + headerAlign: "left", + renderCell: (row) => { + return row.item?.uom ? t(row.item.uom.udfudesc) : '-' + } + }, + { + name: "stockStatus" as keyof JobOrder, + label: t("BOM Status"), + align: "left", + headerAlign: "left", + renderCell: (row) => { + const stockCounts = getStockCounts(row); + return ( + 0 ? 'red' : 'green' }}> + {stockCounts.sufficient}/{stockCounts.sufficient + stockCounts.insufficient} + + ); + } + }, + { + name: "status", + label: t("Status"), + renderCell: (row) => { + return + {t(upperFirst(row.status))} + + } + }, + { + name: "id", + label: t("Actions"), + renderCell: (row) => { + const isPlanning = isPlanningJo(row); + const isReleasing = releasingJoIds.has(row.id) || isBatchReleasing; + const isCancelingRow = cancelingJoIds.has(row.id); + const isPutAwayed = row.stockInLineStatus?.toLowerCase() === "completed"; + return ( + + + {isPlanning ? ( + + ) : ( + + )} + + ) + } + }, + ], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing, cancelingJoIds, cancelSubmitting] + ) + + const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => { + try { + const response = await updateJoReqQty({ + id: jobOrderId, + reqQty: newReqQty + }); + + if (response) { + msg(t("update success")); + await newPageFetch(pagingController, inputs); + } + } catch (error) { + console.error("Error updating reqQty:", error); + msg(t("update failed")); + } + }, [pagingController, inputs, newPageFetch, t]); + + const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => { + const response = await updateJoPlanStart({ id: jobOrderId, planStart }); + if (response) { + await newPageFetch(pagingController, inputs); + } + }, [pagingController, inputs, newPageFetch]); + + const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => { + const response = await updateProductProcessPriority(productProcessId, productionPriority) + if (response) { + await newPageFetch(pagingController, inputs); + } + }, [pagingController, inputs, newPageFetch]); + + // 统一的确认函数 + const handleConfirmEdit = useCallback(async () => { + if (!selectedJoForEdit) return; + + try { + // 更新 Plan Start + if (editPlanStartDate) { + const dateString = `${dayjsToDateString(editPlanStartDate, "input")}T00:00:00`; + await handleUpdatePlanStart(selectedJoForEdit.id, dateString); + } + + // 更新 ReqQty + if (editBomForReqQty) { + const newReqQty = editReqQtyMultiplier * editBomForReqQty.outputQty; + await handleUpdateReqQty(selectedJoForEdit.id, newReqQty); + } + + // 更新 Production Priority + if (editProductProcessId) { + await handleUpdateOperationPriority(editProductProcessId, Number(editProductionPriority)); + } + + setOpenEditDialog(false); + setSelectedJoForEdit(null); + setEditPlanStartDate(null); + setEditReqQtyMultiplier(1); + setEditBomForReqQty(null); + setEditProductionPriority(50); + setEditProductProcessId(null); + } catch (error) { + console.error("Error updating:", error); + msg(t("update failed")); + } + }, [selectedJoForEdit, editPlanStartDate, editBomForReqQty, editReqQtyMultiplier, editProductionPriority, editProductProcessId, handleUpdatePlanStart, handleUpdateReqQty, handleUpdateOperationPriority, t]); + + useEffect(() => { + newPageFetch(pagingController, inputs); + }, [newPageFetch, pagingController, inputs]); + + const handleUpdate = useCallback(async (jo: JobOrder) => { + console.log(jo); + try { + if (jo.id) { + const response = await updateJo({ id: jo.id, status: "storing" }); + console.log(`%c Updated JO:`, "color:lime", response); + const postData = { + itemId: jo?.item?.id!!, + acceptedQty: jo?.reqQty ?? 1, + productLotNo: jo?.code, + productionDate: arrayToDateString(dayjs(), "input"), + jobOrderId: jo?.id, + }; + const res = await createStockInLine(postData); + console.log(`%c Created Stock In Line`, "color:lime", res); + msg(t("update success")); + setInputs(defaultInputs); + setPagingController(defaultPagingController); + } + } catch (e) { + console.log(e); + } finally { + // setIsUploading(false) + } + }, [defaultInputs, t]) + + const getButtonSx = (jo : JobOrder) => { + const joStatus = jo.status?.toLowerCase(); + const silStatus = jo.stockInLineStatus?.toLowerCase(); + let btnSx = {label:"", color:""}; + switch (joStatus) { + case "planning": btnSx = {label: t("release jo"), color:"primary.main"}; break; + case "pending": btnSx = {label: t("scan picked material"), color:"error.main"}; break; + case "processing": btnSx = {label: t("complete jo"), color:"warning.main"}; break; + case "storing": + switch (silStatus) { + case "pending": btnSx = {label: t("process epqc"), color:"success.main"}; break; + case "received": btnSx = {label: t("view putaway"), color:"secondary.main"}; break; + case "escalated": + if (sessionToken?.id == jo.silHandlerId) { + btnSx = {label: t("escalation processing"), color:"warning.main"}; + break; + } + default: btnSx = {label: t("view stockin"), color:"info.main"}; + } + break; + case "completed": btnSx = {label: t("view stockin"), color:"info.main"}; break; + default: btnSx = {label: t("scan picked material"), color:"success.main"}; + } + return btnSx + }; + + const { data: session } = useSession(); + const sessionToken = session as SessionWithTokens | null; + + const [openModal, setOpenModal] = useState(false); + const [modalInfo, setModalInfo] = useState(); + + const onDetailClick = useCallback((record: JobOrder) => { + router.push(`/jo/edit?id=${record.id}`) + }, [router]) + + const closeNewModal = useCallback(() => { + setOpenModal(false); + setInputs(defaultInputs); + setPagingController(defaultPagingController); + }, [defaultInputs]); + + const onSearch = useCallback((query: Record) => { + const transformedQuery = { + ...query, + planStart: query.planStart ? `${query.planStart}T00:00` : query.planStart, + planStartTo: query.planStartTo ? `${query.planStartTo}T23:59:59` : query.planStartTo, + jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "", + joSearchStatus: query.joSearchStatus && query.joSearchStatus !== "All" ? query.joSearchStatus : "all", + }; + + setInputs({ + code: transformedQuery.code, + itemName: transformedQuery.itemName, + planStart: transformedQuery.planStart, + planStartTo: transformedQuery.planStartTo, + jobTypeName: transformedQuery.jobTypeName, + joSearchStatus: transformedQuery.joSearchStatus + }); + + setPagingController(defaultPagingController); + }, [defaultInputs]) + + const onReset = useCallback(() => { + setInputs(defaultInputs); + setPagingController(defaultPagingController); + }, [defaultInputs]) + + const onOpenCreateJoModal = useCallback(() => { + setIsCreateJoModalOpen(() => true) + }, []) + + const onCloseCreateJoModal = useCallback(() => { + setIsCreateJoModalOpen(() => false) + }, []) + + return <> + + + + {t("Selected")}: {selectedPlanningJoIds.length} + + + + + + + + + items={filteredJos} + columns={columns} + setPagingController={setPagingController} + pagingController={pagingController} + totalCount={totalCount} + isAutoPaging={false} + checkboxIds={checkboxIds} + setCheckboxIds={setCheckboxIds} + /> + { + setInputs({ ...defaultInputs }); + setPagingController(defaultPagingController); + }} + /> + + + + {/* 合并后的统一编辑 Dialog */} + + {t("Edit Job Order")} + + + {/* Plan Start Date */} + + setEditPlanStartDate(newValue)} + slotProps={{ + textField: { + fullWidth: true, + margin: "dense", + } + }} + /> + + + {/* Production Priority */} + { + const val = Number(e.target.value); + if (val >= 1 && val <= 100) { + setEditProductionPriority(val); + } + }} + inputProps={{ + min: 1, + max: 100, + step: 1 + }} + /> + + {/* ReqQty */} + {editBomForReqQty && ( + + + + {editBomForReqQty.outputQtyUom} + + + ) : null + }} + sx={{ flex: 1 }} + /> + + × + + { + const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value))); + setEditReqQtyMultiplier(val); + }} + inputProps={{ + min: 1, + step: 1 + }} + sx={{ flex: 1 }} + /> + + = + + + + {editBomForReqQty.outputQtyUom} + + + ) : null + }} + sx={{ flex: 1 }} + /> + + )} + + + + + + + + + !cancelSubmitting && setCancelConfirmJoId(null)} + maxWidth="xs" + fullWidth + > + {t("Confirm cancel job order")} + + {t("Cancel job order confirm message")} + + + + + + + +} + +export default JoWorkbenchSearch; \ No newline at end of file diff --git a/src/components/JoWorkbench/JoWorkbenchTabs.tsx b/src/components/JoWorkbench/JoWorkbenchTabs.tsx new file mode 100644 index 0000000..4d07254 --- /dev/null +++ b/src/components/JoWorkbench/JoWorkbenchTabs.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Box, Tab, Tabs } from "@mui/material"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import JoWorkbenchSearch from "@/components/JoWorkbench/JoWorkbenchSearch"; +import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList"; +import type { SearchJoResultRequest } from "@/app/api/jo/actions"; +import type { BomCombo } from "@/app/api/bom"; +import type { PrinterCombo } from "@/app/api/settings/printer"; +import type { JobTypeResponse } from "@/app/api/jo/actions"; + +export type JoWorkbenchTabsProps = { + defaultSearchInputs: SearchJoResultRequest; + bomCombo: BomCombo[]; + printerCombo: PrinterCombo[]; + jobTypes: JobTypeResponse[]; + defaultTabIndex?: number; +}; + +function TabPanel(props: { value: number; index: number; children: React.ReactNode }) { + const { value, index, children } = props; + if (value !== index) return null; + return {children}; +} + +const JoWorkbenchTabs: React.FC = ({ + defaultSearchInputs, + bomCombo, + printerCombo, + jobTypes, + defaultTabIndex = 0, +}) => { + const { t } = useTranslation("jo"); + const [tab, setTab] = React.useState(defaultTabIndex); + + return ( + + setTab(v)} sx={{ borderBottom: 1, borderColor: "divider" }}> + + + + + + + + + + + + + ); +}; + +export default JoWorkbenchTabs; diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx new file mode 100644 index 0000000..c90f38f --- /dev/null +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -0,0 +1,4947 @@ +"use client"; + +import { + Box, + Button, + Stack, + TextField, + Typography, + Alert, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Checkbox, + TablePagination, + Modal, +} from "@mui/material"; +import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; +import { + useCallback, + useEffect, + useState, + useRef, + useMemo, + startTransition, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "next/navigation"; +import { + updateStockOutLineStatus, + createStockOutLine, + recordPickExecutionIssue, + //applyPickExecutionHoldAndChecked, + fetchFGPickOrdersByUserIdWorkbench, + FGPickOrderResponse, + autoAssignAndReleasePickOrder, + AutoAssignReleaseResponse, + checkPickOrderCompletion, + PickOrderCompletionResponse, + checkAndCompletePickOrderByConsoCode, + confirmLotSubstitution, + updateStockOutLineStatusByQRCodeAndLotNo, // ✅ 添加 + batchSubmitList, // ✅ 添加 + batchSubmitListRequest, // ✅ 添加 + batchSubmitListLineRequest, +} from "@/app/api/pickOrder/actions"; +// 修改:使用 Job Order API +import { + fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench, + updateJoPickOrderHandledBy, + JobOrderLotsHierarchicalResponse, + applyPickExecutionHoldAndChecked, +} from "@/app/api/jo/actions"; +import { assignJobOrderPickOrderForWorkbench } from "@/app/api/jo/workbenchActions"; +import { fetchNameList, NameList } from "@/app/api/user/actions"; +import { FormProvider, useForm } from "react-hook-form"; +import SearchBox, { Criterion } from "../SearchBox"; +import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; +import { + updateInventoryLotLineQuantities, + analyzeQrCode, + fetchLotDetail, +} from "@/app/api/inventory/actions"; +import QrCodeIcon from "@mui/icons-material/QrCode"; +import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; +import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import GoodPickExecutionForm from "../Jodetail/JobPickExecutionForm"; +import FGPickOrderCard from "../Jodetail/FGPickOrderCard"; +import LotConfirmationModal from "../Jodetail/LotConfirmationModal"; +import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; +import LinearProgressWithLabel from "../common/LinearProgressWithLabel"; +import ScanStatusAlert from "../common/ScanStatusAlert"; +import { + workbenchBatchScanPick, + workbenchScanPick, +} from "@/app/api/doworkbench/actions"; +interface Props { + filterArgs: Record; + //onSwitchToRecordTab: () => void; + onBackToList?: () => void; +} + +/** 過期批號:與 noLot 類似——單筆/批量預設 0,除非 Issue 改數(對齊 GoodPickExecutiondetail) */ +function isLotAvailabilityExpired(lot: any): boolean { + return String(lot?.lotAvailability || "").toLowerCase() === "expired"; +} + +/** inventory_lot_line.status = unavailable(API 可能用 lotAvailability 或 lotStatus) */ +function isInventoryLotLineUnavailable(lot: any): boolean { + if (!lot) return false; + if (lot.lotAvailability === "status_unavailable") return true; + return String(lot.lotStatus || "").toLowerCase() === "unavailable"; +} + +/** 同 DO Workbench:多行時優先替換有建議批號的行 */ +function pickExpectedLotForSubstitution( + activeSuggestedLots: any[], +): any | null { + if (!activeSuggestedLots?.length) return null; + const withLotNo = activeSuggestedLots.filter( + (l) => l.lotNo != null && String(l.lotNo).trim() !== "", + ); + if (withLotNo.length === 1) return withLotNo[0]; + if (withLotNo.length > 1) { + const pending = withLotNo.find( + (l) => String(l.stockOutLineStatus || "").toLowerCase() === "pending", + ); + return pending || withLotNo[0]; + } + return activeSuggestedLots[0]; +} + +type PickOrderT = (key: string, options?: Record) => string; + +/** 與 DO Workbench:優先顯示 scan-pick 暫存拒絕訊息 */ +function buildLotRejectDisplayMessage( + lot: any, + scanRejectBySolId: Record, + t: PickOrderT, +): string | undefined { + const solId = Number(lot.stockOutLineId) || 0; + const fromScan = solId > 0 ? scanRejectBySolId[solId]?.trim() : ""; + if (fromScan) return fromScan; + + const fromApi = + typeof lot.stockOutLineRejectMessage === "string" + ? lot.stockOutLineRejectMessage.trim() + : ""; + if (fromApi) return fromApi; + + const st = String(lot.stockOutLineStatus || "").toLowerCase(); + const av = String(lot.lotAvailability || "").toLowerCase(); + const isRejected = st === "rejected" || av === "rejected"; + if (!isRejected) return undefined; + + if (isLotAvailabilityExpired(lot) || av === "expired") { + return t("Rejected: lot expired or no longer valid."); + } + if (av === "insufficient_stock") { + return t("Rejected: no remaining quantity / empty lot."); + } + if (isInventoryLotLineUnavailable(lot) || av === "status_unavailable") { + return t("Rejected: lot unavailable or not yet putaway."); + } + return t("Pick was rejected. Please scan another lot or check stock."); +} + +const JO_ISSUE_PICKED_KEY = (pickOrderId: number) => + `fpsms-jo-issuePickedQty:${pickOrderId}`; + +function loadIssuePickedMapJo(pickOrderId: number): Record { + if (typeof window === "undefined" || !pickOrderId) return {}; + try { + const raw = sessionStorage.getItem(JO_ISSUE_PICKED_KEY(pickOrderId)); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + Object.entries(parsed).forEach(([k, v]) => { + const n = Number(v); + if (!Number.isNaN(n)) out[Number(k)] = n; + }); + return out; + } catch { + return {}; + } +} + +function saveIssuePickedMapJo( + pickOrderId: number, + map: Record, +) { + if (typeof window === "undefined" || !pickOrderId) return; + try { + sessionStorage.setItem( + JO_ISSUE_PICKED_KEY(pickOrderId), + JSON.stringify(map), + ); + } catch { + // ignore quota / private mode + } +} + +// Manual Lot Confirmation Modal (align with GoodPickExecutiondetail, opened by {2fic}) +const ManualLotConfirmationModal: React.FC<{ + open: boolean; + onClose: () => void; + onConfirm: (expectedLotNo: string, scannedLotNo: string) => void; + expectedLot: { lotNo: string; itemCode: string; itemName: string } | null; + scannedLot: { lotNo: string; itemCode: string; itemName: string } | null; + isLoading?: boolean; +}> = ({ + open, + onClose, + onConfirm, + expectedLot, + scannedLot, + isLoading = false, +}) => { + const { t } = useTranslation("jo"); + const [expectedLotInput, setExpectedLotInput] = useState(""); + const [scannedLotInput, setScannedLotInput] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (open) { + setExpectedLotInput(expectedLot?.lotNo || ""); + setScannedLotInput(scannedLot?.lotNo || ""); + setError(""); + } + }, [open, expectedLot, scannedLot]); + + const handleConfirm = () => { + if (!expectedLotInput.trim() || !scannedLotInput.trim()) { + setError(t("Please enter both expected and scanned lot numbers.")); + return; + } + if (expectedLotInput.trim() === scannedLotInput.trim()) { + setError(t("Expected and scanned lot numbers cannot be the same.")); + return; + } + onConfirm(expectedLotInput.trim(), scannedLotInput.trim()); + }; + + return ( + + + + {t("Manual Lot Confirmation")} + + + + + {t("Expected Lot Number")}: + + { + setExpectedLotInput(e.target.value); + setError(""); + }} + sx={{ mb: 2 }} + error={!!error && !expectedLotInput.trim()} + /> + + + + + {t("Scanned Lot Number")}: + + { + setScannedLotInput(e.target.value); + setError(""); + }} + sx={{ mb: 2 }} + error={!!error && !scannedLotInput.trim()} + /> + + + {error && ( + + + {error} + + + )} + + + + + + + + ); +}; + +// QR Code Modal Component (from GoodPickExecution) +const QrCodeModal: React.FC<{ + open: boolean; + onClose: () => void; + lot: any | null; + onQrCodeSubmit: (lotNo: string) => void; + combinedLotData: any[]; +}> = ({ open, onClose, lot, onQrCodeSubmit, combinedLotData }) => { + const { t } = useTranslation("jo"); + const { + values: qrValues, + isScanning, + startScan, + stopScan, + resetScan, + } = useQrCodeScannerContext(); + const [manualInput, setManualInput] = useState(""); + + const [selectedFloor, setSelectedFloor] = useState(null); + const [manualInputSubmitted, setManualInputSubmitted] = + useState(false); + const [manualInputError, setManualInputError] = useState(false); + const [isProcessingQr, setIsProcessingQr] = useState(false); + const [qrScanFailed, setQrScanFailed] = useState(false); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + + const [processedQrCodes, setProcessedQrCodes] = useState>( + new Set(), + ); + const [scannedQrResult, setScannedQrResult] = useState(""); + + // Process scanned QR codes + useEffect(() => { + if (qrValues.length > 0 && lot && !isProcessingQr && !qrScanSuccess) { + const latestQr = qrValues[qrValues.length - 1]; + + if (processedQrCodes.has(latestQr)) { + console.log("QR code already processed, skipping..."); + return; + } + + setProcessedQrCodes((prev) => new Set(prev).add(latestQr)); + + try { + const qrData = JSON.parse(latestQr); + + if (qrData.stockInLineId && qrData.itemId) { + setIsProcessingQr(true); + setQrScanFailed(false); + + fetchStockInLineInfo(qrData.stockInLineId) + .then((stockInLineInfo) => { + console.log("Stock in line info:", stockInLineInfo); + setScannedQrResult(stockInLineInfo.lotNo || "Unknown lot number"); + + if (stockInLineInfo.lotNo === lot.lotNo) { + console.log(` QR Code verified for lot: ${lot.lotNo}`); + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + console.log( + `❌ QR Code mismatch. Expected: ${lot.lotNo}, Got: ${stockInLineInfo.lotNo}`, + ); + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + }) + .catch((error) => { + console.error("Error fetching stock in line info:", error); + setScannedQrResult("Error fetching data"); + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + }) + .finally(() => { + setIsProcessingQr(false); + }); + } else { + const qrContent = latestQr.replace(/[{}]/g, ""); + setScannedQrResult(qrContent); + + if (qrContent === lot.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + } + } catch (error) { + console.log("QR code is not JSON format, trying direct comparison"); + const qrContent = latestQr.replace(/[{}]/g, ""); + setScannedQrResult(qrContent); + + if (qrContent === lot.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + resetScan(); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + } + } + }, [ + qrValues, + lot, + onQrCodeSubmit, + onClose, + resetScan, + isProcessingQr, + qrScanSuccess, + processedQrCodes, + ]); + + // Clear states when modal opens + useEffect(() => { + if (open) { + setManualInput(""); + setManualInputSubmitted(false); + setManualInputError(false); + setIsProcessingQr(false); + setQrScanFailed(false); + setQrScanSuccess(false); + setScannedQrResult(""); + setProcessedQrCodes(new Set()); + } + }, [open]); + + useEffect(() => { + if (lot) { + setManualInput(""); + setManualInputSubmitted(false); + setManualInputError(false); + setIsProcessingQr(false); + setQrScanFailed(false); + setQrScanSuccess(false); + setScannedQrResult(""); + setProcessedQrCodes(new Set()); + } + }, [lot]); + + // Auto-submit manual input when it matches + useEffect(() => { + if ( + manualInput.trim() === lot?.lotNo && + manualInput.trim() !== "" && + !qrScanFailed && + !qrScanSuccess + ) { + console.log(" Auto-submitting manual input:", manualInput.trim()); + + const timer = setTimeout(() => { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + setManualInput(""); + setManualInputError(false); + setManualInputSubmitted(false); + }, 200); + + return () => clearTimeout(timer); + } + }, [manualInput, lot, onQrCodeSubmit, onClose, qrScanFailed, qrScanSuccess]); + + const handleManualSubmit = () => { + if (manualInput.trim() === lot?.lotNo) { + setQrScanSuccess(true); + onQrCodeSubmit(lot.lotNo); + onClose(); + setManualInput(""); + } else { + setQrScanFailed(true); + setManualInputError(true); + setManualInputSubmitted(true); + } + }; + + useEffect(() => { + if (open) { + startScan(); + } + }, [open, startScan]); + + return ( + + + + {t("QR Code Scan for Lot")}: {lot?.lotNo} + + + {isProcessingQr && ( + + + {t("Processing QR code...")} + + + )} + + + + {t("Manual Input")}: + + { + setManualInput(e.target.value); + if (qrScanFailed || manualInputError) { + setQrScanFailed(false); + setManualInputError(false); + setManualInputSubmitted(false); + } + }} + sx={{ mb: 1 }} + error={manualInputSubmitted && manualInputError} + helperText={ + manualInputSubmitted && manualInputError + ? `${t( + "The input is not the same as the expected lot number.", + )}` + : "" + } + /> + + + + + {qrValues.length > 0 && ( + + + {t("QR Scan Result:")} {scannedQrResult} + + + {qrScanSuccess && ( + + {t("Verified successfully!")} + + )} + + )} + + + + + + + ); +}; + +const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { + const workbenchMode = true; + const { t } = useTranslation("jo"); + const { t: tPick } = useTranslation("pickOrder"); + const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + + const currentUserId = session?.id ? parseInt(session.id) : undefined; + + // 修改:使用 Job Order 数据结构 + + const [combinedDataLoading, setCombinedDataLoading] = useState(false); + + // 添加未分配订单状态 + const [unassignedOrders, setUnassignedOrders] = useState([]); + const [isLoadingUnassigned, setIsLoadingUnassigned] = useState(false); + + const { + values: qrValues, + isScanning, + startScan, + stopScan, + resetScan, + } = useQrCodeScannerContext(); + const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); + const [lotConfirmationError, setLotConfirmationError] = useState< + string | null + >(null); + const [expectedLotData, setExpectedLotData] = useState(null); + const [scannedLotData, setScannedLotData] = useState(null); + const [isConfirmingLot, setIsConfirmingLot] = useState(false); + const [qrScanInput, setQrScanInput] = useState(""); + const [qrScanError, setQrScanError] = useState(false); + const [qrScanErrorMsg, setQrScanErrorMsg] = useState(""); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + const [jobOrderData, setJobOrderData] = + useState(null); + const workbenchStoreId = useMemo(() => { + const po = jobOrderData?.pickOrder as + | { storeId?: string | null } + | undefined; + const s = po?.storeId; + return typeof s === "string" && s.trim() !== "" ? s.trim() : null; + }, [jobOrderData]); + const [pickQtyData, setPickQtyData] = useState>({}); + /** 與 DO Workbench 一致:false = 數量只讀;Edit 切換為可輸入「將提交數量」,不開 Issue 表單 */ + const [ + workbenchSubmitQtyFieldEnabledByLotKey, + setWorkbenchSubmitQtyFieldEnabledByLotKey, + ] = useState>({}); + const [searchQuery, setSearchQuery] = useState>({}); + // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) + const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState< + Record + >({}); + const [localSolStatusById, setLocalSolStatusById] = useState< + Record + >({}); + // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 + const [actionBusyBySolId, setActionBusyBySolId] = useState< + Record + >({}); + /** DO Workbench:scan-pick 失敗訊息按 SOL 顯示 */ + const [scanRejectMessageBySolId, setScanRejectMessageBySolId] = useState< + Record + >({}); + const rememberWorkbenchScanReject = useCallback( + (stockOutLineId: number, message: string | undefined | null) => { + const id = Number(stockOutLineId); + const m = String(message ?? "").trim(); + if (!id || !m) return; + setScanRejectMessageBySolId((prev) => ({ ...prev, [id]: m })); + }, + [], + ); + const clearWorkbenchScanReject = useCallback((stockOutLineId: number) => { + const id = Number(stockOutLineId); + if (!id) return; + setScanRejectMessageBySolId((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); + + const [paginationController, setPaginationController] = useState({ + pageNum: 0, + pageSize: 10, + }); + + const [usernameList, setUsernameList] = useState([]); + + const initializationRef = useRef(false); + const scannerInitializedRef = useRef(false); + const autoAssignRef = useRef(false); + + const formProps = useForm(); + const errors = formProps.formState.errors; + const [isSubmittingAll, setIsSubmittingAll] = useState(false); + const [autoAssignStatus, setAutoAssignStatus] = useState< + "idle" | "checking" | "assigned" | "no_orders" + >("idle"); + const [completionStatus, setCompletionStatus] = + useState(null); + const [autoAssignMessage, setAutoAssignMessage] = useState(""); + + // Add QR modal states + const [qrModalOpen, setQrModalOpen] = useState(false); + const [selectedLotForQr, setSelectedLotForQr] = useState(null); + const [selectedFloor, setSelectedFloor] = useState(null); + // Add GoodPickExecutionForm states + const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); + const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = + useState(null); + const [fgPickOrders, setFgPickOrders] = useState([]); + const [fgPickOrdersLoading, setFgPickOrdersLoading] = useState(false); + const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = + useState(false); + const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = + useState<{ itemId: number; stockInLineId: number } | null>(null); + const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = + useState(null); + const [workbenchLotLabelReminderText, setWorkbenchLotLabelReminderText] = + useState(null); + + useEffect(() => { + if (!qrScanSuccess || !workbenchLotLabelModalOpen) return; + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelInitialPayload(null); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelReminderText(null); + }, [qrScanSuccess, workbenchLotLabelModalOpen]); + + // Add these missing state variables + const [isManualScanning, setIsManualScanning] = useState(false); + const [processedQrCodes, setProcessedQrCodes] = useState>( + new Set(), + ); + const [lastProcessedQr, setLastProcessedQr] = useState(""); + const [isRefreshingData, setIsRefreshingData] = useState(false); + // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling + const [processedQrCombinations, setProcessedQrCombinations] = useState< + Map> + >(new Map()); + + // Cache for fetchStockInLineInfo API calls to avoid redundant requests + const stockInLineInfoCache = useRef< + Map + >(new Map()); + const CACHE_TTL = 60000; // 60 seconds cache TTL + const abortControllerRef = useRef(null); + const qrProcessingTimeoutRef = useRef(null); + + // Use refs for processed QR tracking to avoid useEffect dependency issues and delays + const processedQrCodesRef = useRef>(new Set()); + const lastProcessedQrRef = useRef(""); + + // Store callbacks in refs to avoid useEffect dependency issues + const processOutsideQrCodeRef = useRef< + ((latestQr: string, qrScanCountAtInvoke?: number) => Promise) | null + >(null); + const resetScanRef = useRef<(() => void) | null>(null); + const lotConfirmLastQrRef = useRef(""); + const lotConfirmSkipNextScanRef = useRef(false); + const lotConfirmOpenedAtRef = useRef(0); + + // Manual lot confirmation modal state (test shortcut {2fic}) + const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = + useState(false); + const getAllLotsFromHierarchical = useCallback( + (data: JobOrderLotsHierarchicalResponse | null): any[] => { + if (!data || !data.pickOrder || !data.pickOrderLines) { + return []; + } + + const allLots: any[] = []; + + data.pickOrderLines.forEach((line) => { + // 用来记录这一行已经通过 lots 出现过的 lotId(避免 stockouts 再渲染一次) + const lotIdSet = new Set(); + /** 已由有批次建議分配的量(加總後與 pick_order_line.requiredQty 的差額 = 無批次列應顯示的數),對齊 DO Workbench */ + let lotsAllocatedSumForLine = 0; + + // lots:按 lotId 去重并合并 requiredQty(对齐 GoodPickExecutiondetail) + if (line.lots && line.lots.length > 0) { + const lotMap = new Map(); + + line.lots.forEach((lot: any) => { + const lotId = lot.lotId; + if (lotId == null) return; + if (lotMap.has(lotId)) { + const existingLot = lotMap.get(lotId); + existingLot.requiredQty = + (existingLot.requiredQty || 0) + (lot.requiredQty || 0); + } else { + lotMap.set(lotId, { ...lot }); + } + }); + + lotMap.forEach((lot: any) => { + lotsAllocatedSumForLine += Number(lot.requiredQty) || 0; + if (lot.lotId != null) lotIdSet.add(lot.lotId); + + allLots.push({ + ...lot, + pickOrderLineId: line.id, + itemId: line.itemId, + itemCode: line.itemCode, + itemName: line.itemName, + uomCode: line.uomCode, + uomDesc: line.uomDesc, + itemTotalAvailableQty: line.totalAvailableQty ?? null, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + jobOrderId: data.pickOrder.jobOrder.id, + jobOrderCode: data.pickOrder.jobOrder.code, + pickOrderId: data.pickOrder.id, + pickOrderCode: data.pickOrder.code, + pickOrderConsoCode: data.pickOrder.consoCode, + pickOrderTargetDate: data.pickOrder.targetDate, + pickOrderType: data.pickOrder.type, + pickOrderStatus: data.pickOrder.status, + pickOrderAssignTo: data.pickOrder.assignTo, + handler: line.handler, + noLot: false, + }); + }); + } + + /** 工單 API 常在有揀貨後仍回傳 lots: [],缺口只在 stockouts;此時用非 noLot 的已揀量扣 POL(對齊實際剩餘) */ + const stockoutsPickedSumNonNoLot = (line.stockouts ?? []).reduce( + (acc: number, s: any) => { + if (!s || s.noLot) return acc; + return acc + (Number(s.qty) || 0); + }, + 0, + ); + const noLotRemainingBasis = + lotsAllocatedSumForLine > 0 + ? lotsAllocatedSumForLine + : stockoutsPickedSumNonNoLot; + + // stockouts:用于“无 suggested lot / noLot”场景也显示并可 submit 0 闭环 + if (line.stockouts && line.stockouts.length > 0) { + line.stockouts.forEach((stockout: any) => { + const hasLot = stockout.lotId != null; + const lotAlreadyInLots = + hasLot && lotIdSet.has(stockout.lotId as number); + + // 有批次 & 已经通过 lots 渲染过 → 跳过,避免一条变两行 + if (!stockout.noLot && lotAlreadyInLots) { + return; + } + + allLots.push({ + pickOrderLineId: line.id, + itemId: line.itemId, + itemCode: line.itemCode, + itemName: line.itemName, + uomCode: line.uomCode, + uomDesc: line.uomDesc, + itemTotalAvailableQty: line.totalAvailableQty ?? null, + pickOrderLineRequiredQty: line.requiredQty, + pickOrderLineStatus: line.status, + jobOrderId: data.pickOrder.jobOrder.id, + jobOrderCode: data.pickOrder.jobOrder.code, + pickOrderId: data.pickOrder.id, + pickOrderCode: data.pickOrder.code, + pickOrderConsoCode: data.pickOrder.consoCode, + pickOrderTargetDate: data.pickOrder.targetDate, + pickOrderType: data.pickOrder.type, + pickOrderStatus: data.pickOrder.status, + pickOrderAssignTo: data.pickOrder.assignTo, + handler: line.handler, + + lotId: stockout.lotId || null, + lotNo: stockout.lotNo || null, + expiryDate: null, + location: stockout.location || null, + availableQty: stockout.availableQty ?? 0, + // 無批次列:有 SPL 時扣 suggested 合計;僅有 stockouts(lots 空)時扣已揀量(對齊 DO + workbench 僅 SOL 情境) + requiredQty: stockout.noLot + ? Math.max( + 0, + (Number(line.requiredQty) || 0) - noLotRemainingBasis, + ) + : Number(line.requiredQty) || 0, + actualPickQty: stockout.qty ?? 0, + processingStatus: stockout.status || "pending", + lotAvailability: stockout.noLot + ? "insufficient_stock" + : "available", + suggestedPickLotId: null, + stockOutLineId: stockout.id || null, + stockOutLineQty: stockout.qty ?? 0, + stockOutLineStatus: stockout.status || null, + stockInLineId: null, + routerIndex: stockout.noLot ? 999999 : null, + routerArea: null, + routerRoute: null, + noLot: !!stockout.noLot, + }); + }); + } + }); + + return allLots; + }, + [], + ); + const extractFloor = (lot: any): string => { + const raw = lot.routerRoute || lot.routerArea || lot.location || ""; + const match = raw.match(/^(\d+F?)/i) || raw.split("-")[0]; + return (match?.[1] || match || raw || "") + .toUpperCase() + .replace(/(\d)F?/i, "$1F"); + }; + + // 楼层排序权重:4F > 3F > 2F(数字越大越靠前) + const floorSortOrder = (floor: string): number => { + const n = parseInt(floor.replace(/\D/g, ""), 10); + return isNaN(n) ? 0 : n; + }; + const combinedLotData = useMemo(() => { + const lots = getAllLotsFromHierarchical(jobOrderData); + // 前端覆盖:issue form/submit0 不会立刻改写后端 qty 时,用本地缓存让 UI 与 batch submit 计算一致 + return lots.map((lot: any) => { + const solId = Number(lot.stockOutLineId) || 0; + if (solId > 0) { + const hasPickedOverride = Object.prototype.hasOwnProperty.call( + issuePickedQtyBySolId, + solId, + ); + const picked = Number( + issuePickedQtyBySolId[solId] ?? lot.actualPickQty ?? 0, + ); + const statusRaw = + localSolStatusById[solId] ?? lot.stockOutLineStatus ?? ""; + const status = String(statusRaw).toLowerCase(); + const isEnded = status === "completed" || status === "rejected"; + return { + ...lot, + actualPickQty: hasPickedOverride ? picked : lot.actualPickQty, + stockOutLineQty: hasPickedOverride ? picked : lot.stockOutLineQty, + stockOutLineStatus: isEnded ? statusRaw : statusRaw || "checked", + }; + } + return lot; + }); + }, [ + jobOrderData, + getAllLotsFromHierarchical, + issuePickedQtyBySolId, + localSolStatusById, + ]); + + /** 單筆將提交數量:Issue 改數 → pickQtyData → 已完成行用實際量 → noLot/過期/unavailable → 0 → 否則 required(對齊 DO Workbench) */ + const resolveSingleSubmitQty = useCallback( + (lot: any) => { + const required = Number( + lot.requiredQty || lot.pickOrderLineRequiredQty || 0, + ); + const solId = Number(lot.stockOutLineId) || 0; + const lotId = lot.lotId; + const lotKey = + Number.isFinite(solId) && solId > 0 + ? `sol:${solId}` + : `${lot.pickOrderLineId}-${lotId}`; + + const issuePicked = solId > 0 ? issuePickedQtyBySolId[solId] : undefined; + if (issuePicked !== undefined && !Number.isNaN(Number(issuePicked))) { + return Number(issuePicked); + } + + const st = String(lot.stockOutLineStatus || "").toLowerCase(); + if ( + st === "completed" || + st === "partially_completed" || + st === "partially_complete" + ) { + return Number(lot.stockOutLineQty ?? lot.actualPickQty ?? 0); + } + + if (Object.prototype.hasOwnProperty.call(pickQtyData, lotKey)) { + const fromPick = pickQtyData[lotKey]; + if ( + fromPick !== undefined && + fromPick !== null && + !Number.isNaN(Number(fromPick)) + ) { + return Number(fromPick); + } + } + if (isInventoryLotLineUnavailable(lot)) { + return 0; + } + if (lot.noLot === true || !lot.lotId) { + return 0; + } + if (isLotAvailabilityExpired(lot)) { + return 0; + } + return required; + }, + [issuePickedQtyBySolId, pickQtyData], + ); + + const getWorkbenchQtyLotKey = useCallback((lot: any) => { + const solId = Number(lot?.stockOutLineId) || 0; + if (Number.isFinite(solId) && solId > 0) return `sol:${solId}`; + return `${lot?.pickOrderLineId}-${lot?.lotId}`; + }, []); + + const workbenchScanPickQtyFromLot = useCallback( + (lot: any) => { + const solId = Number(lot?.stockOutLineId); + const sourceLot = + Number.isFinite(solId) && solId > 0 + ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? + lot + : lot; + const lotKey = getWorkbenchQtyLotKey(sourceLot); + const hasExplicitPickOverride = Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKey, + ); + const n = Number(resolveSingleSubmitQty(sourceLot)); + if (hasExplicitPickOverride && Number.isFinite(n) && n === 0) + return { qty: 0 } as const; + if (!Number.isFinite(n) || n <= 0) return {}; + return { qty: n } as const; + }, + [resolveSingleSubmitQty, combinedLotData, pickQtyData, getWorkbenchQtyLotKey], + ); + + const lotFloorPrefixFilter = useMemo(() => { + const storeId = String(workbenchStoreId ?? "") + .trim() + .toUpperCase() + .replace(/\s/g, ""); + const floorKey = storeId.replace(/\//g, ""); + return floorKey ? `${floorKey}-` : ""; + }, [workbenchStoreId]); + + const defaultLabelPrinterName = useMemo(() => { + const storeId = String(workbenchStoreId ?? "") + .trim() + .toUpperCase() + .replace(/\s/g, ""); + const floorKey = storeId.replace(/\//g, ""); + if (floorKey === "2F") return "Label機 2F A+B"; + if (floorKey === "4F") return "Label機 4F 乾貨 C, D"; + return undefined; + }, [workbenchStoreId]); + + const openWorkbenchLotLabelModalForLot = useCallback((lot: any, reminderText?: string | null) => { + const itemId = Number(lot?.itemId); + const stockInLineId = Number(lot?.stockInLineId); + const solId = Number(lot?.stockOutLineId); + if (!Number.isFinite(itemId) || itemId <= 0 || !Number.isFinite(solId) || solId <= 0) { + return; + } + setWorkbenchLotLabelContextLot(lot); + if (Number.isFinite(stockInLineId) && stockInLineId > 0) { + setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); + } else { + setWorkbenchLotLabelInitialPayload(null); + } + setWorkbenchLotLabelReminderText(reminderText ?? null); + setWorkbenchLotLabelModalOpen(true); + }, []); + + const shouldOpenWorkbenchLotLabelModalForFailure = useCallback( + (code?: string | null, msg?: string | null): boolean => { + const normalizedCode = String(code || "").toUpperCase(); + if ( + normalizedCode.includes("UNAVAILABLE") || + normalizedCode.includes("EXPIRED") + ) { + return true; + } + const normalizedMsg = String(msg || "").toLowerCase(); + if (!normalizedMsg) return false; + return ( + normalizedMsg.includes("unavailable") || + normalizedMsg.includes("not available") || + normalizedMsg.includes("expired") || + normalizedMsg.includes("不可用") || + normalizedMsg.includes("無法使用") || + normalizedMsg.includes("过期") + ); + }, + [], + ); + + const handleWorkbenchLotLabelScanPick = useCallback( + async ({ + inventoryLotLineId, + lotNo, + qty, + }: { + inventoryLotLineId: number; + lotNo: string; + qty?: number; + }) => { + const lot = workbenchLotLabelContextLot; + if (!lot?.stockOutLineId) { + throw new Error(t("Missing stock out line for this row.")); + } + const qtyPayload = Number.isFinite(Number(qty)) + ? { qty: Number(qty) } + : workbenchScanPickQtyFromLot(lot); + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lotNo || "").trim(), + inventoryLotLineId, + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + ...qtyPayload, + }); + if (res.code !== "SUCCESS") { + const errMsg = + (res as { message?: string })?.message || + t("Workbench scan-pick failed."); + rememberWorkbenchScanReject(Number(lot.stockOutLineId), errMsg); + throw new Error(errMsg); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + if (pickOrderId) { + const latest = + await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId); + setJobOrderData(latest); + setIssuePickedQtyBySolId(loadIssuePickedMapJo(pickOrderId)); + getAllLotsFromHierarchical(latest); + } + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + }, + [ + workbenchLotLabelContextLot, + currentUserId, + workbenchScanPickQtyFromLot, + workbenchStoreId, + rememberWorkbenchScanReject, + clearWorkbenchScanReject, + filterArgs?.pickOrderId, + getAllLotsFromHierarchical, + t, + ], + ); + + const workbenchLotLabelSubmitQty = useMemo(() => { + if (!workbenchLotLabelContextLot) return 0; + return Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) || 0; + }, [workbenchLotLabelContextLot, resolveSingleSubmitQty]); + + const handleWorkbenchLotLabelSubmitQtyChange = useCallback( + (qty: number) => { + if (!workbenchLotLabelContextLot) return; + const lotKey = getWorkbenchQtyLotKey(workbenchLotLabelContextLot); + setPickQtyData((prev) => ({ ...prev, [lotKey]: qty })); + }, + [workbenchLotLabelContextLot, getWorkbenchQtyLotKey], + ); + + const workbenchLotLabelScanPickDisabled = useMemo(() => { + if (!workbenchLotLabelModalOpen || !workbenchLotLabelContextLot) return true; + const lot = workbenchLotLabelContextLot; + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + const isNoLot = !lot.lotNo || String(lot.lotNo).trim() === ""; + if (isNoLot) return false; + if (status === "pending" || status === "rejected") return false; + return true; + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + + const originalCombinedData = useMemo(() => { + return getAllLotsFromHierarchical(jobOrderData); + }, [jobOrderData, getAllLotsFromHierarchical]); + + // Enhanced lotDataIndexes with cached active lots for better performance (align with GoodPickExecutiondetail) + const lotDataIndexes = useMemo(() => { + const byItemId = new Map(); + const byItemCode = new Map(); + const byLotId = new Map(); + const byLotNo = new Map(); + const byStockInLineId = new Map(); + const activeLotsByItemId = new Map(); + const rejectedStatuses = new Set(["rejected"]); + + for (let i = 0; i < combinedLotData.length; i++) { + const lot = combinedLotData[i]; + const solStatus = String(lot.stockOutLineStatus || "").toLowerCase(); + const lotAvailability = String(lot.lotAvailability || "").toLowerCase(); + const processingStatus = String(lot.processingStatus || "").toLowerCase(); + const isUnavailable = isInventoryLotLineUnavailable(lot); + const isExpired = isLotAvailabilityExpired(lot); + const isRejected = + rejectedStatuses.has(lotAvailability) || + rejectedStatuses.has(solStatus) || + rejectedStatuses.has(processingStatus); + const isEnded = solStatus === "checked" || solStatus === "completed"; + const isPartially = + solStatus === "partially_completed" || + solStatus === "partially_complete"; + const isPending = solStatus === "pending" || solStatus === ""; + const isActive = + !isRejected && + !isUnavailable && + !isExpired && + !isEnded && + (isPending || isPartially); + + if (lot.itemId) { + if (!byItemId.has(lot.itemId)) { + byItemId.set(lot.itemId, []); + activeLotsByItemId.set(lot.itemId, []); + } + byItemId.get(lot.itemId)!.push(lot); + if (isActive) activeLotsByItemId.get(lot.itemId)!.push(lot); + } + if (lot.itemCode) { + if (!byItemCode.has(lot.itemCode)) byItemCode.set(lot.itemCode, []); + byItemCode.get(lot.itemCode)!.push(lot); + } + if (lot.lotId) byLotId.set(lot.lotId, lot); + if (lot.lotNo) { + if (!byLotNo.has(lot.lotNo)) byLotNo.set(lot.lotNo, []); + byLotNo.get(lot.lotNo)!.push(lot); + } + if (lot.stockInLineId) { + if (!byStockInLineId.has(lot.stockInLineId)) + byStockInLineId.set(lot.stockInLineId, []); + byStockInLineId.get(lot.stockInLineId)!.push(lot); + } + } + + return { + byItemId, + byItemCode, + byLotId, + byLotNo, + byStockInLineId, + activeLotsByItemId, + }; + }, [combinedLotData]); + + // Cached version of fetchStockInLineInfo to avoid redundant API calls + const fetchStockInLineInfoCached = useCallback( + async (stockInLineId: number): Promise<{ lotNo: string | null }> => { + const now = Date.now(); + const cached = stockInLineInfoCache.current.get(stockInLineId); + if (cached && now - cached.timestamp < CACHE_TTL) { + return { lotNo: cached.lotNo }; + } + + if (abortControllerRef.current) abortControllerRef.current.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + const stockInLineInfo = await fetchStockInLineInfo(stockInLineId); + stockInLineInfoCache.current.set(stockInLineId, { + lotNo: stockInLineInfo.lotNo || null, + timestamp: now, + }); + if (stockInLineInfoCache.current.size > 100) { + const firstKey = stockInLineInfoCache.current.keys().next().value; + if (firstKey !== undefined) + stockInLineInfoCache.current.delete(firstKey); + } + return { lotNo: stockInLineInfo.lotNo || null }; + }, + [], + ); + // 修改:加载未分配的 Job Order 订单 + const loadUnassignedOrders = useCallback(async () => { + setIsLoadingUnassigned(true); + try { + //const orders = await fetchUnassignedJobOrderPickOrders(); + //setUnassignedOrders(orders); + } catch (error) { + console.error("Error loading unassigned orders:", error); + } finally { + setIsLoadingUnassigned(false); + } + }, []); + + // 修改:分配订单给当前用户 + const handleAssignOrder = useCallback( + async (pickOrderId: number) => { + if (!currentUserId) { + console.error("Missing user id in session"); + return; + } + + try { + const result = await assignJobOrderPickOrderForWorkbench( + pickOrderId, + currentUserId, + ); + const msg = String(result?.message || ""); + const assignSkippedByStatus = + msg.toLowerCase().startsWith("skipped assign:"); + if (msg === "Successfully assigned" || assignSkippedByStatus) { + console.log(" Successfully assigned pick order"); + if (!assignSkippedByStatus) { + try { + const refreshed = + await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench( + pickOrderId, + ); + const uniqueItemIds = Array.from( + new Set( + (refreshed?.pickOrderLines ?? []) + .map((line) => line?.itemId) + .filter( + (itemId): itemId is number => + typeof itemId === "number" && + Number.isFinite(itemId) && + itemId > 0, + ), + ), + ); + await Promise.all( + uniqueItemIds.map((itemId) => + updateJoPickOrderHandledBy({ + pickOrderId, + itemId, + userId: currentUserId, + }), + ), + ); + } catch (handledBySyncError) { + console.warn( + "⚠️ Assigned but failed to sync JoPickOrder.handledBy for some lines:", + handledBySyncError, + ); + } + } + // 刷新数据 + window.dispatchEvent(new CustomEvent("pickOrderAssigned")); + // 重新加载未分配订单列表 + loadUnassignedOrders(); + } else { + console.warn("⚠️ Assignment failed:", result.message); + alert(`Assignment failed: ${result.message}`); + } + } catch (error) { + console.error("❌ Error assigning order:", error); + alert("Error occurred during assignment"); + } + }, + [currentUserId, loadUnassignedOrders], + ); + + const fetchFgPickOrdersData = useCallback(async () => { + if (!currentUserId) return; + + setFgPickOrdersLoading(true); + try { + const allFgPickOrders = await fetchFGPickOrdersByUserIdWorkbench( + currentUserId, + ); + setFgPickOrders(Array.isArray(allFgPickOrders) ? allFgPickOrders : []); + console.log(" Fetched FG pick orders(workbench):", allFgPickOrders); + } catch (error) { + console.error("❌ Error fetching FG pick orders:", error); + setFgPickOrders([]); + } finally { + setFgPickOrdersLoading(false); + } + }, [currentUserId]); + + useEffect(() => { + if (combinedLotData.length > 0) { + fetchFgPickOrdersData(); + } + }, [combinedLotData, fetchFgPickOrdersData]); + + // Handle QR code button click + const handleQrCodeClick = (pickOrderId: number) => { + console.log(`QR Code clicked for pick order ID: ${pickOrderId}`); + // TODO: Implement QR code functionality + }; + + // 修改:使用 Job Order API 获取数据 + const fetchJobOrderData = useCallback( + async (pickOrderId?: number) => { + setCombinedDataLoading(true); + try { + if (!pickOrderId) { + console.warn("⚠️ No pickOrderId provided, skipping API call"); + return; + } + + // 直接使用类型化的响应 + const jobOrderData = + await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId); + console.log("✅ Job Order data (hierarchical):", jobOrderData); + + setJobOrderData(jobOrderData); + setIssuePickedQtyBySolId(loadIssuePickedMapJo(pickOrderId)); + + // 使用辅助函数获取所有 lots(不再扁平化) + getAllLotsFromHierarchical(jobOrderData); + } catch (error) { + console.error("❌ Error fetching job order data:", error); + setJobOrderData(null); + setIssuePickedQtyBySolId({}); + } finally { + setCombinedDataLoading(false); + } + }, + [getAllLotsFromHierarchical], + ); + + const applyLocalStockOutLineUpdate = useCallback((..._args: unknown[]) => { + // JO Workbench:以伺服器刷新為主;保留呼叫點與 DO Workbench 一致(不更新本地 SOL 鏡像)。 + }, []); + + const refreshWorkbenchAfterScanPick = useCallback(async () => { + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + await fetchJobOrderData(pickOrderId); + }, [fetchJobOrderData, filterArgs?.pickOrderId]); + + const updateHandledBy = useCallback( + async (pickOrderId: number, itemId: number) => { + if (!currentUserId || !pickOrderId || !itemId) { + return; + } + + try { + console.log( + `Updating JoPickOrder.handledBy for pickOrderId: ${pickOrderId}, itemId: ${itemId}, userId: ${currentUserId}`, + ); + await updateJoPickOrderHandledBy({ + pickOrderId: pickOrderId, + itemId: itemId, + userId: currentUserId, + }); + console.log("✅ JoPickOrder.handledBy updated successfully"); + } catch (error) { + console.error("❌ Error updating JoPickOrder.handledBy:", error); + // Don't throw - this is not critical for the main flow + } + }, + [currentUserId], + ); + // 修改:初始化 — Workbench 先 prime SPL/SOL(後端不寫 pick_order.assignTo),再載入階層資料 + useEffect(() => { + if (session && currentUserId && !initializationRef.current) { + console.log("✅ Session loaded, initializing job order..."); + initializationRef.current = true; + + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + if (pickOrderId) { + void (async () => { + try { + await handleAssignOrder(pickOrderId); + } finally { + await fetchJobOrderData(pickOrderId); + } + })(); + } + loadUnassignedOrders(); + } + }, [ + session, + currentUserId, + fetchJobOrderData, + loadUnassignedOrders, + filterArgs?.pickOrderId, + handleAssignOrder, + ]); + + // 與 GoodPickExecutiondetail 一致:session 就緒後自動開啟背景掃碼(平板現場用) + useEffect(() => { + if (session && currentUserId && !scannerInitializedRef.current) { + scannerInitializedRef.current = true; + console.log("✅ [JO] Auto-starting QR scanner in background mode"); + setIsManualScanning(true); + startScan(); + } + }, [session, currentUserId, startScan]); + + // 僅在元件卸載時重置,讓 React Strict Mode 二次掛載仍能再走一次自動開掃(不因 startScan 引用變化而重複開掃) + useEffect(() => { + return () => { + scannerInitializedRef.current = false; + }; + }, []); + + // Add event listener for manual assignment + useEffect(() => { + const handlePickOrderAssigned = () => { + console.log("🔄 Pick order assigned event received, refreshing data..."); + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + if (pickOrderId) { + fetchJobOrderData(pickOrderId); + } + }; + + window.addEventListener("pickOrderAssigned", handlePickOrderAssigned); + + return () => { + window.removeEventListener("pickOrderAssigned", handlePickOrderAssigned); + }; + }, [fetchJobOrderData, filterArgs?.pickOrderId]); + + /** 純 lotNo 手動輸入:與 DO Workbench 相同,不寫 checked,改提示用 scan-pick / Just Completed */ + const handleQrCodeSubmit = useCallback( + async (lotNo: string) => { + console.log(` Processing QR Code for lot: ${lotNo}`); + + if (!lotNo || lotNo === "null" || lotNo.trim() === "") { + console.error(" Invalid lotNo: null, undefined, or empty"); + return; + } + + const currentLotData = combinedLotData; + console.log( + ` Available lots:`, + currentLotData.map((lot) => lot.lotNo), + ); + + const lotNoLower = lotNo.toLowerCase(); + const matchingLots = currentLotData.filter((lot) => { + if (!lot.lotNo) return false; + return lot.lotNo === lotNo || lot.lotNo.toLowerCase() === lotNoLower; + }); + + if (matchingLots.length === 0) { + console.error(` Lot not found: ${lotNo}`); + setQrScanError(true); + setQrScanSuccess(false); + const availableLotNos = currentLotData + .map((lot) => lot.lotNo) + .join(", "); + console.log( + ` QR Code "${lotNo}" does not match any expected lots. Available lots: ${availableLotNos}`, + ); + return; + } + + const hasExpiredLot = matchingLots.some( + (lot: any) => + String(lot.lotAvailability || "").toLowerCase() === "expired", + ); + if (hasExpiredLot) { + console.warn(`⚠️ [QR PROCESS] Scanned lot ${lotNo} is expired`); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + tPick( + "Workbench uses scan-pick. Please use Just Completed / submit via scan-pick instead of checked status.", + ), + ); + }, + [combinedLotData, tPick], + ); + const handleLotMismatch = useCallback( + (expectedLot: any, scannedLot: any) => { + console.log("⚠️ [LOT MISMATCH] Lot mismatch detected:", { + expectedLot, + scannedLot, + }); + console.log( + "⚠️ [LOT MISMATCH] Opening confirmation modal - NO lot will be marked as scanned until user confirms", + ); + + // ✅ schedule modal open in next tick (avoid flushSync warnings on some builds) + // ✅ IMPORTANT: This function ONLY opens the modal. It does NOT process any lot. + setTimeout(() => { + setExpectedLotData(expectedLot); + setScannedLotData({ + ...scannedLot, + lotNo: scannedLot.lotNo || null, + }); + lotConfirmSkipNextScanRef.current = true; + lotConfirmOpenedAtRef.current = Date.now(); + setLotConfirmationOpen(true); + console.log( + "⚠️ [LOT MISMATCH] Modal opened - waiting for user confirmation", + ); + }, 0); + + // ✅ Fetch lotNo in background for display purposes (cached) + // ✅ This is ONLY for display - it does NOT process any lot + if (!scannedLot.lotNo && scannedLot.stockInLineId) { + console.log( + `⚠️ [LOT MISMATCH] Fetching lotNo for display (stockInLineId: ${scannedLot.stockInLineId})`, + ); + fetchStockInLineInfoCached(scannedLot.stockInLineId) + .then((info) => { + console.log( + `⚠️ [LOT MISMATCH] Fetched lotNo for display: ${info.lotNo}`, + ); + startTransition(() => { + setScannedLotData((prev: any) => ({ + ...prev, + lotNo: info.lotNo || null, + })); + }); + }) + .catch((error) => { + console.error( + `❌ [LOT MISMATCH] Error fetching lotNo for display (stockInLineId may not exist):`, + error, + ); + // ignore display fetch errors - this does NOT affect processing + }); + } + }, + [fetchStockInLineInfoCached], + ); + + const clearLotConfirmationState = useCallback( + (clearProcessedRefs: boolean = false) => { + setLotConfirmationOpen(false); + setLotConfirmationError(null); + setExpectedLotData(null); + setScannedLotData(null); + setSelectedLotForQr(null); + lotConfirmLastQrRef.current = ""; + lotConfirmSkipNextScanRef.current = false; + lotConfirmOpenedAtRef.current = 0; + + if (clearProcessedRefs) { + setTimeout(() => { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + }, 100); + } + }, + [], + ); + + // Add handleLotConfirmation function + const handleLotConfirmation = useCallback( + async (overrideScannedLot?: any) => { + const effectiveScannedLot = overrideScannedLot ?? scannedLotData; + if (!expectedLotData || !effectiveScannedLot || !selectedLotForQr) { + console.error( + "❌ [LOT CONFIRM] Missing required data for lot confirmation", + ); + return; + } + + console.log( + "✅ [LOT CONFIRM] User confirmed lot substitution - processing now", + ); + console.log("✅ [LOT CONFIRM] Expected lot:", expectedLotData); + console.log("✅ [LOT CONFIRM] Scanned lot:", scannedLotData); + console.log("✅ [LOT CONFIRM] Selected lot for QR:", selectedLotForQr); + + setIsConfirmingLot(true); + setLotConfirmationError(null); + try { + let newLotLineId = effectiveScannedLot?.inventoryLotLineId; + if (!newLotLineId && effectiveScannedLot?.stockInLineId) { + try { + if ( + currentUserId && + selectedLotForQr.pickOrderId && + selectedLotForQr.itemId + ) { + try { + await updateHandledBy( + selectedLotForQr.pickOrderId, + selectedLotForQr.itemId, + ); + console.log( + `✅ [LOT CONFIRM] Handler updated for itemId ${selectedLotForQr.itemId}`, + ); + } catch (error) { + console.error( + `❌ [LOT CONFIRM] Error updating handler (non-critical):`, + error, + ); + } + } + console.log( + `🔍 [LOT CONFIRM] Fetching lot detail for stockInLineId: ${effectiveScannedLot.stockInLineId}`, + ); + const ld = await fetchLotDetail(effectiveScannedLot.stockInLineId); + newLotLineId = ld.inventoryLotLineId; + console.log( + `✅ [LOT CONFIRM] Fetched lot detail: inventoryLotLineId=${newLotLineId}`, + ); + } catch (error) { + console.error( + "❌ [LOT CONFIRM] Error fetching lot detail (stockInLineId may not exist):", + error, + ); + // If stockInLineId doesn't exist, we can still proceed with lotNo substitution + // The backend confirmLotSubstitution should handle this case + } + } + if (!newLotLineId) { + console.warn( + "⚠️ [LOT CONFIRM] No inventory lot line id for scanned lot, proceeding with lotNo only", + ); + // Continue anyway - backend may handle lotNo substitution without inventoryLotLineId + } + + console.log("=== [LOT CONFIRM] Lot Confirmation Debug ==="); + console.log("Selected Lot:", selectedLotForQr); + console.log("Pick Order Line ID:", selectedLotForQr.pickOrderLineId); + console.log("Stock Out Line ID:", selectedLotForQr.stockOutLineId); + console.log( + "Suggested Pick Lot ID:", + selectedLotForQr.suggestedPickLotId, + ); + console.log("Lot ID (fallback):", selectedLotForQr.lotId); + console.log("New Inventory Lot Line ID:", newLotLineId); + console.log("Scanned Lot No:", effectiveScannedLot.lotNo); + console.log( + "Scanned StockInLineId:", + effectiveScannedLot.stockInLineId, + ); + + const originalSuggestedPickLotId = + selectedLotForQr.suggestedPickLotId || selectedLotForQr.lotId; + + let switchedToUnavailable = false; + // noLot / missing suggestedPickLotId 场景:没有 originalSuggestedPickLotId,改用 updateStockOutLineStatusByQRCodeAndLotNo + if (!originalSuggestedPickLotId) { + if (!selectedLotForQr?.stockOutLineId) { + throw new Error("Missing stockOutLineId for noLot line"); + } + console.log( + "🔄 [LOT CONFIRM] No originalSuggestedPickLotId, using updateStockOutLineStatusByQRCodeAndLotNo...", + ); + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: selectedLotForQr.pickOrderLineId, + inventoryLotNo: effectiveScannedLot.lotNo || "", + stockInLineId: effectiveScannedLot?.stockInLineId ?? null, + stockOutLineId: selectedLotForQr.stockOutLineId, + itemId: selectedLotForQr.itemId, + status: "checked", + }); + console.log( + "✅ [LOT CONFIRM] updateStockOutLineStatusByQRCodeAndLotNo result:", + res, + ); + switchedToUnavailable = res?.code === "BOUND_UNAVAILABLE"; + const ok = + res?.code === "checked" || + res?.code === "SUCCESS" || + switchedToUnavailable; + if (!ok) { + const errMsg = + res?.code === "LOT_UNAVAILABLE" + ? tPick( + "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", + ) + : res?.message || + tPick( + "Lot switch failed; pick line was not marked as checked.", + ); + setLotConfirmationError(errMsg); + 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, + ); + + // ✅ 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 ?? "" + } 不存在或无法匹配`; + setLotConfirmationError(errMsg); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } + } + + // Workbench:以 scan-pick 過帳(與 DO Workbench 一致);非 workbench 仍寫 checked + if ( + workbenchMode && + selectedLotForQr?.stockOutLineId && + !switchedToUnavailable + ) { + const lotNoToScan = String(effectiveScannedLot.lotNo || "").trim(); + const silId = effectiveScannedLot?.stockInLineId; + if (!lotNoToScan) { + const errMsg = tPick("Cannot resolve lot number for confirmation."); + setLotConfirmationError(errMsg); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + return; + } + const res = await workbenchScanPick({ + stockOutLineId: selectedLotForQr.stockOutLineId, + lotNo: lotNoToScan, + ...(typeof silId === "number" && Number.isFinite(silId) && silId > 0 + ? { stockInLineId: silId } + : {}), + ...workbenchScanPickQtyFromLot(selectedLotForQr), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + if (res.code !== "SUCCESS") { + const errMsg = + (res as { message?: string })?.message || + tPick("Workbench scan-pick failed."); + setLotConfirmationError(errMsg); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + if (selectedLotForQr.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(selectedLotForQr.stockOutLineId), + errMsg, + ); + } + return; + } + clearWorkbenchScanReject(Number(selectedLotForQr.stockOutLineId)); + } else if (selectedLotForQr?.stockOutLineId && !switchedToUnavailable) { + console.log( + `🔄 [LOT CONFIRM] Updating stockOutLine ${selectedLotForQr.stockOutLineId} to 'checked'`, + ); + await updateStockOutLineStatus({ + id: selectedLotForQr.stockOutLineId, + status: "checked", + qty: 0, + }); + console.log( + `✅ [LOT CONFIRM] Stock out line ${selectedLotForQr.stockOutLineId} status updated to 'checked'`, + ); + } + + // Close modal and clean up state BEFORE refreshing + clearLotConfirmationState(false); + + // Clear QR processing state but DON'T clear processedQrCodes yet + setQrScanError(false); + setQrScanSuccess(true); + setQrScanErrorMsg(""); + setQrScanInput(""); + + // Set refreshing flag to prevent QR processing during refresh + setIsRefreshingData(true); + + // Refresh data to show updated lot + console.log("🔄 Refreshing job order data..."); + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + await fetchJobOrderData(pickOrderId); + console.log(" Lot substitution confirmed and data refreshed"); + + // Clear processed QR codes and flags immediately after refresh + // This allows new QR codes to be processed right away + setTimeout(() => { + console.log(" Clearing processed QR codes and resuming scan"); + setProcessedQrCodes(new Set()); + setLastProcessedQr(""); + setQrScanSuccess(false); + setIsRefreshingData(false); + // ✅ Clear processedQrCombinations to allow reprocessing the same QR if needed + if (effectiveScannedLot?.stockInLineId && selectedLotForQr?.itemId) { + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + const itemId = selectedLotForQr.itemId; + if (itemId && newMap.has(itemId)) { + newMap.get(itemId)!.delete(effectiveScannedLot.stockInLineId); + if (newMap.get(itemId)!.size === 0) { + newMap.delete(itemId); + } + } + return newMap; + }); + } + }, 500); // Reduced from 3000ms to 500ms - just enough for UI update + } catch (error) { + console.error("Error confirming lot substitution:", error); + const errMsg = tPick("Lot confirmation failed. Please try again."); + setLotConfirmationError(errMsg); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + // Clear refresh flag on error + setIsRefreshingData(false); + } finally { + setIsConfirmingLot(false); + } + }, + [ + expectedLotData, + scannedLotData, + selectedLotForQr, + fetchJobOrderData, + currentUserId, + updateHandledBy, + tPick, + clearLotConfirmationState, + workbenchMode, + workbenchScanPickQtyFromLot, + workbenchStoreId, + rememberWorkbenchScanReject, + clearWorkbenchScanReject, + ], + ); + + const handleLotConfirmationByRescan = useCallback( + async (rawQr: string): Promise => { + if ( + !lotConfirmationOpen || + !selectedLotForQr || + !expectedLotData || + !scannedLotData + ) { + return false; + } + let payload: any = null; + try { + payload = JSON.parse(rawQr); + } catch { + payload = null; + } + const expectedStockInLineId = Number(selectedLotForQr.stockInLineId); + const mismatchedStockInLineId = Number(scannedLotData?.stockInLineId); + if (payload?.stockInLineId && payload?.itemId) { + const rescannedStockInLineId = Number(payload.stockInLineId); + if ( + Number.isFinite(expectedStockInLineId) && + rescannedStockInLineId === expectedStockInLineId + ) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current(JSON.stringify(payload)); + } + return true; + } + if ( + Number.isFinite(mismatchedStockInLineId) && + rescannedStockInLineId === mismatchedStockInLineId + ) { + await handleLotConfirmation(); + return true; + } + await handleLotConfirmation({ + lotNo: null, + itemCode: expectedLotData?.itemCode, + itemName: expectedLotData?.itemName, + inventoryLotLineId: null, + stockInLineId: rescannedStockInLineId, + }); + return true; + } else { + const scannedText = rawQr?.trim(); + const expectedLotNo = expectedLotData?.lotNo?.trim(); + const mismatchedLotNo = scannedLotData?.lotNo?.trim(); + if (expectedLotNo && scannedText === expectedLotNo) { + clearLotConfirmationState(false); + if (processOutsideQrCodeRef.current) { + await processOutsideQrCodeRef.current( + JSON.stringify({ + itemId: selectedLotForQr.itemId, + stockInLineId: selectedLotForQr.stockInLineId, + }), + ); + } + return true; + } + if (mismatchedLotNo && scannedText === mismatchedLotNo) { + await handleLotConfirmation(); + return true; + } + } + return false; + }, + [ + lotConfirmationOpen, + selectedLotForQr, + expectedLotData, + scannedLotData, + handleLotConfirmation, + clearLotConfirmationState, + ], + ); + + const processOutsideQrCode = useCallback( + async (latestQr: string, _qrScanCountAtInvoke?: number) => { + const totalStartTime = performance.now(); + console.log( + ` [PROCESS OUTSIDE QR START] QR: ${latestQr.substring(0, 50)}...`, + ); + console.log(` Start time: ${new Date().toISOString()}`); + + // ✅ Measure index access time + const indexAccessStart = performance.now(); + const indexes = lotDataIndexes; // Access the memoized indexes + const indexAccessTime = performance.now() - indexAccessStart; + console.log( + ` [PERF] Index access time: ${indexAccessTime.toFixed(2)}ms`, + ); + + // 1) Parse JSON safely (parse once, reuse) + const parseStartTime = performance.now(); + let qrData: any = null; + let parseTime = 0; + try { + qrData = JSON.parse(latestQr); + parseTime = performance.now() - parseStartTime; + console.log(` [PERF] JSON parse time: ${parseTime.toFixed(2)}ms`); + } catch { + console.log( + "QR content is not JSON; skipping lotNo direct submit to avoid false matches.", + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + + try { + const validationStartTime = performance.now(); + if (!(qrData?.stockInLineId && qrData?.itemId)) { + console.log( + "QR JSON missing required fields (itemId, stockInLineId).", + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const validationTime = performance.now() - validationStartTime; + console.log(` [PERF] Validation time: ${validationTime.toFixed(2)}ms`); + + const scannedItemId = qrData.itemId; + const scannedStockInLineId = qrData.stockInLineId; + + // ✅ Check if this combination was already processed + const duplicateCheckStartTime = performance.now(); + const itemProcessedSet = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet?.has(scannedStockInLineId)) { + const duplicateCheckTime = + performance.now() - duplicateCheckStartTime; + console.log( + ` [SKIP] Already processed combination: itemId=${scannedItemId}, stockInLineId=${scannedStockInLineId} (check time: ${duplicateCheckTime.toFixed( + 2, + )}ms)`, + ); + return; + } + const duplicateCheckTime = performance.now() - duplicateCheckStartTime; + console.log( + ` [PERF] Duplicate check time: ${duplicateCheckTime.toFixed(2)}ms`, + ); + + // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) + const lookupStartTime = performance.now(); + const activeSuggestedLots = + indexes.activeLotsByItemId.get(scannedItemId) || []; + // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected + const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; + const lookupTime = performance.now() - lookupStartTime; + console.log( + ` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${ + activeSuggestedLots.length + } active lots, ${allLotsForItem.length} total lots`, + ); + + // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots + // This allows users to scan other lots even when all suggested lots are rejected + const scannedLot = allLotsForItem.find( + (lot: any) => lot.stockInLineId === scannedStockInLineId, + ); + + if (scannedLot) { + const isRejected = + scannedLot.stockOutLineStatus?.toLowerCase() === "rejected" || + scannedLot.lotAvailability === "rejected" || + isInventoryLotLineUnavailable(scannedLot); + + if (isRejected) { + console.warn( + `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `此批次(${ + scannedLot.lotNo || scannedStockInLineId + })已被拒绝,无法使用。请扫描其他批次。`, + ); + }); + // Mark as processed to prevent re-processing + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + return; + } + + const isExpired = + String(scannedLot.lotAvailability || "").toLowerCase() === + "expired"; + if (isExpired) { + console.warn( + `⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is expired`, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + `此批次(${ + scannedLot.lotNo || scannedStockInLineId + })已过期,无法使用。请扫描其他批次。`, + ); + }); + // Mark as processed to prevent re-processing the same expired QR repeatedly + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + return; + } + } + + // ✅ If no active suggested lots, auto-switch to scanned lot (no modal) + if (activeSuggestedLots.length === 0) { + // Check if there are any lots for this item (even if all are rejected) + if (allLotsForItem.length === 0) { + console.error("No lots found for this item"); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg("当前订单中没有此物品的批次信息"); + }); + return; + } + + console.log( + `⚠️ [QR PROCESS] No active suggested lots, auto-switching to scanned lot.`, + ); + + // Find a rejected lot as expected lot (the one that was rejected) + const rejectedLot = allLotsForItem.find( + (lot: any) => + lot.stockOutLineStatus?.toLowerCase() === "rejected" || + lot.lotAvailability === "rejected" || + isInventoryLotLineUnavailable(lot), + ); + const expectedLot = + rejectedLot || + pickExpectedLotForSubstitution( + allLotsForItem.filter( + (l: any) => l.lotNo != null && String(l.lotNo).trim() !== "", + ), + ) || + allLotsForItem[0]; + + let scannedLotNo: string | null = scannedLot?.lotNo || null; + if (!scannedLotNo) { + try { + const info = + await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn( + "Failed to fetch lotNo for stockInLineId:", + scannedStockInLineId, + e, + ); + } + } + if (!scannedLotNo) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + tPick( + "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", + ), + ); + }); + return; + } + + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || + substitutionCode === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionCode !== "SUCCESS" && !switchedToUnavailable) + ) { + const errMsg = + substitutionResult?.message || + tPick("Lot switch failed; pick line was not updated."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + }); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + errMsg, + ); + } + return; + } + } + + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const ok = res.code === "SUCCESS"; + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + tPick("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + failMsg, + ); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + + return; + } + + // ✅ OPTIMIZATION: Direct Map lookup for stockInLineId match (O(1)) + const matchStartTime = performance.now(); + let exactMatch: any = null; + const stockInLineLots = + indexes.byStockInLineId.get(scannedStockInLineId) || []; + // Find exact match from stockInLineId index, then verify it's in active lots + for (let i = 0; i < stockInLineLots.length; i++) { + const lot = stockInLineLots[i]; + if ( + lot.itemId === scannedItemId && + activeSuggestedLots.includes(lot) + ) { + exactMatch = lot; + break; + } + } + const matchTime = performance.now() - matchStartTime; + console.log( + ` [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${ + exactMatch ? "yes" : "no" + }`, + ); + + // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots + // This handles the case where Lot A is rejected and user scans Lot B + // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) + if (!exactMatch) { + const expectedLot = + pickExpectedLotForSubstitution(activeSuggestedLots) || + allLotsForItem[0]; + if (expectedLot) { + const shouldAutoSwitch = + !scannedLot || + scannedLot.stockInLineId !== expectedLot.stockInLineId; + if (shouldAutoSwitch) { + console.log( + `⚠️ [QR PROCESS] Auto-switching (scanned lot ${ + scannedLot?.lotNo || "not in data" + } is not in active suggested lots)`, + ); + + let scannedLotNo: string | null = scannedLot?.lotNo || null; + if (!scannedLotNo) { + try { + const info = + await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn( + "Failed to fetch lotNo for stockInLineId:", + scannedStockInLineId, + e, + ); + } + } + if (!scannedLotNo) { + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + tPick( + "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", + ), + ); + }); + return; + } + + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || + substitutionCode === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionCode !== "SUCCESS" && !switchedToUnavailable) + ) { + const errMsg = + substitutionResult?.message || + tPick("Lot switch failed; pick line was not updated."); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + }); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + errMsg, + ); + } + return; + } + } + + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const ok = res.code === "SUCCESS"; + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + tPick("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + failMsg, + ); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + return; + } + } + } + + if (exactMatch) { + // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 + console.log( + `✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`, + ); + + if (!exactMatch.stockOutLineId) { + console.warn( + "No stockOutLineId on exactMatch, cannot update status by QR.", + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + + try { + const apiStartTime = performance.now(); + console.log( + workbenchMode + ? ` [API CALL START] workbenchScanPick` + : ` [API CALL START] Calling updateStockOutLineStatusByQRCodeAndLotNo`, + ); + console.log( + ` [API CALL] API start time: ${new Date().toISOString()}`, + ); + const res = await workbenchScanPick({ + stockOutLineId: exactMatch.stockOutLineId, + lotNo: exactMatch.lotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : typeof exactMatch.stockInLineId === "number" && + Number.isFinite(exactMatch.stockInLineId) && + exactMatch.stockInLineId > 0 + ? { stockInLineId: exactMatch.stockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(exactMatch), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const apiTime = performance.now() - apiStartTime; + console.log( + ` [API CALL END] Total API time: ${apiTime.toFixed(2)}ms (${( + apiTime / 1000 + ).toFixed(3)}s)`, + ); + console.log( + ` [API CALL] API end time: ${new Date().toISOString()}`, + ); + + const ok = res.code === "SUCCESS"; + if (ok) { + clearWorkbenchScanReject(Number(exactMatch.stockOutLineId)); + + // ✅ Batch state updates using startTransition + const stateUpdateStartTime = performance.now(); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + }); + const stateUpdateTime = performance.now() - stateUpdateStartTime; + console.log( + ` [PERF] State update time: ${stateUpdateTime.toFixed(2)}ms`, + ); + + // Mark this combination as processed + const markProcessedStartTime = performance.now(); + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) { + newMap.set(scannedItemId, new Set()); + } + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + const markProcessedTime = + performance.now() - markProcessedStartTime; + console.log( + ` [PERF] Mark processed time: ${markProcessedTime.toFixed( + 2, + )}ms`, + ); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + + const totalTime = performance.now() - totalStartTime; + console.log( + `✅ [PROCESS OUTSIDE QR END] Total time: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + console.log( + `📊 Breakdown: parse=${parseTime.toFixed( + 2, + )}ms, validation=${validationTime.toFixed( + 2, + )}ms, duplicateCheck=${duplicateCheckTime.toFixed( + 2, + )}ms, lookup=${lookupTime.toFixed( + 2, + )}ms, match=${matchTime.toFixed(2)}ms, api=${apiTime.toFixed( + 2, + )}ms, stateUpdate=${stateUpdateTime.toFixed( + 2, + )}ms, markProcessed=${markProcessedTime.toFixed(2)}ms`, + ); + console.log( + workbenchMode + ? "✅ Workbench scan-pick: list refreshed from server" + : "✅ Status updated locally, no full data refresh needed", + ); + } else { + console.warn("Unexpected response code from backend:", res.code); + const failMsg = + (res as { message?: string })?.message || + tPick("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + exactMatch + ) { + openWorkbenchLotLabelModalForLot(exactMatch, failMsg); + return; + } + if (workbenchMode && exactMatch.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(exactMatch.stockOutLineId), + failMsg, + ); + } + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + }); + } + } catch (e) { + const totalTime = performance.now() - totalStartTime; + console.error( + `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed( + 2, + )}ms`, + ); + console.error( + "Error calling updateStockOutLineStatusByQRCodeAndLotNo:", + e, + ); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + } + + return; // ✅ 直接返回,不需要确认表单 + } + + // ✅ Case 2: itemId 匹配但 stockInLineId 不匹配 + // Workbench 策略:不彈窗,直接切換到掃到的批次並提交一次掃描 + const mismatchCheckStartTime = performance.now(); + const itemProcessedSet2 = processedQrCombinations.get(scannedItemId); + if (itemProcessedSet2?.has(scannedStockInLineId)) { + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log( + ` [SKIP] Already processed this exact combination (check time: ${mismatchCheckTime.toFixed( + 2, + )}ms)`, + ); + return; + } + const mismatchCheckTime = performance.now() - mismatchCheckStartTime; + console.log( + ` [PERF] Mismatch check time: ${mismatchCheckTime.toFixed(2)}ms`, + ); + + const expectedLotStartTime = performance.now(); + const expectedLot = pickExpectedLotForSubstitution(activeSuggestedLots); + if (!expectedLot) { + console.error("Could not determine expected lot for auto-switch"); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const expectedLotTime = performance.now() - expectedLotStartTime; + console.log( + ` [PERF] Get expected lot time: ${expectedLotTime.toFixed(2)}ms`, + ); + + console.log( + `⚠️ Lot mismatch (auto): Expected stockInLineId=${expectedLot.stockInLineId}, Scanned stockInLineId=${scannedStockInLineId}`, + ); + + // 1) 先把掃到的 stockInLineId 轉成 lotNo(workbenchScanPick 需要 lotNo) + let scannedLotNo: string | null = null; + try { + const info = await fetchStockInLineInfoCached(scannedStockInLineId); + scannedLotNo = info?.lotNo || null; + } catch (e) { + console.warn( + "Failed to fetch lotNo for stockInLineId:", + scannedStockInLineId, + e, + ); + } + if (!scannedLotNo) { + const msg = tPick( + "Cannot resolve lot number from QR. Please rescan or use manual confirmation.", + ); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + return; + } + + // 2) 非 workbench:先 confirmLotSubstitution;workbench 僅依 scan-pick 規則與錯誤訊息 + if (!workbenchMode) { + const substitutionResult = await confirmLotSubstitution({ + pickOrderLineId: expectedLot.pickOrderLineId, + stockOutLineId: expectedLot.stockOutLineId, + originalSuggestedPickLotId: expectedLot.suggestedPickLotId, + newInventoryLotNo: "", + newStockInLineId: scannedStockInLineId, + }); + const substitutionCode = (substitutionResult as any)?.code; + const switchedToUnavailable = + substitutionCode === "SUCCESS_UNAVAILABLE" || + substitutionCode === "BOUND_UNAVAILABLE"; + if ( + !substitutionResult || + (substitutionCode !== "SUCCESS" && !switchedToUnavailable) + ) { + const errMsg = + substitutionResult?.message || + tPick("Lot switch failed; pick line was not updated."); + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(errMsg); + if (expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + errMsg, + ); + } + return; + } + } + + // 3) 提交掃描(workbench:直接 workbenchScanPick) + try { + const res = await workbenchScanPick({ + stockOutLineId: expectedLot.stockOutLineId, + lotNo: scannedLotNo, + ...(typeof scannedStockInLineId === "number" && + Number.isFinite(scannedStockInLineId) && + scannedStockInLineId > 0 + ? { stockInLineId: scannedStockInLineId } + : {}), + ...workbenchScanPickQtyFromLot(expectedLot), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + + const ok = res.code === "SUCCESS"; + + if (!ok) { + const failMsg = + (res as { message?: string })?.message || + tPick("Workbench scan-pick failed."); + if ( + shouldOpenWorkbenchLotLabelModalForFailure(res.code, failMsg) && + expectedLot + ) { + openWorkbenchLotLabelModalForLot(expectedLot, failMsg); + return; + } + if (workbenchMode && expectedLot.stockOutLineId != null) { + rememberWorkbenchScanReject( + Number(expectedLot.stockOutLineId), + failMsg, + ); + } + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(failMsg); + return; + } + + clearWorkbenchScanReject(Number(expectedLot.stockOutLineId)); + + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + }); + + setProcessedQrCombinations((prev) => { + const newMap = new Map(prev); + if (!newMap.has(scannedItemId)) + newMap.set(scannedItemId, new Set()); + newMap.get(scannedItemId)!.add(scannedStockInLineId); + return newMap; + }); + + if (workbenchMode) { + await refreshWorkbenchAfterScanPick(); + } + } catch (e) { + console.error("Auto-switch scanPick failed:", e); + setQrScanError(true); + setQrScanSuccess(false); + return; + } + + const totalTime = performance.now() - totalStartTime; + console.log( + `✅ [PROCESS OUTSIDE QR AUTO-SWITCH] Total time: ${totalTime.toFixed( + 2, + )}ms (${(totalTime / 1000).toFixed(3)}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + console.log( + `📊 Breakdown: parse=${parseTime.toFixed( + 2, + )}ms, validation=${validationTime.toFixed( + 2, + )}ms, duplicateCheck=${duplicateCheckTime.toFixed( + 2, + )}ms, lookup=${lookupTime.toFixed(2)}ms, match=${matchTime.toFixed( + 2, + )}ms, mismatchCheck=${mismatchCheckTime.toFixed( + 2, + )}ms, expectedLot=${expectedLotTime.toFixed(2)}ms`, + ); + } catch (error) { + const totalTime = performance.now() - totalStartTime; + console.error( + `❌ [PROCESS OUTSIDE QR ERROR] Total time: ${totalTime.toFixed(2)}ms`, + ); + console.error("Error during QR code processing:", error); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + }, + [ + lotDataIndexes, + processedQrCombinations, + combinedLotData, + fetchStockInLineInfoCached, + workbenchMode, + currentUserId, + clearWorkbenchScanReject, + rememberWorkbenchScanReject, + refreshWorkbenchAfterScanPick, + workbenchScanPickQtyFromLot, + tPick, + workbenchStoreId, + confirmLotSubstitution, + openWorkbenchLotLabelModalForLot, + shouldOpenWorkbenchLotLabelModalForFailure, + ], + ); + + // Store in refs for immediate access in qrValues effect + processOutsideQrCodeRef.current = processOutsideQrCode; + resetScanRef.current = resetScan; + + const handleManualInputSubmit = useCallback(() => { + if (qrScanInput.trim() !== "") { + handleQrCodeSubmit(qrScanInput.trim()); + } + }, [qrScanInput, handleQrCodeSubmit]); + + // Handle QR code submission from modal (internal scanning) + const handleQrCodeSubmitFromModal = useCallback( + async (lotNo: string) => { + if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { + console.log(` QR Code verified for lot: ${lotNo}`); + + const requiredQty = selectedLotForQr.requiredQty; + const lotId = selectedLotForQr.lotId; + + // Create stock out line + const stockOutLineData: CreateStockOutLine = { + consoCode: selectedLotForQr.pickOrderConsoCode, + pickOrderLineId: selectedLotForQr.pickOrderLineId, + inventoryLotLineId: selectedLotForQr.lotId, + qty: 0.0, + }; + + try { + await createStockOutLine(stockOutLineData); + console.log("Stock out line created successfully!"); + + // Close modal + setQrModalOpen(false); + setSelectedLotForQr(null); + + // Set pick quantity + const lotKey = `${selectedLotForQr.pickOrderLineId}-${lotId}`; + setTimeout(() => { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: requiredQty, + })); + console.log( + ` Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`, + ); + }, 500); + + // Refresh data + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + await fetchJobOrderData(pickOrderId); + } catch (error) { + console.error("Error creating stock out line:", error); + } + } + }, + [selectedLotForQr, fetchJobOrderData], + ); + + useEffect(() => { + // Skip if scanner not active or no data or currently refreshing + if ( + !isManualScanning || + qrValues.length === 0 || + combinedLotData.length === 0 || + isRefreshingData + ) + return; + + const latestQr = qrValues[qrValues.length - 1]; + + // ✅ Test shortcut: {2fitestx,y} or {2fittestx,y} where x=itemId, y=stockInLineId + if ( + (latestQr.startsWith("{2fitest") || latestQr.startsWith("{2fittest")) && + latestQr.endsWith("}") + ) { + let content = ""; + if (latestQr.startsWith("{2fittest")) + content = latestQr.substring(9, latestQr.length - 1); + else content = latestQr.substring(8, latestQr.length - 1); + + const parts = content.split(","); + if (parts.length === 2) { + const itemId = parseInt(parts[0].trim(), 10); + const stockInLineId = parseInt(parts[1].trim(), 10); + if (!isNaN(itemId) && !isNaN(stockInLineId)) { + const simulatedQr = JSON.stringify({ itemId, stockInLineId }); + + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + + processOutsideQrCodeRef.current?.(simulatedQr); + resetScanRef.current?.(); + return; + } + } + } + + // ✅ Shortcut: {2fic} open manual lot confirmation modal + if (latestQr === "{2fic}") { + setManualLotConfirmationOpen(true); + resetScanRef.current?.(); + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + return; + } + + if (lotConfirmationOpen) { + if (isConfirmingLot) return; + if (lotConfirmSkipNextScanRef.current) { + lotConfirmSkipNextScanRef.current = false; + lotConfirmLastQrRef.current = latestQr || ""; + return; + } + if (!latestQr) return; + const sameQr = latestQr === lotConfirmLastQrRef.current; + const justOpened = + lotConfirmOpenedAtRef.current > 0 && + Date.now() - lotConfirmOpenedAtRef.current < 800; + if (sameQr && justOpened) return; + lotConfirmLastQrRef.current = latestQr; + void (async () => { + try { + const handled = await handleLotConfirmationByRescan(latestQr); + if (handled && resetScanRef.current) { + resetScanRef.current(); + } + } catch (e) { + console.error("Lot confirmation rescan failed:", e); + } + })(); + return; + } + // Skip processing if manual modal open for same QR + if (manualLotConfirmationOpen) { + if (latestQr === lastProcessedQrRef.current) return; + } + + // Skip if already processed (refs) + if ( + processedQrCodesRef.current.has(latestQr) || + lastProcessedQrRef.current === latestQr + ) + return; + + // Mark processed immediately + lastProcessedQrRef.current = latestQr; + processedQrCodesRef.current.add(latestQr); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) + processedQrCodesRef.current.delete(firstValue); + } + + // Process immediately + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + + processOutsideQrCodeRef.current?.(latestQr); + + // UI state updates (non-blocking) + startTransition(() => { + setLastProcessedQr(latestQr); + setProcessedQrCodes(new Set(processedQrCodesRef.current)); + }); + + return () => { + if (qrProcessingTimeoutRef.current) { + clearTimeout(qrProcessingTimeoutRef.current); + qrProcessingTimeoutRef.current = null; + } + }; + }, [ + qrValues.length, + isManualScanning, + isRefreshingData, + combinedLotData.length, + lotConfirmationOpen, + manualLotConfirmationOpen, + handleLotConfirmationByRescan, + isConfirmingLot, + ]); + + const handlePickQtyChange = useCallback( + (lotKey: string, value: number | string) => { + if (value === "" || value === null || value === undefined) { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: 0, + })); + return; + } + + const numericValue = + typeof value === "string" ? parseFloat(value) : value; + + if (isNaN(numericValue)) { + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: 0, + })); + return; + } + + setPickQtyData((prev) => ({ + ...prev, + [lotKey]: numericValue, + })); + }, + [], + ); + + const checkAndAutoAssignNext = useCallback(async () => { + if (!currentUserId) return; + + try { + const completionResponse = await checkPickOrderCompletion(currentUserId); + + if ( + completionResponse.code === "SUCCESS" && + completionResponse.entity?.hasCompletedOrders + ) { + console.log("Found completed pick orders, auto-assigning next..."); + // 移除前端的自动分配逻辑,因为后端已经处理了 + // await handleAutoAssignAndRelease(); // 删除这个函数 + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + }, [currentUserId]); + + const handleSubmitPickQtyWithQty = useCallback( + async ( + lot: any, + submitQty: number, + source: "justComplete" | "singleSubmit", + ) => { + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + const solId = Number(lot.stockOutLineId); + if (solId > 0 && actionBusyBySolId[solId]) { + console.warn("Action already in progress for stockOutLineId:", solId); + return; + } + + const pickOrderIdForRefresh = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + + try { + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: true })); + const targetUnavailable = isInventoryLotLineUnavailable(lot); + const effectiveSubmitQty = + targetUnavailable && submitQty > 0 ? 0 : submitQty; + + const canonicalLotForSol = + solId > 0 + ? combinedLotData.find((r) => Number(r.stockOutLineId) === solId) ?? + lot + : lot; + + if (workbenchMode && source === "justComplete") { + const solIdForOverride = + Number(canonicalLotForSol.stockOutLineId) || 0; + const lotIdForOverride = canonicalLotForSol.lotId; + const lotKeyForOverride = + Number.isFinite(solIdForOverride) && solIdForOverride > 0 + ? `sol:${solIdForOverride}` + : `${canonicalLotForSol.pickOrderLineId}-${lotIdForOverride}`; + const hasExplicitSubmitOverride = + Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKeyForOverride, + ); + const explicitSubmitOverride = hasExplicitSubmitOverride + ? Number(pickQtyData[lotKeyForOverride]) + : NaN; + + const qtyPayload = workbenchScanPickQtyFromLot(canonicalLotForSol); + const wbJustQty = qtyPayload.qty; + const isUnavailableForJustComplete = + isInventoryLotLineUnavailable(canonicalLotForSol); + const isNoLotForJustComplete = + canonicalLotForSol.noLot === true || + !String(canonicalLotForSol.lotNo ?? "").trim(); + const canPostScanPick = + // unavailable lot: Just Completed must always submit qty=0, even without lotNo + isUnavailableForJustComplete || + // noLot row: Just Completed always submit qty=0 + isNoLotForJustComplete || + (canonicalLotForSol.lotNo && + String(canonicalLotForSol.lotNo).trim() !== "" && + ((hasExplicitSubmitOverride && + Number.isFinite(explicitSubmitOverride) && + explicitSubmitOverride === 0) || + (wbJustQty != null && wbJustQty > 0))); + + if (canPostScanPick) { + const qtyToSend = + isUnavailableForJustComplete + ? 0 + : isNoLotForJustComplete + ? 0 + : hasExplicitSubmitOverride && explicitSubmitOverride === 0 + ? 0 + : Number(wbJustQty); + const res = await workbenchScanPick({ + stockOutLineId: Number(canonicalLotForSol.stockOutLineId), + lotNo: String(canonicalLotForSol.lotNo).trim(), + ...(typeof canonicalLotForSol.stockInLineId === "number" && + Number.isFinite(canonicalLotForSol.stockInLineId) && + canonicalLotForSol.stockInLineId > 0 + ? { stockInLineId: canonicalLotForSol.stockInLineId } + : {}), + qty: qtyToSend, + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(canonicalLotForSol.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || + "Workbench scan-pick failed", + ); + } + clearWorkbenchScanReject(Number(canonicalLotForSol.stockOutLineId)); + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, lotKeyForOverride)) + return prev; + const next = { ...prev }; + delete next[lotKeyForOverride]; + return next; + }); + await refreshWorkbenchAfterScanPick(); + if (canonicalLotForSol.pickOrderConsoCode) { + void checkAndCompletePickOrderByConsoCode( + canonicalLotForSol.pickOrderConsoCode, + ) + .then((completionResponse) => { + console.log( + ` Pick order completion check (workbench just complete):`, + completionResponse, + ); + }) + .catch((error) => { + console.error("Error checking pick order completion:", error); + }); + } + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + console.log( + "Just Completed (workbench): workbenchScanPick posted without QR.", + ); + return; + } + const justCompleteErr = tPick( + "Just Completed (workbench): requires valid quantity; expired rows must not use this button.", + ); + if (solId > 0) { + rememberWorkbenchScanReject(solId, justCompleteErr); + } + setQrScanErrorMsg(justCompleteErr); + throw new Error(justCompleteErr); + } + + if (effectiveSubmitQty === 0 && source === "singleSubmit") { + console.log(`=== SUBMITTING ALL ZEROS CASE ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Stock Out Line ID: ${lot.stockOutLineId}`); + + if (workbenchMode) { + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lot.lotNo ?? "").trim(), + ...(typeof lot.stockInLineId === "number" && + Number.isFinite(lot.stockInLineId) && + lot.stockInLineId > 0 + ? { stockInLineId: lot.stockInLineId } + : {}), + qty: 0, + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(lot.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || + "Workbench scan-pick failed (qty=0)", + ); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + await refreshWorkbenchAfterScanPick(); + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + return; + } + + throw new Error("Unsupported legacy checked flow on workbench page"); + } + + if ( + workbenchMode && + effectiveSubmitQty > 0 && + lot.lotNo && + String(lot.lotNo).trim() !== "" && + !isLotAvailabilityExpired(lot) && + !isInventoryLotLineUnavailable(lot) + ) { + const res = await workbenchScanPick({ + stockOutLineId: Number(lot.stockOutLineId), + lotNo: String(lot.lotNo).trim(), + ...(typeof lot.stockInLineId === "number" && + Number.isFinite(lot.stockInLineId) && + lot.stockInLineId > 0 + ? { stockInLineId: lot.stockInLineId } + : {}), + qty: Number(effectiveSubmitQty), + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + }); + const scanOk = res.code === "SUCCESS"; + if (!scanOk) { + rememberWorkbenchScanReject( + Number(lot.stockOutLineId), + (res as { message?: string })?.message, + ); + throw new Error( + (res as { message?: string })?.message || + "Workbench scan-pick failed", + ); + } + clearWorkbenchScanReject(Number(lot.stockOutLineId)); + const successLotKey = getWorkbenchQtyLotKey(lot); + setPickQtyData((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, successLotKey)) + return prev; + const next = { ...prev }; + delete next[successLotKey]; + return next; + }); + await refreshWorkbenchAfterScanPick(); + if (lot.pickOrderConsoCode) { + void checkAndCompletePickOrderByConsoCode(lot.pickOrderConsoCode) + .then((completionResponse) => { + console.log( + ` Pick order completion check (workbench submit):`, + completionResponse, + ); + }) + .catch((error) => { + console.error("Error checking pick order completion:", error); + }); + } + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + return; + } + + const currentActualPickQty = lot.actualPickQty || 0; + const cumulativeQty = currentActualPickQty + effectiveSubmitQty; + + let newStatus = "partially_completed"; + + if (cumulativeQty >= lot.requiredQty) { + newStatus = "completed"; + } else if (cumulativeQty > 0) { + newStatus = "partially_completed"; + } else { + newStatus = "pending"; + } + + console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); + console.log(`Lot: ${lot.lotNo}`); + console.log(`Required Qty: ${lot.requiredQty}`); + console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); + console.log(`New Submitted Qty: ${effectiveSubmitQty}`); + console.log(`Cumulative Qty: ${cumulativeQty}`); + console.log(`New Status: ${newStatus}`); + console.log(`=====================================`); + + if (!workbenchMode) { + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: newStatus, + qty: effectiveSubmitQty, + }); + applyLocalStockOutLineUpdate( + Number(lot.stockOutLineId), + newStatus, + cumulativeQty, + ); + } + + if (newStatus === "completed" && lot.pickOrderConsoCode) { + console.log( + ` Lot ${lot.lotNo} completed, checking if pick order ${lot.pickOrderConsoCode} is complete...`, + ); + + try { + const completionResponse = + await checkAndCompletePickOrderByConsoCode( + lot.pickOrderConsoCode, + ); + console.log( + ` Pick order completion check result:`, + completionResponse, + ); + + if (completionResponse.code === "SUCCESS") { + console.log( + ` Pick order ${lot.pickOrderConsoCode} completed successfully!`, + ); + } else if (completionResponse.message === "not completed") { + console.log( + `⏳ Pick order not completed yet, more lines remaining`, + ); + } else { + console.error( + ` Error checking completion: ${completionResponse.message}`, + ); + } + } catch (error) { + console.error("Error checking pick order completion:", error); + } + } + + void fetchJobOrderData(pickOrderIdForRefresh); + console.log("Pick quantity submitted successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + } catch (error) { + console.error("Error submitting pick quantity:", error); + setQrScanError(true); + setQrScanSuccess(false); + } finally { + if (solId > 0) + setActionBusyBySolId((prev) => ({ ...prev, [solId]: false })); + } + }, + [ + fetchJobOrderData, + checkAndAutoAssignNext, + actionBusyBySolId, + applyLocalStockOutLineUpdate, + workbenchMode, + currentUserId, + rememberWorkbenchScanReject, + clearWorkbenchScanReject, + refreshWorkbenchAfterScanPick, + combinedLotData, + workbenchScanPickQtyFromLot, + checkAndCompletePickOrderByConsoCode, + pickQtyData, + tPick, + workbenchStoreId, + filterArgs?.pickOrderId, + ], + ); + + const handleSkip = useCallback( + async (lot: any) => { + try { + console.log( + "Just Complete clicked (workbench: scan-pick without QR when possible):", + lot.lotNo, + ); + await handleSubmitPickQtyWithQty(lot, 0, "justComplete"); + } catch (err) { + console.error("Error in Skip:", err); + } + }, + [handleSubmitPickQtyWithQty], + ); + + const hasPendingBatchSubmit = useMemo(() => { + return combinedLotData.some((lot) => { + const status = String(lot.stockOutLineStatus || "").toLowerCase(); + return ( + status === "pending" || + status === "partially_completed" || + status === "partially_complete" + ); + }); + }, [combinedLotData]); + + useEffect(() => { + if (!hasPendingBatchSubmit) return; + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [hasPendingBatchSubmit]); + + const handleSubmitAllScanned = useCallback(async () => { + const startTime = performance.now(); + console.log(` [BATCH SUBMIT START]`); + console.log(` Start time: ${new Date().toISOString()}`); + + const scannedLots = combinedLotData.filter((lot) => { + const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) { + return true; + } + return false; + }); + + if (scannedLots.length === 0) { + console.log("No scanned items to submit"); + return; + } + + setIsSubmittingAll(true); + console.log( + `📦 Submitting ${scannedLots.length} items using workbench batch scan-pick (qty=0)...`, + ); + + try { + const submitStartTime = performance.now(); + + const result = await workbenchBatchScanPick({ + lines: scannedLots.map((lot) => ({ + stockOutLineId: Number(lot.stockOutLineId) || 0, + lotNo: "", + qty: 0, + storeId: workbenchStoreId, + userId: currentUserId ?? 1, + })), + }); + + const submitTime = performance.now() - submitStartTime; + console.log( + ` Batch submit API call completed in ${submitTime.toFixed(2)}ms (${( + submitTime / 1000 + ).toFixed(3)}s)`, + ); + console.log(`📥 Batch submit result:`, result); + + const refreshStartTime = performance.now(); + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + await fetchJobOrderData(pickOrderId); + const refreshTime = performance.now() - refreshStartTime; + console.log( + ` Data refresh time: ${refreshTime.toFixed(2)}ms (${( + refreshTime / 1000 + ).toFixed(3)}s)`, + ); + + const totalTime = performance.now() - startTime; + console.log(` [BATCH SUBMIT END]`); + console.log( + ` Total time: ${totalTime.toFixed(2)}ms (${(totalTime / 1000).toFixed( + 3, + )}s)`, + ); + console.log(` End time: ${new Date().toISOString()}`); + + if (result && result.code === "SUCCESS") { + setQrScanSuccess(true); + setTimeout(() => { + setQrScanSuccess(false); + checkAndAutoAssignNext(); + if (onBackToList) { + onBackToList(); + } + }, 2000); + } else { + console.error("Batch submit failed:", result); + setQrScanError(true); + } + } catch (error) { + console.error("Error submitting all scanned items:", error); + setQrScanError(true); + } finally { + setIsSubmittingAll(false); + } + }, [ + combinedLotData, + fetchJobOrderData, + checkAndAutoAssignNext, + currentUserId, + onBackToList, + workbenchStoreId, + filterArgs?.pickOrderId, + ]); + + const scannedItemsCount = useMemo(() => { + const filtered = combinedLotData.filter((lot) => { + const status = lot.stockOutLineStatus; + const statusLower = String(status || "").toLowerCase(); + if (statusLower === "completed" || statusLower === "complete") { + return false; + } + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) { + return true; + } + return false; + }); + + const noLotCount = filtered.filter((l) => l.noLot === true).length; + const normalCount = filtered.filter((l) => l.noLot !== true).length; + console.log( + `📊 scannedItemsCount calculation: total=${filtered.length}, noLot=${noLotCount}, normal=${normalCount}`, + ); + console.log(`📊 All items breakdown:`, { + total: combinedLotData.length, + noLot: combinedLotData.filter((l) => l.noLot === true).length, + normal: combinedLotData.filter((l) => l.noLot !== true).length, + }); + + return filtered.length; + }, [combinedLotData]); + + // 先定义 filteredByFloor 和 availableFloors + const availableFloors = useMemo(() => { + const floors = new Set(); + combinedLotData.forEach((lot) => { + const f = extractFloor(lot); + if (f) floors.add(f); + }); + return Array.from(floors).sort( + (a, b) => floorSortOrder(b) - floorSortOrder(a), + ); + }, [combinedLotData]); + + const filteredByFloor = useMemo(() => { + if (!selectedFloor) return combinedLotData; + return combinedLotData.filter((lot) => extractFloor(lot) === selectedFloor); + }, [combinedLotData, selectedFloor]); + + // 與批量篩選一致:noLot / 過期 的 pending 也算已處理(對齊 GoodPickExecutiondetail) + const progress = useMemo(() => { + const data = selectedFloor ? filteredByFloor : combinedLotData; + if (data.length === 0) return { completed: 0, total: 0 }; + const nonPendingCount = data.filter((lot) => { + const status = lot.stockOutLineStatus?.toLowerCase(); + if (status !== "pending") return true; + if ( + lot.noLot === true || + isLotAvailabilityExpired(lot) || + isInventoryLotLineUnavailable(lot) + ) + return true; + return false; + }).length; + return { completed: nonPendingCount, total: data.length }; + }, [selectedFloor, filteredByFloor, combinedLotData]); + // Handle reject lot + const handleRejectLot = useCallback( + async (lot: any) => { + if (!lot.stockOutLineId) { + console.error("No stock out line found for this lot"); + return; + } + + try { + await updateStockOutLineStatus({ + id: lot.stockOutLineId, + status: "rejected", + qty: 0, + }); + + const pickOrderId = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + await fetchJobOrderData(pickOrderId); + console.log("Lot rejected successfully!"); + + setTimeout(() => { + checkAndAutoAssignNext(); + }, 1000); + } catch (error) { + console.error("Error rejecting lot:", error); + } + }, + [fetchJobOrderData, checkAndAutoAssignNext], + ); + + // Handle pick execution form + const handlePickExecutionForm = useCallback((lot: any) => { + console.log("=== Pick Execution Form ==="); + console.log("Lot data:", lot); + + if (!lot) { + console.warn("No lot data provided for pick execution form"); + return; + } + + console.log("Opening pick execution form for lot:", lot.lotNo); + + setSelectedLotForExecutionForm(lot); + setPickExecutionFormOpen(true); + + console.log("Pick execution form opened for lot ID:", lot.lotId); + }, []); + + const handlePickExecutionFormSubmit = useCallback( + async (data: any) => { + const lotSnap = selectedLotForExecutionForm; + const pickOrderIdEarly = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : Number(lotSnap?.pickOrderId || 0) || undefined; + + try { + if (currentUserId && lotSnap?.pickOrderId && lotSnap?.itemId) { + try { + await updateHandledBy(lotSnap.pickOrderId, lotSnap.itemId); + console.log( + `✅ [ISSUE FORM] Handler updated for itemId ${lotSnap.itemId}`, + ); + } catch (error) { + console.error( + `❌ [ISSUE FORM] Error updating handler (non-critical):`, + error, + ); + } + } + + console.log("Pick execution form submitted:", data); + const issueData = { + ...data, + type: "Jo", + pickerName: session?.user?.name || undefined, + handledBy: currentUserId || undefined, + }; + + const missN = Number(issueData.missQty ?? 0) || 0; + const badN = Number(issueData.badItemQty ?? 0) || 0; + const badPkgN = Number(issueData.badPackageQty ?? 0) || 0; + const useHoldOnlyApi = missN === 0 && badN === 0 && badPkgN === 0; + + const result = useHoldOnlyApi + ? await applyPickExecutionHoldAndChecked(issueData) + : await recordPickExecutionIssue(issueData); + + console.log( + useHoldOnlyApi + ? "Pick hold/checked applied:" + : "Pick execution issue recorded:", + result, + ); + + if (!result || result.code !== "SUCCESS") { + console.error("❌ Pick execution submit failed:", result); + throw new Error(result?.message || "Submit failed"); + } + + const solId = Number(issueData.stockOutLineId || data?.stockOutLineId); + const picked = Number(issueData.actualPickQty ?? 0); + + if (solId > 0) { + setIssuePickedQtyBySolId((prev) => { + const next = { ...prev, [solId]: picked }; + const pid = filterArgs?.pickOrderId + ? Number(filterArgs.pickOrderId) + : undefined; + if (pid) saveIssuePickedMapJo(pid, next); + return next; + }); + } + + // Hold-only:與整批相同規則,只送一筆 batch,把實揈/完成狀態寫回 DB + if (useHoldOnlyApi && pickOrderIdEarly && solId > 0) { + const freshData = + await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderIdEarly); + const flatLots = getAllLotsFromHierarchical(freshData); + const lotRow = flatLots.find( + (l: any) => Number(l.stockOutLineId) === solId, + ); + if (!lotRow) { + throw new Error( + "Could not find lot row after refresh for batch submit", + ); + } + + const requiredQty = Number( + lotRow.requiredQty || lotRow.pickOrderLineRequiredQty || 0, + ); + const issuePickedVal = picked; + const currentActualPickQty = Number( + issuePickedVal ?? lotRow.actualPickQty ?? 0, + ); + const onlyComplete = + lotRow.stockOutLineStatus === "partially_completed" || + lotRow.stockOutLineStatus === "PARTIALLY_COMPLETE" || + issuePickedVal !== undefined; + const expired = isLotAvailabilityExpired(lotRow); + const unavailable = isInventoryLotLineUnavailable(lotRow); + + let targetActual: number; + let newStatus: string; + + if (unavailable) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else if (expired && issuePickedVal === undefined) { + targetActual = 0; + newStatus = "completed"; + } else if (onlyComplete) { + targetActual = currentActualPickQty; + newStatus = "completed"; + } else { + const remainingQty = Math.max( + 0, + requiredQty - currentActualPickQty, + ); + const cumulativeQty = currentActualPickQty + remainingQty; + targetActual = cumulativeQty; + newStatus = "partially_completed"; + if (requiredQty > 0 && cumulativeQty >= requiredQty) { + newStatus = "completed"; + } + } + + const line: batchSubmitListLineRequest = { + stockOutLineId: solId, + pickOrderLineId: Number(lotRow.pickOrderLineId), + inventoryLotLineId: lotRow.lotId ? Number(lotRow.lotId) : null, + requiredQty, + actualPickQty: targetActual, + stockOutLineStatus: newStatus, + pickOrderConsoCode: String(lotRow.pickOrderConsoCode || ""), + noLot: Boolean(lotRow.noLot === true), + }; + + const batchResult = await batchSubmitList({ + userId: currentUserId || 0, + lines: [line], + }); + + if (!batchResult || batchResult.code !== "SUCCESS") { + throw new Error( + batchResult?.message || + "Batch submit failed after hold adjustment", + ); + } + + const conso = String(lotRow.pickOrderConsoCode || "").trim(); + if (conso) { + try { + await checkAndCompletePickOrderByConsoCode(conso); + } catch (e) { + console.error("❌ completion check after single batch:", e); + } + } + } + + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + + await fetchJobOrderData(pickOrderIdEarly); + } catch (error) { + console.error("Error submitting pick execution form:", error); + throw error; + } + }, + [ + fetchJobOrderData, + getAllLotsFromHierarchical, + currentUserId, + selectedLotForExecutionForm, + updateHandledBy, + filterArgs, + session?.user?.name, + batchSubmitList, + checkAndCompletePickOrderByConsoCode, + isLotAvailabilityExpired, + isInventoryLotLineUnavailable, + ], + ); + // Calculate remaining required quantity + const calculateRemainingRequiredQty = useCallback((lot: any) => { + const requiredQty = lot.requiredQty || 0; + const stockOutLineQty = lot.stockOutLineQty || 0; + return Math.max(0, requiredQty - stockOutLineQty); + }, []); + + // Search criteria + const searchCriteria: Criterion[] = [ + { + label: t("Pick Order Code"), + paramName: "pickOrderCode", + type: "text", + }, + { + label: t("Item Code"), + paramName: "itemCode", + type: "text", + }, + { + label: t("Item Name"), + paramName: "itemName", + type: "text", + }, + { + label: t("Lot No"), + paramName: "lotNo", + type: "text", + }, + ]; + + const handlePageChange = useCallback((event: unknown, newPage: number) => { + setPaginationController((prev) => ({ + ...prev, + pageNum: newPage, + })); + }, []); + + const handlePageSizeChange = useCallback( + (event: React.ChangeEvent) => { + const newPageSize = parseInt(event.target.value, 10); + setPaginationController({ + pageNum: 0, + pageSize: newPageSize, + }); + }, + [], + ); + + // Pagination data: align DO workbench grouping display + const paginatedData = useMemo(() => { + const sourceData = selectedFloor ? filteredByFloor : combinedLotData; + const sortedData = [...sourceData].sort((a, b) => { + const floorA = extractFloor(a); + const floorB = extractFloor(b); + const orderA = floorSortOrder(floorA); + const orderB = floorSortOrder(floorB); + if (orderA !== orderB) return orderB - orderA; // 4F, 3F, 2F + // Same floor: group same item together first (DO-like visual grouping) + const aItem = String(a.itemCode || ""); + const bItem = String(b.itemCode || ""); + if (aItem !== bItem) return aItem.localeCompare(bItem); + + const aName = String(a.itemName || ""); + const bName = String(b.itemName || ""); + if (aName !== bName) return aName.localeCompare(bName); + + const aIndex = Number(a.routerIndex ?? 0); + const bIndex = Number(b.routerIndex ?? 0); + if (aIndex !== bIndex) return aIndex - bIndex; + + return (a.lotNo || "").localeCompare(b.lotNo || ""); + }); + + const flattened = sortedData.map((lot, idx, arr) => { + const key = `${lot.itemId ?? "null"}-${lot.itemCode ?? ""}`; + const prev = idx > 0 ? arr[idx - 1] : undefined; + const prevKey = prev + ? `${prev.itemId ?? "null"}-${prev.itemCode ?? ""}` + : null; + const isGroupFirst = key !== prevKey; + const groupDisplayIndex = + arr + .slice(0, idx + 1) + .filter((row, rowIdx, all) => { + const rowKey = `${row.itemId ?? "null"}-${row.itemCode ?? ""}`; + const before = rowIdx > 0 ? all[rowIdx - 1] : undefined; + const beforeKey = before + ? `${before.itemId ?? "null"}-${before.itemCode ?? ""}` + : null; + return rowKey !== beforeKey; + }).length; + return { lot, isGroupFirst, groupDisplayIndex }; + }); + + const startIndex = + paginationController.pageNum * paginationController.pageSize; + const endIndex = startIndex + paginationController.pageSize; + return flattened.slice(startIndex, endIndex); + }, [selectedFloor, filteredByFloor, combinedLotData, paginationController]); + + // Add these functions for manual scanning + const handleStartScan = useCallback(() => { + console.log(" Starting manual QR scan..."); + setIsManualScanning(true); + setProcessedQrCodes(new Set()); + setLastProcessedQr(""); + setQrScanError(false); + setQrScanSuccess(false); + startScan(); + }, [startScan]); + + const handleStopScan = useCallback(() => { + console.log(" Stopping manual QR scan..."); + setIsManualScanning(false); + setQrScanError(false); + setQrScanSuccess(false); + stopScan(); + resetScan(); + }, [stopScan, resetScan]); + useEffect(() => { + return () => { + // Cleanup when component unmounts (e.g., when switching tabs) + if (isManualScanning) { + console.log("🧹 Component unmounting, stopping QR scanner..."); + stopScan(); + resetScan(); + } + }; + }, [isManualScanning, stopScan, resetScan]); + // 勿在 combinedLotData 仍為空時自動停掃:API 未回傳前會誤觸,與 GoodPickExecutiondetail(已註解掉同段)一致。 + // 無資料時 qrValues effect 本來就不會處理掃碼;真正無單據可再手動按停止。 + + const getStatusMessage = useCallback( + (lot: any) => { + if ( + lot?.noLot === true || + lot?.lotAvailability === "insufficient_stock" + ) { + return t("This order is insufficient, please pick another lot."); + } + switch (lot.stockOutLineStatus?.toLowerCase()) { + case "pending": + return t("Please finish QR code scan and pick order."); + case "checked": + return t("Please submit the pick order."); + case "partially_completed": + return t( + "Partial quantity submitted. Please submit more or complete the order.", + ); + case "completed": + return t("Pick order completed successfully!"); + case "rejected": + return t("Lot has been rejected and marked as unavailable."); + case "unavailable": + return t("This order is insufficient, please pick another lot."); + default: + return t("Please finish QR code scan and pick order."); + } + }, + [t], + ); + + return ( + + lot.lotAvailability !== "rejected" && + lot.stockOutLineStatus !== "rejected" && + lot.stockOutLineStatus !== "completed" + } + > + + + {/* Progress bar + scan status fixed at top */} + + + + + + + {availableFloors.map((floor) => ( + + ))} + + {/* Job Order Header */} + {jobOrderData && ( + + + + {t("Job Order")}:{" "} + {jobOrderData.pickOrder?.jobOrder?.code || "-"} + + + {t("Pick Order Code")}:{" "} + {jobOrderData.pickOrder?.code || "-"} + + + {t("Target Date")}:{" "} + {jobOrderData.pickOrder?.targetDate || "-"} + + + + )} + + {/* Combined Lot Table */} + + + + {!isManualScanning ? ( + + ) : ( + + )} + {/* ADD THIS: Submit All Scanned Button */} + + + + + + + + + {t("Index")} + {t("Item Code")} + {t("Route")} + {t("Handler")} + + {t("Lot No")} + + {t("Lot Required Pick Qty")} + + {t("Available Qty")} + {t("Scan Result")} + {t("Qty will submit")} + + {t("Submit Required Pick Qty")} + + + + + {paginatedData.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( + paginatedData.map((row) => { + const lot = row.lot; + const solIdForKey = Number(lot.stockOutLineId) || 0; + const lotKeyForSubmitQty = + Number.isFinite(solIdForKey) && solIdForKey > 0 + ? `sol:${solIdForKey}` + : `${lot.pickOrderLineId}-${lot.lotId}`; + const submitQtyStatus = String( + lot.stockOutLineStatus || "", + ).toLowerCase(); + const isSubmitQtyCompleted = + submitQtyStatus === "completed" || + submitQtyStatus === "partially_completed" || + submitQtyStatus === "partially_complete"; + const lockedSubmitQtyDisplay = + isInventoryLotLineUnavailable(lot) && + !isSubmitQtyCompleted + ? 0 + : resolveSingleSubmitQty(lot); + const hasPickOverride = + Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKeyForSubmitQty, + ); + const fromPickRow = hasPickOverride + ? pickQtyData[lotKeyForSubmitQty] + : undefined; + const workbenchSubmitQtyDisplay = + hasPickOverride && + fromPickRow !== undefined && + fromPickRow !== null && + !Number.isNaN(Number(fromPickRow)) + ? Number(fromPickRow) + : lockedSubmitQtyDisplay; + + return ( + + + + {row.isGroupFirst ? row.groupDisplayIndex : ""} + + + + {row.isGroupFirst ? ( + <> + {lot.itemCode}
+ {lot.itemName}
+ {lot.uomDesc} + + ) : ""} +
+ + + + {lot.routerRoute || "-"} + + + {lot.handler || "-"} + + + + + + {lot.lotNo ? ( + /* + + */ + lot.lotAvailability === "expired" ? ( + <> + {lot.lotNo}{" "} + + {t( + "is expired. Please check around have available QR code or not.", + )} + + + ) : isInventoryLotLineUnavailable(lot) && + !( + String( + lot.stockOutLineStatus || "", + ).toLowerCase() === "completed" || + String( + lot.stockOutLineStatus || "", + ).toLowerCase() === "partially_completed" || + String( + lot.stockOutLineStatus || "", + ).toLowerCase() === "partially_complete" + ) ? ( + <> + {lot.lotNo}{" "} + + {t( + "is unavable. Please check around have available QR code or not.", + )} + + + + ) : ( + lot.lotNo + ) + ) : ( + + {t( + "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", + )} + + )} + + + {Number(lot.stockOutLineId) > 0 && + Number(lot.itemId) > 0 ? ( + + ) : null} + + + + {(() => { + const requiredQty = lot.requiredQty || 0; + const unit = + lot.noLot === true || !lot.lotId + ? lot.uomDesc || "" + : lot.uomDesc || ""; + return `${requiredQty.toLocaleString()}(${unit})`; + })()} + + + {(() => { + const avail = lot.itemTotalAvailableQty; + if (avail == null) return "-"; + const unit = lot.uomDesc || ""; + return `${Number( + avail, + ).toLocaleString()}(${unit})`; + })()} + + + + {(() => { + const status = + lot.stockOutLineStatus?.toLowerCase(); + const isRejected = + status === "rejected" || + lot.lotAvailability === "rejected"; + const isNoLot = !lot.lotNo; + + if (isRejected && !isNoLot) { + return ( + + + + ); + } + + if ( + isLotAvailabilityExpired(lot) && + status !== "rejected" + ) { + return ( + + + + ); + } + + if ( + !isNoLot && + status !== "pending" && + status !== "rejected" + ) { + return ( + + + + ); + } + + if ( + isNoLot && + (status === "partially_completed" || + status === "completed") + ) { + return ( + + + + ); + } + + return null; + })()} + + + + {workbenchSubmitQtyDisplay} + + + + + {(() => { + const status = + lot.stockOutLineStatus?.toLowerCase(); + const isRejected = + status === "rejected" || + lot.lotAvailability === "rejected"; + const isNoLot = !lot.lotNo; + const isUnavailableLot = + isInventoryLotLineUnavailable(lot); + + if (isRejected && !isNoLot) { + const rejectDisplay = buildLotRejectDisplayMessage( + lot, + scanRejectMessageBySolId, + tPick, + ); + return ( + + {rejectDisplay ?? + t( + "This lot is rejected, please scan another lot.", + )} + + ); + } + + const lotKey = lotKeyForSubmitQty; + const qtyFieldEnabled = + workbenchSubmitQtyFieldEnabledByLotKey[ + lotKey + ] === true; + const displayedSubmitQty = + workbenchSubmitQtyDisplay; + const hasPickOverrideRow = + Object.prototype.hasOwnProperty.call( + pickQtyData, + lotKey, + ); + const textFieldValue = qtyFieldEnabled + ? hasPickOverrideRow + ? String(pickQtyData[lotKey]) + : String(displayedSubmitQty) + : String(displayedSubmitQty); + + return ( + + { + if (!qtyFieldEnabled) return; + if (e.key !== "{") return; + e.preventDefault(); + setWorkbenchSubmitQtyFieldEnabledByLotKey( + (prev) => ({ + ...prev, + [lotKey]: false, + }), + ); + ( + e.currentTarget as HTMLInputElement + ).blur(); + }} + onChange={(e) => { + if (!qtyFieldEnabled) return; + handlePickQtyChange( + lotKey, + e.target.value, + ); + }} + inputProps={{ min: 0, step: 1 }} + sx={{ + width: 96, + "& .MuiInputBase-input": { + fontSize: "0.75rem", + py: 0.5, + textAlign: "center", + }, + }} + /> + + + + ); + })()} + + +
+ ); + }) + )} +
+
+
+ + + `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` + } + /> +
+
+ + {/* QR Code Modal */} + {!lotConfirmationOpen && ( + { + setQrModalOpen(false); + setSelectedLotForQr(null); + // Keep scanner active like GoodPickExecutiondetail. + resetScan(); + }} + lot={selectedLotForQr} + combinedLotData={combinedLotData} + onQrCodeSubmit={handleQrCodeSubmitFromModal} + /> + )} + { + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + setWorkbenchLotLabelReminderText(null); + }} + initialPayload={workbenchLotLabelInitialPayload} + initialItemId={ + workbenchLotLabelContextLot != null + ? Number(workbenchLotLabelContextLot.itemId) + : null + } + defaultPrinterName={defaultLabelPrinterName} + hideScanSection={ + workbenchLotLabelInitialPayload != null || + workbenchLotLabelContextLot != null + } + warehouseCodePrefixFilter={lotFloorPrefixFilter} + triggerLotAvailableQty={ + workbenchLotLabelContextLot != null + ? Number(workbenchLotLabelContextLot.availableQty) + : null + } + triggerLotUom={ + workbenchLotLabelContextLot != null + ? String(workbenchLotLabelContextLot.uomDesc ?? "").trim() || null + : null + } + disableScanPick={workbenchLotLabelScanPickDisabled} + onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} + submitQty={workbenchLotLabelSubmitQty} + onSubmitQtyChange={handleWorkbenchLotLabelSubmitQtyChange} + reminderText={workbenchLotLabelReminderText ?? undefined} + /> + {/* Add Lot Confirmation Modal */} + {lotConfirmationOpen && expectedLotData && scannedLotData && ( + { + console.log( + `⏱️ [LOT CONFIRM MODAL] Closing modal, reset scanner and release raw-QR dedupe`, + ); + if (resetScanRef.current) { + resetScanRef.current(); + } + clearLotConfirmationState(false); + setTimeout(() => { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + }, 250); + }} + onConfirm={handleLotConfirmation} + expectedLot={expectedLotData} + scannedLot={scannedLotData} + isLoading={isConfirmingLot} + errorMessage={lotConfirmationError} + /> + )} + + {/* Manual Lot Confirmation Modal (test shortcut {2fic}) */} + setManualLotConfirmationOpen(false)} + // Reuse existing handler: expectedLotInput=current lot, scannedLotInput=new lot + onConfirm={(currentLotNo, newLotNo) => { + // Use existing manual flow from handleManualLotConfirmation in other screens: + // Here we route through updateStockOutLineStatusByQRCodeAndLotNo via handleManualLotConfirmation-like inline logic. + // For now: open LotConfirmationModal path by setting expected/scanned and letting user confirm substitution. + setExpectedLotData({ + lotNo: currentLotNo, + itemCode: "", + itemName: "", + }); + setScannedLotData({ + lotNo: newLotNo, + itemCode: "", + itemName: "", + inventoryLotLineId: null, + stockInLineId: null, + }); + setManualLotConfirmationOpen(false); + setLotConfirmationOpen(true); + }} + expectedLot={expectedLotData} + scannedLot={scannedLotData} + isLoading={isConfirmingLot} + /> + {/* Pick Execution Form Modal */} + {pickExecutionFormOpen && selectedLotForExecutionForm && ( + { + setPickExecutionFormOpen(false); + setSelectedLotForExecutionForm(null); + }} + onSubmit={handlePickExecutionFormSubmit} + selectedLot={selectedLotForExecutionForm} + selectedPickOrderLine={{ + id: selectedLotForExecutionForm.pickOrderLineId, + itemId: selectedLotForExecutionForm.itemId, + itemCode: selectedLotForExecutionForm.itemCode, + itemName: selectedLotForExecutionForm.itemName, + pickOrderCode: selectedLotForExecutionForm.pickOrderCode, + // Add missing required properties from GetPickOrderLineInfo interface + availableQty: selectedLotForExecutionForm.availableQty || 0, + requiredQty: selectedLotForExecutionForm.requiredQty || 0, + uomDesc: selectedLotForExecutionForm.uomDesc || "", + uomShortDesc: selectedLotForExecutionForm.uomShortDesc || "", + pickedQty: selectedLotForExecutionForm.actualPickQty || 0, + suggestedList: [], + noLotLines: [], + }} + pickOrderId={selectedLotForExecutionForm.pickOrderId} + pickOrderCreateDate={new Date()} + /> + )} +
+
+ ); +}; + +export default JobPickExecution; diff --git a/src/components/Jodetail/JodetailSearch.tsx b/src/components/Jodetail/JodetailSearch.tsx index a3d7513..4eafa51 100644 --- a/src/components/Jodetail/JodetailSearch.tsx +++ b/src/components/Jodetail/JodetailSearch.tsx @@ -15,6 +15,7 @@ import { import { arrayToDayjs, } from "@/app/utils/formatUtil"; +import JoPickOrderList from "@/components/JoWorkbench/JoPickOrderList"; import { Button, Grid, Stack, Tab, Tabs, TabsProps, Typography, Box, TextField, Autocomplete } from "@mui/material"; import Jodetail from "./Jodetail" import PickExecution from "./JobPickExecution"; @@ -26,7 +27,7 @@ import JobPickExecutionsecondscan from "./JobPickExecutionsecondscan"; import FInishedJobOrderRecord from "./FInishedJobOrderRecord"; import JobPickExecution from "./JobPickExecution"; import CompleteJobOrderRecord from "./completeJobOrderRecord"; -import JoPickOrderList from "./JoPickOrderList"; +//import JoPickOrderList from "./JoPickOrderList"; import { fetchUnassignedJobOrderPickOrders, assignJobOrderPickOrder, diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 7e367ca..ee5cb7a 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -117,12 +117,14 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN], path: "/putAway", }, + /* { icon: , label: "Finished Good Order", requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN], path: "/finishedGood", }, + */ { icon: , label: "Finished Good Management", @@ -135,14 +137,15 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], path: "/stockRecord", }, - /* + + { icon: , label: "Do Workbench", requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], path: "/doworkbench", }, - */ + ], }, { @@ -188,6 +191,14 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], path: "/bag", }, + /* + { + icon: , + label: "Job Order Workbench", + requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], + path: "/jo/workbench", + }, + */ ], }, { @@ -389,6 +400,22 @@ const NavigationContent: React.FC = () => { if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) { ensureOpen.push("Management Job Order"); } + if ( + pathname === "/jo/workbench" || + pathname.startsWith("/jo/workbench/") || + pathname === "/jodetail" || + pathname.startsWith("/jodetail/") + ) { + ensureOpen.push("Management Job Order"); + } + if ( + pathname === "/doworkbench" || + pathname.startsWith("/doworkbench/") || + pathname === "/doworkbenchsearch" || + pathname.startsWith("/doworkbenchsearch/") + ) { + ensureOpen.push("Store Management"); + } if (ensureOpen.length === 0) return; setOpenItems((prev) => { const set = new Set(prev); @@ -423,7 +450,13 @@ const NavigationContent: React.FC = () => { walk(navigationItems); // Pick the most specific (longest) match to avoid double-highlighting - const matches = leafPaths.filter((p) => pathname === p || pathname.startsWith(p + "/")); + const matches = leafPaths.filter((p) => { + if (pathname === p) return true; + if (!pathname.startsWith(p + "/")) return false; + // `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash) + if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false; + return true; + }); matches.sort((a, b) => b.length - a.length); return matches[0] ?? ""; }, [hasAbility, navigationItems, pathname]); diff --git a/src/components/PickOrderSearch/AssignAndRelease.tsx b/src/components/PickOrderSearch/AssignAndRelease.tsx index c7a5398..33752b1 100644 --- a/src/components/PickOrderSearch/AssignAndRelease.tsx +++ b/src/components/PickOrderSearch/AssignAndRelease.tsx @@ -25,6 +25,8 @@ import { newassignPickOrder, AssignPickOrderInputs, releaseAssignedPickOrders, + assignPickOrderWorkbenchV2, + releasePickOrderWorkbenchV2, } from "@/app/api/pickOrder/actions"; import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; import { FormProvider, useForm } from "react-hook-form"; @@ -158,8 +160,9 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { console.log("First record:", res.records[0]); // 新增:在前端也过滤掉 "assigned" 状态的项目 - const filteredRecords = res.records.filter((item: any) => item.status !== "assigned"); - + const filteredRecords = res.records.filter( + (item: any) => (item.status || "").toLowerCase() === "pending" + ); const itemRows: ItemRow[] = filteredRecords.map((item: any) => ({ id: item.id, pickOrderId: item.pickOrderId, @@ -356,7 +359,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { setIsUploading(true); try { // Step 1: Assign the pick orders - const assignRes = await newassignPickOrder({ + const assignRes = await assignPickOrderWorkbenchV2({ pickOrderIds: selectedPickOrderIds, assignTo: data.assignTo, }); @@ -365,7 +368,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { console.log("Assign successful:", assignRes); // Step 2: Release the assigned pick orders - const releaseRes = await releaseAssignedPickOrders({ + const releaseRes = await releasePickOrderWorkbenchV2({ pickOrderIds: selectedPickOrderIds, assignTo: data.assignTo, }); @@ -623,6 +626,7 @@ const AssignAndRelease: React.FC = ({ filterArgs }) => { + {/* + */} + + + + + ); +}; + +interface Props { + filterArgs?: Record; +} + +const toNum = (v: unknown, d = 0): number => { + const n = Number(v); + return Number.isFinite(n) ? n : d; +}; + +const toStr = (v: unknown): string => (typeof v === "string" ? v : ""); + +const isCompletedStatus = (status: string | undefined): boolean => { + const s = String(status || "").toLowerCase(); + return s === "completed" || s === "partially_completed" || s === "partially_complete"; +}; + +const isCheckedStatus = (status: string | undefined): boolean => + String(status || "").toLowerCase() === "checked"; + +const isRejectedStatus = (status: string | undefined): boolean => + String(status || "").toLowerCase() === "rejected"; + +const isInventoryLotLineUnavailable = (row: LotRow): boolean => { + const solSt = String(row.status || "").toLowerCase(); + if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") return false; + if (String(row.lotAvailability || "").toLowerCase() === "status_unavailable") return true; + return String(row.lotStatus || "").toLowerCase() === "unavailable"; +}; + +const isLotExpired = (row: LotRow): boolean => { + if (String(row.lotAvailability || "").toLowerCase() === "expired") return true; + if (!row.expiryDate) return false; + const d = dayjs(row.expiryDate).startOf("day"); + return d.isValid() && d.isBefore(dayjs().startOf("day")); +}; + +const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => { + const c = String(code || "").toUpperCase(); + const m = String(message || ""); + if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true; + if (/^Reject switch lot:/i.test(m)) return true; + if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true; + return false; +}; + +function safeDisplayTargetDate(targetDate: string | number[]): string { + try { + if (Array.isArray(targetDate) && targetDate.length >= 3) { + return arrayToDayjs(targetDate).format(OUTPUT_DATE_FORMAT); + } + const value = typeof targetDate === "string" ? targetDate : String(targetDate ?? ""); + const d = dayjs(value); + return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : "-"; + } catch { + return "-"; + } +} + +function lineHasStockOutOrSuggestion(details: PickOrderLotDetailResponse[]): boolean { + if (!details.length) return false; + return details.some((d) => { + const sol = toNum(d.stockOutLineId); + const spl = toNum(d.suggestedPickLotId); + return sol > 0 || spl > 0 || d.noLot === true; + }); +} + +function mapLotDetailsToRows( + details: PickOrderLotDetailResponse[], + ctx: { + pickOrderId: number; + pickOrderLineId: number; + pickOrderCode: string; + itemCode: string; + itemName: string; + totalAvailableQty?: number | null; + }, +): LotRow[] { + return details.map((d, i) => { + const solId = toNum(d.stockOutLineId); + const lotId = toNum(d.lotId, i); + const stockInLineId = toNum(d.stockInLineId); + return { + key: solId > 0 ? `sol:${solId}` : `lot:${lotId}:${i}`, + pickOrderId: ctx.pickOrderId, + pickOrderLineId: ctx.pickOrderLineId, + pickOrderCode: ctx.pickOrderCode, + itemCode: ctx.itemCode, + itemName: ctx.itemName, + uomDesc: toStr(d.stockUnit), + requiredQty: toNum(d.requiredQty), + availableQty: toNum(d.remainingAfterAllPickOrders ?? d.availableQty), + itemTotalAvailableQty: toNum(ctx.totalAvailableQty), + stockOutLineId: solId, + status: toStr(d.stockOutLineStatus ?? "pending"), + pickedQty: toNum(d.actualPickQty ?? d.stockOutLineQty), + lotNo: toStr(d.lotNo), + location: toStr(d.location), + itemId: toNum(d.itemId) || undefined, + stockInLineId: stockInLineId > 0 ? stockInLineId : undefined, + suggestedPickLotId: toNum(d.suggestedPickLotId) || undefined, + lotAvailability: toStr((d as any).lotAvailability), + lotStatus: toStr((d as any).lotStatus), + expiryDate: toStr((d as any).expiryDate), + stockOutLineRejectMessage: toStr((d as any).stockOutLineRejectMessage), + }; + }); +} + +const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { + const { t } = useTranslation("pickOrder"); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const userId = session?.id ? parseInt(session.id, 10) : 0; + + const [originalTopRows, setOriginalTopRows] = useState([]); + const [filteredTopRows, setFilteredTopRows] = useState([]); + const [pickOrderLoading, setPickOrderLoading] = useState(false); + const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10 }); + const [totalCountItems, setTotalCountItems] = useState(0); + const localizeBackendMessage = (msg: unknown, fallbackKey: string) => { + const text = typeof msg === "string" ? msg.trim() : ""; + if (!text) return t(fallbackKey); + return t(text, { defaultValue: text }); + }; + const [selectedPickOrderLineId, setSelectedPickOrderLineId] = useState(null); + const [selectedPickOrderId, setSelectedPickOrderId] = useState(null); + const [selectedTopMeta, setSelectedTopMeta] = useState<{ + pickOrderCode: string; + itemCode: string; + itemName: string; + totalAvailableQty?: number; + } | null>(null); + + const [lotRows, setLotRows] = useState([]); + const [qtyBySolId, setQtyBySolId] = useState>({}); + const [qtyEditableBySolId, setQtyEditableBySolId] = useState>({}); + const [lotPagingController, setLotPagingController] = useState({ pageNum: 0, pageSize: 10 }); + const [loading, setLoading] = useState(false); + const [submittingSolId, setSubmittingSolId] = useState(null); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = useState(false); + const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState(null); + const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] = + useState<{ itemId: number; stockInLineId: number } | null>(null); + const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false); + const [lotConfirmationError, setLotConfirmationError] = useState(null); + const [expectedLotData, setExpectedLotData] = useState(null); + const [scannedLotData, setScannedLotData] = useState(null); + const [isConfirmingLot, setIsConfirmingLot] = useState(false); + const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); + const [qrScanError, setQrScanError] = useState(false); + const [qrScanSuccess, setQrScanSuccess] = useState(false); + const [qrScanErrorMsg, setQrScanErrorMsg] = useState(""); + const [qrScanSuccessMsg, setQrScanSuccessMsg] = useState(""); + const lastProcessedQrRef = useRef(""); + const processedQrCodesRef = useRef>(new Set()); + const lotConfirmLastQrRef = useRef(""); + const lotConfirmSkipNextScanRef = useRef(false); + const lotConfirmOpenedAtRef = useRef(0); + + const { values: qrValues, isScanning, startScan, resetScan } = useQrCodeScannerContext(); + + const paginatedTopRows = useMemo(() => { + const start = (pagingController.pageNum - 1) * pagingController.pageSize; + return filteredTopRows.slice(start, start + pagingController.pageSize); + }, [filteredTopRows, pagingController]); + + const paginatedLotRows = useMemo(() => { + const start = lotPagingController.pageNum * lotPagingController.pageSize; + return lotRows.slice(start, start + lotPagingController.pageSize); + }, [lotRows, lotPagingController]); + + const lotRowIndexes = useMemo(() => { + const byItemId = new Map(); + const byStockInLineId = new Map(); + const activeLotsByItemId = new Map(); + + for (const row of lotRows) { + const itemId = Number(row.itemId); + const stockInLineId = Number(row.stockInLineId); + const isActive = + row.stockOutLineId > 0 && + !isCompletedStatus(row.status) && + !isCheckedStatus(row.status); + + if (Number.isFinite(itemId) && itemId > 0) { + if (!byItemId.has(itemId)) byItemId.set(itemId, []); + byItemId.get(itemId)!.push(row); + if (isActive) { + if (!activeLotsByItemId.has(itemId)) activeLotsByItemId.set(itemId, []); + activeLotsByItemId.get(itemId)!.push(row); + } + } + + if (Number.isFinite(stockInLineId) && stockInLineId > 0) { + if (!byStockInLineId.has(stockInLineId)) byStockInLineId.set(stockInLineId, []); + byStockInLineId.get(stockInLineId)!.push(row); + } + } + + return { byItemId, byStockInLineId, activeLotsByItemId }; + }, [lotRows]); + + const fetchNewPageItems = useCallback( + async (paging: { pageNum: number; pageSize: number }, extra: Record) => { + if (!userId) return; + setPickOrderLoading(true); + setError(""); + try { + const params = { + ...extra, + pageNum: 0, + pageSize: 9999, + status: "released", + type: "consumable", + assignTo: userId, + }; + const res = await fetchPickOrderWithStockClient(params); + const records = Array.isArray(res?.records) ? res.records : []; + const rows: TopRow[] = records.flatMap((r: any) => { + const pickOrderId = toNum(r?.id); + const code = toStr(r?.code); + const status = toStr(r?.status); + const targetDate = r?.targetDate; + const lines = Array.isArray(r?.pickOrderLines) ? r.pickOrderLines : []; + return lines.map((line: any, idx: number) => ({ + rowKey: `po:${pickOrderId}:line:${toNum(line?.id, idx)}`, + pickOrderId, + pickOrderLineId: toNum(line?.id), + pickOrderCode: code, + itemCode: toStr(line?.itemCode), + itemName: toStr(line?.itemName), + requiredQty: toNum(line?.requiredQty), + currentStock: toNum(line?.availableQty), + pickedQty: toNum(line?.pickedQty), + stockUnit: toStr(line?.uomDesc ?? line?.uomShortDesc), + targetDate: targetDate ?? "", + status, + })); + }); + setOriginalTopRows(rows); + setFilteredTopRows(rows); + const pageSize = paging.pageSize || 10; + const pageNum = paging.pageNum || 1; + setTotalCountItems(rows.length); + setPagingController({ pageNum, pageSize }); + return rows; + } catch (e) { + console.error(e); + setError(t("Load released pick orders failed")); + setOriginalTopRows([]); + setFilteredTopRows([]); + setTotalCountItems(0); + return [] as TopRow[]; + } finally { + setPickOrderLoading(false); + } + }, + [t, userId], + ); + + const refreshReleasedTopRowsAfterMutation = useCallback(async () => { + const latestRows = + (await fetchNewPageItems( + pagingController, + (filterArgs || {}) as Record, + )) || []; + if ( + selectedPickOrderLineId != null && + !latestRows.some((r) => r.pickOrderLineId === selectedPickOrderLineId) + ) { + setSelectedPickOrderLineId(null); + setSelectedPickOrderId(null); + setSelectedTopMeta(null); + setLotRows([]); + setQtyBySolId({}); + setQtyEditableBySolId({}); + setLotPagingController({ pageNum: 0, pageSize: 10 }); + } + }, [fetchNewPageItems, filterArgs, pagingController, selectedPickOrderLineId]); + + const searchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Item Code"), paramName: "itemCode", type: "text" }, + { label: t("Pick Order Code"), paramName: "pickOrderCode", type: "text" }, + { label: t("Item Name"), paramName: "itemName", type: "text" }, + { label: t("Target Date From"), label2: t("Target Date To"), paramName: "targetDate", type: "dateRange" }, + ], + [t], + ); + + const handleSearch = useCallback( + (query: Record) => { + const filtered = originalTopRows.filter((row) => { + const itemCodeMatch = !query.itemCode || row.itemCode.toLowerCase().includes(query.itemCode.toLowerCase()); + const pickOrderCodeMatch = + !query.pickOrderCode || row.pickOrderCode.toLowerCase().includes(query.pickOrderCode.toLowerCase()); + const itemNameMatch = !query.itemName || row.itemName.toLowerCase().includes(query.itemName.toLowerCase()); + const targetDate = Array.isArray(row.targetDate) + ? arrayToDayjs(row.targetDate) + : dayjs(typeof row.targetDate === "string" ? row.targetDate : ""); + let dateMatch = true; + if (query.targetDate || query.targetDateTo) { + const fromDate = query.targetDate ? dayjs(query.targetDate) : null; + const toDate = query.targetDateTo ? dayjs(query.targetDateTo) : null; + if (targetDate.isValid()) { + if (fromDate && fromDate.isValid()) dateMatch = dateMatch && (targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day")); + if (toDate && toDate.isValid()) dateMatch = dateMatch && (targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day")); + } + } + return itemCodeMatch && pickOrderCodeMatch && itemNameMatch && dateMatch; + }); + setFilteredTopRows(filtered); + setTotalCountItems(filtered.length); + setPagingController((prev) => ({ ...prev, pageNum: 1 })); + }, + [originalTopRows], + ); + + const handleReset = useCallback(() => { + setFilteredTopRows(originalTopRows); + setTotalCountItems(originalTopRows.length); + setPagingController((prev) => ({ ...prev, pageNum: 1 })); + }, [originalTopRows]); + + useEffect(() => { + if (userId) fetchNewPageItems(pagingController, (filterArgs || {}) as Record); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, filterArgs, fetchNewPageItems]); + + const loadLineDetailV2 = useCallback( + async ( + pickOrderId: number, + pickOrderLineId: number, + meta: { + pickOrderCode: string; + itemCode: string; + itemName: string; + totalAvailableQty?: number; + }, + ) => { + if (!userId || pickOrderLineId <= 0) return; + setLoading(true); + setError(""); + setMessage(""); + try { + let details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); + let list = Array.isArray(details) ? details : []; + if (!lineHasStockOutOrSuggestion(list)) { + const suggestRes = await suggestPickOrderWorkbenchV2(pickOrderId, userId); + if (suggestRes.code !== "SUCCESS") { + setError(t("Suggest pick failed")); + setLotRows([]); + return; + } + details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); + list = Array.isArray(details) ? details : []; + setMessage(t("Suggestion success")); + } + setLotRows( + mapLotDetailsToRows(list, { + pickOrderId, + pickOrderLineId, + pickOrderCode: meta.pickOrderCode, + itemCode: meta.itemCode, + itemName: meta.itemName, + totalAvailableQty: meta.totalAvailableQty, + }), + ); + setQtyEditableBySolId({}); + } catch (e) { + console.error(e); + setError(t("Load workbench data failed")); + setLotRows([]); + } finally { + setLoading(false); + } + }, + [t, userId], + ); + + const submitRow = useCallback( + async (row: LotRow, forceQty?: number) => { + if (!userId) return; + if (!row.stockOutLineId) { + setError(t("No stock out line for this lot")); + return; + } + const qtyInput = qtyBySolId[row.stockOutLineId]; + const qtyValue = forceQty ?? (typeof qtyInput === "number" && Number.isFinite(qtyInput) ? qtyInput : undefined); + setSubmittingSolId(row.stockOutLineId); + setError(""); + setMessage(""); + try { + const res = await workbenchScanPick({ + stockOutLineId: row.stockOutLineId, + lotNo: row.lotNo.trim(), + ...(Number.isFinite(Number(row.stockInLineId)) && Number(row.stockInLineId) > 0 + ? { stockInLineId: Number(row.stockInLineId) } + : {}), + ...(typeof qtyValue === "number" && Number.isFinite(qtyValue) ? { qty: qtyValue } : {}), + userId, + }); + const ok = String(res.code || "").toUpperCase() === "SUCCESS"; + if (!ok) { + const errMsg = localizeBackendMessage(res.message, "Scan pick failed"); + setError(errMsg); + setQrScanErrorMsg(errMsg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + }); + return; + } + const okMsg = localizeBackendMessage(res.message, "Scan pick success"); + setMessage(okMsg); + setQrScanSuccessMsg(okMsg); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + }); + if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { + await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); + } + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + await refreshReleasedTopRowsAfterMutation(); + } catch (e) { + console.error(e); + setError(t("Scan pick failed")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scan pick failed")); + }); + } finally { + setSubmittingSolId(null); + } + }, + [qtyBySolId, loadLineDetailV2, refreshReleasedTopRowsAfterMutation, selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta, t, userId], + ); + + const hasQtyOverrideBySolId = useCallback( + (stockOutLineId: number) => Object.prototype.hasOwnProperty.call(qtyBySolId, stockOutLineId), + [qtyBySolId], + ); + + const resolveSingleSubmitQty = useCallback( + (lot: LotRow): number => { + const override = qtyBySolId[lot.stockOutLineId]; + if (typeof override === "number" && Number.isFinite(override) && override >= 0) { + return override; + } + return Number(lot.requiredQty) || 0; + }, + [qtyBySolId], + ); + + const workbenchScanPickQtyFromLot = useCallback( + (lot: LotRow) => { + const hasExplicitOverride = hasQtyOverrideBySolId(lot.stockOutLineId); + const n = Number(resolveSingleSubmitQty(lot)); + if (hasExplicitOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const; + if (!Number.isFinite(n) || n <= 0) return {}; + return { qty: n } as const; + }, + [hasQtyOverrideBySolId, resolveSingleSubmitQty], + ); + + const handleJustComplete = useCallback( + async (row: LotRow) => { + if (!row.stockOutLineId) { + setError(t("No stock out line for this lot")); + return; + } + + const lotNo = String(row.lotNo || "").trim(); + const isUnavailable = isInventoryLotLineUnavailable(row); + const isExpired = isLotExpired(row); + const hasExplicitOverride = hasQtyOverrideBySolId(row.stockOutLineId); + const explicitQty = hasExplicitOverride ? Number(qtyBySolId[row.stockOutLineId]) : NaN; + const qtyPayload = workbenchScanPickQtyFromLot(row); + const wbJustQty = qtyPayload.qty; + + const canPostScanPick = + isUnavailable || + (lotNo !== "" && + ((hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) || + (wbJustQty != null && wbJustQty > 0))); + + if (!canPostScanPick) { + const msg = t( + "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + return; + } + + if (isExpired && !isUnavailable) { + const msg = t( + "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.", + ); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + return; + } + + const qtyToSend = + isUnavailable || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) + ? 0 + : Number(wbJustQty); + + await submitRow(row, qtyToSend); + }, + [hasQtyOverrideBySolId, qtyBySolId, submitRow, t, workbenchScanPickQtyFromLot], + ); + + const handleLineSelect = useCallback( + async (row: TopRow, checked: boolean) => { + if (!checked) { + if (selectedPickOrderLineId === row.pickOrderLineId) { + setSelectedPickOrderLineId(null); + setSelectedPickOrderId(null); + setSelectedTopMeta(null); + setLotRows([]); + setQtyBySolId({}); + setQtyEditableBySolId({}); + setLotPagingController({ pageNum: 0, pageSize: 10 }); + } + return; + } + setSelectedPickOrderLineId(row.pickOrderLineId); + setSelectedPickOrderId(row.pickOrderId); + setSelectedTopMeta({ + pickOrderCode: row.pickOrderCode, + itemCode: row.itemCode, + itemName: row.itemName, + totalAvailableQty: row.currentStock, + }); + setLotRows([]); + setQtyBySolId({}); + setQtyEditableBySolId({}); + setLotPagingController({ pageNum: 0, pageSize: 10 }); + setMessage(""); + await loadLineDetailV2(row.pickOrderId, row.pickOrderLineId, { + pickOrderCode: row.pickOrderCode, + itemCode: row.itemCode, + itemName: row.itemName, + totalAvailableQty: row.currentStock, + }); + }, + [loadLineDetailV2, selectedPickOrderLineId], + ); + + const openWorkbenchLotLabelModalForLot = useCallback((lot: LotRow) => { + const itemId = Number(lot.itemId); + const stockInLineId = Number(lot.stockInLineId); + setWorkbenchLotLabelContextLot(lot); + if (Number.isFinite(itemId) && itemId > 0 && Number.isFinite(stockInLineId) && stockInLineId > 0) { + setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId }); + } else { + setWorkbenchLotLabelInitialPayload(null); + } + setWorkbenchLotLabelModalOpen(true); + }, []); + + const handleWorkbenchLotLabelScanPick = useCallback( + async ({ inventoryLotLineId, lotNo, qty }: { inventoryLotLineId: number; lotNo: string; qty?: number }) => { + if (!userId) throw new Error(t("User not found")); + if (!workbenchLotLabelContextLot?.stockOutLineId) { + throw new Error(t("No stock out line for this lot")); + } + const fallbackQty = Number( + resolveSingleSubmitQty(workbenchLotLabelContextLot), + ); + const res = await workbenchScanPick({ + stockOutLineId: workbenchLotLabelContextLot.stockOutLineId, + inventoryLotLineId, + lotNo, + ...(typeof qty === "number" && Number.isFinite(qty) + ? { qty } + : Number.isFinite(fallbackQty) && fallbackQty >= 0 + ? { qty: fallbackQty } + : {}), + userId, + }); + if (res.code !== "SUCCESS") { + throw new Error((res.message as string) || t("Scan pick failed")); + } + if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { + await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); + } + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + }, + [ + loadLineDetailV2, + qtyBySolId, + selectedPickOrderId, + selectedPickOrderLineId, + selectedTopMeta, + t, + userId, + workbenchLotLabelContextLot, + ], + ); + + const handleScanLotByLotNo = useCallback( + async (lotNo: string) => { + const normalized = String(lotNo || "").trim(); + if (!normalized) return; + const target = lotRows.find( + (r) => + String(r.lotNo || "").trim() === normalized && + r.stockOutLineId > 0 && + !isCompletedStatus(r.status) && + !isCheckedStatus(r.status), + ); + if (!target) { + setError(t("Lot not found in current line")); + return; + } + await submitRow(target); + }, + [lotRows, submitRow, t], + ); + + const resolveScanCandidate = useCallback( + (rawQr: string): ConfirmLotState | null => { + const latest = String(rawQr || "").trim(); + if (!latest) return null; + try { + const parsed = JSON.parse(latest); + const stockInLineId = toNum(parsed?.stockInLineId); + if (stockInLineId > 0) { + const row = lotRows.find((r) => Number(r.stockInLineId) === stockInLineId && r.stockOutLineId > 0); + if (!row) return null; + return { + lotNo: String(row.lotNo || "").trim(), + itemCode: row.itemCode, + itemName: row.itemName, + stockInLineId, + row, + }; + } + } catch { + // non-json; fallback to lotNo match + } + const lotNo = latest.replace(/[{}]/g, "").trim(); + if (!lotNo) return null; + const row = lotRows.find((r) => String(r.lotNo || "").trim() === lotNo && r.stockOutLineId > 0); + if (!row) return null; + return { + lotNo, + itemCode: row.itemCode, + itemName: row.itemName, + stockInLineId: row.stockInLineId, + row, + }; + }, + [lotRows], + ); + + const toConfirmLotState = useCallback((row: LotRow): ConfirmLotState => { + return { + lotNo: String(row.lotNo || "").trim(), + itemCode: row.itemCode, + itemName: row.itemName, + stockInLineId: row.stockInLineId, + row, + }; + }, []); + + const toConfirmLotStateWithOverrides = useCallback( + (row: LotRow, override: { lotNo?: string; stockInLineId?: number }): ConfirmLotState => { + return { + lotNo: String(override.lotNo ?? row.lotNo ?? "").trim(), + itemCode: row.itemCode, + itemName: row.itemName, + stockInLineId: override.stockInLineId ?? row.stockInLineId, + row, + }; + }, + [], + ); + + const pickExpectedRowForSubstitution = useCallback((rows: LotRow[]): LotRow | null => { + if (!rows.length) return null; + const withLotNo = rows.filter((r) => String(r.lotNo || "").trim() !== ""); + if (withLotNo.length === 1) return withLotNo[0]; + if (withLotNo.length > 1) { + const pending = withLotNo.find((r) => String(r.status || "").toLowerCase() === "pending"); + return pending || withLotNo[0]; + } + return rows[0]; + }, []); + + const clearLotConfirmationState = useCallback((clearProcessedRefs = false) => { + setLotConfirmationOpen(false); + setLotConfirmationError(null); + setExpectedLotData(null); + setScannedLotData(null); + lotConfirmLastQrRef.current = ""; + lotConfirmSkipNextScanRef.current = false; + lotConfirmOpenedAtRef.current = 0; + if (clearProcessedRefs) { + setTimeout(() => { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + }, 100); + } + }, []); + + const handleLotConfirmation = useCallback( + async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => { + const expected = overrideExpected ?? expectedLotData; + const scanned = overrideScanned ?? scannedLotData; + if (!expected || !scanned) return; + setIsConfirmingLot(true); + setLotConfirmationError(null); + setError(""); + setMessage(""); + try { + const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0); + let switchedToUnavailable = false; + if (originalSuggestedPickLotId > 0) { + const res = await confirmLotSubstitution({ + pickOrderLineId: expected.row.pickOrderLineId, + stockOutLineId: expected.row.stockOutLineId, + originalSuggestedPickLotId, + newInventoryLotNo: scanned.lotNo, + newStockInLineId: Number(scanned.stockInLineId ?? 0), + }); + switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE"; + const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message); + if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) { + const msg = (res.message as string) || t("Lot switch failed"); + setLotConfirmationError(msg); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + return; + } + if (nonBlockingReject && !switchedToUnavailable) { + const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick."); + setMessage(warnMsg); + } + } + + if (!switchedToUnavailable) { + const res = await workbenchScanPick({ + stockOutLineId: expected.row.stockOutLineId, + lotNo: scanned.lotNo, + ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0 + ? { stockInLineId: Number(scanned.stockInLineId) } + : {}), + ...workbenchScanPickQtyFromLot(expected.row), + userId, + }); + 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; + } + } + setMessage(t("Scan pick success")); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + setQrScanSuccessMsg(t("Scan pick success")); + }); + if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { + await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); + } + await refreshReleasedTopRowsAfterMutation(); + clearLotConfirmationState(true); + } catch (e) { + console.error(e); + const msg = t("Lot confirmation failed. Please try again."); + setLotConfirmationError(msg); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + } finally { + setIsConfirmingLot(false); + } + }, + [ + clearLotConfirmationState, + expectedLotData, + loadLineDetailV2, + refreshReleasedTopRowsAfterMutation, + scannedLotData, + selectedPickOrderId, + selectedPickOrderLineId, + selectedTopMeta, + t, + userId, + workbenchScanPickQtyFromLot, + ], + ); + + const handleLotConfirmationByRescan = useCallback( + async (rawQr: string): Promise => { + if (!lotConfirmationOpen || !expectedLotData || !scannedLotData) return false; + const latest = String(rawQr || "").trim(); + if (!latest) return false; + + let parsed: any; + try { + parsed = JSON.parse(latest); + } catch { + return false; + } + + const rescannedItemId = toNum(parsed?.itemId); + const rescannedStockInLineId = toNum(parsed?.stockInLineId); + if (rescannedItemId <= 0 || rescannedStockInLineId <= 0) return false; + + const expectedItemId = Number(expectedLotData.row.itemId || 0); + if (expectedItemId > 0 && rescannedItemId !== expectedItemId) return false; + + const expectedStockInLineId = Number(expectedLotData.stockInLineId || expectedLotData.row.stockInLineId || 0); + const scannedStockInLineId = Number(scannedLotData.stockInLineId || scannedLotData.row.stockInLineId || 0); + + if (expectedStockInLineId > 0 && rescannedStockInLineId === expectedStockInLineId) { + clearLotConfirmationState(false); + await submitRow(expectedLotData.row); + return true; + } + + if (scannedStockInLineId > 0 && rescannedStockInLineId === scannedStockInLineId) { + await handleLotConfirmation(); + return true; + } + + const itemRows = lotRowIndexes.byItemId.get(rescannedItemId) || []; + const rowByStockInLineId = itemRows.find( + (r) => + Number(r.stockInLineId) === rescannedStockInLineId && + r.stockOutLineId > 0 && + !isCompletedStatus(r.status) && + !isCheckedStatus(r.status), + ); + + if (rowByStockInLineId) { + await handleLotConfirmation(toConfirmLotState(rowByStockInLineId)); + return true; + } + + try { + const info = await fetchStockInLineInfo(rescannedStockInLineId); + const rescannedLotNo = String(info?.lotNo || "").trim(); + if (!rescannedLotNo) return false; + await handleLotConfirmation( + toConfirmLotStateWithOverrides(expectedLotData.row, { + lotNo: rescannedLotNo, + stockInLineId: rescannedStockInLineId, + }), + ); + } catch { + return false; + } + return true; + }, + [ + clearLotConfirmationState, + expectedLotData, + handleLotConfirmation, + lotConfirmationOpen, + lotRowIndexes, + scannedLotData, + submitRow, + toConfirmLotState, + toConfirmLotStateWithOverrides, + ], + ); + + const processOutsideQrCode = useCallback( + async (rawQr: string) => { + const latest = String(rawQr || "").trim(); + if (!latest) return; + setError(""); + setMessage(""); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(false); + }); + + if (latest === "{2fic}") { + setManualLotConfirmationOpen(true); + return; + } + + if (lotConfirmationOpen) { + const handled = await handleLotConfirmationByRescan(latest); + if (handled) return; + } + + let parsed: any; + try { + parsed = JSON.parse(latest); + } catch { + setError(t("Invalid QR format. Expected JSON with itemId and stockInLineId.")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Invalid QR format. Expected JSON with itemId and stockInLineId.")); + }); + resetScan(); + return; + } + + const scannedItemId = toNum(parsed?.itemId); + const scannedStockInLineId = toNum(parsed?.stockInLineId); + if (scannedItemId <= 0 || scannedStockInLineId <= 0) { + setError(t("Invalid QR data. itemId and stockInLineId are required.")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Invalid QR data. itemId and stockInLineId are required.")); + }); + resetScan(); + return; + } + + const activeSuggestedLots = lotRowIndexes.activeLotsByItemId.get(scannedItemId) || []; + const allLotsForItem = lotRowIndexes.byItemId.get(scannedItemId) || []; + if (allLotsForItem.length === 0) { + setError(t("Scanned item is not found in current line")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned item is not found in current line")); + }); + resetScan(); + return; + } + + const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem; + const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0]; + + const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; + const scannedRowInItem = + scannedRows.find( + (r) => + Number(r.itemId) === scannedItemId && + r.stockOutLineId > 0, + ) || + null; + + if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { + const rejectMsg = + scannedRowInItem.stockOutLineRejectMessage || + t("This lot is rejected. Please scan another lot."); + setError(rejectMsg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(rejectMsg); + }); + return; + } + + if (scannedRowInItem && isInventoryLotLineUnavailable(scannedRowInItem)) { + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(false); + }); + setMessage(t("This lot is unavailable, please scan another lot.")); + openWorkbenchLotLabelModalForLot(scannedRowInItem); + return; + } + + if (scannedRowInItem && isLotExpired(scannedRowInItem)) { + const expiredMsg = t("Lot is expired"); + setError(expiredMsg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg( + scannedRowInItem.expiryDate + ? `${expiredMsg} (expiry=${scannedRowInItem.expiryDate})` + : expiredMsg, + ); + }); + openWorkbenchLotLabelModalForLot(scannedRowInItem); + return; + } + + if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) { + setError(t("Scanned lot is already completed or checked")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned lot is already completed or checked")); + }); + return; + } + + let scannedState: ConfirmLotState | null = null; + if (scannedRowInItem) { + scannedState = toConfirmLotState(scannedRowInItem); + } else { + try { + const info = await fetchStockInLineInfo(scannedStockInLineId); + const scannedLotNo = String(info?.lotNo || "").trim(); + if (!scannedLotNo) { + setError(t("Scanned lot is not found for current item")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned lot is not found for current item")); + }); + resetScan(); + return; + } + scannedState = toConfirmLotStateWithOverrides(expectedRow, { + lotNo: scannedLotNo, + stockInLineId: scannedStockInLineId, + }); + } catch { + setError(t("Scanned lot is not found for current item")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(t("Scanned lot is not found for current item")); + }); + resetScan(); + return; + } + } + + if ( + Number(expectedRow.stockInLineId) > 0 && + Number(scannedState.stockInLineId) > 0 && + Number(expectedRow.stockInLineId) === Number(scannedState.stockInLineId) + ) { + await submitRow(expectedRow); + return; + } + + await handleLotConfirmation(scannedState, toConfirmLotState(expectedRow)); + }, + [ + handleLotConfirmation, + handleLotConfirmationByRescan, + lotConfirmationOpen, + pickExpectedRowForSubstitution, + lotRowIndexes, + resetScan, + submitRow, + t, + toConfirmLotState, + toConfirmLotStateWithOverrides, + ], + ); + + useEffect(() => { + if (!userId) return; + if (!isScanning) startScan(); + }, [isScanning, startScan, userId]); + + useEffect(() => { + if (!selectedPickOrderLineId) { + lastProcessedQrRef.current = ""; + processedQrCodesRef.current.clear(); + } + }, [selectedPickOrderLineId]); + + useEffect(() => { + if (!qrValues.length || lotRows.length === 0) return; + const latest = String(qrValues[qrValues.length - 1] || ""); + if (!latest) return; + + if (lotConfirmationOpen) { + if (isConfirmingLot) return; + if (lotConfirmSkipNextScanRef.current) { + lotConfirmSkipNextScanRef.current = false; + lotConfirmLastQrRef.current = latest; + return; + } + const sameQr = latest === lotConfirmLastQrRef.current; + const justOpened = + lotConfirmOpenedAtRef.current > 0 && + Date.now() - lotConfirmOpenedAtRef.current < 800; + if (sameQr && justOpened) return; + lotConfirmLastQrRef.current = latest; + void (async () => { + try { + const handled = await handleLotConfirmationByRescan(latest); + if (handled) resetScan(); + } catch (e) { + console.error("Lot confirmation rescan failed:", e); + } + })(); + return; + } + + if (latest === lastProcessedQrRef.current || processedQrCodesRef.current.has(latest)) return; + lastProcessedQrRef.current = latest; + processedQrCodesRef.current.add(latest); + if (processedQrCodesRef.current.size > 100) { + const firstValue = processedQrCodesRef.current.values().next().value; + if (firstValue !== undefined) processedQrCodesRef.current.delete(firstValue); + } + + const run = async () => { + try { + // JO shortcut: {2fitestx,y} -> simulate JSON qr + if ( + (latest.startsWith("{2fitest") || latest.startsWith("{2fittest")) && + latest.endsWith("}") + ) { + let content = ""; + if (latest.startsWith("{2fittest")) content = latest.substring(9, latest.length - 1); + else content = latest.substring(8, latest.length - 1); + const parts = content.split(","); + if (parts.length === 2) { + const itemId = parseInt(parts[0].trim(), 10); + const stockInLineId = parseInt(parts[1].trim(), 10); + if (!Number.isNaN(itemId) && !Number.isNaN(stockInLineId)) { + await processOutsideQrCode(JSON.stringify({ itemId, stockInLineId })); + return; + } + } + } + await processOutsideQrCode(latest); + } finally { + resetScan(); + } + }; + void run(); + }, [ + handleLotConfirmationByRescan, + isConfirmingLot, + lotConfirmationOpen, + lotRows.length, + processOutsideQrCode, + qrValues, + resetScan, + ]); + + return ( + + lot.stockOutLineId > 0 && + !isCompletedStatus(lot.status) && + !isCheckedStatus(lot.status) && + String(lot.lotNo || "").trim() !== "" + } + > + + + + + + + {pickOrderLoading ? ( + + ) : ( + + + + + {t("Selected")} + {t("Pick Order Code")} + {t("Item Code")} + {t("Item Name")} + {t("Order Quantity")} + {t("Current Stock")} + {t("Picked Qty")} + {t("Stock Unit")} + {t("Target Date")} + {t("Status")} + + + + {paginatedTopRows.length === 0 ? ( + + + + {t("No data available")} + + + + ) : ( + paginatedTopRows.map((row) => ( + + + void handleLineSelect(row, checked)} + /> + + {row.pickOrderCode || "-"} + {row.itemCode || "-"} + {row.itemName || "-"} + {row.requiredQty.toLocaleString()} + {row.currentStock.toLocaleString()} + {row.pickedQty.toLocaleString()} + {row.stockUnit || "-"} + {safeDisplayTargetDate(row.targetDate)} + {t(row.status || "-")} + + )) + )} + +
+
+ )} +
+ + setPagingController((prev) => ({ ...prev, pageNum: newPage + 1 }))} + onRowsPerPageChange={(e) => + setPagingController({ + pageNum: 1, + pageSize: parseInt(e.target.value, 10), + }) + } + rowsPerPageOptions={[10, 25, 50, 100]} + labelRowsPerPage={t("Rows per page")} + /> + +
+
+
+ + {loading ? ( + + + {t("Loading")} + + ) : null} + + {error ? {error} : null} + {message ? {message} : null} + + + + + + + {t("Index")} + {t("Item Code")} + {t("Route")} + {t("Lot No")} + {t("Lot Required Pick Qty")} + {t("Available Qty")} + {t("Scan Result")} + {t("Qty will submit")} + {t("Submit Required Pick Qty")} + + + + {paginatedLotRows.map((r, idx) => ( + + {idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""} + + {idx === 0 ? ( + <> + {r.itemCode || "-"}
+ {r.itemName || "-"}
+ {r.uomDesc || "-"} + + ) : ( + "" + )} +
+ {r.location || "-"} + + + {r.lotNo || "-"} + {r.stockOutLineId > 0 ? ( + + ) : null} + + + {`${r.requiredQty.toLocaleString()}(${r.uomDesc || ""})`} + + {`${Number( + r.itemTotalAvailableQty ?? r.availableQty ?? 0, + ).toLocaleString()}(${r.uomDesc || ""})`} + + + + + + + { + const editable = qtyEditableBySolId[r.stockOutLineId] === true; + if (!editable) return; + if (e.key !== "{") return; + e.preventDefault(); + setQtyEditableBySolId((prev) => ({ + ...prev, + [r.stockOutLineId]: false, + })); + (e.currentTarget as HTMLInputElement).blur(); + }} + onChange={(e) => { + const v = e.target.value; + setQtyBySolId((prev) => { + if (v === "" || v == null) { + if (!Object.prototype.hasOwnProperty.call(prev, r.stockOutLineId)) return prev; + const next = { ...prev }; + delete next[r.stockOutLineId]; + return next; + } + const n = Number(v); + if (!Number.isFinite(n) || n < 0) { + if (!Object.prototype.hasOwnProperty.call(prev, r.stockOutLineId)) return prev; + const next = { ...prev }; + delete next[r.stockOutLineId]; + return next; + } + return { ...prev, [r.stockOutLineId]: n }; + }); + }} + sx={{ width: 96 }} + disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true} + inputProps={{ min: 0, step: 1 }} + /> + + + + + + + + +
+ ))} + {lotRows.length === 0 ? ( + + + + {t("No lot rows. Select a line in the table above.")} + + + + ) : null} +
+
+
+ setLotPagingController((prev) => ({ ...prev, pageNum: newPage }))} + onRowsPerPageChange={(e) => + setLotPagingController({ + pageNum: 0, + pageSize: parseInt(e.target.value, 10), + }) + } + rowsPerPageOptions={[10, 25, 50]} + labelRowsPerPage={t("Rows per page")} + /> +
+ { + setWorkbenchLotLabelModalOpen(false); + setWorkbenchLotLabelContextLot(null); + setWorkbenchLotLabelInitialPayload(null); + }} + initialPayload={workbenchLotLabelInitialPayload} + initialItemId={workbenchLotLabelContextLot?.itemId ?? null} + hideScanSection={workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null} + triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} + triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} + submitQty={ + workbenchLotLabelContextLot?.stockOutLineId + ? Number(resolveSingleSubmitQty(workbenchLotLabelContextLot)) + : null + } + onSubmitQtyChange={(qty) => { + const solId = Number(workbenchLotLabelContextLot?.stockOutLineId); + if (!Number.isFinite(solId) || solId <= 0) return; + if (!Number.isFinite(qty) || qty < 0) { + setQtyBySolId((prev) => { + if (!Object.prototype.hasOwnProperty.call(prev, solId)) return prev; + const next = { ...prev }; + delete next[solId]; + return next; + }); + return; + } + setQtyBySolId((prev) => ({ ...prev, [solId]: qty })); + }} + onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} + /> + setManualLotConfirmationOpen(false)} + onConfirm={(expectedLotNo, scannedLotNo) => { + const expected = resolveScanCandidate(expectedLotNo); + const scanned = resolveScanCandidate(scannedLotNo); + if (!expected || !scanned) { + setError(t("Lot not found in current line")); + return; + } + setManualLotConfirmationOpen(false); + void handleLotConfirmation(scanned, expected); + }} + expectedLot={ + expectedLotData + ? { + lotNo: expectedLotData.lotNo, + itemCode: expectedLotData.itemCode, + itemName: expectedLotData.itemName, + } + : null + } + scannedLot={ + scannedLotData + ? { + lotNo: scannedLotData.lotNo, + itemCode: scannedLotData.itemCode, + itemName: scannedLotData.itemName, + } + : null + } + isLoading={isConfirmingLot} + /> +
+
+ ); +}; + +export default WorkbenchPickExecution; diff --git a/src/components/PickOrderSearch/assignTo.tsx b/src/components/PickOrderSearch/assignTo.tsx index 87ab821..21d2232 100644 --- a/src/components/PickOrderSearch/assignTo.tsx +++ b/src/components/PickOrderSearch/assignTo.tsx @@ -22,9 +22,8 @@ import { import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { - newassignPickOrder, AssignPickOrderInputs, - releaseAssignedPickOrders, + releasePickOrderWorkbenchV2, fetchPickOrderWithStockClient, // Add this import } from "@/app/api/pickOrder/actions"; import { fetchNameList, NameList } from "@/app/api/user/actions"; @@ -39,7 +38,6 @@ import dayjs from "dayjs"; import arraySupport from "dayjs/plugin/arraySupport"; import SearchBox, { Criterion } from "../SearchBox"; import { sortBy, uniqBy } from "lodash"; -import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; dayjs.extend(arraySupport); interface Props { @@ -196,7 +194,7 @@ const handleRelease = useCallback(async () => { console.log("Using assigned user:", assignToValue); console.log("selectedPickOrderIds:", selectedPickOrderIds); - const releaseRes = await releaseAssignedPickOrders({ + const releaseRes = await releasePickOrderWorkbenchV2({ pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), assignTo: assignToValue }); @@ -204,30 +202,6 @@ const handleRelease = useCallback(async () => { if (releaseRes.code === "SUCCESS") { console.log("Pick orders released successfully"); - // Get the consoCode from the response - const consoCode = (releaseRes.entity as any)?.consoCode; - - if (consoCode) { - // Create StockOutLine records for each pick order line - for (const pickOrder of selectedPickOrders) { - for (const line of pickOrder.pickOrderLines) { - try { - const stockOutLineData = { - consoCode: consoCode, - pickOrderLineId: line.id, - inventoryLotLineId: 0, // This will be set when user scans QR code - qty: line.requiredQty, - }; - - console.log("Creating stock out line:", stockOutLineData); - await createStockOutLine(stockOutLineData); - } catch (error) { - console.error("Error creating stock out line for line", line.id, error); - } - } - } - } - fetchNewPageItems(pagingController, filterArgs); } else { console.error("Release failed:", releaseRes.message); diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index c23176e..5fcafe8 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -158,17 +158,38 @@ const ProductProcessList: React.FC = ({ } const result = new Map(); + const isDone = (status: unknown) => { + const s = String(status ?? "").trim().toLowerCase(); + return s === "completed" || s === "pass"; + }; + byJobOrder.forEach((jobOrderProcesses, jobOrderId) => { const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null); - const allLinesDone = - jobOrderProcesses.length > 0 && - jobOrderProcesses.every((p) => { + + const packingProcesses = jobOrderProcesses.filter( + (p) => String((p as any).code ?? "").trim() === "包裝" + ); + const nonPackingProcesses = jobOrderProcesses.filter( + (p) => String((p as any).code ?? "").trim() !== "包裝" + ); + + const allNonPackingDone = + nonPackingProcesses.length === 0 || + nonPackingProcesses.every((p) => { + const lines = p.lines ?? []; + return lines.length > 0 && lines.every((l) => isDone(l.status)); + }); + + const hasOnePackingDone = + packingProcesses.length > 0 && + packingProcesses.some((p) => { const lines = p.lines ?? []; - // 没有 lines 的情况认为未完成,避免误放行 - return lines.length > 0 && lines.every((l) => lineDone(l.status)); + return lines.some((l) => isDone(l.status)); }); - - result.set(jobOrderId, hasStockInLine && allLinesDone); + + const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone; + + result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk); }); return result; @@ -486,7 +507,7 @@ const ProductProcessList: React.FC = ({ const finishedCount = (process.lines || []).filter( - (l) => String(l.status ?? "").trim().toLowerCase() === "completed" + (l) => String(l.status ?? "").trim().toLowerCase() === "completed" || String(l.status ?? "").trim().toLowerCase() === "pass" ).length; const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0; @@ -543,6 +564,7 @@ const ProductProcessList: React.FC = ({ {/* {t("Item Name")}: */} {process.itemCode} {process.itemName} + {process.bomDescription ? ` (${t(process.bomDescription as string)})` : ""} {t("Production Priority")}: {process.productionPriority} diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index f34d270..d10f1c4 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -31,6 +31,7 @@ import { InventoryLotDetailResponse, SaveApproverStockTakeRecordRequest, saveApproverStockTakeRecord, + batchSaveApproverStockTakeRecordsByIds, getApproverInventoryLotDetailsAllPending, getApproverInventoryLotDetailsAllApproved, updateStockTakeRecordStatusToNotMatch, @@ -745,75 +746,32 @@ const ApproverStockTakeAll: React.FC = ({ } setBatchSaving(true); - let successCount = 0; - let skippedApproverEmpty = 0; - let errorCount = 0; try { - for (const detail of sortedDetails) { - if (detail.stockTakeRecordStatus === "completed") { - continue; - } + const recordIds = sortedDetails + .map((d) => d.stockTakeRecordId) + .filter((id): id is number => typeof id === "number" && id > 0); - const built = buildApproverSaveRequest( - detail, - qtySelection, - approverQty, - approverBadQty, - currentUserId, - t - ); - if (!built.ok) { - if (built.reason === "skip_approver_empty") { - skippedApproverEmpty += 1; - continue; - } - errorCount += 1; - continue; - } - - try { - await saveApproverStockTakeRecord(built.request, selectedSession.stockTakeId); - successCount += 1; - const { goodQty, finalQty, finalBadQty, selection } = built; - setInventoryLotDetails((prev) => - prev.map((d) => - d.id === detail.id - ? { - ...d, - finalQty: goodQty, - approverQty: selection === "approver" ? finalQty : d.approverQty, - approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, - stockTakeRecordStatus: "completed", - } - : d - ) - ); - } catch (e: any) { - errorCount += 1; - let msg = e?.message || t("Failed to save approver stock take record"); - if (e?.response) { - try { - const errorData = await e.response.json(); - msg = errorData.message || errorData.error || msg; - } catch { - /* ignore */ - } - } - console.error("Batch save row failed", detail.id, msg); - } + if (recordIds.length === 0) { + onSnackbar(t("No valid records to batch save"), "warning"); + return; } + const result = await batchSaveApproverStockTakeRecordsByIds({ + stockTakeId: selectedSession.stockTakeId, + approverId: currentUserId, + recordIds, + }); + onSnackbar( - t("Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors", { - success: successCount, - skipped: skippedApproverEmpty, - errors: errorCount, + t("Batch approver save completed: {{success}} success, {{errors}} errors", { + success: result.successCount, + errors: result.errorCount, }), - errorCount > 0 ? "warning" : "success" + result.errorCount > 0 ? "warning" : "success" ); - if (appliedFilters && successCount > 0) { + if (appliedFilters && result.successCount > 0) { await loadDetails(appliedFilters); } } catch (e: any) { @@ -835,10 +793,6 @@ const ApproverStockTakeAll: React.FC = ({ mode, appliedFilters, inventoryLotDetails.length, - sortedDetails, - qtySelection, - approverQty, - approverBadQty, ]); const formatNumber = (num: number | null | undefined): string => { diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 6428f12..1e624ab 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -19,15 +19,19 @@ export const detectLanguage = async (): Promise => { {}, ); const headersList = headers(); + console.time("[i18n] detectLanguage total"); + console.time("[i18n] getServerSession"); const session = await getServerSession(authOptions); - + console.timeEnd("[i18n] getServerSession"); + console.time("[i18n] universalLanguageDetect"); const lang = universalLanguageDetect({ supportedLanguages: SUPPORTED_LANGUAGES, fallbackLanguage: FALLBACK_LANG, acceptLanguageHeader: headersList.get("accept-language") || undefined, serverCookies: cookiesObj, }); - + console.timeEnd("[i18n] universalLanguageDetect"); + console.timeEnd("[i18n] detectLanguage total"); return lang; }; @@ -38,6 +42,8 @@ const languageDetector: LanguageDetectorAsyncModule = { }; const initI18next = async (namespaces: string[]): Promise => { + const label = `[i18n] initI18next ns=${namespaces.join(",")}`; + console.time(label); const i18nInstance = createInstance(); await i18nInstance .use(languageDetector) diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 825f768..07de0c8 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -7,11 +7,23 @@ "Stock Record": "庫存記錄", "No options": "沒有選項", "Drink": "飲料", + "packaging": "提料中", "Issue BOM List": "問題 BOM 列表", "File Name": "檔案名稱", "Please Select BOM": "請選擇 BOM", + "Plan Start": "預計生產日期", + "Floor": "樓層", + "Job Order Type": "工單類型", + + "FG": "成品", + "WIP": "半成品", + "BOM Type": "BOM 類型", "No Lot": "沒有批號", "Select All": "全選", + "Do Workbench": "新版成品出倉", + "DO Workbench": "新版成品出倉", + "storing": "待品檢入倉", + "Submit Qty": "提交數量", "Waiting QC Put Away Job Orders": "待QC上架工單", "Put Awayed Job Orders": "已上架工單", "Loading BOM Detail...": "正在載入 BOM 明細…", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index 2ec0049..27ee7b5 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -9,11 +9,16 @@ "Estimated Arrival From": "預計送貨日期", "Estimated Arrival To": "預計送貨日期至", "Status": "來貨狀態", + "DO Workbench": "新版成品出倉", "Order Date From": "訂單日期", + "Workbench Batch Release": "批量放單", + "do workbench": "新版成品出倉", + "Do Workbench": "新版成品出倉", "Delivery Order Code": "送貨訂單編號", "Truck Lance Code": "車線號碼", "Select Remark": "選擇備註", "Confirm Assignment": "確認分配", + "Submit Qty": "提交數量", "Required Date": "所需日期", "Submit Miss Item": "提交缺貨品", "Submit Quantity": "提交數量", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 9885ad9..ed7c798 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -8,8 +8,10 @@ "Process": "工序", "Create Job Order": "建立工單", "Code": "工單編號", + "storing": "待品檢入倉", "Name": "成品/半成品名稱", "Picked Qty": "已提料數量", + "Insufficient available quantity on lot (may have been picked by another user)": "掃描的批次已被其他用戶完全提料。請掃描其他批次。", "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有QR碼,可能是剛剛入庫或轉移入庫或轉移出庫。", "is expired. Please check around have available QR code or not.": "已過期。請檢查周圍是否有可用的 QR 碼。", "Confirm All": "確認所有提料", @@ -113,7 +115,8 @@ "Today": "今天", "Yesterday": "昨天", "Two Days Ago": "前天", - "Item Code": "成品/半成品編號", + "Item Code": "物料編號", + "Floor": "樓層", "Paused": "已暫停", "paused": "已暫停", "Total pick orders": "總提料單數量", @@ -166,6 +169,7 @@ "Job Order Code": "工單編號", "View Details": "查看詳情", "Skip": "跳過", + "packaging": "提料中", "Handler": "提料員", "RELEASED": "已放單", "Released": "已放單", @@ -385,6 +389,7 @@ "success": "成功", "Total (Verified + Bad + Missing) must equal Required quantity": "驗證數量 + 不良數量 + 缺失數量必須等於需求數量", "BOM Status": "材料預備狀況", + "Job Order Type": "工單類型", "Estimated Production Date": "預計生產日期", "Plan Start": "預計生產日期", "Plan Start From": "預計生產日期", @@ -597,5 +602,8 @@ "seq": "序號", "Handled By": "處理者", "Job Order Pick Execution": "工單提料", + "BOM Type": "BOM 類型", + "BOM Description": "BOM 說明", + "Floor": "樓層", "Finish": "完成" } diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index f067210..a1c2d5a 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -9,7 +9,13 @@ "Status": "來貨狀態", "N/A": "不適用", "Release Pick Orders": "放單", + "released": "已放單", + "Loading...": "載入中...", + "Suggestion success": "建議成功", + "Scan pick success": "掃描提料成功", "Remark": "備註", + "Available Qty": "可用數量", + "Picked Qty": "已提料數量", "Escalated": "上報狀態", "NotEscalated": "無上報", "Assigned To": "已分配", @@ -27,6 +33,8 @@ "Lane Code": "車線號碼", "Fetching all matching records...": "正在獲取所有匹配的記錄...", "Edit": "改數", + "Submit Qty": "提交數量", + "Suggestion success": "建議成功", "Just Completed": "已完成", "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.": "已完成(工作台):需有效批號與可提交數量;過期列請勿使用此按鈕。", "Do you want to start?": "確定開始嗎?", @@ -254,7 +262,12 @@ "The input is not the same as the expected lot number.": "輸入的批次號碼與預期的不符。", "Verified successfully!": "驗證成功!", "Cancel": "取消", + "storing": "待品檢入倉", + "pick successful": "提料成功", + "Suggestion success": "建議成功", + "Insufficient available quantity on lot (may have been picked by another user)": "掃描的批次已被其他用戶完全提料。請掃描其他批次。", "Scan": "掃描", + "Before today": "今天之前", "Scanned": "已掃描", "Loading data...": "正在載入數據...", "No available stock for this item": "沒有可用庫存", @@ -274,6 +287,7 @@ "Selected items will join above created group": "已選擇的貨品將加入以上建立的分組", "Issue":"問題", "Pick Execution Issue Form":"提料問題表單", + "Lot line is unavailable":"掃描批次不可用", "This form is for reporting issues only. You must report either missing items or bad items.":"此表單僅用於報告問題。您必須報告缺少的貨品或不良貨品。", "Bad item Qty":"不良貨品數量", "Missing item Qty":"貨品遺失數量", @@ -449,11 +463,15 @@ "No entries available": "該樓層未有需處理訂單", "Today": "是日", "Tomorrow": "翌日", + "packaging": "提料中", "No Stock Available": "沒有庫存可用", "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", - "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有 QR 碼,可能有剛剛入庫或轉移入庫 QR 碼。", + "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有 QR 碼,可能有剛剛入庫、轉移入庫或轉移出庫的 QR 碼。", + "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", "Day After Tomorrow": "後日", + "Lot line is unavailable": "掃描批次不可用", "Select Date": "請選擇日期", + "Suggest Lot No.": "推薦批號", "Search by Shop": "搜尋商店", "Search by Truck": "搜尋貨車", "Print DN & Label": "列印提料單和送貨單標籤", @@ -497,5 +515,7 @@ "SuggestedPickLot qty is invalid: {{qty}}": "建議揀貨數量無效:{{qty}}。", "Reject switch lot: available {{available}} less than required {{required}}": "此批次貨品已被其他送貨單留起,請掃描其他批次。", "Reject switch lot: picked {{picked}} already greater or equal required {{required}}": "換批被拒:已揀數量({{picked}})已達或超過建議量({{required}}),無法再拆分換批。", - "Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。" + "Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。", + "No lot rows. Select a line in the table above.": "尚無批號資料。請在上方表格勾選一行提料單明細。", + "No stock out line for this lot": "此批號尚無出庫行,無法提交。" } \ No newline at end of file