Infrastructure: Implement master/dev/vX branching, save git path, and fix username casing

This commit is contained in:
Daniel Bedeleanu
2026-04-10 21:51:22 +03:00
parent 93edcc261b
commit 8a3783c7e9
3397 changed files with 57402 additions and 98 deletions

View File

@@ -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

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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);

View File

@@ -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": "*"
}
}
}
}

View File

@@ -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"
}
}

View File

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -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"
}

View File

@@ -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: [],