Infrastructure: Implement master/dev/vX branching, save git path, and fix username casing
This commit is contained in:
@@ -20,7 +20,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icon-192x192.png" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
281
frontend/components/AIOnboarding.tsx
Normal file
281
frontend/components/AIOnboarding.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout } from 'lucide-react';
|
||||
import { inventoryApi } from '@/lib/api';
|
||||
|
||||
interface AIOnboardingProps {
|
||||
onCancel: () => void;
|
||||
onComplete: (itemData: any) => void;
|
||||
categories: any[];
|
||||
}
|
||||
|
||||
export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnboardingProps) {
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState<any>(null);
|
||||
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImage(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processImage = async () => {
|
||||
if (!image) return;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const blob = await (await fetch(image)).blob();
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'label.jpg');
|
||||
|
||||
const data = await inventoryApi.analyzeLabel(formData);
|
||||
|
||||
if (data.error) {
|
||||
toast.error(`AI Error: ${data.error}`);
|
||||
setUploading(false); // Force stop loading state
|
||||
return;
|
||||
}
|
||||
|
||||
setExtractedData(data);
|
||||
toast.success("AI extraction complete!");
|
||||
} catch (error) {
|
||||
toast.error("Failed to process image with AI");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmOnboarding = async () => {
|
||||
// Sanitize data for the backend (Pydantic validation)
|
||||
const newItem = {
|
||||
name: String(extractedData.name || "New AI Item"),
|
||||
category: String(extractedData.category || "Uncategorized"),
|
||||
type: extractedData.type ? String(extractedData.type) : null,
|
||||
part_number: extractedData.part_number ? String(extractedData.part_number) : null,
|
||||
color: extractedData.color ? String(extractedData.color) : null,
|
||||
specs: String(extractedData.specs || ""),
|
||||
barcode: String(extractedData.barcode || extractedData.part_number || extractedData.serial_number || `AI-${Date.now()}`),
|
||||
quantity: parseFloat(String(extractedData.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
labels_data: JSON.stringify(extractedData)
|
||||
};
|
||||
onComplete(newItem);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950 flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/20 rounded-xl">
|
||||
<Sparkles className="text-primary w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">AI Discovery</h2>
|
||||
<p className="text-[10px] text-slate-500 uppercase font-black tracking-widest">Powered by Gemini 1.5</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!image ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0">
|
||||
<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" />
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p>
|
||||
<p className="text-[11px] text-slate-500 px-8 text-center uppercase tracking-widest leading-relaxed">
|
||||
Scan or upload a sharp photo of the item specifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 h-28 shrink-0">
|
||||
<button
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-bold shadow-2xl shadow-primary/20 active:scale-95 transition-all"
|
||||
>
|
||||
<Camera size={24} />
|
||||
<span className="text-sm">Scan Camera</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-2 bg-slate-900 text-slate-200 border border-slate-800 rounded-3xl font-bold active:scale-95 transition-all"
|
||||
>
|
||||
<ImageIcon size={24} />
|
||||
<span className="text-sm">Upload Photo</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="file" ref={cameraInputRef} onChange={handleFileChange} accept="image/*" capture="environment" className="hidden" />
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
) : !extractedData ? (
|
||||
<div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
|
||||
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-slate-900">
|
||||
<img src={image} className="w-full h-full object-contain" alt="Captured label" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm flex flex-col items-center justify-center gap-4 z-10 transition-all">
|
||||
<div className="relative">
|
||||
<RefreshCw className="w-12 h-12 text-primary animate-spin" />
|
||||
<Sparkles className="absolute -top-2 -right-2 text-amber-400 w-6 h-6 animate-pulse" />
|
||||
</div>
|
||||
<p className="text-white font-black tracking-tight uppercase text-xs">Gemini is processing...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 shrink-0 pb-2">
|
||||
<button
|
||||
onClick={() => setImage(null)}
|
||||
disabled={uploading}
|
||||
className="px-6 py-4 bg-slate-900 border border-slate-800 text-slate-400 rounded-2xl font-bold transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
Retake
|
||||
</button>
|
||||
<button
|
||||
onClick={processImage}
|
||||
disabled={uploading}
|
||||
className="flex-1 py-4 bg-primary text-white rounded-2xl font-black text-lg shadow-xl shadow-primary/30 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50 hover:bg-blue-500"
|
||||
>
|
||||
{uploading ? "ANALYZING..." : "EXTRACT DATA"}
|
||||
{!uploading && <Check size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Validation Mask</span>
|
||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full font-bold">AI READY</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-hide">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Name</label>
|
||||
<textarea
|
||||
value={extractedData.name || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, name: e.target.value})}
|
||||
className="bg-transparent w-full text-xl font-bold outline-none text-white placeholder:text-slate-700 resize-none h-20"
|
||||
placeholder="Product name..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Category Group</label>
|
||||
<select
|
||||
value={extractedData.category || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, category: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
>
|
||||
<option value="" className="bg-slate-900 font-bold">Other / New</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.name} className="bg-slate-900">{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Type</label>
|
||||
<input
|
||||
value={extractedData.type || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. SFP+"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{backgroundColor: extractedData.color || 'transparent'}} />
|
||||
<input
|
||||
value={extractedData.color || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, color: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. Turquoise"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Technical Specifications</label>
|
||||
<textarea
|
||||
value={extractedData.specs || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, specs: e.target.value})}
|
||||
className="bg-transparent w-full text-sm leading-relaxed outline-none resize-none h-20 text-slate-300"
|
||||
placeholder="Technical details..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Part Number (P/N)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash size={14} className="text-primary/60" />
|
||||
<input
|
||||
value={extractedData.part_number || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, part_number: e.target.value})}
|
||||
className="bg-transparent w-full font-mono text-sm outline-none text-slate-200"
|
||||
placeholder="ID code..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Initial Stock</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout size={14} className="text-amber-500/60" />
|
||||
<input
|
||||
type="number"
|
||||
value={extractedData.quantity || 1}
|
||||
onChange={(e) => setExtractedData({...extractedData, quantity: parseInt(e.target.value)})}
|
||||
className="bg-transparent w-full font-black text-lg outline-none text-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/30 p-4 rounded-[1.5rem] border border-slate-800/50">
|
||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block">System Metadata (S/N if found)</label>
|
||||
<div className="text-[10px] text-slate-500 font-mono flex flex-wrap gap-2">
|
||||
{extractedData.serial_number && <span>S/N: {extractedData.serial_number}</span>}
|
||||
{extractedData.additional_data && <span>+ More Data</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 shrink-0">
|
||||
<button
|
||||
onClick={confirmOnboarding}
|
||||
className="py-5 bg-primary text-white rounded-[1.8rem] font-black text-lg shadow-2xl shadow-primary/20 active:scale-95 transition-all hover:bg-blue-500"
|
||||
>
|
||||
CONFIRM TO CATALOG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExtractedData(null)}
|
||||
className="py-3 text-[10px] text-slate-600 font-bold uppercase tracking-widest hover:text-slate-400"
|
||||
>
|
||||
Reset Extraction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
347
frontend/components/Scanner.tsx
Normal file
347
frontend/components/Scanner.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
||||
import { Camera, RefreshCw, XCircle, Type, Search } from 'lucide-react';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface ScannerProps {
|
||||
onScanSuccess: (decodedText: string) => void;
|
||||
onOCRMatch?: (text: string) => void;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerProps) {
|
||||
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
|
||||
const [isStarted, setIsStarted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOCRMode, setIsOCRMode] = useState(false);
|
||||
const [ocrProcessing, setOcrProcessing] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [maxZoom, setMaxZoom] = useState(1);
|
||||
const [hasZoom, setHasZoom] = useState(false);
|
||||
const [detectedWords, setDetectedWords] = useState<{ text: string, bbox: { x0: number, y0: number, x1: number, y1: number } }[]>([]);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||
const isBusy = useRef(false);
|
||||
const scannerId = "reader-container-unique";
|
||||
|
||||
useEffect(() => {
|
||||
if (!html5QrCodeRef.current) {
|
||||
html5QrCodeRef.current = new Html5Qrcode(scannerId);
|
||||
}
|
||||
|
||||
const startScanner = async () => {
|
||||
if (isBusy.current) return;
|
||||
isBusy.current = true;
|
||||
try {
|
||||
if (html5QrCodeRef.current?.isScanning) {
|
||||
await html5QrCodeRef.current.stop();
|
||||
}
|
||||
|
||||
const config = {
|
||||
fps: 15, // Lower FPS for stability
|
||||
qrbox: { width: 280, height: 280 },
|
||||
aspectRatio: 1.0,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.QR_CODE,
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
Html5QrcodeSupportedFormats.CODE_39,
|
||||
Html5QrcodeSupportedFormats.EAN_13,
|
||||
Html5QrcodeSupportedFormats.UPC_A,
|
||||
Html5QrcodeSupportedFormats.DATAMATRIX
|
||||
],
|
||||
// Simplified constraints for maximum compatibility
|
||||
videoConstraints: {
|
||||
facingMode: "environment"
|
||||
}
|
||||
};
|
||||
|
||||
await html5QrCodeRef.current?.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText) => {
|
||||
onScanSuccess(decodedText);
|
||||
},
|
||||
() => {} // Ignore frame errors
|
||||
);
|
||||
|
||||
// Check for zoom capability via video track
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
||||
const caps = track?.getCapabilities() as any;
|
||||
if (track && caps?.zoom) {
|
||||
setHasZoom(true);
|
||||
setMaxZoom(caps.zoom.max || 3);
|
||||
}
|
||||
|
||||
setIsStarted(true);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
const errorMsg = String(err);
|
||||
if (!errorMsg.includes("is already starting") && !errorMsg.includes("already under transition")) {
|
||||
console.error("Scanner failed", err);
|
||||
setError(errorMsg || "Failed to access camera");
|
||||
setIsStarted(false);
|
||||
}
|
||||
} finally {
|
||||
isBusy.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!paused) {
|
||||
startScanner();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (html5QrCodeRef.current?.isScanning) {
|
||||
html5QrCodeRef.current.stop()
|
||||
.then(() => {
|
||||
setIsStarted(false);
|
||||
})
|
||||
.catch(e => console.error("Stop failed", e));
|
||||
}
|
||||
};
|
||||
}, [paused, onScanSuccess]); // Minimal deps
|
||||
|
||||
// Separate effect for OCR interval to avoid restarting scanner
|
||||
useEffect(() => {
|
||||
let intervalId: any;
|
||||
if (isOCRMode && isStarted && !paused) {
|
||||
intervalId = setInterval(() => {
|
||||
handleOCR();
|
||||
}, 5000); // 5 seconds is safer
|
||||
}
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [isOCRMode, isStarted, paused]);
|
||||
|
||||
const handleOCR = async () => {
|
||||
if (ocrProcessing) return;
|
||||
setOcrProcessing(true);
|
||||
try {
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
const vWidth = video.videoWidth || 1280;
|
||||
const vHeight = video.videoHeight || 720;
|
||||
|
||||
// Digital Zoom/Crop: 60% of center
|
||||
const cropFactor = 0.6;
|
||||
const sw = vWidth * cropFactor;
|
||||
const sh = vHeight * cropFactor;
|
||||
const sx = (vWidth - sw) / 2;
|
||||
const sy = (vHeight - sh) / 2;
|
||||
|
||||
canvas.width = 1200;
|
||||
canvas.height = (sh / sw) * 1200;
|
||||
|
||||
console.log(`OCR Capture: ${vWidth}x${vHeight} -> ${canvas.width}x${canvas.height}`);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// More balanced filter for both paper and metal labels
|
||||
ctx.filter = 'grayscale(100%) contrast(180%) brightness(105%)';
|
||||
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Timeout protection for worker initialization (especially when offline for first run)
|
||||
const workerPromise = createWorker('eng');
|
||||
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("OCR Engine timeout - check internet for first run")), 8000));
|
||||
|
||||
const worker = await Promise.race([workerPromise, timeoutPromise]) as any;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
|
||||
// DIAGNOSTIC: Set captured image early so user can see what was sent to AI
|
||||
setCapturedImage(dataUrl);
|
||||
|
||||
const result = await worker.recognize(dataUrl);
|
||||
console.log("OCR Result Size:", result.data?.text?.length);
|
||||
|
||||
const data = result.data;
|
||||
|
||||
// FALLBACK: If we have text but no word coordinates
|
||||
if (data && data.text && (!data.words || data.words.length === 0)) {
|
||||
console.log("Fallback to direct match");
|
||||
onOCRMatch(data.text);
|
||||
await worker.terminate();
|
||||
setCapturedImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || !data.words || data.words.length === 0) {
|
||||
console.warn("OCR produced no text or words.");
|
||||
toast.error("Label not readable. Try better light or zoom.");
|
||||
await worker.terminate();
|
||||
setCapturedImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store words with their bounding boxes
|
||||
const words = data.words.map((w: any) => ({
|
||||
text: w.text,
|
||||
bbox: w.bbox
|
||||
}));
|
||||
|
||||
setDetectedWords(words);
|
||||
setIsSelecting(true);
|
||||
await worker.terminate();
|
||||
} catch (err) {
|
||||
console.error("OCR failed", err);
|
||||
toast.error("OCR selection failed");
|
||||
} finally {
|
||||
setOcrProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWordSelect = (text: string) => {
|
||||
onOCRMatch(text);
|
||||
// Reset selection state
|
||||
setIsSelecting(false);
|
||||
setCapturedImage(null);
|
||||
setDetectedWords([]);
|
||||
};
|
||||
|
||||
const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[340px] mx-auto overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
|
||||
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
|
||||
<div className="w-[280px] h-[280px] border-2 border-primary/50 rounded-3xl relative">
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
|
||||
|
||||
<div className="absolute top-4 right-4 z-30 flex gap-2">
|
||||
<button
|
||||
onClick={() => setIsOCRMode(!isOCRMode)}
|
||||
className={cn(
|
||||
"p-2 rounded-xl border backdrop-blur-md transition-all",
|
||||
isOCRMode ? "bg-primary border-primary text-white shadow-lg" : "bg-slate-900/50 border-slate-700 text-slate-300"
|
||||
)}
|
||||
>
|
||||
<Type size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selection UI */}
|
||||
{isSelecting && capturedImage && (
|
||||
<div className="absolute inset-0 z-50 bg-slate-950 flex flex-col">
|
||||
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden">
|
||||
<img src={capturedImage} className="max-w-full max-h-full object-contain" id="ocr-canvas-preview" />
|
||||
{/* Render Bounding Boxes */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative" style={{ width: '100%', height: '100%' }}>
|
||||
{detectedWords.map((w, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleWordSelect(w.text)}
|
||||
className="absolute border border-primary bg-primary/20 rounded-sm active:bg-primary/50 transition-colors pointer-events-auto"
|
||||
style={{
|
||||
left: `${(w.bbox.x0 / 1600) * 100}%`,
|
||||
top: `${(w.bbox.y0 / (1600 * (9/16))) * 100}%`, // Assuming 16:9 ratio, this may need adjustment based on real canvas aspect
|
||||
width: `${((w.bbox.x1 - w.bbox.x0) / 1600) * 100}%`,
|
||||
height: `${((w.bbox.y1 - w.bbox.y0) / (1600 * (9/16))) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
|
||||
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
|
||||
<button
|
||||
onClick={() => { setIsSelecting(false); setCapturedImage(null); }}
|
||||
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-black text-xs uppercase tracking-widest"
|
||||
>
|
||||
Cancel & Rescan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOCRMode && (
|
||||
<div className="absolute inset-0 z-20 pointer-events-none border-2 border-primary/30 rounded-[2rem] animate-pulse" />
|
||||
)}
|
||||
|
||||
{!isStarted && !error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 gap-4">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">Initializing camera...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-slate-900 text-slate-300 px-8 text-center gap-4">
|
||||
<XCircle className="w-10 h-10 text-red-500" />
|
||||
<div>
|
||||
<p className="font-bold text-white">Camera Error</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-6 py-2 bg-slate-800 rounded-full text-sm font-bold hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-6 left-0 right-0 z-30 flex flex-col items-center gap-4 px-6">
|
||||
{hasZoom && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
// Cycle through: 1x -> 2x -> Max/2 -> Max
|
||||
let nextZoom = 1;
|
||||
if (zoom === 1) nextZoom = Math.min(2, maxZoom);
|
||||
else if (zoom < maxZoom / 2) nextZoom = Math.floor(maxZoom / 2);
|
||||
else if (zoom < maxZoom) nextZoom = maxZoom;
|
||||
else nextZoom = 1;
|
||||
|
||||
const video = document.querySelector(`#${scannerId} video`) as HTMLVideoElement;
|
||||
const track = (video?.srcObject as MediaStream)?.getVideoTracks()[0];
|
||||
if (track) {
|
||||
await track.applyConstraints({ advanced: [{ zoom: nextZoom }] as any });
|
||||
setZoom(nextZoom);
|
||||
}
|
||||
}}
|
||||
className="bg-slate-900/90 backdrop-blur-md border border-slate-700 text-white w-14 h-14 rounded-full flex flex-col items-center justify-center shadow-2xl active:scale-95 transition-transform"
|
||||
>
|
||||
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
|
||||
<span className="text-[8px] uppercase text-primary font-bold">Zoom</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOCRMode ? (
|
||||
<button
|
||||
onClick={handleOCR}
|
||||
disabled={ocrProcessing}
|
||||
className={cn(
|
||||
"w-full py-4 rounded-2xl font-black text-xs uppercase tracking-[0.2em] flex items-center justify-center gap-3 shadow-2xl transition-all active:scale-95 border border-white/10",
|
||||
ocrProcessing ? "bg-slate-800 text-slate-500" : "bg-primary text-white"
|
||||
)}
|
||||
>
|
||||
{ocrProcessing ? <RefreshCw className="animate-spin" size={18} /> : <Search size={18} />}
|
||||
{ocrProcessing ? "Analyzing..." : "Find by Label (OCR)"}
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-black/40 backdrop-blur-sm py-2 px-6 rounded-full border border-white/5">
|
||||
<p className="text-[10px] text-center text-slate-300 uppercase tracking-widest font-black">
|
||||
Center barcode for auto-scan
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/lib/api.ts
Normal file
110
frontend/lib/api.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const getBackendUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8000';
|
||||
|
||||
const host = window.location.hostname;
|
||||
|
||||
// If we are on HTTPS (Proxy/Mobile mode), we use port 3002 for the backend
|
||||
if (window.location.protocol === 'https:') {
|
||||
if (host.includes('.loca.lt')) {
|
||||
return 'https://inventory-ai-api.loca.lt';
|
||||
}
|
||||
return `https://${host}:3002`;
|
||||
}
|
||||
|
||||
return `http://${host}:8000`;
|
||||
};
|
||||
|
||||
export const inventoryApi = {
|
||||
getItems: async () => {
|
||||
const res = await axios.get(`${getBackendUrl()}/items/`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
syncBulkOperations: async (userId: number, operations: any[]) => {
|
||||
const url = `${getBackendUrl()}/operations/bulk-sync`;
|
||||
try {
|
||||
const res = await axios.post(url, {
|
||||
user_id: userId,
|
||||
operations: operations
|
||||
});
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error("Sync API Error at:", url, err);
|
||||
if (err.response?.status === 404) {
|
||||
throw new Error(`404: Endpoint not found at ${url}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
analyzeLabel: async (formData: FormData) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/items/extract-label`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createItem: async (userId: number, itemData: any) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/items/`, itemData, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
updateItem: async (itemId: number, itemData: any) => {
|
||||
const res = await axios.put(`${getBackendUrl()}/items/${itemId}`, itemData);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
adjustStock: async (endpoint: string, data: any) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/operations/${endpoint}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
deleteItem: async (itemId: number) => {
|
||||
const res = await axios.delete(`${getBackendUrl()}/items/${itemId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
getAuditLogs: async (limit: number = 50) => {
|
||||
const res = await axios.get(`${getBackendUrl()}/operations/logs`, { params: { limit } });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// Users
|
||||
getUsers: async () => {
|
||||
const res = await axios.get(`${getBackendUrl()}/users/`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
createUser: async (userData: any) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/users/`, userData);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
login: async (credentials: any) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/users/login`, credentials);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
deleteUser: async (userId: number) => {
|
||||
const res = await axios.delete(`${getBackendUrl()}/users/${userId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// Categories
|
||||
getCategories: async () => {
|
||||
const res = await axios.get(`${getBackendUrl()}/categories/`);
|
||||
return res.data;
|
||||
},
|
||||
createCategory: async (data: any) => {
|
||||
const res = await axios.post(`${getBackendUrl()}/categories/`, data);
|
||||
return res.data;
|
||||
},
|
||||
deleteCategory: async (id: number) => {
|
||||
const res = await axios.delete(`${getBackendUrl()}/categories/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
41
frontend/lib/db.ts
Normal file
41
frontend/lib/db.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import Dexie, { Table } from 'dexie';
|
||||
|
||||
export interface Item {
|
||||
id?: number;
|
||||
barcode: string;
|
||||
name: string;
|
||||
category: string;
|
||||
part_number?: string;
|
||||
color?: string;
|
||||
specs?: string;
|
||||
quantity: number;
|
||||
min_quantity: number;
|
||||
image_url?: string;
|
||||
labels_data?: string;
|
||||
}
|
||||
|
||||
export interface PendingOperation {
|
||||
id?: number;
|
||||
type: 'CHECK_IN' | 'CHECK_OUT' | 'TRASH';
|
||||
barcode: string;
|
||||
quantity: number;
|
||||
timestamp: number;
|
||||
synced: 0 | 1; // 0 for false, 1 for true
|
||||
uuid: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export class InventoryDatabase extends Dexie {
|
||||
items!: Table<Item>;
|
||||
pendingOperations!: Table<PendingOperation>;
|
||||
|
||||
constructor() {
|
||||
super('InventoryDatabase');
|
||||
this.version(3).stores({
|
||||
items: '++id, barcode, name, category, part_number, color',
|
||||
pendingOperations: '++id, barcode, timestamp, synced, uuid'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new InventoryDatabase();
|
||||
62
frontend/lib/sync.ts
Normal file
62
frontend/lib/sync.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db, PendingOperation } from './db';
|
||||
import { inventoryApi } from './api';
|
||||
|
||||
export const syncOfflineOperations = async (userId: number) => {
|
||||
const pending = await db.pendingOperations
|
||||
.filter(op => op.synced === 0)
|
||||
.toArray();
|
||||
|
||||
if (pending.length === 0) return { success: 0, errors: 0 };
|
||||
|
||||
try {
|
||||
// Format operations for the backend (convert timestamp to ISO string if needed)
|
||||
const formattedOps = pending.map(op => ({
|
||||
type: op.type,
|
||||
barcode: op.barcode,
|
||||
quantity: op.quantity,
|
||||
timestamp: new Date(op.timestamp).toISOString(),
|
||||
uuid: op.uuid,
|
||||
details: op.details
|
||||
}));
|
||||
|
||||
const results = await inventoryApi.syncBulkOperations(userId, formattedOps);
|
||||
|
||||
// Mark successfully synced operations in IndexedDB
|
||||
// For simplicity, if the whole bulk call succeeds, we mark all as synced or delete them
|
||||
// Real implementation should match success/error per item
|
||||
|
||||
const successBarcodes = new Set(results.success.map((s: any) => s.barcode));
|
||||
|
||||
for (const op of pending) {
|
||||
if (successBarcodes.has(op.barcode)) {
|
||||
await db.pendingOperations.update(op.id!, { synced: 1 });
|
||||
// Or remove it: await db.pendingOperations.delete(op.id!);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up synced operations
|
||||
await db.pendingOperations.where('synced').equals(1).delete();
|
||||
|
||||
return {
|
||||
success: results.success.length,
|
||||
errors: results.errors.length,
|
||||
details: results
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchAndCacheItems = async () => {
|
||||
try {
|
||||
const items = await inventoryApi.getItems();
|
||||
// Update local cache
|
||||
await db.items.clear();
|
||||
await db.items.bulkAdd(items);
|
||||
return items;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch items, using cache:', error);
|
||||
return await db.items.toArray();
|
||||
}
|
||||
};
|
||||
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
15
frontend/next.config.mjs
Normal file
15
frontend/next.config.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import withPWAInit from 'next-pwa';
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: 'public',
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Config options here
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
245
frontend/package-lock.json
generated
245
frontend/package-lock.json
generated
@@ -8,14 +8,19 @@
|
||||
"name": "inventory-pwa",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.4.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.446.0",
|
||||
"next": "^15.0.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -3906,6 +3911,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
@@ -3940,6 +3951,17 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -4057,6 +4079,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bootstrap-icons": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz",
|
||||
@@ -4348,6 +4376,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -4439,7 +4479,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -4622,6 +4661,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -4632,6 +4680,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dexie": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz",
|
||||
"integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -5538,6 +5592,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -5553,6 +5627,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
|
||||
@@ -5807,6 +5897,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -5912,12 +6011,24 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html5-qrcode": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -6401,6 +6512,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-weakmap": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||
@@ -6886,7 +7003,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -6896,7 +7012,6 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -7118,6 +7233,48 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/node-fetch/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.37",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
||||
@@ -7272,6 +7429,15 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -7768,6 +7934,15 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -7827,6 +8002,23 @@
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -7907,6 +8099,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
@@ -8955,6 +9153,30 @@
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^7.0.0",
|
||||
"wasm-feature-detect": "^1.8.0",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -9388,6 +9610,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -9967,6 +10195,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,23 +9,28 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.15.0",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.4.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.446.0",
|
||||
"next": "^15.0.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "^15.0.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"next-pwa": "^5.6.0"
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.0"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
0
frontend/public/custom-logo.png
Normal file
0
frontend/public/custom-logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
frontend/public/logo_TFM.png
Normal file
BIN
frontend/public/logo_TFM.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "Inventory PWA",
|
||||
"short_name": "Inventory",
|
||||
"description": "Unified Web & Mobile Inventory System",
|
||||
"description": "Unified inventory management with offline scanning",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#3b82f6",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ const config: Config = {
|
||||
foreground: "#ffffff",
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
scan: {
|
||||
'0%, 100%': { top: '0%' },
|
||||
'50%': { top: '100%' },
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'scan-fast': 'scan 2.5s ease-in-out infinite',
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user