import { useState, useCallback, useEffect } from 'react'; import { db, Item } from '@/lib/db'; import { toast } from 'react-hot-toast'; interface UseScannerOptions { inventory: Item[]; isOnline: boolean; onSync: () => Promise; onMatchFound?: (item: Item, adjustType: 'ADD' | 'REMOVE') => void; onMultipleMatches?: (items: Item[]) => void; onFieldCapture?: (field: string, value: string) => void; } // Fuzzy string matching with Levenshtein distance // Returns true if strings are similar enough (allowing 1-2 character differences) function fuzzyMatch(str1: string, str2: string, maxDistance: number = 2): boolean { const s1 = str1.toLowerCase().replace(/\s+/g, ''); const s2 = str2.toLowerCase().replace(/\s+/g, ''); if (s1 === s2) return true; if (Math.abs(s1.length - s2.length) > maxDistance) return false; // Levenshtein distance const matrix: number[][] = Array(s2.length + 1).fill(null).map(() => Array(s1.length + 1).fill(0)); for (let i = 0; i <= s1.length; i++) matrix[0][i] = i; for (let j = 0; j <= s2.length; j++) matrix[j][0] = j; for (let j = 1; j <= s2.length; j++) { for (let i = 1; i <= s1.length; i++) { const indicator = s1[i - 1] === s2[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j][i - 1] + 1, matrix[j - 1][i] + 1, matrix[j - 1][i - 1] + indicator ); } } return matrix[s2.length][s1.length] <= maxDistance; } export function useScanner(options: UseScannerOptions) { const { inventory, isOnline, onSync, onMatchFound, onMultipleMatches, onFieldCapture } = options; const [mode, setMode] = useState<'CHECK_IN' | 'CHECK_OUT' | 'TRASH'>('CHECK_OUT'); const [showScanner, setShowScanner] = useState(false); const [lastScanned, setLastScanned] = useState(null); const [isScannerReady, setIsScannerReady] = useState(false); const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null); const preloadOCR = useCallback(async () => { try { const { createWorker } = await import('tesseract.js'); const worker = await createWorker('eng'); await worker.terminate(); setIsScannerReady(true); } catch (e) { console.warn("OCR Preload failed - will retry on demand", e); } }, []); useEffect(() => { preloadOCR(); }, [preloadOCR]); const onScanSuccess = useCallback(async (barcode: string) => { setLastScanned(barcode); setShowScanner(false); const normalizedBarcode = barcode.toLowerCase(); const item = await db.items.where('barcode').equals(barcode) .or('part_number').equals(normalizedBarcode).first(); if (!item) { toast.error(`Item ${barcode} not found in catalog.`); return; } await db.pendingOperations.add({ type: mode, barcode: item.barcode, quantity: 1, timestamp: Date.now(), synced: 0, uuid: crypto.randomUUID() }); const newQty = mode === 'CHECK_IN' ? item.quantity + 1 : item.quantity - 1; await db.items.update(item.id!, { quantity: newQty }); toast.success(`${mode === 'CHECK_IN' ? 'Checked in' : 'Checked out'} ${item.name}`); if (isOnline) { await onSync(); } }, [mode, isOnline, onSync]); const onOCRMatch = useCallback(async (text: string) => { const cleanText = text.toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); // Garbage Filter: Ignore noisy strings const tokens = cleanText.split(/[\s\n,]+/) .filter(t => t.length >= 3) .filter(t => !/^\d+\.\d+$/.test(t)) .filter(t => !/^\d{2,4}-\d{2}-\d{2}$/.test(t)); if (tokens.length === 0) return; // Targeted Field Scan Logic if (fieldScanning?.active) { if (fieldScanning.field === 'box_label') { const potentialLabel = tokens[0] || cleanText; toast.success(`Captured: ${potentialLabel}`); setFieldScanning(null); if (onFieldCapture) { onFieldCapture('box_label', potentialLabel); } return; } } toast(`Scanning: ${cleanText.substring(0, 30)}...`, { icon: '🔍', duration: 1500, id: 'ocr-scan' }); // BOX SCANNING LOGIC const possibleBoxItems = inventory.filter(item => { if (!item.box_label) return false; const boxText = item.box_label.toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); if (cleanText.includes(boxText)) return true; const boxTokens = boxText.split(/[\s/+-]/).filter(t => t.length >= 4); const matchedTokens = boxTokens.filter(bt => tokens.includes(bt)); return matchedTokens.length >= 2 || (boxTokens.length === 1 && matchedTokens.length === 1); }); if (possibleBoxItems.length === 1) { toast.success(`Box identified: 1 item found`, { duration: 3000, id: 'ocr-success' }); setShowScanner(false); if (onMatchFound) { onMatchFound(possibleBoxItems[0], mode === 'CHECK_IN' ? 'ADD' : 'REMOVE'); } return; } else if (possibleBoxItems.length > 1) { toast.success(`Box identified: ${possibleBoxItems.length} items found`, { duration: 3000, id: 'ocr-success' }); setShowScanner(false); if (onMultipleMatches) { onMultipleMatches(possibleBoxItems); } return; } // INDIVIDUAL ITEM MATCHING let bestMatch = null; let maxMatchScore = 0; for (const item of inventory) { let score = 0; const pn = (item.part_number || '').toLowerCase(); const sn = (item.serial_number || '').toLowerCase(); const name = item.name.toLowerCase(); const category = item.category.toLowerCase(); const ocrKey = (item.ocr_text || '').toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); if (ocrKey) { if (cleanText.includes(ocrKey)) { score += 1000; } else { const ocrTokens = ocrKey.split(/[\s/+-]+/).filter(t => t.length >= 2); const matchedTokens = ocrTokens.filter(token => { return tokens.some(t => fuzzyMatch(t, token, 2)); }); if (matchedTokens.length >= Math.ceil(ocrTokens.length * 0.7)) { score += 800; } } } if (sn && cleanText.includes(sn)) score += 500; if (pn) { if (cleanText.includes(pn)) { score += 200; } else { const pnTokens = pn.split(/[\s/+-]/).filter(t => t.length >= 2); const matchedPnTokens = pnTokens.filter(pnToken => tokens.some(t => fuzzyMatch(t, pnToken, 1)) ); if (matchedPnTokens.length >= Math.max(2, Math.ceil(pnTokens.length * 0.6))) { score += 150; } } } if (pn) { const pnTokens = pn.split(/[\s/+-]/).filter(t => t.length >= 3); pnTokens.forEach(t => { if (cleanText.includes(t)) { score += 50; } else if (tokens.some(scanToken => fuzzyMatch(scanToken, t, 1))) { score += 30; } }); } const nameTokens = name.split(/[\s/+-]/).filter(t => t.length >= 3); nameTokens.forEach(t => { if (cleanText.includes(t)) score += 10; }); if (category && cleanText.includes(category)) score += 20; if (score > maxMatchScore) { maxMatchScore = score; bestMatch = item; } } if (bestMatch && maxMatchScore >= 40) { toast.success(`Matched: ${bestMatch.name}`, { duration: 3000, id: 'ocr-success' }); setShowScanner(false); if (onMatchFound) { onMatchFound(bestMatch, mode === 'CHECK_IN' ? 'ADD' : 'REMOVE'); } } }, [mode, inventory, fieldScanning, isOnline, onMatchFound, onMultipleMatches, onFieldCapture]); return { mode, setMode, showScanner, setShowScanner, lastScanned, setLastScanned, isScannerReady, fieldScanning, setFieldScanning, onScanSuccess, onOCRMatch, preloadOCR }; }