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