The extracted image blob from AI extraction was not being sent to the backend because Blob objects cannot be JSON serialized. Fixed by: 1. Converting Blob to base64 before sending to API 2. Renaming fields: extractedImageBlob → extracted_image_bytes, imageProcessing → image_processing 3. Removing Blob from local DB (keep original data structure for IndexedDB) 4. Applying same fix to both single item and batch update flows This ensures the auto-photo-save feature receives the image data it needs.
517 lines
19 KiB
TypeScript
517 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { db, Item } from '@/lib/db';
|
|
import { inventoryApi } from '@/lib/api';
|
|
import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync';
|
|
import { useScanner } from '@/hooks/useScanner';
|
|
import { useStockAdjustment } from '@/hooks/useStockAdjustment';
|
|
import { useSync } from '@/hooks/useSync';
|
|
import Scanner from '@/components/Scanner';
|
|
import AIOnboarding from '@/components/AIOnboarding';
|
|
import PageShell from '@/components/PageShell';
|
|
import ItemComparisonModal from '@/components/ItemComparisonModal';
|
|
import StockAdjustmentPanel from '@/components/StockAdjustmentPanel';
|
|
import NewItemDialog from '@/components/NewItemDialog';
|
|
import ScannerSection from '@/components/ScannerSection';
|
|
import { toast } from 'react-hot-toast';
|
|
import {
|
|
Package,
|
|
Camera,
|
|
Plus,
|
|
Minus,
|
|
Trash2,
|
|
AlertTriangle,
|
|
X,
|
|
ChevronRight,
|
|
Edit2,
|
|
RefreshCw,
|
|
Search
|
|
} from 'lucide-react';
|
|
import { generateBarcode128, getQRCodeURL } from '@/lib/labels';
|
|
import { clsx, type ClassValue } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
import axios from 'axios';
|
|
import versionData from '../VERSION.json';
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
role: string;
|
|
}
|
|
|
|
function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
export default function Home() {
|
|
const [mounted, setMounted] = useState(false);
|
|
const [isOnline, setIsOnline] = useState(true);
|
|
const [inventory, setInventory] = useState<Item[]>([]);
|
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
|
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
|
|
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
|
const [boxMatches, setBoxMatches] = useState<Item[]>([]);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editedItem, setEditedItem] = useState<Partial<Item>>({});
|
|
const [trashReason, setTrashReason] = useState('Damaged');
|
|
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
|
const [categories, setCategories] = useState<any[]>([]);
|
|
const [comparisonModal, setComparisonModal] = useState<{ show: boolean, newItem: any, existingItem: any, existingId: number | null }>({ show: false, newItem: null, existingItem: null, existingId: null });
|
|
const [comparisonLoading, setComparisonLoading] = useState(false);
|
|
|
|
const { syncing, handleSync } = useSync({
|
|
isOnline,
|
|
currentUser,
|
|
onInventoryUpdate: setInventory
|
|
});
|
|
|
|
const {
|
|
mode,
|
|
setMode,
|
|
showScanner,
|
|
setShowScanner,
|
|
lastScanned,
|
|
isScannerReady,
|
|
fieldScanning,
|
|
setFieldScanning,
|
|
onScanSuccess,
|
|
onOCRMatch,
|
|
} = useScanner({
|
|
inventory,
|
|
isOnline,
|
|
onSync: handleSync,
|
|
onMatchFound: (item, adjustType) => {
|
|
setSelectedItem(item);
|
|
setAdjustType(adjustType);
|
|
},
|
|
onMultipleMatches: (items) => {
|
|
setBoxMatches(items);
|
|
},
|
|
onFieldCapture: (field, value) => {
|
|
if (field === 'box_label') {
|
|
setEditedItem(prev => ({ ...prev, box_label: value }));
|
|
}
|
|
}
|
|
});
|
|
|
|
const {
|
|
adjustQty,
|
|
setAdjustQty,
|
|
adjustType,
|
|
setAdjustType,
|
|
handleAdjustStock
|
|
} = useStockAdjustment({
|
|
selectedItem,
|
|
isOnline,
|
|
currentUser,
|
|
onAdjustmentComplete: () => {
|
|
setSelectedItem(null);
|
|
},
|
|
onInventoryUpdate: setInventory
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!localStorage.getItem('inventory_token')) {
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
|
|
setMounted(true);
|
|
setIsOnline(navigator.onLine);
|
|
|
|
const savedUser = localStorage.getItem('inventory_user');
|
|
if (savedUser) {
|
|
setCurrentUser(JSON.parse(savedUser));
|
|
}
|
|
|
|
// Initial data fetch
|
|
inventoryApi.getCategories().then(c => setCategories(c)).catch(() => { });
|
|
loadInventory();
|
|
|
|
const handleOnline = () => {
|
|
setIsOnline(true);
|
|
};
|
|
const handleOffline = () => setIsOnline(false);
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
// Active polling for network status AND periodic background refresh (30s)
|
|
const interval = setInterval(() => {
|
|
setIsOnline(navigator.onLine);
|
|
if (navigator.onLine) {
|
|
loadInventory(); // Keep other devices in sync
|
|
}
|
|
}, 30000);
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
const loadInventory = async () => {
|
|
const cached = await db.items.toArray();
|
|
setInventory(cached);
|
|
|
|
if (navigator.onLine) {
|
|
const fresh = await fetchAndCacheItems();
|
|
setInventory(fresh);
|
|
}
|
|
};
|
|
|
|
const handleOnboardingComplete = async (itemData: any) => {
|
|
try {
|
|
if (itemData.part_number) {
|
|
itemData.part_number = itemData.part_number.toLowerCase();
|
|
}
|
|
|
|
// Prepare data for backend: convert Blob to base64 and rename fields
|
|
const backendData = { ...itemData };
|
|
delete backendData.extractedImageBlob; // Remove Blob from local DB version
|
|
|
|
// If extracted image blob is present, convert to base64 for backend
|
|
if (itemData.extractedImageBlob) {
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(itemData.extractedImageBlob);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
reader.onload = () => {
|
|
const base64 = (reader.result as string).split(',')[1]; // Remove data URL prefix
|
|
backendData.extracted_image_bytes = base64;
|
|
backendData.image_processing = itemData.imageProcessing; // Use correct field name for API
|
|
delete backendData.imageProcessing; // Remove old field name
|
|
resolve(null);
|
|
};
|
|
reader.onerror = reject;
|
|
});
|
|
}
|
|
|
|
// 1. Add to local DB cache (without Blob)
|
|
await db.items.add(itemData);
|
|
|
|
// 2. If online, try to push to backend immediately
|
|
if (isOnline && currentUser) {
|
|
try {
|
|
await inventoryApi.createItem(currentUser.id, backendData);
|
|
toast.success("Item saved to cloud catalog!");
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
} catch (err: any) {
|
|
const status = err.response?.status;
|
|
const responseData = err.response?.data;
|
|
|
|
if (status === 409 && responseData?.detail) {
|
|
// Show comparison modal for duplicate part number
|
|
const detail = responseData.detail;
|
|
setComparisonModal({
|
|
show: true,
|
|
newItem: backendData,
|
|
existingItem: detail.existing_item,
|
|
existingId: detail.existing_id
|
|
});
|
|
return; // Don't close onboarding, user will decide
|
|
} else {
|
|
console.error("Cloud sync failed:", err.message);
|
|
toast.error("Item saved locally. Cloud sync failed.");
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
}
|
|
}
|
|
} else if (!isOnline) {
|
|
toast.success("Item saved locally. Will sync when online.");
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
} else {
|
|
toast("Please log in to save to cloud.", { icon: '⚠️' });
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Failed to save item");
|
|
}
|
|
};
|
|
|
|
const handleComparisonUpdate = async () => {
|
|
if (!comparisonModal.existingId) return;
|
|
setComparisonLoading(true);
|
|
try {
|
|
// Prepare data: convert Blob to base64 if present
|
|
const updateData = { ...comparisonModal.newItem };
|
|
|
|
if (updateData.extractedImageBlob) {
|
|
const reader = new FileReader();
|
|
reader.readAsDataURL(updateData.extractedImageBlob);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
reader.onload = () => {
|
|
const base64 = (reader.result as string).split(',')[1];
|
|
updateData.extracted_image_bytes = base64;
|
|
updateData.image_processing = updateData.imageProcessing;
|
|
delete updateData.extractedImageBlob;
|
|
delete updateData.imageProcessing;
|
|
resolve(null);
|
|
};
|
|
reader.onerror = reject;
|
|
});
|
|
}
|
|
|
|
// Update existing item
|
|
await inventoryApi.updateItem(comparisonModal.existingId, updateData);
|
|
toast.success("Item updated successfully!");
|
|
setComparisonModal({ show: false, newItem: null, existingItem: null, existingId: null });
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
} catch (err: any) {
|
|
console.error("Update failed:", err.message);
|
|
toast.error("Failed to update item");
|
|
} finally {
|
|
setComparisonLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleComparisonSkip = () => {
|
|
// Item was already added to local DB, just close modal and onboarding
|
|
setComparisonModal({ show: false, newItem: null, existingItem: null, existingId: null });
|
|
setShowOnboarding(false);
|
|
toast("Local copy saved. Not synced to cloud.", { icon: '💾' });
|
|
loadInventory();
|
|
};
|
|
|
|
const handleUpdateItem = async () => {
|
|
if (!selectedItem) return;
|
|
try {
|
|
const updated = { ...selectedItem, ...editedItem };
|
|
// Normalize PN
|
|
if (updated.part_number) updated.part_number = updated.part_number.toLowerCase();
|
|
|
|
await db.items.update(selectedItem.id!, updated);
|
|
|
|
if (isOnline) {
|
|
await inventoryApi.updateItem(selectedItem.id!, updated);
|
|
}
|
|
|
|
toast.success("Item updated successfully");
|
|
setIsEditing(false);
|
|
setSelectedItem(updated as Item);
|
|
await loadInventory();
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
toast.error("Failed to update item");
|
|
}
|
|
};
|
|
|
|
const handleDeleteItem = async () => {
|
|
if (!selectedItem || !selectedItem.id) return;
|
|
if (!window.confirm(`Are you sure you want to delete "${selectedItem.name}" completely from the catalog?`)) return;
|
|
|
|
try {
|
|
await db.items.delete(selectedItem.id);
|
|
if (isOnline) {
|
|
await inventoryApi.deleteItem(selectedItem.id);
|
|
}
|
|
toast.success("Item deleted from catalog");
|
|
setSelectedItem(null);
|
|
await loadInventory();
|
|
} catch (err: any) {
|
|
console.error(err);
|
|
toast.error("Failed to delete item");
|
|
}
|
|
};
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const filteredInventory = inventory.filter(item => {
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
item.name.toLowerCase().includes(query) ||
|
|
item.category.toLowerCase().includes(query) ||
|
|
(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.ocr_text?.toLowerCase().includes(query) ?? false)
|
|
);
|
|
});
|
|
|
|
// Extract unique item types and box labels for suggestions
|
|
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
|
|
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
|
|
|
|
if (!mounted) return null;
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="p-3 md:p-8 overflow-x-hidden w-full max-w-7xl mx-auto">
|
|
{/* Search datalists for autocomplete */}
|
|
<datalist id="existing-types">
|
|
{existingTypes.map(t => <option key={t} value={t} />)}
|
|
</datalist>
|
|
<datalist id="existing-boxes">
|
|
{existingBoxes.map(b => <option key={b} value={b} />)}
|
|
</datalist>
|
|
|
|
{/* Header */}
|
|
<header className="flex flex-col sm:flex-row justify-between sm:items-center gap-3 md:gap-4 mb-3 md:mb-4 w-full px-1">
|
|
<div className="flex items-center gap-2 md:gap-3">
|
|
<div className="p-1">
|
|
<img
|
|
src="/logo.png"
|
|
alt="TFM aInventory"
|
|
className="h-8 md:h-10 object-contain drop-shadow-xl rounded-lg"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl md:text-3xl font-normal tracking-tight text-white leading-tight">TFM aInventory</h1>
|
|
<p className="text-xs md:text-sm text-secondary font-normal tracking-tight mt-1 leading-relaxed">Check-in, Check-out & Trash Operations</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between sm:justify-end gap-2 md:gap-3 bg-surface/50 sm:bg-transparent px-3 py-2 sm:p-0 rounded-2xl border border-slate-800/50 sm:border-none">
|
|
<div className="flex flex-wrap items-center gap-2 md:gap-3">
|
|
{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-xs font-normal text-green-500/90 whitespace-nowrap">
|
|
Scanner: OK
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2" data-testid={!isOnline ? "offline-indicator" : undefined}>
|
|
<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-xs font-normal whitespace-nowrap ${isOnline ? 'text-green-500/90' : 'text-rose-500/90'}`} data-testid={!isOnline ? "offline-sync-indicator" : undefined}>
|
|
Sync: {isOnline ? 'Active' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleSync}
|
|
disabled={syncing}
|
|
data-testid="manual-sync-button"
|
|
className="p-2.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={18} className={syncing ? "animate-spin text-primary" : ""} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<ScannerSection
|
|
mode={mode}
|
|
onModeChange={(newMode) => setMode(newMode as any)}
|
|
showScanner={showScanner}
|
|
onShowScanner={setShowScanner}
|
|
onScanSuccess={onScanSuccess}
|
|
onOCRMatch={onOCRMatch}
|
|
onAddItemClick={() => setShowOnboarding(true)}
|
|
/>
|
|
|
|
{/* Onboarding Overlay */}
|
|
{showOnboarding && (
|
|
<AIOnboarding
|
|
categories={categories}
|
|
inventory={inventory}
|
|
onCancel={() => setShowOnboarding(false)}
|
|
onComplete={handleOnboardingComplete}
|
|
/>
|
|
)}
|
|
|
|
{/* Item Comparison Modal (for duplicate Part Numbers) */}
|
|
<ItemComparisonModal
|
|
show={comparisonModal.show}
|
|
existingItem={comparisonModal.existingItem}
|
|
newItem={comparisonModal.newItem}
|
|
onUpdate={handleComparisonUpdate}
|
|
onSkip={handleComparisonSkip}
|
|
loading={comparisonLoading}
|
|
/>
|
|
|
|
{/* Stock Adjustment Panel */}
|
|
<StockAdjustmentPanel
|
|
selectedItem={selectedItem}
|
|
isEditing={isEditing}
|
|
editedItem={editedItem}
|
|
adjustQty={adjustQty}
|
|
adjustType={adjustType}
|
|
trashReason={trashReason}
|
|
categories={categories}
|
|
fieldScanning={fieldScanning}
|
|
onCancel={() => {
|
|
setSelectedItem(null);
|
|
setIsEditing(false);
|
|
}}
|
|
onEdit={(item) => {
|
|
setEditedItem(item);
|
|
setIsEditing(true);
|
|
}}
|
|
onEditChange={setEditedItem}
|
|
onQuantityChange={setAdjustQty}
|
|
onTypeChange={setAdjustType}
|
|
onReasonChange={setTrashReason}
|
|
onShowScanner={(active, field) => {
|
|
setFieldScanning({ active, field });
|
|
setShowScanner(true);
|
|
}}
|
|
onAdjustStock={handleAdjustStock}
|
|
onUpdateItem={handleUpdateItem}
|
|
onDeleteItem={handleDeleteItem}
|
|
/>
|
|
|
|
|
|
|
|
{/* Box Contents Selection Modal */}
|
|
{boxMatches.length > 0 && !selectedItem && (
|
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-background/80 animate-in fade-in duration-200">
|
|
<div className="w-full max-w-lg bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl p-4 md:p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300 flex flex-col max-h-[85vh]">
|
|
<div className="flex justify-between items-center mb-3 md:mb-4 shrink-0">
|
|
<div>
|
|
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
|
<Package className="text-primary" />
|
|
Box Contents
|
|
</h3>
|
|
<p className="text-xs text-secondary font-normal mt-1">Select the item you want to {mode.replace('_', ' ').toLowerCase()}</p>
|
|
</div>
|
|
<button onClick={() => setBoxMatches([])} className="p-2 hover:bg-slate-800 rounded-full">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
<div className="overflow-y-auto w-full pr-2 space-y-2 md:space-y-3 pb-3 md:pb-4">
|
|
{boxMatches.map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => {
|
|
setSelectedItem(item);
|
|
setBoxMatches([]);
|
|
setAdjustType(mode === 'CHECK_IN' ? 'ADD' : 'REMOVE');
|
|
}}
|
|
className="w-full text-left bg-background/50 hover:bg-slate-800 border border-slate-800/80 p-3 md:p-4 rounded-2xl flex items-center gap-2 md:gap-3 transition-all active:scale-[0.98] group"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-normal text-white">{item.name}</p>
|
|
<p className="text-xs text-secondary font-normal mt-1">Part #: {item.part_number || 'N/A'}</p>
|
|
</div>
|
|
<ChevronRight size={18} className="text-secondary group-hover:text-primary" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* Footer Branding */}
|
|
<footer className="mt-12 md:mt-20 mb-4 md:mb-8 flex flex-col items-center gap-2 opacity-70">
|
|
<p className="text-sm font-normal text-secondary">Powered by TFM Group Software</p>
|
|
<div className="h-px w-16 bg-slate-700/60" />
|
|
<p className="text-xs font-mono text-secondary">v{versionData.version} • {versionData.last_build} • BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
|
|
</footer>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|