diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index fab5c80..3160ef3 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -28,6 +28,7 @@ export interface SearchInventory extends Pageable { code: string; name: string; type: string; + lotNo?: string; } export interface InventoryResultByPage { diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index 3df6219..c6f9c6b 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -51,6 +51,7 @@ interface Props { pagingController: typeof defaultPagingController; totalCount: number; inventory: InventoryResult | null; + filterLotNo?: string; onStockTransferSuccess?: () => void | Promise; printerCombo?: PrinterCombo[]; onStockAdjustmentSuccess?: () => void | Promise; @@ -58,6 +59,7 @@ interface Props { const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, + filterLotNo, onStockTransferSuccess, printerCombo = [], onStockAdjustmentSuccess, }) => { @@ -108,10 +110,12 @@ const InventoryLotLineTable: React.FC = ({ } }, [addEntryModalOpen]); - const availableLotLines = useMemo( - () => (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"), - [inventoryLotLines] - ); + const availableLotLines = useMemo(() => { + const base = (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"); + const f = filterLotNo?.trim?.() ? filterLotNo.trim() : ''; + if (!f) return base; + return base.filter((line) => line.lotNo === f); + }, [inventoryLotLines, filterLotNo]); const originalQty = selectedLotLine?.availableQty || 0; const remainingQty = originalQty - qtyToBeTransferred; diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index c919510..3fb389b 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -7,7 +7,9 @@ import { uniq, uniqBy } from 'lodash'; import InventoryTable from './InventoryTable'; import { defaultPagingController } from '../SearchResults/SearchResults'; import InventoryLotLineTable from './InventoryLotLineTable'; +import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider'; import { + analyzeQrCode, SearchInventory, SearchInventoryLotLine, fetchInventories, @@ -68,6 +70,15 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) + // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider) + const qrScanner = useQrCodeScannerContext(); + const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle'); + const [scanHoverCancel, setScanHoverCancel] = useState(false); + + // Resolved lot no for filtering + const [lotNoFilter, setLotNoFilter] = useState(''); + const [scannedItemId, setScannedItemId] = useState(null); + // Opening inventory (pure opening stock for items without existing inventory) const [openingItems, setOpeningItems] = useState([]); const [openingModalOpen, setOpeningModalOpen] = useState(false); @@ -122,6 +133,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { query: Record, actionType: 'reset' | 'search' | 'paging' | 'init', pagingController: typeof defaultPagingController, + lotNo: string, ) => { console.log('%c Action Type 1.', 'color:red', actionType); // Avoid loading data again @@ -134,6 +146,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { code: query?.itemCode ?? '', name: query?.itemName ?? '', type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', + lotNo: lotNo?.trim() ? lotNo.trim() : undefined, pageNum: pagingController.pageNum - 1, pageSize: pagingController.pageSize, }; @@ -154,19 +167,21 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { ); } } + + return response; }, [], ); useEffect(() => { - refetchInventoryData(defaultInputs, 'init', defaultPagingController); + refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); }, [defaultInputs, refetchInventoryData]); useEffect(() => { // if (!isEqual(inventoriesPagingController, defaultPagingController)) { - refetchInventoryData(inputs, 'paging', inventoriesPagingController) + refetchInventoryData(inputs, 'paging', inventoriesPagingController, lotNoFilter) // } - }, [inventoriesPagingController]) + }, [inventoriesPagingController, inputs, lotNoFilter, refetchInventoryData]) // Inventory Lot Line const refetchInventoryLotLineData = useCallback( @@ -217,14 +232,20 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // Reset const onReset = useCallback(() => { - refetchInventoryData(defaultInputs, 'reset', defaultPagingController); + refetchInventoryData(defaultInputs, 'reset', defaultPagingController, ''); refetchInventoryLotLineData(null, 'reset', defaultPagingController); // setFilteredInventories(inventories); + setLotNoFilter(''); + setScannedItemId(null); + setScanUiMode('idle'); + setScanHoverCancel(false); + qrScanner.stopScan(); + qrScanner.resetScan(); setInputs(() => defaultInputs) setInventoriesPagingController(() => defaultPagingController) setInventoryLotLinesPagingController(() => defaultPagingController) - }, []); + }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); // Click Row const onInventoryRowClick = useCallback( @@ -239,16 +260,86 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // On Search const onSearch = useCallback( (query: Record) => { - refetchInventoryData(query, 'search', defaultPagingController); + setLotNoFilter(''); + setScannedItemId(null); + setScanUiMode('idle'); + setScanHoverCancel(false); + qrScanner.stopScan(); + qrScanner.resetScan(); + refetchInventoryData(query, 'search', defaultPagingController, ''); refetchInventoryLotLineData(null, 'search', defaultPagingController); setInputs(() => query); setInventoriesPagingController(() => defaultPagingController); setInventoryLotLinesPagingController(() => defaultPagingController); }, - [refetchInventoryData, refetchInventoryLotLineData], + [qrScanner, refetchInventoryData, refetchInventoryLotLineData], ); + const startLotScan = useCallback(() => { + setScanHoverCancel(false); + setScanUiMode('scanning'); + qrScanner.resetScan(); + qrScanner.startScan(); + }, [qrScanner]); + + const cancelLotScan = useCallback(() => { + qrScanner.stopScan(); + qrScanner.resetScan(); + setScanUiMode('idle'); + setScanHoverCancel(false); + }, [qrScanner]); + + useEffect(() => { + if (scanUiMode !== 'scanning') return; + + const itemId = qrScanner.result?.itemId; + const stockInLineId = qrScanner.result?.stockInLineId; + if (!itemId || !stockInLineId) return; + + (async () => { + try { + const res = await analyzeQrCode({ + itemId: Number(itemId), + stockInLineId: Number(stockInLineId), + }); + + const resolvedLotNo = res?.scanned?.lotNo?.trim?.() ? res.scanned.lotNo.trim() : ''; + if (!resolvedLotNo) return; + + setLotNoFilter(resolvedLotNo); + setScannedItemId(res?.itemId ?? Number(itemId)); + + const invRes = await refetchInventoryData(inputs, 'search', defaultPagingController, resolvedLotNo); + const records = invRes?.records ?? []; + const target = records.find((r) => r.itemId === (res?.itemId ?? Number(itemId))) ?? null; + + if (target) { + onInventoryRowClick(target); + } else { + refetchInventoryLotLineData(null, 'search', defaultPagingController); + setSelectedInventory(null); + } + + setInventoriesPagingController(() => defaultPagingController); + setInventoryLotLinesPagingController(() => defaultPagingController); + } catch (e) { + console.error('Failed to analyze QR code:', e); + } finally { + // Always go back to initial state after a scan attempt + cancelLotScan(); + } + })(); + }, [ + cancelLotScan, + inputs, + onInventoryRowClick, + qrScanner.result, + refetchInventoryData, + refetchInventoryLotLineData, + scanUiMode, + ]); + console.log('', 'color: #666', inventoriesPagingController); const handleOpenOpeningInventoryModal = useCallback(() => { @@ -360,13 +451,30 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { }} onReset={onReset} extraActions={ - + + {scanUiMode === 'idle' ? ( + + ) : ( + <> + + + + )} + + + } /> = ({ inventories, printerCombo }) => { setPagingController={setInventoryLotLinesPagingController} totalCount={inventoryLotLinesTotalCount} inventory={selectedInventory} + filterLotNo={lotNoFilter} printerCombo={printerCombo ?? []} onStockTransferSuccess={() => refetchInventoryLotLineData( diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index b6b53df..3e71239 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -272,6 +272,9 @@ "Add entry for items without inventory": "為無庫存貨品新增倉存", "Enter item code or name to search": "輸入貨品編號或名稱以搜索", "Current Stock": "現有庫存", + "Search lot by QR code": "尋找批次(掃描二維碼)", + "Please scan...": "請掃描...", + "Stop QR Scan": "停止掃碼", "No Data": "沒有數據" }