"use client"; import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Box, Button, Checkbox, CircularProgress, Grid, Modal, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Typography, } from "@mui/material"; 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, } from "@/app/api/pickOrder/actions"; import { workbenchScanPick } from "@/app/api/doworkbench/actions"; import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils"; 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 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; pickOrderCode: string; itemCode: string; itemName: string; uomDesc: string; requiredQty: number; availableQty: number; itemTotalAvailableQty?: number | null; 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 ConfirmLotState = { lotNo: string; itemCode: string; itemName: 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; } 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 errMsg = localizeBackendMessage(res.message, "Scan pick failed"); setError(errMsg); setQrScanErrorMsg(errMsg); const okMsg = localizeBackendMessage(res.message, "Scan pick success"); setMessage(okMsg); setQrScanSuccessMsg(okMsg); if (workbenchScanPickResponseNeedsFullRefresh(res)) { if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) { await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta); } } else { const entity = res.entity as any; setLotRows((prev) => prev.map((r) => r.stockOutLineId === row.stockOutLineId ? { ...r, status: toStr(entity?.status || r.status), pickedQty: toNum(entity?.qty, r.pickedQty) } : r, ), ); } 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;