From 5b8c6039efbbebf17c1edd3294b5ac3bc8926875 Mon Sep 17 00:00:00 2001 From: Daniel Bedeleanu Date: Sun, 19 Apr 2026 12:21:15 +0300 Subject: [PATCH] refactor: extract useScanner hook from page.tsx --- .claude/settings.local.json | 4 +- frontend/app/page.tsx | 254 ++++++----------------------------- frontend/hooks/useScanner.ts | 236 ++++++++++++++++++++++++++++++++ frontend/package-lock.json | 1 - frontend/public/sw.js | 2 +- 5 files changed, 284 insertions(+), 213 deletions(-) create mode 100644 frontend/hooks/useScanner.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67f022f4..44bc7e70 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -64,7 +64,9 @@ "Bash(curl -v http://localhost:8906/users)", "Bash(NEXT_PUBLIC_API_URL=http://localhost:8916 npm run dev -- --port 8917)", "Bash(curl -sf http://localhost:8917)", - "Bash(xargs sed *)" + "Bash(xargs sed *)", + "Bash(sed -i -e 's/\\\\btext-slate-100\\\\b/text-secondary/g' -e s/placeholder:text-slate-700/placeholder:text-muted/g -e s/hover:text-slate-300/hover:text-secondary/g app/page.tsx)", + "Bash(sed -i 's/\\\\btext-slate-300\\\\b/text-secondary/g' app/page.tsx)" ] } } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 87238737..d9038162 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'; import { db, Item } from '@/lib/db'; import { inventoryApi } from '@/lib/api'; import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync'; +import { useScanner } from '@/hooks/useScanner'; import Scanner from '@/components/Scanner'; import AIOnboarding from '@/components/AIOnboarding'; import PageShell from '@/components/PageShell'; @@ -42,58 +43,54 @@ function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -// 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 default function Home() { const [mounted, setMounted] = useState(false); const [isOnline, setIsOnline] = useState(true); - const [mode, setMode] = useState<'CHECK_IN' | 'CHECK_OUT' | 'TRASH'>('CHECK_OUT'); - const [showScanner, setShowScanner] = useState(false); + const [inventory, setInventory] = useState([]); const [showOnboarding, setShowOnboarding] = useState(false); const [selectedBoxLabel, setSelectedBoxLabel] = useState(null); const [selectedItem, setSelectedItem] = useState(null); const [boxMatches, setBoxMatches] = useState([]); const [isEditing, setIsEditing] = useState(false); - const [isScannerReady, setIsScannerReady] = useState(false); const [editedItem, setEditedItem] = useState>({}); const [adjustQty, setAdjustQty] = useState(1); const [adjustType, setAdjustType] = useState<'ADD' | 'REMOVE' | 'TRASH'>('ADD'); const [trashReason, setTrashReason] = useState('Damaged'); - const [lastScanned, setLastScanned] = useState(null); - const [inventory, setInventory] = useState([]); const [syncing, setSyncing] = useState(false); const [currentUser, setCurrentUser] = useState(null); const [categories, setCategories] = useState([]); - const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null); const [comparisonModal, setComparisonModal] = useState<{ show: boolean, newItem: any, existingItem: any, existingId: number | null }>({ show: false, newItem: null, existingItem: null, existingId: null }); const [comparisonLoading, setComparisonLoading] = useState(false); + const { + mode, + setMode, + showScanner, + setShowScanner, + lastScanned, + isScannerReady, + fieldScanning, + setFieldScanning, + onScanSuccess, + onOCRMatch, + } = useScanner({ + inventory, + isOnline, + onSync: handleSync, + onMatchFound: (item, adjustType) => { + setSelectedItem(item); + setAdjustType(adjustType); + }, + onMultipleMatches: (items) => { + setBoxMatches(items); + }, + onFieldCapture: (field, value) => { + if (field === 'box_label') { + setEditedItem(prev => ({ ...prev, box_label: value })); + } + } + }); + useEffect(() => { if (!localStorage.getItem('inventory_token')) { window.location.href = '/login'; @@ -111,11 +108,9 @@ export default function Home() { // Initial data fetch inventoryApi.getCategories().then(c => setCategories(c)).catch(() => { }); loadInventory(); - preloadOCR(); const handleOnline = () => { setIsOnline(true); - handleSync(); // Auto-sync pending ops when back online }; const handleOffline = () => setIsOnline(false); @@ -147,17 +142,21 @@ export default function Home() { } }; - const preloadOCR = async () => { + const handleSync = useCallback(async () => { + if (!isOnline || !currentUser) return; + setSyncing(true); try { - // Import the library only when needed to keep bundle small - 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); + const result = await syncOfflineOperations(currentUser.id); + if (result.success > 0) { + toast.success(`Synced ${result.success} operations!`); + } + await loadInventory(); + } catch (error) { + console.error("Sync failed", error); + } finally { + setSyncing(false); } - }; + }, [isOnline, currentUser]); const handleOnboardingComplete = async (itemData: any) => { try { @@ -250,171 +249,6 @@ export default function Home() { } }, [isOnline, currentUser]); - 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 }); - - setInventory(prev => prev.map(i => i.id === item.id ? { ...i, quantity: newQty } : i)); - - toast.success(`${mode === 'CHECK_IN' ? 'Checked in' : 'Checked out'} ${item.name}`); - - if (isOnline) { - handleSync(); - } - }, [mode, isOnline, handleSync]); - - const onOCRMatch = useCallback(async (text: string) => { - // 1. Clean and normalize - const cleanText = text.toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); - - // Garbage Filter: Ignore noisy strings (measurements like 0.11, dates like 2024-07-25) - // We only keep tokens that are at least 3 chars AND not just decimals - const tokens = cleanText.split(/[\s\n,]+/) - .filter(t => t.length >= 3) - .filter(t => !/^\d+\.\d+$/.test(t)) // Filter out decimals like 0.11 or 0.12 - .filter(t => !/^\d{2,4}-\d{2}-\d{2}$/.test(t)); // Filter out dates - - if (tokens.length === 0) return; - - // [NEW] Targeted Field Scan Logic - if (fieldScanning?.active) { - if (fieldScanning.field === 'box_label') { - const potentialLabel = tokens[0] || cleanText; - setEditedItem(prev => ({ ...prev, box_label: potentialLabel })); - setFieldScanning(null); - toast.success(`Captured: ${potentialLabel}`); - return; - } - } - - // Toast only potentially useful scans - toast(`Scanning: ${cleanText.substring(0, 30)}...`, { icon: '🔍', duration: 1500, id: 'ocr-scan' }); - - // [BOX SCANNING LOGIC] Prioritize checking if this is a known box - const possibleBoxItems = inventory.filter(item => { - if (!item.box_label) return false; - const boxText = item.box_label.toLowerCase().replace(/[^a-z0-9\s/+-]/g, ' '); - // Exact or Partial include match for box label - if (cleanText.includes(boxText)) return true; - // Strong token matching (for words >= 4 chars) - 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' }); - setSelectedItem(possibleBoxItems[0]); - setAdjustType(mode === 'CHECK_IN' ? 'ADD' : 'REMOVE'); - setShowScanner(false); - return; - } else if (possibleBoxItems.length > 1) { - toast.success(`Box identified: ${possibleBoxItems.length} items found`, { duration: 3000, id: 'ocr-success' }); - setBoxMatches(possibleBoxItems); - setShowScanner(false); - return; - } - - // [INDIVIDUAL ITEM MATCHING] Fallback to classic specific identification - 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, ' '); - - // Priority 0: OCR Key match (Heuristic provided by AI) with fuzzy tolerance - if (ocrKey) { - if (cleanText.includes(ocrKey)) { - score += 1000; // Exact match - } else { - // Fuzzy match for OCR keys (allows 2-char differences for robustness) - 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; // Fuzzy match (70%+ token coverage) - } - } - } - - // Priority 1: Serial Number (Absolute match) - if (sn && cleanText.includes(sn)) score += 500; - - // Priority 2: Part Number (High confidence, with fuzzy tolerance) - if (pn) { - if (cleanText.includes(pn)) { - score += 200; // Exact match - } else { - // Fuzzy match for part numbers (allows 1-char differences) - 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; // Fuzzy match (60%+ token coverage) - } - } - } - - // Priority 3: Token based matching for PN (LC/UPC etc) - 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; // Fuzzy token match - } - }); - } - - // Priority 4: Name & Category - 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; - } - } - - // Threshold: Need a significant score to match automatically - if (bestMatch && maxMatchScore >= 40) { - toast.success(`Matched: ${bestMatch.name}`, { duration: 3000, id: 'ocr-success' }); - setSelectedItem(bestMatch); - setAdjustType(mode === 'CHECK_IN' ? 'ADD' : 'REMOVE'); - setShowScanner(false); - } - }, [mode, inventory]); const handleUpdateItem = async () => { if (!selectedItem) return; diff --git a/frontend/hooks/useScanner.ts b/frontend/hooks/useScanner.ts new file mode 100644 index 00000000..66e00d0e --- /dev/null +++ b/frontend/hooks/useScanner.ts @@ -0,0 +1,236 @@ +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 + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ca23256b..26c44d72 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9070,7 +9070,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ diff --git a/frontend/public/sw.js b/frontend/public/sw.js index fdc8c481..c49fbc66 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"53f524d1d4c3e9aacb2a1a2109dc2adc"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/734-a8fef85a8c784115.js",revision:"a8fef85a8c784115"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-afaa032632ceece7.js",revision:"afaa032632ceece7"},{url:"/_next/static/chunks/app/inventory/page-1f15a77b7a342fb7.js",revision:"1f15a77b7a342fb7"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-4482d4a85e9b279f.js",revision:"4482d4a85e9b279f"},{url:"/_next/static/chunks/app/logs/page-765c718070a4649d.js",revision:"765c718070a4649d"},{url:"/_next/static/chunks/app/page-1c15a08fff34ad15.js",revision:"1c15a08fff34ad15"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/b6329e971f4fec8d.css",revision:"b6329e971f4fec8d"},{url:"/_next/static/eKbRdO467_-8Bwc1CAAlT/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/eKbRdO467_-8Bwc1CAAlT/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"f220de17ed2759af6a092bc846299d75"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/734-c98618bae1f61f75.js",revision:"c98618bae1f61f75"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-b5a0014cdd0fc944.js",revision:"b5a0014cdd0fc944"},{url:"/_next/static/chunks/app/inventory/page-c482ce5ec1231357.js",revision:"c482ce5ec1231357"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-2f2c5f938e349482.js",revision:"2f2c5f938e349482"},{url:"/_next/static/chunks/app/logs/page-2bcb0789e8ec1d22.js",revision:"2bcb0789e8ec1d22"},{url:"/_next/static/chunks/app/page-74e206babd405de9.js",revision:"74e206babd405de9"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/a2eb6dada43a2a9e.css",revision:"a2eb6dada43a2a9e"},{url:"/_next/static/eQG8Ul60TVnOFY7KCkCRO/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/eQG8Ul60TVnOFY7KCkCRO/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")});