| @@ -417,6 +417,8 @@ export async function fetchWorkbenchCompletedLotDetails( | |||
| export type WorkbenchScanPayload = { | |||
| itemId: number; | |||
| stockInLineId: number; | |||
| /** POL UomConversion id — when set, sameItemLots only include matching lot UOM */ | |||
| uomId?: number | null; | |||
| }; | |||
| export async function fetchWorkbenchPrinters() { | |||
| return serverFetchJson<any[]>(`${BASE_API_URL}/printers`, { | |||
| @@ -433,9 +435,16 @@ export async function analyzeWorkbenchQrCode(payload: WorkbenchScanPayload) { | |||
| }); | |||
| } | |||
| export async function fetchWorkbenchAvailableLotsByItem(itemId: number) { | |||
| export async function fetchWorkbenchAvailableLotsByItem( | |||
| itemId: number, | |||
| uomId?: number | null, | |||
| ) { | |||
| const qs = | |||
| uomId != null && Number.isFinite(Number(uomId)) && Number(uomId) > 0 | |||
| ? `?uomId=${Number(uomId)}` | |||
| : ""; | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}`, | |||
| `${BASE_API_URL}/inventoryLotLine/workbench/available-lots-by-item/${itemId}${qs}`, | |||
| { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| @@ -23,6 +23,7 @@ export interface LotLineInfo { | |||
| lotNo: string; | |||
| remainingQty: number; | |||
| uom: string; | |||
| uomId: number; | |||
| } | |||
| export interface SearchInventoryLotLine extends Pageable { | |||
| @@ -170,7 +171,10 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||
| export const fetchInventories = cache(fetchInventoriesImpl); | |||
| /** Inventory search page: latest inventory row per item (no baseUnit/uomId filter). */ | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 | |||
| * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | |||
| */ | |||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | |||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | |||
| @@ -95,6 +95,30 @@ export interface ItemWithDetails { | |||
| uomDesc: string; | |||
| currentStockBalance: number; | |||
| } | |||
| export interface PickUomOption { | |||
| uomId: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| } | |||
| export interface AvailablePickUomsResponse { | |||
| itemId: number; | |||
| stockUom: PickUomOption | null; | |||
| options: PickUomOption[]; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 35 | v1.0.1 | 2026-07-22 */ | |||
| export const fetchAvailablePickUoms = cache(async (itemId: number) => { | |||
| return serverFetchJson<AvailablePickUomsResponse>( | |||
| `${BASE_API_URL}/items/${itemId}/available-pick-uoms`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["items"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchItemsWithDetails = cache(async (searchParams?: Record<string, any>) => { | |||
| if (searchParams) { | |||
| const queryString = new URLSearchParams(searchParams).toString(); | |||
| @@ -551,6 +551,7 @@ function saveIssuePickedMap(doPickOrderId: number, map: Record<number, number>) | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 29 | v1.0.1 | 2026-07-22 */ | |||
| const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | |||
| filterArgs, | |||
| onSwitchToRecordTab, | |||
| @@ -1076,6 +1077,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemId: line.item.id, | |||
| itemCode: line.item.code, | |||
| itemName: line.item.name, | |||
| uomId: line.item.uomId ?? null, | |||
| uomDesc: line.item.uomDesc, | |||
| uomShortDesc: line.item.uomShortDesc, | |||
| @@ -1147,6 +1149,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| itemId: line.item.id, | |||
| itemCode: line.item.code, | |||
| itemName: line.item.name, | |||
| uomId: line.item.uomId ?? null, | |||
| uomDesc: line.item.uomDesc, | |||
| uomShortDesc: line.item.uomShortDesc, | |||
| @@ -1688,14 +1691,91 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| }); | |||
| return; | |||
| } | |||
| // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | |||
| const lookupStartTime = performance.now(); | |||
| const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| let activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | |||
| // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected | |||
| const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||
| let allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||
| const lookupTime = performance.now() - lookupStartTime; | |||
| console.log(` [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); | |||
| // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs | |||
| // (same item may have multiple lines with different UOMs on one pick order) | |||
| let scannedUomId = 0; | |||
| const allowedUomIds = new Set( | |||
| allLotsForItem | |||
| .map((l: any) => Number(l?.uomId)) | |||
| .filter((id: number) => Number.isFinite(id) && id > 0), | |||
| ); | |||
| const hasLineUoms = allowedUomIds.size > 0; | |||
| try { | |||
| const lotDetail = await fetchLotDetail(scannedStockInLineId); | |||
| scannedUomId = Number(lotDetail?.uomId) || 0; | |||
| } catch (e) { | |||
| if (hasLineUoms) { | |||
| console.warn( | |||
| "[QR PROCESS] lot-detail UOM check failed; rejecting scan", | |||
| e, | |||
| ); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| } | |||
| if ( | |||
| hasLineUoms && | |||
| (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) | |||
| ) { | |||
| console.warn( | |||
| ` [QR PROCESS] UOM mismatch: allowed=[${Array.from(allowedUomIds).join(",")}], scannedLot=${scannedUomId}, stockInLineId=${scannedStockInLineId}`, | |||
| ); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| // Strict UOM filter — never fall back to other UOM lines | |||
| if (scannedUomId > 0 && hasLineUoms) { | |||
| allLotsForItem = allLotsForItem.filter( | |||
| (l: any) => Number(l?.uomId) === scannedUomId, | |||
| ); | |||
| activeSuggestedLots = activeSuggestedLots.filter( | |||
| (l: any) => Number(l?.uomId) === scannedUomId, | |||
| ); | |||
| if (allLotsForItem.length === 0) { | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg( | |||
| t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ), | |||
| ); | |||
| }); | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.delete(latestQr); | |||
| return; | |||
| } | |||
| } | |||
| // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | |||
| // This allows users to scan other lots even when all suggested lots are rejected | |||
| @@ -2646,7 +2726,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO | |||
| const qrDetectionStartTime = performance.now(); | |||
| console.log(` [QR DETECTION] Latest QR detected: ${latestQr?.substring(0, 50)}...`); | |||
| console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); | |||
| //console.log(` [QR DETECTION] Detection time: ${new Date().toISOString()}`); | |||
| console.log(` [QR DETECTION] Time since QR scanner set value: ${(qrDetectionStartTime - qrValuesChangeStartTime).toFixed(2)}ms`); | |||
| const qrPayload = parseWorkbenchScanQrPayload(latestQr); | |||
| @@ -3457,7 +3537,7 @@ const handleStartScan = useCallback(() => { | |||
| resetScan(); | |||
| lastConsumedQrValuesLengthRef.current = 0; | |||
| }, [stopScan, resetScan]); | |||
| // ... existing code around line 1469 ... | |||
| const handlelotnull = useCallback(async (lot: any) => { | |||
| // 优先使用 stockouts 中的 id,如果没有则使用 stockOutLineId | |||
| const stockOutLineId = lot.stockOutLineId; | |||
| @@ -4106,7 +4186,7 @@ paginatedData.map((row, index) => { | |||
| ? Number(fromPickRow) | |||
| : lockedSubmitQtyDisplay; | |||
| // 检查是否是 issue lot | |||
| const isIssueLot = lot.stockOutLineStatus === 'rejected' || !lot.lotNo; | |||
| const rejectDisplay = buildLotRejectDisplayMessage(lot, scanRejectMessageBySolId, t); | |||
| const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); | |||
| @@ -4131,14 +4211,20 @@ paginatedData.map((row, index) => { | |||
| }} | |||
| > | |||
| <TableCell> | |||
| <Typography variant="body2" fontWeight="bold"> | |||
| <Typography variant="body1" fontWeight="bold"> | |||
| {row.isGroupFirst ? row.groupDisplayIndex : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell>{row.isGroupFirst ? lot.itemCode : ""}</TableCell> | |||
| <TableCell> | |||
| <Typography variant="body1"> | |||
| {row.isGroupFirst ? lot.itemCode : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body1"> | |||
| {row.isGroupFirst ? lot.itemName + '(' + lot.stockUnit + ')' : ""} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| @@ -4146,7 +4232,7 @@ paginatedData.map((row, index) => { | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={1} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| {(() => { | |||
| const hasLotNo = Boolean(lot.lotNo); | |||
| @@ -4165,7 +4251,7 @@ paginatedData.map((row, index) => { | |||
| : 'inherit'; | |||
| return ( | |||
| <Typography | |||
| variant={isIssueText ? "body2" : "body1"} | |||
| variant={isIssueText ? "body2" : "body2"} | |||
| sx={{ | |||
| color: textColor, | |||
| whiteSpace: 'pre-wrap', | |||
| @@ -4213,7 +4299,7 @@ paginatedData.map((row, index) => { | |||
| sx={{ | |||
| flexShrink: 0, | |||
| fontSize: "0.75rem", | |||
| py: 0.25, | |||
| //py: 0.25, | |||
| minWidth: "auto", | |||
| px: 1, | |||
| whiteSpace: "nowrap", | |||
| @@ -4556,6 +4642,12 @@ paginatedData.map((row, index) => { | |||
| ).trim() || null | |||
| : null | |||
| } | |||
| expectedUomId={ | |||
| workbenchLotLabelContextLot != null && | |||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||
| ? Number(workbenchLotLabelContextLot.uomId) | |||
| : null | |||
| } | |||
| disableScanPick={workbenchLotLabelScanPickDisabled} | |||
| onWorkbenchScanPick={handleWorkbenchLotLabelScanPick} | |||
| submitQty={workbenchLotLabelSubmitQty} | |||
| @@ -63,6 +63,8 @@ type QrCodeAnalysisResponse = { | |||
| inventoryLotLineId: number; | |||
| warehouseCode?: string | null; | |||
| warehouseName?: string | null; | |||
| uom?: string | null; | |||
| uomId?: number | null; | |||
| } | null; | |||
| sameItemLots: Array<{ | |||
| lotNo: string; | |||
| @@ -70,6 +72,7 @@ type QrCodeAnalysisResponse = { | |||
| stockInLineId?: number | null; | |||
| availableQty: number; | |||
| uom: string; | |||
| uomId?: number | null; | |||
| warehouseCode?: string | null; | |||
| warehouseName?: string | null; | |||
| }>; | |||
| @@ -95,6 +98,8 @@ export interface WorkbenchLotLabelPrintModalProps { | |||
| /** 提貨台表格列上的可用量/單位(API 的 sameItemLots 不含掃描行,需補上才能顯示「目前這筆」) */ | |||
| triggerLotAvailableQty?: number | null; | |||
| triggerLotUom?: string | null; | |||
| /** POL UomConversion id — workbench lot list only returns matching UOM */ | |||
| expectedUomId?: number | null; | |||
| /** 此出庫行已掃碼/已完成時為 true,停用所有「掃碼提貨」(仍可列印標籤) */ | |||
| disableScanPick?: boolean; | |||
| /** | |||
| @@ -141,6 +146,7 @@ function isLabelPrinter(p: Printer): boolean { | |||
| return s.includes("label") && !s.includes("a4"); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ | |||
| const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -155,6 +161,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| hideTriggeredLot = false, | |||
| triggerLotAvailableQty = null, | |||
| triggerLotUom = null, | |||
| expectedUomId = null, | |||
| disableScanPick = false, | |||
| onWorkbenchScanPick, | |||
| submitQty = null, | |||
| @@ -257,13 +264,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| if (id != null) setSelectedPrinterId(id); | |||
| }, [open, printers, selectedPrinterId, pickDefaultPrinterId]); | |||
| const resolveExpectedUomId = useCallback((): number | null => { | |||
| const n = Number(expectedUomId); | |||
| return Number.isFinite(n) && n > 0 ? n : null; | |||
| }, [expectedUomId]); | |||
| const analyzePayload = useCallback( | |||
| async (payload: ScanPayload) => { | |||
| setLastPayload(payload); | |||
| setScanError(null); | |||
| setAnalysisLoading(true); | |||
| try { | |||
| const data = (await analyzeWorkbenchQrCode(payload)) as QrCodeAnalysisResponse; | |||
| const uomId = resolveExpectedUomId(); | |||
| const data = (await analyzeWorkbenchQrCode({ | |||
| ...payload, | |||
| ...(uomId != null ? { uomId } : {}), | |||
| })) as QrCodeAnalysisResponse; | |||
| setAnalysis(data); | |||
| setSnackbar({ | |||
| open: true, | |||
| @@ -277,7 +293,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setAnalysisLoading(false); | |||
| } | |||
| }, | |||
| [], | |||
| [resolveExpectedUomId], | |||
| ); | |||
| const analyzeByItem = useCallback( | |||
| @@ -290,7 +306,11 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setScanError(null); | |||
| setAnalysisLoading(true); | |||
| try { | |||
| const data = (await fetchWorkbenchAvailableLotsByItem(itemId)) as { | |||
| const uomId = resolveExpectedUomId(); | |||
| const data = (await fetchWorkbenchAvailableLotsByItem( | |||
| itemId, | |||
| uomId, | |||
| )) as { | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| @@ -315,7 +335,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| setAnalysisLoading(false); | |||
| } | |||
| }, | |||
| [], | |||
| [resolveExpectedUomId], | |||
| ); | |||
| const handleAnalyze = useCallback(async () => { | |||
| @@ -381,30 +401,51 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = | |||
| Number.isFinite(tableQty) && tableQty >= 0 ? tableQty : 0; | |||
| const fromApi = Number(scannedRow?.availableQty ?? 0); | |||
| const scanned = analysis.scanned; | |||
| const scannedLot = scannedLotLineId | |||
| ? { | |||
| lotNo: scanned?.lotNo ?? "", | |||
| inventoryLotLineId: scannedLotLineId, | |||
| stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, | |||
| availableQty: Math.max(fromApi, fromTable) as number, | |||
| uom: (scannedRow?.uom ?? triggerLotUom ?? "") as string, | |||
| warehouseCode: | |||
| scanned?.warehouseCode ?? scannedRow?.warehouseCode, | |||
| warehouseName: | |||
| scanned?.warehouseName ?? scannedRow?.warehouseName, | |||
| _scanned: true as const, | |||
| } | |||
| : null; | |||
| const expectUom = Number(expectedUomId); | |||
| const hasExpectUom = Number.isFinite(expectUom) && expectUom > 0; | |||
| const scannedUomId = Number(scanned?.uomId ?? scannedRow?.uomId ?? 0); | |||
| const scannedUomOk = | |||
| !hasExpectUom || | |||
| !Number.isFinite(scannedUomId) || | |||
| scannedUomId <= 0 || | |||
| scannedUomId === expectUom; | |||
| const scannedLot = | |||
| scannedLotLineId && scannedUomOk | |||
| ? { | |||
| lotNo: scanned?.lotNo ?? "", | |||
| inventoryLotLineId: scannedLotLineId, | |||
| stockInLineId: Number(scanned?.stockInLineId ?? 0) || null, | |||
| availableQty: Math.max(fromApi, fromTable) as number, | |||
| uom: (scanned?.uom ?? scannedRow?.uom ?? triggerLotUom ?? "") as string, | |||
| uomId: scannedUomId > 0 ? scannedUomId : null, | |||
| warehouseCode: | |||
| scanned?.warehouseCode ?? scannedRow?.warehouseCode, | |||
| warehouseName: | |||
| scanned?.warehouseName ?? scannedRow?.warehouseName, | |||
| _scanned: true as const, | |||
| } | |||
| : null; | |||
| const merged = [ | |||
| ...(!hideTriggeredLot && scannedLot ? [scannedLot] : []), | |||
| ...list | |||
| .filter((x) => x.inventoryLotLineId !== scannedLotLineId) | |||
| .filter((x) => { | |||
| if (!hasExpectUom) return true; | |||
| const id = Number(x.uomId); | |||
| return !Number.isFinite(id) || id <= 0 || id === expectUom; | |||
| }) | |||
| .map((x) => ({ ...x, _scanned: false as const })), | |||
| ]; | |||
| return merged; | |||
| }, [analysis, hideTriggeredLot, triggerLotAvailableQty, triggerLotUom]); | |||
| }, [ | |||
| analysis, | |||
| hideTriggeredLot, | |||
| triggerLotAvailableQty, | |||
| triggerLotUom, | |||
| expectedUomId, | |||
| ]); | |||
| const filteredLots = useMemo(() => { | |||
| const prefix = String(warehouseCodePrefixFilter ?? "").trim(); | |||
| @@ -56,6 +56,7 @@ type SearchQuery = Partial< | |||
| >; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ | |||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const { t } = useTranslation(['inventory', 'common', 'item']); | |||
| @@ -50,7 +50,9 @@ interface ItemRow { | |||
| id: string; | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| pickOrderLineId?: number; | |||
| itemId: number; | |||
| uomId?: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| @@ -85,6 +87,7 @@ const style = { | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| @@ -167,11 +170,13 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| // const filteredRecords = res.records.filter( | |||
| // (item: any) => (item.status || "").toLowerCase() === "pending" | |||
| //); | |||
| const itemRows: ItemRow[] = res.records.map((item: any) => ({ | |||
| id: item.id, | |||
| const itemRows: ItemRow[] = res.records.map((item: any, index: number) => ({ | |||
| id: item.id ?? `${item.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`, | |||
| pickOrderId: item.pickOrderId, | |||
| pickOrderCode: item.pickOrderCode, | |||
| pickOrderLineId: item.pickOrderLineId, | |||
| itemId: item.itemId, | |||
| uomId: item.uomId, | |||
| itemCode: item.itemCode, | |||
| itemName: item.itemName, | |||
| requiredQty: item.requiredQty, | |||
| @@ -493,7 +498,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| ) : ( | |||
| groupedItems.map((group) => ( | |||
| group.items.map((item, index) => ( | |||
| <TableRow key={item.id}> | |||
| <TableRow key={item.id || `${group.pickOrderId}-${item.pickOrderLineId ?? item.itemId}-${index}`}> | |||
| {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| @@ -1,6 +1,5 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| @@ -43,15 +42,16 @@ interface Group { | |||
| interface CreatedItemsTableProps { | |||
| items: CreatedItem[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| onItemSelect: (itemId: number, checked: boolean, uomId?: number) => void; | |||
| onQtyChange: (itemId: number, qty: number, uomId?: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string, uomId?: number) => void; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| items, | |||
| groups, | |||
| @@ -65,15 +65,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedItems = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| const handleQtyChange = useCallback((itemId: number, uomId: number, value: string) => { | |||
| const numValue = Number(value); | |||
| if (!isNaN(numValue) && numValue >= 1) { | |||
| onQtyChange(itemId, numValue); | |||
| onQtyChange(itemId, numValue, uomId); | |||
| } | |||
| }, [onQtyChange]); | |||
| @@ -117,11 +116,11 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedItems.map((item) => ( | |||
| <TableRow key={item.itemId}> | |||
| <TableRow key={`${item.itemId}_${item.uomId}`}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={item.isSelected} | |||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked)} | |||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked, item.uomId)} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| @@ -134,7 +133,7 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.itemId, e.target.value)} | |||
| onChange={(e) => onGroupChange(item.itemId, e.target.value, item.uomId)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| @@ -157,14 +156,14 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2">{item.uomDesc}</Typography> | |||
| <Typography variant="body2">{item.uomDesc || item.uom || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => handleQtyChange(item.itemId, e.target.value)} | |||
| onChange={(e) => handleQtyChange(item.itemId, item.uomId, e.target.value)} | |||
| inputProps={{ | |||
| min: 1, | |||
| step: 1, | |||
| @@ -208,4 +207,4 @@ const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| ); | |||
| }; | |||
| export default CreatedItemsTable; | |||
| export default CreatedItemsTable; | |||
| @@ -1,6 +1,5 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| @@ -22,8 +21,12 @@ import dayjs from 'dayjs'; | |||
| interface SearchItemWithQty { | |||
| id: number; | |||
| /** Unique search-row key: itemId + uomId */ | |||
| rowKey: string; | |||
| label: string; | |||
| qty: number | null; | |||
| uomId?: number; | |||
| uom?: string; | |||
| currentStockBalance?: number; | |||
| uomDesc?: string; | |||
| targetDate?: string | null; | |||
| @@ -40,17 +43,18 @@ interface SearchResultsTableProps { | |||
| items: SearchItemWithQty[]; | |||
| selectedItemIds: (string | number)[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number | null) => void; | |||
| onQtyBlur: (itemId: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| isItemInCreated: (itemId: number) => boolean; | |||
| onItemSelect: (rowKey: string, checked: boolean) => void; | |||
| onQtyChange: (rowKey: string, qty: number | null) => void; | |||
| onQtyBlur: (rowKey: string) => void; | |||
| onGroupChange: (rowKey: string, groupId: string) => void; | |||
| isItemInCreated: (itemId: number, uomId?: number) => boolean; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| items, | |||
| selectedItemIds, | |||
| @@ -67,16 +71,14 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedResults = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| // Only allow numbers | |||
| const handleQtyChange = useCallback((rowKey: string, value: string) => { | |||
| if (value === "" || /^\d*\.?\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(itemId, numValue); | |||
| onQtyChange(rowKey, numValue); | |||
| } | |||
| }, [onQtyChange]); | |||
| @@ -119,50 +121,45 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedResults.map((item) => ( | |||
| <TableRow key={item.id}> | |||
| paginatedResults.map((item) => { | |||
| const rowKey = item.rowKey || `${item.id}_${item.uomId || 0}`; | |||
| const inCreated = isItemInCreated(item.id, item.uomId); | |||
| return ( | |||
| <TableRow key={rowKey}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={selectedItemIds.includes(item.id)} | |||
| onChange={(e) => onItemSelect(item.id, e.target.checked)} | |||
| disabled={isItemInCreated(item.id)} | |||
| checked={selectedItemIds.includes(rowKey) || inCreated} | |||
| onChange={(e) => onItemSelect(rowKey, e.target.checked)} | |||
| disabled={inCreated} | |||
| /> | |||
| </TableCell> | |||
| {/* Item */} | |||
| <TableCell> | |||
| <Box> | |||
| <Typography variant="body2"> | |||
| {item.label.split(' - ')[1] || item.label} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.label.split(' - ')[0] || ''} | |||
| </Typography> | |||
| </Box> | |||
| <Typography variant="body2"> | |||
| {item.label.split(' - ')[1] || item.label} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.label.split(' - ')[0] || ''} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Group */} | |||
| <TableCell> | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.id, e.target.value)} | |||
| onChange={(e) => onGroupChange(rowKey, e.target.value)} | |||
| displayEmpty | |||
| disabled={isItemInCreated(item.id)} | |||
| > | |||
| disabled={inCreated} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("No Group")}</em> | |||
| <em>{t("No Group")}</em> | |||
| </MenuItem> | |||
| {groups.map((group) => ( | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| {group.name} | |||
| </MenuItem> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </Select> | |||
| </FormControl> | |||
| </TableCell> | |||
| {/* Current Stock */} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| @@ -172,54 +169,42 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| {item.currentStockBalance?.toLocaleString()||0} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Stock Unit */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.uomDesc || "-"} | |||
| {item.uomDesc || item.uom || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {/* Order Quantity */} | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => { | |||
| const value = e.target.value; | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(item.id, numValue); | |||
| } | |||
| }} | |||
| onBlur={() => { | |||
| // Trigger auto-add check when user finishes input (clicks elsewhere) | |||
| onQtyBlur(item.id); // ← Change this to call onQtyBlur instead! | |||
| }} | |||
| inputProps={{ | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => handleQtyChange(rowKey, e.target.value)} | |||
| onBlur={() => onQtyBlur(rowKey)} | |||
| disabled={inCreated} | |||
| inputProps={{ | |||
| min: 1, | |||
| max: item.currentStockBalance || 0, | |||
| step: 1, | |||
| style: { textAlign: 'center' } | |||
| }} | |||
| sx={{ | |||
| }} | |||
| sx={{ | |||
| width: '80px', | |||
| '& .MuiInputBase-input': { | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| } | |||
| }} | |||
| disabled={isItemInCreated(item.id)} | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| {/* Target Date */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? dayjs(item.targetDate).format(OUTPUT_DATE_FORMAT) : "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -242,4 +227,4 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| ); | |||
| }; | |||
| export default SearchResultsTable; | |||
| export default SearchResultsTable; | |||
| @@ -35,6 +35,7 @@ import { | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { workbenchScanPick } from "@/app/api/doworkbench/actions"; | |||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; | |||
| import { fetchLotDetail } from "@/app/api/inventory/actions"; | |||
| import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal"; | |||
| import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider"; | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| @@ -66,6 +67,7 @@ type LineRow = { | |||
| requiredQty: number; | |||
| pickedQty: number; | |||
| stockUnit: string; | |||
| uomId?: number; | |||
| status: string; | |||
| lotsRaw: unknown[]; | |||
| }; | |||
| @@ -89,6 +91,7 @@ type LotRow = { | |||
| itemCode: string; | |||
| itemName: string; | |||
| uomDesc: string; | |||
| uomId?: number; | |||
| requiredQty: number; | |||
| pickOrderLineRequiredQty?: number; | |||
| availableQty: number; | |||
| @@ -273,6 +276,7 @@ function flattenLotsFromPickOrder(po: PickOrderTopRow): LotRow[] { | |||
| itemCode: line.itemCode, | |||
| itemName: line.itemName, | |||
| uomDesc: toStr(lot.stockUnit) || line.stockUnit, | |||
| uomId: line.uomId, | |||
| requiredQty: toNum(lot.requiredQty, line.requiredQty), | |||
| pickOrderLineRequiredQty: line.requiredQty, | |||
| availableQty: toNum(lot.availableQty), | |||
| @@ -333,6 +337,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||
| requiredQty: toNum(line?.requiredQty), | |||
| pickedQty: sumLinePickedQtyFromLots(lots), | |||
| stockUnit: toStr(item?.uomDesc ?? item?.uomCode), | |||
| uomId: toNum(item?.uomId) > 0 ? toNum(item?.uomId) : undefined, | |||
| status: toStr(line?.status), | |||
| lotsRaw: lots, | |||
| }; | |||
| @@ -351,6 +356,7 @@ function mapHierarchicalToPickOrders(data: unknown): PickOrderTopRow[] { | |||
| }); | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 28 | v1.0.1 | 2026-07-22 */ | |||
| const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| @@ -770,6 +776,16 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| processedQrCodesRef.current.add(latestQr); | |||
| }, []); | |||
| /** Soft-fail / switch-PO: allow the same QR to be processed again. */ | |||
| const releaseProcessedQr = useCallback((qr: string) => { | |||
| const key = String(qr || "").trim(); | |||
| if (!key) return; | |||
| processedQrCodesRef.current.delete(key); | |||
| if (lastProcessedQrRef.current === key) { | |||
| lastProcessedQrRef.current = ""; | |||
| } | |||
| }, []); | |||
| const openUnpickableScanLotLabelModal = useCallback( | |||
| ( | |||
| pickRow: LotRow, | |||
| @@ -1177,16 +1193,102 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return; | |||
| } | |||
| const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem; | |||
| const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0]; | |||
| // Case A: reject only if scanned lot UOM matches none of this item's POL UOMs | |||
| // (same item may have multiple lines with different UOMs on one pick order) | |||
| let scannedUomId = 0; | |||
| const allowedUomIds = new Set( | |||
| allLotsForItem | |||
| .map((l) => Number(l?.uomId)) | |||
| .filter((id) => Number.isFinite(id) && id > 0), | |||
| ); | |||
| const hasLineUoms = allowedUomIds.size > 0; | |||
| try { | |||
| const lotDetail = await fetchLotDetail(scannedStockInLineId); | |||
| scannedUomId = Number(lotDetail?.uomId) || 0; | |||
| } catch (e) { | |||
| if (hasLineUoms) { | |||
| console.warn( | |||
| "[QR PROCESS] lot-detail UOM check failed; rejecting scan", | |||
| e, | |||
| ); | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| } | |||
| if ( | |||
| hasLineUoms && | |||
| (!(scannedUomId > 0) || !allowedUomIds.has(scannedUomId)) | |||
| ) { | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| // Strict UOM filter — never fall back to other UOM lines | |||
| const filterByScannedUom = (rows: LotRow[]) => { | |||
| if (!(scannedUomId > 0) || !hasLineUoms) return rows; | |||
| return rows.filter((r) => Number(r.uomId) === scannedUomId); | |||
| }; | |||
| const activeForUom = filterByScannedUom(activeSuggestedLots); | |||
| const allForUom = filterByScannedUom(allLotsForItem); | |||
| if (hasLineUoms && scannedUomId > 0 && allForUom.length === 0) { | |||
| const msg = t( | |||
| "This lot UOM does not match the pick line. Please scan another lot.", | |||
| ); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| const expectedPool = activeForUom.length > 0 ? activeForUom : allForUom; | |||
| let expectedRow = pickExpectedRowForSubstitution(expectedPool) || allForUom[0]; | |||
| if (!expectedRow) { | |||
| 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")); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || []; | |||
| const scannedRowInItem = | |||
| let scannedRowInItem = | |||
| scannedRows.find( | |||
| (r) => | |||
| Number(r.itemId) === scannedItemId && | |||
| r.stockOutLineId > 0, | |||
| r.stockOutLineId > 0 && | |||
| (!(scannedUomId > 0) || !hasLineUoms || Number(r.uomId) === scannedUomId), | |||
| ) || | |||
| (!(scannedUomId > 0) || !hasLineUoms | |||
| ? scannedRows.find( | |||
| (r) => | |||
| Number(r.itemId) === scannedItemId && | |||
| r.stockOutLineId > 0, | |||
| ) | |||
| : undefined) || | |||
| null; | |||
| if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) { | |||
| @@ -1231,14 +1333,46 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| 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")); | |||
| // Same lot already bound to a completed/checked SOL: only reuse for another | |||
| // pending line with the SAME uomId (never cross UOM A ↔ B). | |||
| if ( | |||
| scannedRowInItem && | |||
| (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status)) | |||
| ) { | |||
| const completedPolId = Number(scannedRowInItem.pickOrderLineId); | |||
| const completedSolId = Number(scannedRowInItem.stockOutLineId); | |||
| const pendingPoolBase = activeForUom.length > 0 ? activeForUom : allForUom; | |||
| const pendingSameUom = pendingPoolBase.filter((r) => { | |||
| if (!(r.stockOutLineId > 0)) return false; | |||
| if (isCompletedStatus(r.status) || isCheckedStatus(r.status) || isRejectedStatus(r.status)) { | |||
| return false; | |||
| } | |||
| if (Number(r.stockOutLineId) === completedSolId) return false; | |||
| if (completedPolId > 0 && Number(r.pickOrderLineId) === completedPolId) return false; | |||
| // Require explicit same UOM — do not reuse across different UOM lines | |||
| if (scannedUomId > 0 && hasLineUoms) { | |||
| return Number(r.uomId) === scannedUomId; | |||
| } | |||
| // Without line UOM metadata, do not auto-reuse (avoids A/B cross-pick) | |||
| return false; | |||
| }); | |||
| return; | |||
| const nextExpected = | |||
| pickExpectedRowForSubstitution(pendingSameUom) || pendingSameUom[0] || null; | |||
| if (nextExpected) { | |||
| expectedRow = nextExpected; | |||
| // Force substitution path using scanned SIL, not the completed SOL row. | |||
| scannedRowInItem = null; | |||
| } else { | |||
| const msg = t("This lot has already been picked. Please scan another lot."); | |||
| setError(msg); | |||
| startTransition(() => { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| setQrScanErrorMsg(msg); | |||
| }); | |||
| releaseProcessedQr(latest); | |||
| return; | |||
| } | |||
| } | |||
| let scannedState: ConfirmLotState | null = null; | |||
| @@ -1292,6 +1426,7 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| pickExpectedRowForSubstitution, | |||
| lotRowIndexes, | |||
| openUnpickableScanLotLabelModal, | |||
| releaseProcessedQr, | |||
| resetScan, | |||
| submitRow, | |||
| t, | |||
| @@ -1306,10 +1441,9 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }, [isScanning, startScan, userId]); | |||
| useEffect(() => { | |||
| if (!selectedPickOrderId) { | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.clear(); | |||
| } | |||
| // Clear on any PO selection change (including PO1 → PO2), so the same lot QR can be scanned again. | |||
| lastProcessedQrRef.current = ""; | |||
| processedQrCodesRef.current.clear(); | |||
| }, [selectedPickOrderId]); | |||
| useEffect(() => { | |||
| @@ -1779,6 +1913,12 @@ const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| statusTitleSeverity={workbenchLotLabelStatusBanner.severity} | |||
| triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null} | |||
| triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null} | |||
| expectedUomId={ | |||
| workbenchLotLabelContextLot != null && | |||
| Number(workbenchLotLabelContextLot.uomId) > 0 | |||
| ? Number(workbenchLotLabelContextLot.uomId) | |||
| : null | |||
| } | |||
| submitQty={ | |||
| workbenchLotLabelContextLot?.stockOutLineId | |||
| ? Number(resolveLockedSubmitQtyDisplay(workbenchLotLabelContextLot)) | |||
| @@ -70,6 +70,8 @@ interface CreatedItem { | |||
| // Add interface for search items with quantity | |||
| interface SearchItemWithQty extends ItemCombo { | |||
| /** Unique per item+uom search row */ | |||
| rowKey: string; | |||
| qty: number | null; // Changed from number to number | null | |||
| jobOrderCode?: string; | |||
| jobOrderId?: number; | |||
| @@ -77,6 +79,10 @@ interface SearchItemWithQty extends ItemCombo { | |||
| targetDate?: string | null; // Allow null values | |||
| groupId?: number | null; // Allow null values | |||
| } | |||
| const toSearchRowKey = (itemId: number, uomId?: number | null) => | |||
| `${itemId}_${Number(uomId) > 0 ? Number(uomId) : 0}`; | |||
| interface JobOrderDetailPickLine { | |||
| id: number; | |||
| code: string; | |||
| @@ -95,8 +101,9 @@ interface Group { | |||
| } | |||
| // Move the counter outside the component to persist across re-renders | |||
| let checkboxChangeCallCount = 0; | |||
| let processingItems = new Set<number>(); | |||
| let processingItems = new Set<string>(); | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 31 | v1.0.1 | 2026-07-22 */ | |||
| const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [items, setItems] = useState<ItemCombo[]>([]); | |||
| @@ -174,6 +181,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| uom: item.uom, | |||
| uomId: 0, | |||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | |||
| rowKey: toSearchRowKey(item.id, 0), | |||
| jobOrderCode: jobOrderDetail.code, | |||
| jobOrderId: jobOrderDetail.id, | |||
| currentStockBalance: 0, // Add default value | |||
| @@ -263,49 +271,50 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ); | |||
| }, []); | |||
| // Modified handler for search item selection | |||
| // Modified handler for search item selection (legacy filteredItems path) | |||
| const handleSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||
| if (isSelected) { | |||
| const item = filteredItems.find(i => i.id === itemId); | |||
| if (!item) return; | |||
| const existingItem = createdItems.find(created => created.itemId === item.id); | |||
| if (existingItem) { | |||
| const uomId = Number(item.uomId) || 0; | |||
| if (createdItems.some((c) => c.itemId === item.id && Number(c.uomId) === uomId)) { | |||
| alert(t("Item already exists in created items")); | |||
| return; | |||
| } | |||
| // Fix the newCreatedItem creation - add missing uomDesc | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomDesc: item.uomDesc || "", // Add missing uomDesc | |||
| uomId, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, // Use item's targetDate or fallback to form's targetDate | |||
| groupId: item.groupId || undefined, // Handle null values | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| } | |||
| }, [filteredItems, createdItems, t, targetDate]); | |||
| // Handler for created item selection | |||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||
| const handleCreatedItemSelect = useCallback((itemId: number, isSelected: boolean, uomId?: number) => { | |||
| setCreatedItems(prev => | |||
| prev.map(item => | |||
| item.itemId === itemId ? { ...item, isSelected } : item | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)) | |||
| ? { ...item, isSelected } | |||
| : item | |||
| ) | |||
| ); | |||
| }, []); | |||
| const handleQtyChange = useCallback((itemId: number, newQty: number) => { | |||
| const handleQtyChange = useCallback((itemId: number, newQty: number, uomId?: number) => { | |||
| setCreatedItems(prev => | |||
| prev.map(item => | |||
| item.itemId === itemId | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)) | |||
| ? { | |||
| ...item, | |||
| qty: Math.max(1, Math.min(newQty, Math.max(0, item.currentStockBalance ?? 0))), | |||
| @@ -315,18 +324,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ); | |||
| }, []); | |||
| // Check if item is already in created items | |||
| const isItemInCreated = useCallback((itemId: number) => { | |||
| return createdItems.some(item => item.itemId === itemId); | |||
| // Check if item (+ optional uom) is already in created items | |||
| const isItemInCreated = useCallback((itemId: number, uomId?: number) => { | |||
| return createdItems.some( | |||
| (item) => | |||
| item.itemId === itemId && | |||
| (uomId == null || Number(item.uomId) === Number(uomId)), | |||
| ); | |||
| }, [createdItems]); | |||
| // 1) Created Items 行内改组:只改这一行的 groupId,并把该行 targetDate 同步为该组日期 | |||
| const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string) => { | |||
| const handleCreatedItemGroupChange = useCallback((itemId: number, newGroupId: string, uomId?: number) => { | |||
| const gid = newGroupId ? Number(newGroupId) : undefined; | |||
| const group = groups.find(g => g.id === gid); | |||
| setCreatedItems(prev => | |||
| prev.map(it => | |||
| it.itemId === itemId | |||
| it.itemId === itemId && | |||
| (uomId == null || Number(it.uomId) === Number(uomId)) | |||
| ? { | |||
| ...it, | |||
| groupId: gid, | |||
| @@ -410,27 +424,23 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| } | |||
| }, [t]); | |||
| const checkAndAutoAddItem = useCallback((itemId: number) => { | |||
| const item = secondSearchResults.find(i => i.id === itemId); | |||
| const checkAndAutoAddItem = useCallback((rowKey: string) => { | |||
| const item = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (!item) return; | |||
| // Check if item has ALL 3 conditions: | |||
| // 1. Item is selected (checkbox checked) | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| // 2. Group is assigned | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| const isSelected = selectedSecondSearchItemIds.includes(key); | |||
| const hasGroup = item.groupId !== undefined && item.groupId !== null; | |||
| // 3. Quantity is entered | |||
| const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) { | |||
| // Auto-add to created items | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id, item.uomId)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomId: Number(item.uomId) || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| @@ -438,33 +448,29 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| // Remove from search results since it's now in created items | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); | |||
| } | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| // Add this function after checkAndAutoAddItem | |||
| // Add this function after checkAndAutoAddItem | |||
| const handleQtyBlur = useCallback((itemId: number) => { | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| const handleQtyBlur = useCallback((rowKey: string) => { | |||
| setTimeout(() => { | |||
| const currentItem = secondSearchResults.find(i => i.id === itemId); | |||
| const currentItem = secondSearchResults.find((i) => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (!currentItem) return; | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| const key = currentItem.rowKey || toSearchRowKey(currentItem.id, currentItem.uomId); | |||
| const isSelected = selectedSecondSearchItemIds.includes(key); | |||
| const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; | |||
| const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) { | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id, currentItem.uomId)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: currentItem.id, | |||
| itemName: currentItem.label, | |||
| itemCode: currentItem.label, | |||
| qty: currentItem.qty || 1, | |||
| uom: currentItem.uom || "", | |||
| uomId: currentItem.uomId || 0, | |||
| uomId: Number(currentItem.uomId) || 0, | |||
| uomDesc: currentItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: currentItem.currentStockBalance, | |||
| @@ -472,18 +478,18 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| groupId: currentItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSecondSearchResults(prev => prev.filter((searchItem) => (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== key)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== key)); | |||
| } | |||
| }, 0); | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => { | |||
| const handleSearchItemGroupChange = useCallback((rowKey: string, groupId: string) => { | |||
| const gid = groupId ? Number(groupId) : undefined; | |||
| const group = groups.find(g => g.id === gid); | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: gid, | |||
| @@ -492,9 +498,8 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| : item | |||
| )); | |||
| // Check auto-add after group assignment | |||
| setTimeout(() => { | |||
| checkAndAutoAddItem(itemId); | |||
| checkAndAutoAddItem(rowKey); | |||
| }, 0); | |||
| }, [groups, checkAndAutoAddItem]); | |||
| // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) | |||
| @@ -550,6 +555,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| uomId: item.uomId, | |||
| uom: item.uom, | |||
| uomDesc: item.uomDesc, | |||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||
| currentStockBalance: item.currentStockBalance, | |||
| qty: null, | |||
| targetDate: targetDate, | |||
| @@ -1017,7 +1023,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| name: "id", | |||
| label: "", | |||
| type: "checkbox", | |||
| disabled: (item) => isItemInCreated(item.id), // Disable if already in created items | |||
| disabled: (item) => isItemInCreated(item.id, item.uomId), // Disable if already in created items | |||
| }, | |||
| { | |||
| @@ -1141,7 +1147,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| }, [formProps]); | |||
| // 添加数量变更处理函数 | |||
| const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => { | |||
| const handleSecondSearchQtyChange = useCallback((rowKey: string, newQty: number | null) => { | |||
| const getClampedQty = (qty: number | null, stock?: number) => { | |||
| if (qty === null) return null; | |||
| const maxQty = Math.max(0, stock ?? 0); | |||
| @@ -1150,7 +1156,9 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| setSecondSearchResults(prev => | |||
| prev.map(item => | |||
| item.id === itemId ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } : item | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { ...item, qty: getClampedQty(newQty, item.currentStockBalance) } | |||
| : item | |||
| ) | |||
| ); | |||
| @@ -1163,24 +1171,24 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const newIds = ids(selectedSecondSearchItemIds); | |||
| setSelectedSecondSearchItemIds(newIds); | |||
| // 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面 | |||
| if (newIds.length === secondSearchResults.length) { | |||
| // 全选:将所有搜索结果添加到创建项目 | |||
| secondSearchResults.forEach(item => { | |||
| if (!isItemInCreated(item.id)) { | |||
| if (!isItemInCreated(item.id, item.uomId)) { | |||
| handleSearchItemSelect(item.id, true); | |||
| } | |||
| }); | |||
| } else { | |||
| // 部分选择:只处理当前页面的选择 | |||
| secondSearchResults.forEach(item => { | |||
| const isSelected = newIds.includes(item.id); | |||
| const isCurrentlyInCreated = isItemInCreated(item.id); | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| const isSelected = newIds.includes(key); | |||
| const isCurrentlyInCreated = isItemInCreated(item.id, item.uomId); | |||
| if (isSelected && !isCurrentlyInCreated) { | |||
| handleSearchItemSelect(item.id, true); | |||
| } else if (!isSelected && isCurrentlyInCreated) { | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id)); | |||
| setCreatedItems(prev => prev.filter(createdItem => | |||
| !(createdItem.itemId === item.id && Number(createdItem.uomId) === Number(item.uomId)) | |||
| )); | |||
| } | |||
| }); | |||
| } | |||
| @@ -1192,13 +1200,19 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const newlyDeselected = previousIds.filter(id => !ids.includes(id)); | |||
| newlySelected.forEach(id => { | |||
| if (!isItemInCreated(id as number)) { | |||
| handleSearchItemSelect(id as number, true); | |||
| const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); | |||
| if (row && !isItemInCreated(row.id, row.uomId)) { | |||
| handleSearchItemSelect(row.id, true); | |||
| } | |||
| }); | |||
| newlyDeselected.forEach(id => { | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id)); | |||
| const row = secondSearchResults.find(item => (item.rowKey || toSearchRowKey(item.id, item.uomId)) === String(id)); | |||
| if (row) { | |||
| setCreatedItems(prev => prev.filter(createdItem => | |||
| !(createdItem.itemId === row.id && Number(createdItem.uomId) === Number(row.uomId)) | |||
| )); | |||
| } | |||
| }); | |||
| } | |||
| }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); | |||
| @@ -1209,7 +1223,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| name: "id", | |||
| label: "", | |||
| type: "checkbox", | |||
| disabled: (item) => isItemInCreated(item.id), | |||
| disabled: (item) => isItemInCreated(item.id, item.uomId), | |||
| }, | |||
| { | |||
| name: "label", | |||
| @@ -1260,7 +1274,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| renderCell: (item) => ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}> | |||
| <Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */} | |||
| {item.uom || "-"} | |||
| {item.uomDesc || item.uom || "-"} | |||
| </Typography> | |||
| </Box> | |||
| ), | |||
| @@ -1280,7 +1294,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| handleSecondSearchQtyChange(item.id, numValue); | |||
| handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), numValue); | |||
| } | |||
| }} | |||
| inputProps={{ | |||
| @@ -1299,7 +1313,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| const value = e.target.value; | |||
| const numValue = value === "" ? null : Number(value); | |||
| if (numValue !== null && numValue < 1) { | |||
| handleSecondSearchQtyChange(item.id, 1); // Enforce min value | |||
| handleSecondSearchQtyChange(item.rowKey || toSearchRowKey(item.id, item.uomId), 1); // Enforce min value | |||
| } | |||
| }} | |||
| /> | |||
| @@ -1368,6 +1382,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| uomId: item.uomId, | |||
| uom: item.uom, | |||
| uomDesc: item.uomDesc, | |||
| rowKey: toSearchRowKey(item.id, item.uomId), | |||
| currentStockBalance: item.currentStockBalance, | |||
| qty: null, | |||
| targetDate: undefined, | |||
| @@ -1493,7 +1508,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| }, []); | |||
| const getValidationMessage = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| const itemsWithoutGroup = selectedItems.filter(item => | |||
| @@ -1517,42 +1532,42 @@ const getValidationMessage = useCallback(() => { | |||
| // Fix the handleAddSelectedToCreatedItems function to properly clear selections | |||
| const handleAddSelectedToCreatedItems = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| // Add selected items to created items with their own group info | |||
| selectedItems.forEach(item => { | |||
| if (!isItemInCreated(item.id)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| } | |||
| }); | |||
| // Clear the selection | |||
| const toAdd: CreatedItem[] = []; | |||
| const addedKeys: string[] = []; | |||
| for (const item of selectedItems) { | |||
| const key = item.rowKey || toSearchRowKey(item.id, item.uomId); | |||
| if (isItemInCreated(item.id, item.uomId)) continue; | |||
| toAdd.push({ | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: Number(item.uomId) || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }); | |||
| addedKeys.push(key); | |||
| } | |||
| if (toAdd.length > 0) { | |||
| setCreatedItems((prev) => [...prev, ...toAdd]); | |||
| } | |||
| setSelectedSecondSearchItemIds([]); | |||
| // Remove the selected/added items from search results entirely | |||
| setSecondSearchResults(prev => prev.filter(item => | |||
| !selectedSecondSearchItemIds.includes(item.id) | |||
| )); | |||
| setSecondSearchResults((prev) => | |||
| prev.filter((item) => !addedKeys.includes(item.rowKey || toSearchRowKey(item.id, item.uomId))), | |||
| ); | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| // Add a validation function to check if selected items are valid | |||
| const areSelectedItemsValid = useCallback(() => { | |||
| const selectedItems = secondSearchResults.filter(item => | |||
| selectedSecondSearchItemIds.includes(item.id) | |||
| selectedSecondSearchItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ); | |||
| return selectedItems.every(item => | |||
| @@ -1566,17 +1581,17 @@ const getValidationMessage = useCallback(() => { | |||
| // Move these handlers to the component level (outside of CustomSearchResultsTable) | |||
| // Handle individual checkbox change - ONLY select, don't add to created items | |||
| const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { | |||
| const handleIndividualCheckboxChange = useCallback((rowKey: string, checked: boolean) => { | |||
| checkboxChangeCallCount++; | |||
| if (checked) { | |||
| // Add to selected IDs | |||
| setSelectedSecondSearchItemIds(prev => [...prev, itemId]); | |||
| setSelectedSecondSearchItemIds(prev => [...prev, rowKey]); | |||
| // Set the item's group and targetDate to current group when selected | |||
| setSecondSearchResults(prev => { | |||
| const updatedResults = prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| @@ -1588,29 +1603,27 @@ const getValidationMessage = useCallback(() => { | |||
| // Check if should auto-add after state update | |||
| setTimeout(() => { | |||
| // Check if we're already processing this item | |||
| if (processingItems.has(itemId)) { | |||
| //alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`); | |||
| if (processingItems.has(rowKey)) { | |||
| return; | |||
| } | |||
| const updatedItem = updatedResults.find(i => i.id === itemId); | |||
| const updatedItem = updatedResults.find(i => (i.rowKey || toSearchRowKey(i.id, i.uomId)) === rowKey); | |||
| if (updatedItem) { | |||
| const isSelected = true; // We just selected it | |||
| const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; | |||
| const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; | |||
| // Only auto-add if item has quantity (scenario 2: enter quantity first, then select) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) { | |||
| // Mark this item as being processed | |||
| processingItems.add(itemId); | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id, updatedItem.uomId)) { | |||
| processingItems.add(rowKey); | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: updatedItem.id, | |||
| itemName: updatedItem.label, | |||
| itemCode: updatedItem.label, | |||
| qty: updatedItem.qty || 1, | |||
| uom: updatedItem.uom || "", | |||
| uomId: updatedItem.uomId || 0, | |||
| uomId: Number(updatedItem.uomId) || 0, | |||
| uomDesc: updatedItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: updatedItem.currentStockBalance, | |||
| @@ -1618,29 +1631,16 @@ const getValidationMessage = useCallback(() => { | |||
| groupId: updatedItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId)); | |||
| // Remove from processing set after a short delay | |||
| setSecondSearchResults(current => current.filter(searchItem => | |||
| (searchItem.rowKey || toSearchRowKey(searchItem.id, searchItem.uomId)) !== rowKey | |||
| )); | |||
| setSelectedSecondSearchItemIds(current => current.filter(id => id !== rowKey)); | |||
| setTimeout(() => { | |||
| processingItems.delete(itemId); | |||
| processingItems.delete(rowKey); | |||
| }, 100); | |||
| } | |||
| // Show final debug info in one alert | |||
| /* | |||
| alert(`FINAL DEBUG INFO for item ${itemId}: | |||
| Function called ${checkboxChangeCallCount} times | |||
| Is Selected: ${isSelected} | |||
| Has Group: ${hasGroup} | |||
| Has Quantity: ${hasQty} | |||
| Quantity: ${updatedItem.qty} | |||
| Group ID: ${updatedItem.groupId} | |||
| Is Item In Created: ${isItemInCreated(updatedItem.id)} | |||
| Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)} | |||
| Processing items: ${Array.from(processingItems).join(', ')}`); | |||
| */ | |||
| } | |||
| }, 0); | |||
| @@ -1648,11 +1648,11 @@ Processing items: ${Array.from(processingItems).join(', ')}`); | |||
| }); | |||
| } else { | |||
| // Remove from selected IDs | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== rowKey)); | |||
| // Clear the item's group and targetDate when deselected | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === rowKey | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| @@ -1668,17 +1668,19 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| if (checked) { | |||
| // Select all items on current page that are not already in created items | |||
| const newSelectedIds = paginatedResults | |||
| .filter(item => !isItemInCreated(item.id)) | |||
| .map(item => item.id); | |||
| .filter(item => !isItemInCreated(item.id, item.uomId)) | |||
| .map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); | |||
| setSelectedSecondSearchItemIds(prev => { | |||
| const existingIds = prev.filter(id => !paginatedResults.some(item => item.id === id)); | |||
| const existingIds = prev.filter(id => !paginatedResults.some(item => | |||
| (item.rowKey || toSearchRowKey(item.id, item.uomId)) === id | |||
| )); | |||
| return [...existingIds, ...newSelectedIds]; | |||
| }); | |||
| // Set group and targetDate for all selected items on current page | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| newSelectedIds.includes(item.id) | |||
| newSelectedIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| @@ -1688,12 +1690,12 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| )); | |||
| } else { | |||
| // Deselect all items on current page | |||
| const pageItemIds = paginatedResults.map(item => item.id); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(id as number))); | |||
| const pageItemIds = paginatedResults.map(item => item.rowKey || toSearchRowKey(item.id, item.uomId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => !pageItemIds.includes(String(id)))); | |||
| // Clear group and targetDate for all deselected items on current page | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| pageItemIds.includes(item.id) | |||
| pageItemIds.includes(item.rowKey || toSearchRowKey(item.id, item.uomId)) | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| @@ -497,6 +497,10 @@ | |||
| "Tomorrow": "Tomorrow", | |||
| "packaging": "packaging", | |||
| "This lot is not available, please scan another lot.": "This lot is not available, please scan another lot.", | |||
| "This lot UOM does not match the pick line. Please scan another lot.": "This lot UOM does not match the pick line. Please scan another lot.", | |||
| "Scanned lot UOM does not match pick line UOM": "This lot UOM does not match the pick line. Please scan another lot.", | |||
| "This lot has already been picked. Please scan another lot.": "This lot has already been picked. Please scan another lot.", | |||
| "Scanned lot is already completed or checked": "This lot has already been picked. Please scan another lot.", | |||
| "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.", | |||
| "Lot is expired (expiry={{expiry}})": "Lot is expired (expiry={{expiry}})", | |||
| "Day After Tomorrow": "Day After Tomorrow", | |||
| @@ -507,6 +507,10 @@ | |||
| "packaging": "提料中", | |||
| "No Stock Available": "沒有庫存可用", | |||
| "This lot is not available, please scan another lot.": "此批號不可用,請掃描其他批號。", | |||
| "This lot UOM does not match the pick line. Please scan another lot.": "此批號單位與提貨行不符,請掃描其他批號。", | |||
| "Scanned lot UOM does not match pick line UOM": "此批號單位與提貨行不符,請掃描其他批號。", | |||
| "This lot has already been picked. Please scan another lot.": "此批號已提貨,請掃描其他批號。", | |||
| "Scanned lot is already completed or checked": "此物料已提貨,請掃描其他批號。", | |||
| "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "請檢查周圍是否有其他可用 QR 碼。", | |||
| "Lot is expired (expiry={{expiry}})": "掃描批號已過期(到期日={{expiry}})", | |||
| "Day After Tomorrow": "後日", | |||