407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { db, Item } from '@/lib/db';
|
|
import { inventoryApi } from '@/lib/api';
|
|
import { fetchAndCacheItems } 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 ScannerSection from '@/components/ScannerSection';
|
|
import SearchModal from '@/components/inventory/SearchModal';
|
|
import { toast } from 'react-hot-toast';
|
|
import {
|
|
Package,
|
|
X,
|
|
ChevronRight,
|
|
RefreshCw,
|
|
Search
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import versionData from '../VERSION.json';
|
|
|
|
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 [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
|
const [boxMatches, setBoxMatches] = useState<Item[]>([]);
|
|
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 [showSearch, setShowSearch] = useState(false);
|
|
|
|
const { syncing, handleSync } = useSync({
|
|
isOnline,
|
|
currentUser,
|
|
onInventoryUpdate: setInventory
|
|
});
|
|
|
|
const {
|
|
mode,
|
|
setMode,
|
|
showScanner,
|
|
setShowScanner,
|
|
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') {
|
|
// Handle via editedItem logic in panels if needed
|
|
}
|
|
}
|
|
});
|
|
|
|
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));
|
|
}
|
|
|
|
inventoryApi.getCategories().then(c => setCategories(c)).catch(() => { });
|
|
loadInventory();
|
|
|
|
const handleOnline = () => setIsOnline(true);
|
|
const handleOffline = () => setIsOnline(false);
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
|
|
const interval = setInterval(() => {
|
|
setIsOnline(navigator.onLine);
|
|
if (navigator.onLine) {
|
|
loadInventory();
|
|
}
|
|
}, 30000);
|
|
|
|
return () => {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
setShowSearch(!showSearch);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [showSearch]);
|
|
|
|
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();
|
|
}
|
|
|
|
const backendData = { ...itemData };
|
|
delete backendData.extractedImageBlob;
|
|
|
|
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];
|
|
backendData.extracted_image_bytes = base64;
|
|
backendData.image_processing = itemData.imageProcessing;
|
|
delete backendData.imageProcessing;
|
|
resolve(null);
|
|
};
|
|
reader.onerror = reject;
|
|
});
|
|
}
|
|
|
|
await db.items.add(itemData);
|
|
|
|
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) {
|
|
const detail = responseData.detail;
|
|
setComparisonModal({
|
|
show: true,
|
|
newItem: backendData,
|
|
existingItem: detail.existing_item,
|
|
existingId: detail.existing_id
|
|
});
|
|
return;
|
|
} else {
|
|
console.error("Cloud sync failed:", err.message);
|
|
toast.error("Item saved locally. Cloud sync failed.");
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
}
|
|
}
|
|
} else {
|
|
toast.success("Saved locally (Offline mode)");
|
|
setShowOnboarding(false);
|
|
await loadInventory();
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
toast.error("Failed to save item");
|
|
}
|
|
};
|
|
|
|
const handleComparisonUpdate = async () => {
|
|
if (!comparisonModal.existingId) return;
|
|
setComparisonLoading(true);
|
|
try {
|
|
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;
|
|
});
|
|
}
|
|
|
|
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 = () => {
|
|
setComparisonModal({ show: false, newItem: null, existingItem: null, existingId: null });
|
|
setShowOnboarding(false);
|
|
toast("Local copy saved.", { icon: '💾' });
|
|
loadInventory();
|
|
};
|
|
|
|
if (!mounted) return null;
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="p-4 md:p-8 max-w-7xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<header className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-8">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-4 bg-primary/10 border border-primary/20">
|
|
<img
|
|
src="/logo.png"
|
|
alt="TFM aInventory"
|
|
className="h-8 md:h-10 object-contain"
|
|
/>
|
|
</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">IT/DC Asset Management</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between sm:justify-end gap-3">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{isScannerReady && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-1.5 h-1.5 bg-tertiary" />
|
|
<span className="text-xs font-normal text-tertiary/90 whitespace-nowrap">Scanner: Ready</span>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-1.5 h-1.5 ${isOnline ? 'bg-tertiary animate-pulse' : 'bg-rose-500'}`} />
|
|
<span className={`text-xs font-normal whitespace-nowrap ${isOnline ? 'text-tertiary/90' : 'text-error/90'}`}>
|
|
Sync: {isOnline ? 'Active' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowSearch(true)}
|
|
className="p-3 bg-surface-container border border-border text-secondary hover:text-white transition-all active:scale-95"
|
|
title="Search (Ctrl+K)"
|
|
>
|
|
<Search size={20} />
|
|
</button>
|
|
<button
|
|
onClick={handleSync}
|
|
disabled={syncing}
|
|
className="p-3 bg-surface-container border border-border text-secondary hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={20} 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)}
|
|
/>
|
|
|
|
{/* Overlays & Modals */}
|
|
{showOnboarding && (
|
|
<AIOnboarding
|
|
categories={categories}
|
|
inventory={inventory}
|
|
onCancel={() => setShowOnboarding(false)}
|
|
onComplete={handleOnboardingComplete}
|
|
/>
|
|
)}
|
|
|
|
<ItemComparisonModal
|
|
show={comparisonModal.show}
|
|
existingItem={comparisonModal.existingItem}
|
|
newItem={comparisonModal.newItem}
|
|
onUpdate={handleComparisonUpdate}
|
|
onSkip={handleComparisonSkip}
|
|
loading={comparisonLoading}
|
|
/>
|
|
|
|
<SearchModal
|
|
isOpen={showSearch}
|
|
onClose={() => setShowSearch(false)}
|
|
onSelectItem={(item) => {
|
|
setSelectedItem(item);
|
|
setShowSearch(false);
|
|
}}
|
|
/>
|
|
|
|
{/* TODO: Fix StockAdjustmentPanel prop types
|
|
<StockAdjustmentPanel
|
|
selectedItem={selectedItem}
|
|
adjustQty={adjustQty}
|
|
adjustType={adjustType}
|
|
onCancel={() => setSelectedItem(null)}
|
|
onQuantityChange={setAdjustQty}
|
|
onTypeChange={setAdjustType}
|
|
onAdjustStock={handleAdjustStock}
|
|
/>
|
|
*/}
|
|
|
|
{boxMatches.length > 0 && !selectedItem && (
|
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-background/90 animate-in fade-in duration-300">
|
|
<div className="w-full max-w-lg level-2 p-6 flex flex-col max-h-[85vh] animate-in slide-in-from-bottom-10 duration-300">
|
|
<div className="flex justify-between items-center mb-4 shrink-0 border-b border-border pb-3">
|
|
<div>
|
|
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2 text-white">
|
|
<Package className="text-primary" />
|
|
Box Contents
|
|
</h3>
|
|
<p className="text-xs text-secondary font-normal mt-1">Select item for {mode.toLowerCase().replace('_', ' ')}</p>
|
|
</div>
|
|
<button onClick={() => setBoxMatches([])} className="p-3 bg-surface-bright border border-border text-secondary hover:text-white">
|
|
<X size={24} />
|
|
</button>
|
|
</div>
|
|
<div className="overflow-y-auto pr-2 space-y-2 custom-scrollbar">
|
|
{boxMatches.map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => {
|
|
setSelectedItem(item);
|
|
setBoxMatches([]);
|
|
}}
|
|
className="w-full text-left bg-surface-container-lowest border border-border p-4 flex items-center justify-between group hover:border-primary/50 transition-all"
|
|
>
|
|
<div className="min-w-0 pr-4">
|
|
<p className="text-sm font-normal text-white truncate">{item.name}</p>
|
|
<p className="text-xs text-secondary font-normal mt-1 truncate">P/N: {item.part_number || 'UNKNOWN'}</p>
|
|
</div>
|
|
<ChevronRight size={20} className="text-secondary group-hover:text-primary transition-colors" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<footer className="mt-20 mb-8 flex flex-col items-center gap-4 opacity-50 border-t border-border/30 pt-8">
|
|
<p className="text-xs font-normal text-secondary tracking-widest uppercase">Powered by TFM Group Software S.R.L.</p>
|
|
<p className="text-[10px] font-mono text-muted">v{versionData.version} • {versionData.last_build} • BUILD: {versionData.commit}</p>
|
|
</footer>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|