feat: add side-by-side item comparison for duplicate Part Numbers
New Component: ItemComparisonModal.tsx - Shows existing vs new item side-by-side - Highlights fields that are different (in yellow) - Options to Update item or Skip (local-only save) - Shows existing item ID and comparison details Backend Changes: - Updated error message to say 'Part Number' not 'barcode' - 409 response includes existing item data for comparison - Clear, user-friendly conflict messaging Frontend Changes: - New state for comparison modal (newItem, existingItem, existingId) - handleOnboardingComplete() shows modal on 409 conflict - handleComparisonUpdate() calls updateItem() API - handleComparisonSkip() saves locally without syncing - Better error handling distinguishes 409 from other failures Workflow: 1. User imports item with Part Number that already exists 2. System shows comparison modal 3. User can: - Update (merges new data into existing) - Skip (saves locally, doesn't sync to cloud)
This commit is contained in:
@@ -97,13 +97,18 @@ def create_item(
|
|||||||
current_user: auth.TokenData = Depends(auth.get_current_user)
|
current_user: auth.TokenData = Depends(auth.get_current_user)
|
||||||
):
|
):
|
||||||
"""[C-01] Create item — only for authenticated users. [M-02] user_id from token."""
|
"""[C-01] Create item — only for authenticated users. [M-02] user_id from token."""
|
||||||
# [DUPLICATE CHECK] Prevent duplicate barcodes (part numbers)
|
# [DUPLICATE CHECK] Prevent duplicate part numbers
|
||||||
if item.barcode:
|
if item.barcode:
|
||||||
existing = db.query(models.Item).filter(models.Item.barcode == item.barcode).first()
|
existing = db.query(models.Item).filter(models.Item.barcode == item.barcode).first()
|
||||||
if existing:
|
if existing:
|
||||||
|
# Return existing item data for user comparison
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"Item with barcode '{item.barcode}' already exists (ID: {existing.id}). Update it instead or use a different barcode."
|
detail={
|
||||||
|
"message": f"Item with Part Number '{item.barcode}' already exists in inventory.",
|
||||||
|
"existing_id": existing.id,
|
||||||
|
"existing_item": schemas.Item.model_validate(existing).model_dump()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# [AUTO-PERSIST] Create Category/Color if not exists
|
# [AUTO-PERSIST] Create Category/Color if not exists
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync';
|
|||||||
import Scanner from '@/components/Scanner';
|
import Scanner from '@/components/Scanner';
|
||||||
import AIOnboarding from '@/components/AIOnboarding';
|
import AIOnboarding from '@/components/AIOnboarding';
|
||||||
import PageShell from '@/components/PageShell';
|
import PageShell from '@/components/PageShell';
|
||||||
|
import ItemComparisonModal from '@/components/ItemComparisonModal';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
@@ -90,6 +91,8 @@ export default function Home() {
|
|||||||
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||||
const [categories, setCategories] = useState<any[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
|
||||||
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localStorage.getItem('inventory_token')) {
|
if (!localStorage.getItem('inventory_token')) {
|
||||||
@@ -169,31 +172,68 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
await inventoryApi.createItem(currentUser.id, itemData);
|
await inventoryApi.createItem(currentUser.id, itemData);
|
||||||
toast.success("Item saved to cloud catalog!");
|
toast.success("Item saved to cloud catalog!");
|
||||||
|
setShowOnboarding(false);
|
||||||
|
await loadInventory();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const status = err.response?.status;
|
const status = err.response?.status;
|
||||||
const detail = err.response?.data?.detail || err.message;
|
const responseData = err.response?.data;
|
||||||
console.error("Cloud sync failed:", detail);
|
|
||||||
|
|
||||||
if (status === 409) {
|
if (status === 409 && responseData?.detail) {
|
||||||
toast.error(`Item already exists. ${detail}`);
|
// Show comparison modal for duplicate part number
|
||||||
|
const detail = responseData.detail;
|
||||||
|
setComparisonModal({
|
||||||
|
show: true,
|
||||||
|
newItem: itemData,
|
||||||
|
existingItem: detail.existing_item,
|
||||||
|
existingId: detail.existing_id
|
||||||
|
});
|
||||||
|
return; // Don't close onboarding, user will decide
|
||||||
} else {
|
} else {
|
||||||
|
console.error("Cloud sync failed:", err.message);
|
||||||
toast.error("Item saved locally. Cloud sync failed.");
|
toast.error("Item saved locally. Cloud sync failed.");
|
||||||
|
setShowOnboarding(false);
|
||||||
|
await loadInventory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!isOnline) {
|
} else if (!isOnline) {
|
||||||
toast.success("Item saved locally. Will sync when online.");
|
toast.success("Item saved locally. Will sync when online.");
|
||||||
|
setShowOnboarding(false);
|
||||||
|
await loadInventory();
|
||||||
} else {
|
} else {
|
||||||
toast("Please log in to save to cloud.", { icon: '⚠️' });
|
toast("Please log in to save to cloud.", { icon: '⚠️' });
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowOnboarding(false);
|
|
||||||
await loadInventory();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error("Failed to save item");
|
toast.error("Failed to save item");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleComparisonUpdate = async () => {
|
||||||
|
if (!comparisonModal.existingId) return;
|
||||||
|
setComparisonLoading(true);
|
||||||
|
try {
|
||||||
|
// Update existing item
|
||||||
|
await inventoryApi.updateItem(comparisonModal.existingId, comparisonModal.newItem);
|
||||||
|
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 handleSync = useCallback(async () => {
|
const handleSync = useCallback(async () => {
|
||||||
if (!isOnline || !currentUser) return;
|
if (!isOnline || !currentUser) return;
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
@@ -616,6 +656,16 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 Overlay */}
|
{/* Stock Adjustment Overlay */}
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 animate-in fade-in duration-200">
|
||||||
|
|||||||
123
frontend/components/ItemComparisonModal.tsx
Normal file
123
frontend/components/ItemComparisonModal.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertTriangle, X, RefreshCw, SkipForward } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ItemComparisonModalProps {
|
||||||
|
show: boolean;
|
||||||
|
existingItem: any;
|
||||||
|
newItem: any;
|
||||||
|
onUpdate: () => Promise<void>;
|
||||||
|
onSkip: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ItemComparisonModal({
|
||||||
|
show,
|
||||||
|
existingItem,
|
||||||
|
newItem,
|
||||||
|
onUpdate,
|
||||||
|
onSkip,
|
||||||
|
loading = false
|
||||||
|
}: ItemComparisonModalProps) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'type', label: 'Type' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'category', label: 'Category' },
|
||||||
|
{ key: 'connector', label: 'Connector' },
|
||||||
|
{ key: 'size', label: 'Size' },
|
||||||
|
{ key: 'color', label: 'Color' },
|
||||||
|
{ key: 'part_number', label: 'Part Number' },
|
||||||
|
{ key: 'ocr_text', label: 'OCR Key' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isDifferent = (key: string) => {
|
||||||
|
const existing = String(existingItem?.[key] || '').trim();
|
||||||
|
const newVal = String(newItem?.[key] || '').trim();
|
||||||
|
return existing !== newVal && newVal !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = fields.some(f => isDifferent(f.key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-slate-950/80">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<AlertTriangle size={24} className="text-amber-500" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-black text-white">Part Number Already Exists</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Compare the versions below</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison Table */}
|
||||||
|
<div className="space-y-3 mb-8">
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-4 pb-4 border-b border-slate-800">
|
||||||
|
<div className="text-xs font-bold text-slate-500">Field</div>
|
||||||
|
<div className="text-xs font-bold text-slate-400">Current (ID: {existingItem?.id})</div>
|
||||||
|
<div className="text-xs font-bold text-primary">New (Import)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.map(field => {
|
||||||
|
const existing = String(existingItem?.[field.key] || '').trim() || '—';
|
||||||
|
const newVal = String(newItem?.[field.key] || '').trim() || '—';
|
||||||
|
const different = isDifferent(field.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.key}
|
||||||
|
className={`grid grid-cols-3 gap-4 p-3 rounded-lg ${
|
||||||
|
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-bold text-slate-300">{field.label}</div>
|
||||||
|
<div className={`text-sm font-mono ${different ? 'text-amber-200' : 'text-slate-400'}`}>
|
||||||
|
{existing}
|
||||||
|
</div>
|
||||||
|
<div className={`text-sm font-mono ${different ? 'text-primary font-bold' : 'text-slate-400'}`}>
|
||||||
|
{newVal}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasChanges && (
|
||||||
|
<div className="p-4 bg-slate-800/50 rounded-xl mb-6 border border-slate-700">
|
||||||
|
<p className="text-sm text-slate-300">✓ Items are identical. No update needed.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-4 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-2xl text-sm font-black transition-all active:scale-95 disabled:opacity-50 border border-slate-700"
|
||||||
|
>
|
||||||
|
<SkipForward size={16} /> Skip
|
||||||
|
</button>
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-4 bg-primary hover:bg-blue-600 text-white rounded-2xl text-sm font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl shadow-primary/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="animate-spin" /> Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} /> Update Item
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user