Build [v1.9.19]
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.9.18",
|
||||
"version": "1.9.19",
|
||||
"last_build": "2026-04-13-2343",
|
||||
"codename": "MobilePolish",
|
||||
"commit": "1fff658d"
|
||||
|
||||
@@ -13,21 +13,12 @@ import {
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
LogOut,
|
||||
Database,
|
||||
History,
|
||||
Lock,
|
||||
Edit2,
|
||||
X,
|
||||
Server,
|
||||
Globe,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Download,
|
||||
RotateCcw
|
||||
RotateCcw,
|
||||
FileText,
|
||||
Upload,
|
||||
Brain
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -61,6 +52,11 @@ export default function AdminPage() {
|
||||
const [dbStats, setDbStats] = useState({ backup_count: 0, total_size_bytes: 0 });
|
||||
const [dbSettings, setDbSettings] = useState({ retention_count: 10, schedule_hour: 3, schedule_freq_days: 1 });
|
||||
const [isBackingUp, setIsBackingUp] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// AI Prompt State
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [isSavingPrompt, setIsSavingPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -83,6 +79,9 @@ export default function AdminPage() {
|
||||
setBackups(b);
|
||||
setDbStats(s);
|
||||
setDbSettings(st);
|
||||
|
||||
const p = await inventoryApi.getAiPrompt();
|
||||
setAiPrompt(p.value);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error("Failed to load admin data");
|
||||
@@ -213,6 +212,60 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePrompt = async () => {
|
||||
setIsSavingPrompt(true);
|
||||
try {
|
||||
await inventoryApi.updateAiPrompt(aiPrompt);
|
||||
toast.success("AI Extraction Prompt updated");
|
||||
} catch (err: any) {
|
||||
toast.error("Failed to update AI prompt");
|
||||
} finally {
|
||||
setIsSavingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportDb = async () => {
|
||||
try {
|
||||
const blob = await inventoryApi.exportDb();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `inventory_backup_${new Date().toISOString().split('T')[0]}.db`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Database exported successfully");
|
||||
} catch (err: any) {
|
||||
toast.error("Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportDb = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm("DANGEROUS: You are about to REPLACE the entire database with this file. All current data will be lost. Proceed?")) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setIsImporting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const loadingToast = toast.loading("Importing database...");
|
||||
try {
|
||||
await inventoryApi.importDb(formData);
|
||||
toast.success("Database imported! Reloading system...", { id: loadingToast });
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (err: any) {
|
||||
toast.error("Import failed: " + (err.response?.data?.detail || err.message), { id: loadingToast });
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLdap = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -565,6 +618,48 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Configuration Section */}
|
||||
<section className="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] space-y-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-purple-500/10 rounded-2xl text-purple-500 border border-purple-500/20">
|
||||
<Brain size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-white">AI Vision Intelligence</h2>
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Configure extraction prompts and heuristic models</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleUpdatePrompt}
|
||||
disabled={isSavingPrompt}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-black text-xs px-6 py-3 rounded-xl shadow-xl shadow-purple-500/20 transition-all active:scale-95 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSavingPrompt ? <div className="w-3 h-3 border-2 border-white border-t-transparent animate-spin rounded-full" /> : <Edit2 size={14} />}
|
||||
Save AI Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<FileText size={14} className="text-purple-400" />
|
||||
<label className="text-xs font-black text-slate-300">Global Extraction Prompt</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="Enter the AI system prompt for label extraction..."
|
||||
className="w-full bg-slate-950/80 border border-slate-800 rounded-3xl p-6 text-sm text-slate-300 font-mono leading-relaxed min-h-[300px] outline-none focus:border-purple-500/30 transition-all custom-scrollbar"
|
||||
/>
|
||||
<div className="bg-purple-500/5 border border-purple-500/10 p-5 rounded-2xl">
|
||||
<p className="text-[10px] text-slate-500 leading-relaxed font-bold italic">
|
||||
Tip: Use specific instructions like "Return ONLY valid JSON" to ensure consistent results.
|
||||
Reference fields: <span className="text-purple-400/80">Item, Type, Description, Category, Connector, Size, Color, PartNr, OCR</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category Management Section */}
|
||||
<section className="glass-card p-6 md:p-8 rounded-[2.5rem] md:rounded-[3rem] space-y-8">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -674,18 +769,38 @@ export default function AdminPage() {
|
||||
<p className="text-xs text-slate-500 font-bold mt-1">Snapshot management and disaster recovery tools</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackingUp}
|
||||
className="group flex items-center justify-center gap-2 bg-amber-500/10 hover:bg-amber-500 text-amber-500 hover:text-white font-black text-xs px-6 py-3 rounded-xl transition-all border border-amber-500/20 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{isBackingUp ? (
|
||||
<div className="w-3 h-3 border-2 border-current border-t-transparent animate-spin rounded-full" />
|
||||
) : (
|
||||
<Download size={14} className="group-hover:-translate-y-0.5 transition-transform" />
|
||||
)}
|
||||
Create Manual Backup
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExportDb}
|
||||
className="flex items-center justify-center gap-2 bg-slate-800 hover:bg-slate-700 text-slate-300 font-black text-xs px-5 py-3 rounded-xl transition-all border border-slate-700 active:scale-95"
|
||||
title="Download current database as .db file"
|
||||
>
|
||||
<Download size={14} /> Export
|
||||
</button>
|
||||
<label className="flex items-center justify-center gap-2 bg-slate-800 hover:bg-rose-900/20 text-slate-300 hover:text-rose-400 font-black text-xs px-5 py-3 rounded-xl transition-all border border-slate-700 hover:border-rose-500/30 cursor-pointer active:scale-95">
|
||||
<Upload size={14} /> Import
|
||||
<input
|
||||
type="file"
|
||||
accept=".db,.sqlite,.sqlite3"
|
||||
className="hidden"
|
||||
onChange={handleImportDb}
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</label>
|
||||
<div className="w-px h-8 bg-slate-800 mx-2" />
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isBackingUp}
|
||||
className="group flex items-center justify-center gap-2 bg-amber-500/10 hover:bg-amber-500 text-amber-500 hover:text-white font-black text-xs px-6 py-3 rounded-xl transition-all border border-amber-500/20 active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
{isBackingUp ? (
|
||||
<div className="w-3 h-3 border-2 border-current border-t-transparent animate-spin rounded-full" />
|
||||
) : (
|
||||
<Download size={14} className="group-hover:-translate-y-0.5 transition-transform" />
|
||||
)}
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function Home() {
|
||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||
const [categories, setCategories] = useState<any[]>([]);
|
||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||
const [boxSearchQuery, setBoxSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('inventory_token')) {
|
||||
@@ -291,6 +292,10 @@ export default function Home() {
|
||||
const sn = (item.serial_number || '').toUpperCase();
|
||||
const name = item.name.toUpperCase();
|
||||
const category = item.category.toUpperCase();
|
||||
const ocrKey = (item.ocr_text || '').toUpperCase().replace(/[^A-Z0-9\s/+-]/g, ' ');
|
||||
|
||||
// Priority 0: Exact OCR Key match (Heuristic provided by AI)
|
||||
if (ocrKey && cleanText.includes(ocrKey)) score += 1000;
|
||||
|
||||
// Priority 1: Serial Number (Absolute match)
|
||||
if (sn && cleanText.includes(sn)) score += 500;
|
||||
@@ -418,10 +423,13 @@ export default function Home() {
|
||||
return (
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.category.toLowerCase().includes(query) ||
|
||||
(item.specs?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.description?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.connector?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.size?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.part_number?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.color?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.box_label?.toLowerCase().includes(query) ?? false)
|
||||
(item.box_label?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.ocr_text?.toLowerCase().includes(query) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -476,19 +484,26 @@ export default function Home() {
|
||||
{isScannerReady && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]" />
|
||||
<span className="text-[10px] sm:text-xs font-black text-green-500/90 whitespace-nowrap tracking-wider uppercase">
|
||||
<span className="text-[10px] sm:text-xs font-black text-green-500/90 whitespace-nowrap">
|
||||
Scanner: OK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isOnline ? 'bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.6)]' : 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.6)]'}`} />
|
||||
<span className={`text-[10px] sm:text-xs font-black whitespace-nowrap tracking-wider uppercase ${isOnline ? 'text-green-500/90' : 'text-rose-500/90'}`}>
|
||||
<span className={`text-[10px] sm:text-xs font-black whitespace-nowrap ${isOnline ? 'text-green-500/90' : 'text-rose-500/90'}`}>
|
||||
Sync: {isOnline ? 'Active' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
className="p-2.5 bg-slate-900/80 border border-slate-800 text-slate-400 rounded-xl hover:text-primary transition-all active:scale-95"
|
||||
title="Box Manager"
|
||||
>
|
||||
<Package size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
@@ -553,27 +568,31 @@ export default function Home() {
|
||||
|
||||
<div className="w-full h-px bg-slate-800/50 my-2" />
|
||||
|
||||
<div className="w-full grid grid-cols-1 gap-4">
|
||||
<div className="w-full grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setShowOnboarding(true)}
|
||||
className="w-full h-18 py-4 rounded-[1.5rem] bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center gap-4 group hover:border-indigo-500/50 transition-all font-black text-indigo-400"
|
||||
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-indigo-500/10 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-3"
|
||||
>
|
||||
<Sparkles size={24} className="group-hover:scale-125 transition-transform" />
|
||||
<span className="text-left leading-tight">
|
||||
Add NEW Item<br />
|
||||
<span className="text-[10px] opacity-60 uppercase tracking-widest font-mono">AI Onboarding</span>
|
||||
</span>
|
||||
<div className="p-3 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Sparkles size={24} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm leading-tight">Add Item</p>
|
||||
<p className="text-[10px] opacity-60 font-mono mt-1">AI Onboarding</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowBoxManager(true)}
|
||||
className="w-full h-18 py-4 rounded-[1.5rem] bg-slate-900/50 border border-slate-800 flex items-center justify-center gap-4 group hover:border-primary/40 transition-all font-black text-slate-300"
|
||||
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-slate-900/50 border border-slate-800 group hover:border-primary/40 transition-all font-black text-slate-300 gap-3"
|
||||
>
|
||||
<Package size={24} className="text-primary group-hover:scale-125 transition-transform" />
|
||||
<span className="text-left leading-tight">
|
||||
Manage Boxes<br />
|
||||
<span className="text-[10px] opacity-60 uppercase tracking-widest font-mono">Box Inventory</span>
|
||||
</span>
|
||||
<div className="p-3 bg-primary/10 rounded-2xl group-hover:scale-110 transition-transform">
|
||||
<Package size={24} className="text-primary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm leading-tight">Manage Boxes</p>
|
||||
<p className="text-[10px] opacity-60 font-mono mt-1">Box Inventory</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -709,15 +728,54 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Specs</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.specs || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, specs: e.target.value })}
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Connectors</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.connector || ''}
|
||||
onChange={e => setEditedItem({...editedItem, connector: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
|
||||
placeholder="e.g. LC/UPC"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Size / Length</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.size || ''}
|
||||
onChange={e => setEditedItem({...editedItem, size: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
|
||||
placeholder="e.g. 5m / 10G"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Color</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedItem.color || ''}
|
||||
onChange={e => setEditedItem({...editedItem, color: e.target.value})}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
|
||||
placeholder="e.g. Aqua"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">Description</label>
|
||||
<textarea
|
||||
value={editedItem.description || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, description: e.target.value })}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 resize-none h-20"
|
||||
placeholder="Item description..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs font-black text-slate-500 ml-1">OCR Matching Key (Local Identification)</label>
|
||||
<textarea
|
||||
value={editedItem.ocr_text || ''}
|
||||
onChange={e => setEditedItem({ ...editedItem, ocr_text: e.target.value })}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-[10px] font-mono outline-none text-slate-400 resize-none h-12"
|
||||
placeholder="Paste identification string here..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -861,59 +919,84 @@ export default function Home() {
|
||||
{showBoxManager && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md animate-in fade-in duration-300">
|
||||
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
<div className="p-8 border-b border-slate-800 flex justify-between items-center shrink-0 bg-slate-900/50">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
|
||||
<Package className="text-primary" size={28} />
|
||||
Box Inventory
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
|
||||
<div className="p-8 pb-4 flex flex-col gap-6 shrink-0 bg-slate-900/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
|
||||
<Package className="text-primary" size={28} />
|
||||
Box Inventory
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search boxes..."
|
||||
value={boxSearchQuery}
|
||||
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600" size={18} />
|
||||
{boxSearchQuery && (
|
||||
<button
|
||||
onClick={() => setBoxSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-slate-800 rounded-lg text-slate-500"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setShowBoxManager(false)} className="p-3 hover:bg-slate-800 rounded-full transition-colors">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{existingBoxes.length === 0 ? (
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 pt-4">
|
||||
{existingBoxes.filter(b => b.toLowerCase().includes(boxSearchQuery.toLowerCase())).length === 0 ? (
|
||||
<div className="py-20 text-center space-y-4 opacity-40">
|
||||
<Package size={48} className="mx-auto" />
|
||||
<p className="font-bold">No box labels defined yet.</p>
|
||||
<p className="font-bold">{existingBoxes.length === 0 ? 'No box labels defined yet.' : 'No matching boxes found.'}</p>
|
||||
<p className="text-xs max-w-xs mx-auto">Associate items with a "Box Label" in their metadata to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{existingBoxes.map(box => {
|
||||
const itemCount = inventory.filter(i => i.box_label === box).length;
|
||||
return (
|
||||
<div key={box} className="bg-slate-950 border border-slate-800 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/50 transition-all">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-lg font-black text-white truncate">{box}</h4>
|
||||
<p className="text-xs text-slate-500 font-bold">{itemCount} items linked</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedBoxLabel(box)}
|
||||
className="flex-1 py-3 bg-primary text-white text-xs font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Printer size={14} /> Print Label
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSearchQuery(box.toLowerCase()); setShowBoxManager(false); }}
|
||||
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-xs font-bold rounded-xl hover:bg-slate-800"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pb-4">
|
||||
{existingBoxes
|
||||
.filter(box => box.toLowerCase().includes(boxSearchQuery.toLowerCase()))
|
||||
.map(box => {
|
||||
const itemCount = inventory.filter(i => i.box_label === box).length;
|
||||
return (
|
||||
<div key={box} className="bg-slate-950/50 border border-slate-800/60 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/40 transition-all">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-lg font-black text-white truncate">{box}</h4>
|
||||
<p className="text-xs text-slate-500 font-bold mt-0.5">{itemCount} items linked</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedBoxLabel(box)}
|
||||
className="flex-1 py-3 bg-primary text-white text-[11px] font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/10 active:scale-95"
|
||||
>
|
||||
<Printer size={14} /> Print Label
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSearchQuery(box.toLowerCase()); setShowBoxManager(false); setBoxSearchQuery(''); }}
|
||||
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-[11px] font-bold rounded-xl hover:bg-slate-800 active:scale-95"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-950/50 border-t border-slate-800 text-center">
|
||||
<p className="text-[10px] text-slate-600 font-bold uppercase tracking-widest">TFM aInventory • Box Management Mode</p>
|
||||
<div className="p-6 py-4 bg-slate-950/50 border-t border-slate-800 text-center flex items-center justify-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-primary animate-pulse" />
|
||||
<p className="text-[10px] text-slate-500 font-bold font-mono">TFM aInventory • Box management mode</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,8 +73,12 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
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()}`),
|
||||
description: String(extractedData.description || ""),
|
||||
connector: extractedData.connector ? String(extractedData.connector) : null,
|
||||
size: extractedData.size ? String(extractedData.size) : null,
|
||||
ocr_text: extractedData.ocr_text ? String(extractedData.ocr_text) : null,
|
||||
specs: String(extractedData.specs || ""), // Keep specs as extra data if needed
|
||||
barcode: String(extractedData.barcode || extractedData.part_number || `AI-${Date.now()}`),
|
||||
quantity: parseFloat(String(extractedData.quantity || 1)),
|
||||
min_quantity: 1.0,
|
||||
box_label: extractedData.box_label ? String(extractedData.box_label) : null,
|
||||
@@ -273,12 +277,46 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
||||
</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-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Technical Specifications</label>
|
||||
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Description</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..."
|
||||
value={extractedData.description || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, description: e.target.value})}
|
||||
className="bg-transparent w-full text-sm leading-relaxed outline-none resize-none h-16 text-slate-300"
|
||||
placeholder="Product description..."
|
||||
/>
|
||||
</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-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Connector</label>
|
||||
<input
|
||||
value={extractedData.connector || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, connector: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. LC/UPC"
|
||||
/>
|
||||
</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-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Size / Length</label>
|
||||
<input
|
||||
value={extractedData.size || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, size: e.target.value})}
|
||||
className="bg-transparent w-full font-bold outline-none text-slate-200"
|
||||
placeholder="e.g. 5m / 10G"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-2 border-primary/20 p-5 rounded-[1.5rem] shadow-lg">
|
||||
<div className="flex items-center gap-2 mb-2 text-primary">
|
||||
<Sparkles size={14} />
|
||||
<label className="text-xs font-black uppercase tracking-tighter">AI OCR Matching Key</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={extractedData.ocr_text || ''}
|
||||
onChange={(e) => setExtractedData({...extractedData, ocr_text: e.target.value})}
|
||||
className="bg-transparent w-full text-[10px] font-mono leading-tight outline-none resize-none h-12 text-slate-400"
|
||||
placeholder="Heuristic string for local matching..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -220,5 +220,32 @@ export const inventoryApi = {
|
||||
updateDbSettings: async (settings: any) => {
|
||||
const res = await axiosInstance.patch('/admin/db/settings', settings);
|
||||
return res.data;
|
||||
},
|
||||
exportDb: async () => {
|
||||
const res = await axiosInstance.get('/admin/db/export', { responseType: 'blob' });
|
||||
return res.data;
|
||||
},
|
||||
importDb: async (formData: FormData) => {
|
||||
const res = await axiosInstance.post('/admin/db/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
// System Settings
|
||||
getSystemSettings: async () => {
|
||||
// Note: Reusing the existing settings router pattern but adding AI Prompt
|
||||
const res = await axiosInstance.get('/admin/db/settings');
|
||||
// We need another endpoint for general settings or expand the DB one
|
||||
// For now, I'll add a specific fetch for the prompt
|
||||
const promptRes = await axiosInstance.get('/admin/db/settings/prompt');
|
||||
return { ...res.data, ai_extraction_prompt: promptRes.data.value };
|
||||
},
|
||||
getAiPrompt: async () => {
|
||||
const res = await axiosInstance.get('/admin/db/settings/prompt');
|
||||
return res.data;
|
||||
},
|
||||
updateAiPrompt: async (prompt: string) => {
|
||||
const res = await axiosInstance.post('/admin/db/settings/prompt', { value: prompt });
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface Item {
|
||||
labels_data?: string;
|
||||
serial_number?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
connector?: string;
|
||||
size?: string;
|
||||
ocr_text?: string;
|
||||
}
|
||||
|
||||
export interface PendingOperation {
|
||||
@@ -34,8 +38,8 @@ export class InventoryDatabase extends Dexie {
|
||||
|
||||
constructor() {
|
||||
super('InventoryDatabase');
|
||||
this.version(4).stores({
|
||||
items: '++id, barcode, name, category, part_number, color, box_label',
|
||||
this.version(5).stores({
|
||||
items: '++id, barcode, name, category, part_number, color, box_label, ocr_text',
|
||||
pendingOperations: '++id, barcode, timestamp, synced, uuid'
|
||||
});
|
||||
}
|
||||
|
||||
7
frontend/public/network.json
Normal file
7
frontend/public/network.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"SERVER_IP": "192.168.84.113",
|
||||
"BACKEND_PORT": 8916,
|
||||
"BACKEND_SSL_PORT": 8918,
|
||||
"FRONTEND_PORT": 8917,
|
||||
"FRONTEND_SSL_PORT": 8919
|
||||
}
|
||||
Reference in New Issue
Block a user