Build [v1.7.0] - GitGuard - Infrastructure Hardening
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { db, Item } from '@/lib/db';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
import PageShell from '@/components/PageShell';
|
||||
import Scanner from '@/components/Scanner';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
Edit2
|
||||
Edit2,
|
||||
Camera
|
||||
} from 'lucide-react';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -50,6 +52,10 @@ export default function InventoryPage() {
|
||||
const [catEditedName, setCatEditedName] = useState('');
|
||||
const [catEditedDesc, setCatEditedDesc] = useState('');
|
||||
|
||||
// Scanner state
|
||||
const [showScanner, setShowScanner] = useState(false);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedUser = localStorage.getItem('inventory_user');
|
||||
@@ -182,6 +188,31 @@ export default function InventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onOCRMatch = useCallback(async (text: string) => {
|
||||
const cleanText = text.toUpperCase().replace(/[^A-Z0-9\s/+-]/g, ' ');
|
||||
const tokens = cleanText.split(/[\s\n,]+/).filter(t => t.length >= 3);
|
||||
|
||||
if (fieldScanning?.active && fieldScanning.field === 'box_label') {
|
||||
const label = tokens[0] || cleanText;
|
||||
setEditedItem(prev => ({ ...prev, box_label: label }));
|
||||
setFieldScanning(null);
|
||||
setShowScanner(false);
|
||||
toast.success(`Captured: ${label}`);
|
||||
return;
|
||||
}
|
||||
}, [fieldScanning]);
|
||||
|
||||
const onScanSuccess = useCallback((barcode: string) => {
|
||||
// Inventory page doesn't do check-in via scanner, it just finds the item
|
||||
const item = inventory.find(i => i.barcode === barcode);
|
||||
if (item) {
|
||||
setSelectedItem(item);
|
||||
setShowScanner(false);
|
||||
} else {
|
||||
toast.error(`Item with barcode ${barcode} not found in catalog`);
|
||||
}
|
||||
}, [inventory]);
|
||||
|
||||
// Group items by category
|
||||
const categories = Array.from(new Set(inventory.map(i => i.category)));
|
||||
const filteredCategories = categories.filter(c =>
|
||||
@@ -189,8 +220,9 @@ export default function InventoryPage() {
|
||||
inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
// Extract unique item types and box labels for suggestions
|
||||
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
||||
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
@@ -200,6 +232,9 @@ export default function InventoryPage() {
|
||||
<datalist id="existing-types">
|
||||
{existingTypes.map(t => <option key={t} value={t} />)}
|
||||
</datalist>
|
||||
<datalist id="existing-boxes">
|
||||
{existingBoxes.map(b => <option key={b} value={b} />)}
|
||||
</datalist>
|
||||
|
||||
<header className="flex items-center gap-5 mb-10">
|
||||
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||
@@ -412,6 +447,33 @@ export default function InventoryPage() {
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
list="existing-boxes"
|
||||
value={editedItem.box_label || ''}
|
||||
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
|
||||
placeholder="e.g. SFPs 40G Cisco"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFieldScanning({ active: true, field: 'box_label' });
|
||||
setShowScanner(true);
|
||||
toast.success("Ready to scan Box label...");
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-2 p-2 rounded-lg transition-all",
|
||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-slate-500 hover:bg-slate-800"
|
||||
)}
|
||||
>
|
||||
<Camera size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label>
|
||||
@@ -542,6 +604,13 @@ export default function InventoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScanner && (
|
||||
<Scanner
|
||||
onScanSuccess={onScanSuccess}
|
||||
onOCRMatch={onOCRMatch}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import PageShell from '@/components/PageShell';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
Package,
|
||||
Camera,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
@@ -80,6 +81,7 @@ export default function Home() {
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('inventory_token')) {
|
||||
@@ -240,6 +242,17 @@ export default function Home() {
|
||||
|
||||
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' });
|
||||
|
||||
@@ -662,14 +675,30 @@ export default function Home() {
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label>
|
||||
<input
|
||||
type="text"
|
||||
list="existing-boxes"
|
||||
value={editedItem.box_label || ''}
|
||||
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
|
||||
placeholder="e.g. SFPs 40G Cisco"
|
||||
/>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
list="existing-boxes"
|
||||
value={editedItem.box_label || ''}
|
||||
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
|
||||
placeholder="e.g. SFPs 40G Cisco"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFieldScanning({ active: true, field: 'box_label' });
|
||||
setShowScanner(true);
|
||||
toast.success("Scanning for BOX label...");
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-2 p-2 rounded-lg transition-all",
|
||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-slate-500 hover:bg-slate-800"
|
||||
)}
|
||||
>
|
||||
<Camera size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Specs</label>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState<any>(null);
|
||||
const [mode, setMode] = useState<'item' | 'box'>('item');
|
||||
|
||||
// Extract unique item types for suggestions
|
||||
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
||||
@@ -42,7 +43,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData);
|
||||
const data = await inventoryApi.analyzeLabel(formData, mode);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
@@ -51,7 +52,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
}
|
||||
|
||||
setExtractedData(data);
|
||||
toast.success("AI extraction complete!");
|
||||
if (mode === 'box') {
|
||||
toast.success(`Box identified: ${data.box_label || data.name}`);
|
||||
} else {
|
||||
toast.success("AI extraction complete!");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to process image with AI");
|
||||
console.error(error);
|
||||
@@ -97,13 +102,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
|
||||
{!image ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0">
|
||||
<div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
|
||||
<button
|
||||
onClick={() => setMode('item')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
<Package size={18} />
|
||||
<span className="text-xs">Item Label</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('box')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
<Layers size={18} />
|
||||
<span className="text-xs">Box / Container</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4">
|
||||
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner">
|
||||
<Camera size={32} className="text-slate-500" />
|
||||
<Camera size={32} className={mode === 'box' ? 'text-primary' : 'text-slate-500'} />
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p>
|
||||
<p className="text-slate-300 mb-2 text-center font-bold">
|
||||
{mode === 'box' ? 'Container Discovery Mode' : 'Label Insight Mode'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
|
||||
Scan or upload a sharp photo of the item specifications
|
||||
{mode === 'box'
|
||||
? 'Scan the large, prominent label or hand-written name on the container'
|
||||
: 'Scan or upload a sharp photo of the item specifications'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const isBusy = useRef(false);
|
||||
const isTransitioning = useRef(false); // Flag to track start/stop transitions
|
||||
const scannerId = "reader-container-unique";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,11 +34,20 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
}
|
||||
|
||||
const startScanner = async () => {
|
||||
if (isBusy.current) return;
|
||||
if (isBusy.current || isTransitioning.current) return;
|
||||
isBusy.current = true;
|
||||
isTransitioning.current = true;
|
||||
|
||||
try {
|
||||
// If already scanning, we MUST stop it first
|
||||
if (html5QrCodeRef.current?.isScanning) {
|
||||
await html5QrCodeRef.current.stop();
|
||||
try {
|
||||
await html5QrCodeRef.current.stop();
|
||||
} catch (e) {
|
||||
console.warn("Graceful stop failed, might be in transition:", e);
|
||||
// If it's already in transition, we just return and wait for the next effect trigger
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -85,6 +95,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
}
|
||||
} finally {
|
||||
isBusy.current = false;
|
||||
isTransitioning.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,11 +105,15 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
|
||||
|
||||
return () => {
|
||||
if (html5QrCodeRef.current?.isScanning) {
|
||||
isTransitioning.current = true;
|
||||
html5QrCodeRef.current.stop()
|
||||
.then(() => {
|
||||
setIsStarted(false);
|
||||
})
|
||||
.catch(e => console.error("Stop failed", e));
|
||||
.catch(e => console.error("Unmount stop failed", e))
|
||||
.finally(() => {
|
||||
isTransitioning.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [paused, onScanSuccess]);
|
||||
|
||||
@@ -75,8 +75,8 @@ export const inventoryApi = {
|
||||
}
|
||||
},
|
||||
|
||||
analyzeLabel: async (formData: FormData) => {
|
||||
const res = await axiosInstance.post('/items/extract-label', formData, {
|
||||
analyzeLabel: async (formData: FormData, mode: string = "item") => {
|
||||
const res = await axiosInstance.post(`/items/extract-label?mode=${mode}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data;
|
||||
|
||||
Reference in New Issue
Block a user