From d90a57cb165c6cdf9258d332eea8dd762242dd3f Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sun, 26 Apr 2026 00:23:41 +0800 Subject: [PATCH] update job order list part , can use record part again. and updated but not yet finish consuble pick order --- src/app/api/pickOrder/actions.ts | 6 +- src/components/Jodetail/JodetailSearch.tsx | 3 +- .../NavigationContent/NavigationContent.tsx | 9 +- .../WorkbenchPickExecution.tsx | 1517 ++++++++++++++--- 4 files changed, 1298 insertions(+), 237 deletions(-) diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index 7def07b..f32018c 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -927,11 +927,12 @@ export const resuggestPickOrder = async (pickOrderId: number) => { * 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) => { +export const suggestPickOrderWorkbenchV2 = async (pickOrderId: number, userId: number) => { const result = await serverFetchJson( - `${BASE_API_URL}/suggestedPickLot/resuggest/${pickOrderId}`, + `${BASE_API_URL}/pickOrder/workbench/suggest-v2/${pickOrderId}`, { method: "POST", + body: JSON.stringify({ userId }), headers: { "Content-Type": "application/json" }, }, ); @@ -1105,6 +1106,7 @@ export interface PickOrderLotDetailResponse { lotNo: string | null; // ✅ 改为可空 expiryDate: string | null; // ✅ 改为可空 location: string | null; // ✅ 改为可空 + itemId: number | null; stockUnit: string | null; inQty: number | null; availableQty: number | null; // ✅ 改为可空 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 203feae..2b95b4c 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -175,7 +175,7 @@ const NavigationContent: React.FC = () => { icon: , label: "Job Order Pickexcution", requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN], - path: "/jo/workbench", + path: "/jodetail", }, { icon: , @@ -398,7 +398,12 @@ const NavigationContent: React.FC = () => { if (pathname === "/productionProcess" || pathname.startsWith("/productionProcess/")) { ensureOpen.push("Management Job Order"); } - if (pathname === "/jo/workbench" || pathname.startsWith("/jo/workbench/")) { + if ( + pathname === "/jo/workbench" || + pathname.startsWith("/jo/workbench/") || + pathname === "/jodetail" || + pathname.startsWith("/jodetail/") + ) { ensureOpen.push("Management Job Order"); } if ( diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 58e6c6b..87fed55 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -1,12 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, + Box, Button, Checkbox, CircularProgress, Grid, + Modal, Paper, Stack, Table, @@ -23,21 +25,43 @@ import { useSession } from "next-auth/react"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; import arraySupport from "dayjs/plugin/arraySupport"; +import SearchBox, { Criterion } from "../SearchBox"; +import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; import { SessionWithTokens } from "@/config/authConfig"; import { fetchPickOrderWithStockClient, fetchWorkbenchPickOrderLineDetailV2, + confirmLotSubstitution, suggestPickOrderWorkbenchV2, type PickOrderLotDetailResponse, + updateStockOutLineStatusByQRCodeAndLotNo, } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils"; -import SearchBox, { Criterion } from "../SearchBox"; -import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; +import { fetchStockInLineInfo } from "@/app/api/po/actions"; +import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; +import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; +import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; +import ScanStatusAlert from "@/components/common/ScanStatusAlert"; dayjs.extend(arraySupport); -type Row = { +type TopRow = { + rowKey: string; + pickOrderId: number; + pickOrderLineId: number; + pickOrderCode: string; + itemCode: string; + itemName: string; + requiredQty: number; + currentStock: number; + pickedQty: number; + stockUnit: string; + targetDate: string | number[]; + status: string; +}; + +type LotRow = { key: string; pickOrderId: number; pickOrderLineId: number; @@ -47,48 +71,166 @@ type Row = { uomDesc: string; requiredQty: number; availableQty: number; - originalAvailableQty: number; - expiryDate: string; - location: string; stockOutLineId: number; status: string; pickedQty: number; lotNo: string; + location: string; + itemId?: number; stockInLineId?: number; + suggestedPickLotId?: number; + lotAvailability?: string; + lotStatus?: string; + expiryDate?: string; + stockOutLineRejectMessage?: string; }; -type TopRow = { - rowKey: string; - pickOrderId: number; - pickOrderLineId: number; - pickOrderCode: string; +type ConfirmLotState = { + lotNo: string; itemCode: string; itemName: string; - requiredQty: number; - currentStock: number; - pickedQty: number; - stockUnit: string; - targetDate: string | number[]; - status: string; + stockInLineId?: number; + row: LotRow; +}; + +type LotRowIndexes = { + byItemId: Map; + byStockInLineId: Map; + activeLotsByItemId: Map; +}; + +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) return; + setExpectedLotInput(expectedLot?.lotNo || ""); + setScannedLotInput(scannedLot?.lotNo || ""); + setError(""); + }, [expectedLot, open, scannedLot]); + return ( + + + + {t("Manual Lot Confirmation")} + + { + setExpectedLotInput(e.target.value); + setError(""); + }} + sx={{ mb: 2 }} + /> + { + setScannedLotInput(e.target.value); + setError(""); + }} + /> + {error ? ( + + + {error} + + + ) : null} + + + + + + + ); }; interface Props { - filterArgs?: Record; + filterArgs?: Record; } -const toNum = (v: unknown, d = 0) => { +const toNum = (v: unknown, d = 0): number => { const n = Number(v); return Number.isFinite(n) ? n : d; }; -const toStr = (v: unknown) => (typeof v === "string" ? v : ""); + +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 s = typeof targetDate === "string" ? targetDate : String(targetDate ?? ""); - const d = dayjs(s); + const value = typeof targetDate === "string" ? targetDate : String(targetDate ?? ""); + const d = dayjs(value); return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : "-"; } catch { return "-"; @@ -106,17 +248,9 @@ function lineHasStockOutOrSuggestion(details: PickOrderLotDetailResponse[]): boo function mapLotDetailsToRows( details: PickOrderLotDetailResponse[], - ctx: { - pickOrderId: number; - pickOrderLineId: number; - pickOrderCode: string; - itemCode: string; - itemName: string; - }, -): Row[] { + ctx: { pickOrderId: number; pickOrderLineId: number; pickOrderCode: string; itemCode: string; itemName: string }, +): LotRow[] { return details.map((d, i) => { - const inQty = toNum(d.inQty); - const outQty = toNum(d.outQty); const solId = toNum(d.stockOutLineId); const lotId = toNum(d.lotId, i); return { @@ -129,13 +263,18 @@ function mapLotDetailsToRows( uomDesc: toStr(d.stockUnit), requiredQty: toNum(d.requiredQty), availableQty: toNum(d.remainingAfterAllPickOrders ?? d.availableQty), - originalAvailableQty: inQty - outQty, - expiryDate: toStr(d.expiryDate), - location: toStr(d.location), 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: lotId > 0 ? lotId : 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), }; }); } @@ -148,11 +287,8 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { 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 [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10 }); + const [totalCountItems, setTotalCountItems] = useState(0); const [selectedPickOrderLineId, setSelectedPickOrderLineId] = useState(null); const [selectedPickOrderId, setSelectedPickOrderId] = useState(null); @@ -162,65 +298,128 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { itemName: string; } | null>(null); - const [rows, setRows] = useState([]); - const [qtyBySolId, setQtyBySolId] = useState>({}); + 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: Record, extra: Record) => { + async (paging: { pageNum: number; pageSize: number }, extra: Record) => { if (!userId) return; setPickOrderLoading(true); setError(""); try { const params = { - ...paging, ...extra, - pageNum: (paging.pageNum || 1) - 1, - pageSize: paging.pageSize || 10, + pageNum: 0, + pageSize: 9999, status: "released", type: "consumable", assignTo: userId, }; const res = await fetchPickOrderWithStockClient(params); - if (res?.records) { - const topRows: TopRow[] = res.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(topRows); - setFilteredTopRows(topRows); - setTotalCountItems(res.total ?? 0); - } else { - setOriginalTopRows([]); - setFilteredTopRows([]); - setTotalCountItems(0); - } + 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); } @@ -228,105 +427,74 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { [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", - }, + { 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) => { + (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 : ""); - - 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()); - let dateMatch = true; if (query.targetDate || query.targetDateTo) { - try { - if (!targetDate.isValid()) { - dateMatch = true; - } else if (query.targetDate && !query.targetDateTo) { - const fromDate = dayjs(query.targetDate); - dateMatch = targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day"); - } else if (!query.targetDate && query.targetDateTo) { - const toDate = dayjs(query.targetDateTo); - dateMatch = targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day"); - } else if (query.targetDate && query.targetDateTo) { - const fromDate = dayjs(query.targetDate); - const toDate = dayjs(query.targetDateTo); - dateMatch = - (targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day")) && - (targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day")); - } - } catch { - dateMatch = true; + 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]); - const handlePageChange = useCallback((event: unknown, newPage: number) => { - setPagingController((prev) => ({ - ...prev, - pageNum: newPage + 1, - })); - }, []); - - const handlePageSizeChange = useCallback((event: React.ChangeEvent) => { - const newPageSize = parseInt(event.target.value, 10); - setPagingController({ - pageNum: 1, - pageSize: newPageSize, - }); - }, []); - useEffect(() => { - if (userId) { - fetchNewPageItems(pagingController, filterArgs || {}); - } - }, [userId, pagingController, filterArgs, fetchNewPageItems]); + if (userId) fetchNewPageItems(pagingController, (filterArgs || {}) as Record); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, filterArgs, fetchNewPageItems]); const loadLineDetailV2 = useCallback( async ( @@ -341,20 +509,18 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { try { let details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); let list = Array.isArray(details) ? details : []; - if (!lineHasStockOutOrSuggestion(list)) { - const suggestRes = await suggestPickOrderWorkbenchV2(pickOrderId); + const suggestRes = await suggestPickOrderWorkbenchV2(pickOrderId, userId); if (suggestRes.code !== "SUCCESS") { setError(suggestRes.message || t("Suggest pick failed")); - setRows([]); + setLotRows([]); return; } details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId); list = Array.isArray(details) ? details : []; setMessage(suggestRes.message || t("Suggestion created")); } - - setRows( + setLotRows( mapLotDetailsToRows(list, { pickOrderId, pickOrderLineId, @@ -363,10 +529,11 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { itemName: meta.itemName, }), ); + setQtyEditableBySolId({}); } catch (e) { console.error(e); setError(t("Load workbench data failed")); - setRows([]); + setLotRows([]); } finally { setLoading(false); } @@ -375,38 +542,46 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { ); const submitRow = useCallback( - async (row: Row, forceQty?: number, forceLotNo?: string) => { + 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 ?? (qtyInput === "" || qtyInput == null ? undefined : Number(qtyInput)); - const lotNo = (forceLotNo ?? row.lotNo).trim(); + 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.stockInLineId ? { stockInLineId: row.stockInLineId } : {}), + lotNo: row.lotNo.trim(), ...(typeof qtyValue === "number" && Number.isFinite(qtyValue) ? { qty: qtyValue } : {}), userId, }); if (res.code !== "SUCCESS") { setError((res.message as string) || t("Scan pick failed")); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg((res.message as string) || t("Scan pick failed")); + }); return; } setMessage((res.message as string) || t("Scan pick success")); + startTransition(() => { + setQrScanError(false); + setQrScanSuccess(true); + setQrScanSuccessMsg((res.message as string) || t("Scan pick success")); + }); if (workbenchScanPickResponseNeedsFullRefresh(res)) { if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); } } else { const entity = res.entity as any; - setRows((prev) => + setLotRows((prev) => prev.map((r) => r.stockOutLineId === row.stockOutLineId ? { ...r, status: toStr(entity?.status || r.status), pickedQty: toNum(entity?.qty, r.pickedQty) } @@ -414,14 +589,104 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { ), ); } + 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, selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta, t, userId], + [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( @@ -431,20 +696,20 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { setSelectedPickOrderLineId(null); setSelectedPickOrderId(null); setSelectedTopMeta(null); - setRows([]); + 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, - }); - setRows([]); + setSelectedTopMeta({ pickOrderCode: row.pickOrderCode, itemCode: row.itemCode, itemName: row.itemName }); + setLotRows([]); setQtyBySolId({}); + setQtyEditableBySolId({}); + setLotPagingController({ pageNum: 0, pageSize: 10 }); setMessage(""); await loadLineDetailV2(row.pickOrderId, row.pickOrderLineId, { pickOrderCode: row.pickOrderCode, @@ -455,8 +720,641 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { [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, + stockInLineId: 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); + } + } else { + const res = await updateStockOutLineStatusByQRCodeAndLotNo({ + pickOrderLineId: expected.row.pickOrderLineId, + inventoryLotNo: scanned.lotNo, + stockInLineId: scanned.stockInLineId ?? null, + stockOutLineId: expected.row.stockOutLineId, + itemId: Number(expected.row.itemId ?? 0), + status: "checked", + }); + switchedToUnavailable = res.code === "BOUND_UNAVAILABLE"; + if (res.code !== "SUCCESS" && res.code !== "checked" && !switchedToUnavailable) { + const msg = (res.message as string) || t("Lot switch failed"); + setLotConfirmationError(msg); + setError(msg); + startTransition(() => { + setQrScanError(true); + setQrScanSuccess(false); + setQrScanErrorMsg(msg); + }); + return; + } + } + + 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() !== "" + } + > + @@ -465,7 +1363,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { {pickOrderLoading ? ( ) : ( - + @@ -482,37 +1380,35 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { - {filteredTopRows.map((row) => ( - - - handleLineSelect(row, e.target.checked)} - disabled={loading} - /> - - {row.pickOrderCode} - {row.itemCode} - {row.itemName} - {row.requiredQty} - - {row.currentStock.toLocaleString()} - - {row.pickedQty} - {row.stockUnit || "-"} - {safeDisplayTargetDate(row.targetDate)} - {row.status || "-"} - - ))} - {filteredTopRows.length === 0 ? ( + {paginatedTopRows.length === 0 ? ( - {t("No released consumable assigned to current user")} + {t("No data available")} - ) : null} + ) : ( + 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)} + {row.status || "-"} + + )) + )}
@@ -521,16 +1417,18 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { 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")} - labelDisplayedRows={({ from, to, count }) => - `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` - } /> @@ -543,7 +1441,12 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { {t("Loading")}
) : null} - + {error ? {error} : null} {message ? {message} : null} @@ -552,55 +1455,123 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { - {t("Lot#")} - {t("Lot Expiry Date")} - {t("Lot Location")} - {t("Stock Unit")} + {t("Index")} + {t("Item Code")} + {t("Route")} + {t("Lot No")} {t("Lot Required Pick Qty")} - {t("Original Available Qty")} - {t("Lot Actual Pick Qty")} - {t("Remaining Available Qty")} - {t("Action")} + {t("Available Qty")} + {t("Scan Result")} + {t("Qty will submit")} + {t("Submit Required Pick Qty")} - {rows.map((r) => ( + {paginatedLotRows.map((r, idx) => ( - {r.lotNo || "-"} - {r.expiryDate || "-"} + {idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""} + + {idx === 0 ? ( + <> + {r.itemCode || "-"}
+ {r.itemName || "-"}
+ {r.uomDesc || "-"} + + ) : ( + "" + )} +
{r.location || "-"} - {r.uomDesc || "-"} - {r.requiredQty} - {r.originalAvailableQty.toLocaleString()} + + + {r.lotNo || "-"} + {r.stockOutLineId > 0 ? ( + + ) : null} + + + {`${r.requiredQty.toLocaleString()}(${r.uomDesc || ""})`} + {`${r.availableQty.toLocaleString()}(${r.uomDesc || ""})`} - setQtyBySolId((p) => ({ ...p, [r.stockOutLineId]: e.target.value }))} - sx={{ width: 84 }} - disabled={!r.stockOutLineId} + sx={{ + color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled", + "&.Mui-checked": { + color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled", + }, + }} /> - {r.availableQty.toLocaleString()} - + + { + 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 }} + /> -
))} - {rows.length === 0 ? ( + {lotRows.length === 0 ? ( @@ -612,8 +1583,90 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => {
+ 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} + onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} + 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 })); + }} + /> + 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} + /> +
+ ); };