Files
HartOMat/frontend/src/pages/Upload.tsx
T
Hartmut b6bac080bb feat: duplicate product detection — STEP conflict warnings on Excel import and CAD upload
- Excel preview detects when a product already has a different STEP file linked
- Excel preview detects intra-Excel conflicts (same product, different CAD model names)
- Product STEP upload warns when replacing an existing file and shows render count
- All warnings are non-blocking (amber badges, toast warnings)
- LEARNINGS.md: all open items resolved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 13:05:40 +01:00

908 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
FileSpreadsheet, CheckCircle, X, Plus,
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy, AlertTriangle,
} from 'lucide-react'
import { toast } from 'sonner'
import { uploadExcel, finalizeExcelImport, getImportValidation } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection, ImportValidation, ValidationIssue } from '../api/uploads'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
import StepDropzone from '../components/upload/StepDropzone'
import StepIndicator from '../components/shared/StepIndicator'
import HelpTooltip from '../components/HelpTooltip'
function StatCard({ icon, value, label, description, color }: {
icon: React.ReactNode
value: number
label: string
description: string
color: string
}) {
if (value === 0) return null
return (
<div className={`flex gap-3 p-3 rounded-lg border ${color}`}>
<div className="shrink-0 mt-0.5">{icon}</div>
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-lg font-bold">{value}</span>
<span className="text-sm font-medium">{label}</span>
</div>
<p className="text-xs opacity-75 mt-0.5">{description}</p>
</div>
</div>
)
}
export default function UploadPage() {
const navigate = useNavigate()
const qc = useQueryClient()
// Step 1: Excel parsed (preview only — no products created yet)
const [previewResult, setPreviewResult] = useState<ExcelPreviewResult | null>(null)
// Step 2: per-row include toggles
const [includedRows, setIncludedRows] = useState<Record<number, boolean>>({})
// Step 3: per-row selected output_type_ids
const [rowOutputTypes, setRowOutputTypes] = useState<Record<number, Record<string, boolean>>>({})
const [step, setStep] = useState<1 | 2 | 3 | 4>(1)
const [notes, setNotes] = useState('')
const [createdOrder, setCreatedOrder] = useState<{ id: string; order_number: string } | null>(null)
const [createDraftForSkipped, setCreateDraftForSkipped] = useState(false)
const { data: outputTypes = [] } = useQuery<OutputType[]>({
queryKey: ['output-types'],
queryFn: () => listOutputTypes(false),
})
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: async () => {
const res = await api.get('/templates')
return res.data as Array<{ id: string; category_key: string; name: string }>
},
})
const [validationId, setValidationId] = useState<string | null>(null)
const [showValidationDialog, setShowValidationDialog] = useState(false)
const { data: validationData } = useQuery({
queryKey: ['validation', validationId],
queryFn: () => getImportValidation(validationId!),
enabled: !!validationId && showValidationDialog,
refetchInterval: (query) => {
const d = query.state.data as ImportValidation | undefined
return d?.status === 'completed' || d?.status === 'failed' ? false : 2000
},
})
const saveAlias = useMutation({
mutationFn: async ({ alias, materialName }: { alias: string; materialName: string }) => {
await api.post('/materials/seed-aliases', { mappings: [{ display_name: alias, render_name: materialName }] })
},
onSuccess: () => toast.success('Alias saved'),
onError: () => toast.error('Failed to save alias'),
})
const uploadMut = useMutation({
mutationFn: uploadExcel,
onSuccess: (data) => {
setPreviewResult(data)
// Default: include all rows that have an identifier (pim_id or produkt_baureihe)
// Pre-uncheck duplicate rows so only the first occurrence is included
const inc: Record<number, boolean> = {}
const rot: Record<number, Record<string, boolean>> = {}
data.rows.forEach((row) => {
const hasId = !!(row.pim_id || row.produkt_baureihe)
inc[row.row_index] = hasId && !row.is_duplicate
rot[row.row_index] = {}
})
setIncludedRows(inc)
setRowOutputTypes(rot)
data.warnings.forEach((w) => toast.warning(w))
if (data.validation_id) {
setValidationId(data.validation_id)
setShowValidationDialog(true)
}
setStep(2)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
})
const finalizeMut = useMutation({
mutationFn: async () => {
if (!previewResult) throw new Error('No preview result')
const templateId = templates?.find(
(t) => t.category_key === previewResult.category_key,
)?.id
const included_row_indices: number[] = []
const output_type_selections: OutputTypeSelection[] = []
previewResult.rows.forEach((row) => {
if (!includedRows[row.row_index]) return
if (!row.pim_id && !row.produkt_baureihe) return
included_row_indices.push(row.row_index)
const selectedTypes = rowOutputTypes[row.row_index] || {}
const typeIds = Object.entries(selectedTypes)
.filter(([, checked]) => checked)
.map(([id]) => id)
if (typeIds.length > 0) {
output_type_selections.push({
row_index: row.row_index,
output_type_ids: typeIds,
})
}
})
const primaryOrder = await finalizeExcelImport({
excel_path: previewResult.excel_path,
included_row_indices,
output_type_selections,
notes: notes || undefined,
template_id: templateId,
})
// Optionally create a tracking-only draft for the unchecked rows
let draftOrder = null
if (createDraftForSkipped && excludedWithId.length > 0) {
const skippedIndices = excludedWithId.map((r) => r.row_index)
draftOrder = await finalizeExcelImport({
excel_path: previewResult.excel_path,
included_row_indices: skippedIndices,
output_type_selections: [],
notes: 'Draft — awaiting STEP files',
template_id: templateId,
})
}
return { primaryOrder, draftOrder }
},
onSuccess: ({ primaryOrder, draftOrder }) => {
qc.invalidateQueries({ queryKey: ['orders'] })
setCreatedOrder({ id: primaryOrder.id, order_number: primaryOrder.order_number })
if (draftOrder) {
const n = draftOrder.items?.length ?? 0
toast.success(
`Draft ${draftOrder.order_number} created with ${n} product${n !== 1 ? 's' : ''} awaiting STEP files`,
)
}
setStep(4)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create order'),
})
const onDrop = useCallback(
(files: File[]) => { if (files[0]) uploadMut.mutate(files[0]) },
[uploadMut],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
},
multiple: false,
})
// Rows that are included and have an identifier
const includedWithId = previewResult?.rows.filter(
(r) => includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
) ?? []
// Rows that are excluded (have an identifier but not included in the primary order)
const excludedWithId = previewResult?.rows.filter(
(r) => !includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
) ?? []
// Count how many rows actually have an output type selected
const rowsWithOutputType = includedWithId.filter((row) => {
const sel = rowOutputTypes[row.row_index] || {}
return Object.values(sel).some(Boolean)
}).length
function toggleAllOutputType(typeId: string, checked: boolean) {
setRowOutputTypes((prev) => {
const updated = { ...prev }
includedWithId.forEach((row) => {
updated[row.row_index] = { ...(updated[row.row_index] || {}), [typeId]: checked }
})
return updated
})
}
function deselectWithoutStep() {
if (!previewResult) return
setIncludedRows((prev) => {
const updated = { ...prev }
previewResult.rows.forEach((row) => {
if (!row.has_step && (row.pim_id || row.produkt_baureihe)) {
updated[row.row_index] = false
}
})
return updated
})
}
return (
<div className="p-8 max-w-full mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-content">Upload Order List</h1>
<p className="text-sm text-content-muted mt-1">
Import products from Excel and create a new output job order.
</p>
</div>
<StepIndicator step={step} total={4} labels={['Upload', 'Review', 'Configure', 'STEP Files']} />
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
{step === 1 && (
<div
{...getRootProps()}
className={`card p-16 text-center cursor-pointer border-2 border-dashed transition-colors ${
isDragActive
? 'border-accent bg-accent-light'
: 'border-border-default hover:border-accent'
}`}
>
<input {...getInputProps()} />
{uploadMut.isPending ? (
<div className="text-content-secondary">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full mx-auto mb-3" />
Parsing Excel file\u2026
</div>
) : (
<>
<FileSpreadsheet size={44} className="text-content-muted mx-auto mb-3" />
<p className="text-content-secondary font-medium text-lg">
{isDragActive ? 'Drop the Excel file here' : 'Drag & drop an Excel order list'}
</p>
<p className="text-content-muted text-sm mt-1">or click to browse \u2014 .xlsx / .xls</p>
</>
)}
</div>
)}
{/* ── Step 2: Product Matching Report ─────────────────────────────── */}
{step === 2 && previewResult && (
<div className="space-y-4">
{/* File info header */}
<div className="card p-4">
<div className="flex items-center gap-3">
<CheckCircle size={20} className="text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold text-content truncate">{previewResult.filename}</p>
<p className="text-sm text-content-secondary">
{previewResult.row_count} rows parsed
{previewResult.category_key && <> &middot; Primary category: <strong>{previewResult.category_key}</strong></>}
</p>
</div>
<button
className="btn-secondary text-xs"
onClick={() => { setPreviewResult(null); setStep(1) }}
title="Discard this preview and upload a different Excel file"
>
<X size={13} /> Replace
</button>
</div>
</div>
{/* Import summary — explained stats */}
<div className="card p-4">
<div className="flex items-center gap-2 mb-3">
<Info size={15} className="text-content-muted" />
<h3 className="text-sm font-semibold text-content-secondary">Preview Summary</h3>
</div>
<p className="text-xs text-content-secondary mb-3 leading-relaxed">
No products have been created yet. This is a <strong>preview</strong> of what will happen when you finalize the order.
Each unique <strong>Product (Series)</strong> in the Excel becomes one product in the library.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<StatCard
icon={<PackagePlus size={18} className="text-blue-600" />}
value={previewResult.new_product_count}
label="new products"
description="Will be created when you finalize the order."
color="bg-status-info-bg border-border-default text-status-info-text"
/>
<StatCard
icon={<PackageCheck size={18} className="text-green-600" />}
value={previewResult.existing_product_count}
label="existing products"
description="Already in the library from a previous import."
color="bg-status-success-bg border-border-default text-status-success-text"
/>
<StatCard
icon={<Ban size={18} className="text-amber-600" />}
value={previewResult.no_pim_id_count}
label="rows skipped"
description="No PIM-ID or Product Series found. Cannot be matched to a product."
color="bg-status-warning-bg border-border-default text-status-warning-text"
/>
<StatCard
icon={<Copy size={18} className="text-orange-600" />}
value={previewResult.duplicate_count}
label="duplicate Series"
description="Same Product Series appears multiple times. Pre-unchecked — only first occurrence imported."
color="bg-status-warning-bg border-border-default text-status-warning-text"
/>
<StatCard
icon={<AlertTriangle size={18} className="text-amber-600" />}
value={previewResult.step_conflict_count ?? 0}
label="STEP conflicts"
description="Product exists with a different STEP file than referenced in Excel."
color="bg-amber-50 border-amber-200 text-amber-800"
/>
<StatCard
icon={<AlertTriangle size={18} className="text-amber-600" />}
value={previewResult.cad_name_conflict_count ?? 0}
label="CAD name conflicts"
description="Same product appears in multiple rows with different CAD model names."
color="bg-amber-50 border-amber-200 text-amber-800"
/>
</div>
</div>
{/* Duplicate warning banner */}
{previewResult.duplicate_count > 0 && (
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3">
<div className="flex items-start gap-2">
<span className="text-status-warning-text font-bold text-sm shrink-0"></span>
<div>
<p className="text-sm font-semibold text-status-warning-text">
{previewResult.duplicate_count} duplicate Product Series row{previewResult.duplicate_count !== 1 ? 's' : ''} detected
</p>
<p className="text-xs text-status-warning-text mt-0.5">
Each product is unique only the <strong>first occurrence</strong> of a Product Series will be imported.
Duplicate rows are pre-unchecked (shown in amber). You can manually re-check them to overwrite the first.
</p>
</div>
</div>
</div>
)}
{/* Row table */}
<div className="card overflow-auto">
<div className="px-4 py-3 border-b border-border-light bg-surface-alt">
<h3 className="text-sm font-semibold text-content-secondary">Row Details</h3>
<p className="text-xs text-content-secondary mt-0.5">
Uncheck rows you don't want to include in the order.
</p>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary w-10">
<input
type="checkbox"
checked={previewResult.rows.every((r) => (!r.pim_id && !r.produkt_baureihe) || includedRows[r.row_index])}
onChange={(e) => {
const updated: Record<number, boolean> = {}
previewResult.rows.forEach((r) => {
updated[r.row_index] = (r.pim_id || r.produkt_baureihe) ? e.target.checked : false
})
setIncludedRows(updated)
}}
title="Select / deselect all rows"
/>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Series</th>
<th className="px-4 py-2 font-medium text-content-secondary">
<span className="flex items-center gap-1">
Gew. Produkt
<HelpTooltip help={{ title: 'Gew. Produkt', body: 'Gewähltes Produkt the specific product variant selected in the Excel file.' }} />
</span>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
</tr>
</thead>
<tbody>
{previewResult.rows.map((row) => {
const hasId = !!(row.pim_id || row.produkt_baureihe)
return (
<tr key={row.row_index} className={`border-b ${row.is_duplicate ? 'bg-status-warning-bg border-border-default hover:bg-status-warning-bg' : 'border-border-light hover:bg-surface-hover'}`}>
<td className="px-4 py-2">
<input
type="checkbox"
disabled={!hasId}
checked={!!includedRows[row.row_index]}
onChange={(e) =>
setIncludedRows({ ...includedRows, [row.row_index]: e.target.checked })
}
/>
</td>
<td className="px-4 py-2 font-mono text-xs">
{row.pim_id || <span className="text-content-muted">&mdash;</span>}
</td>
<td className="px-4 py-2 text-xs">
{row.produkt_baureihe || <span className="text-content-muted">&mdash;</span>}
</td>
<td className="px-4 py-2 text-sm">
{row.gewaehltes_produkt || '\u2014'}
</td>
<td className="px-4 py-2">
{row.category_key ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
{row.category_key}
</span>
) : (
<span className="text-content-muted">&mdash;</span>
)}
</td>
<td className="px-4 py-2">
{!hasId ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted"
title="No PIM-ID or Product Series — this row will be skipped"
>
skipped
</span>
) : row.is_duplicate ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium"
title={`Duplicate Product Series — first occurrence is row ${row.duplicate_of_row}. Uncheck to exclude.`}
>
Duplicate of row {row.duplicate_of_row}
</span>
) : row.product_exists ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium"
title="This product already exists in the library"
>
existing
</span>
) : (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium"
title="This product will be created when you finalize"
>
new (will be created)
</span>
)}
</td>
<td className="px-4 py-2 text-center">
<div className="flex items-center justify-center gap-1">
{!hasId ? null : row.has_step ? (
<CheckCircle size={14} className="text-green-500" aria-label="STEP file linked" />
) : (
<X size={14} className="text-red-400" aria-label="No STEP file" />
)}
{row.step_conflict && (
<span title={`STEP conflict: DB has "${row.step_conflict_existing_name}", Excel references "${row.step_conflict_excel_name}"`}>
<AlertTriangle size={14} className="text-amber-500" />
</span>
)}
{row.cad_name_conflict && (
<span title={`CAD name conflict with row ${row.cad_name_conflict_row}: "${row.cad_name_conflict_other_name}"`}>
<AlertTriangle size={14} className="text-amber-500" />
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
className="btn-primary"
disabled={includedWithId.length === 0}
onClick={() => setStep(3)}
>
Next: Select Output Types &rarr;
</button>
</div>
</div>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<h2 className="font-semibold text-content">Select Output Types</h2>
<button className="text-sm text-content-muted hover:text-content-secondary" onClick={() => setStep(2)}>
&larr; Back
</button>
</div>
<p className="text-sm text-content-secondary">
Choose which output types to request for each included product.
Leave unchecked to create a tracking-only line.
</p>
</div>
{outputTypes.length > 0 && (
<div className="card p-4">
<p className="text-xs font-medium text-content-secondary mb-2">Select/deselect all rows:</p>
<div className="flex items-center gap-3 flex-wrap">
{outputTypes.map((ot) => (
<div key={ot.id} className="flex items-center gap-1.5">
<input
type="checkbox"
id={`all-ot-${ot.id}`}
onChange={(e) => toggleAllOutputType(ot.id, e.target.checked)}
/>
<label htmlFor={`all-ot-${ot.id}`} className="text-sm cursor-pointer">
{ot.name}
</label>
</div>
))}
{includedWithId.some((r) => !r.has_step) && (
<button
type="button"
className="ml-auto flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-red-200 text-red-700 bg-red-50 hover:bg-red-100"
onClick={deselectWithoutStep}
title="Uncheck all rows that have no STEP file linked"
>
<X size={12} />
Deselect without STEP
</button>
)}
</div>
</div>
)}
<div className="card overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Product Name</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="STEP file linked">STEP</th>
{outputTypes.map((ot) => (
<th key={ot.id} className="px-4 py-2 font-medium text-content-secondary text-center">
{ot.name}
</th>
))}
</tr>
</thead>
<tbody>
{includedWithId.map((row) => (
<tr key={row.row_index} className="border-b border-border-light hover:bg-surface-hover">
<td className="px-4 py-2 font-mono text-xs">{row.pim_id || '\u2014'}</td>
<td className="px-4 py-2">{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'}</td>
<td className="px-4 py-2 text-center">
{row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" aria-label="STEP file linked" />
) : (
<X size={14} className="text-red-400 mx-auto" aria-label="No STEP file" />
)}
</td>
{outputTypes.map((ot) => (
<td key={ot.id} className="px-4 py-2 text-center">
<input
type="checkbox"
checked={!!(rowOutputTypes[row.row_index]?.[ot.id])}
onChange={(e) =>
setRowOutputTypes((prev) => ({
...prev,
[row.row_index]: {
...(prev[row.row_index] || {}),
[ot.id]: e.target.checked,
},
}))
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rowsWithOutputType === 0 && includedWithId.length > 0 && (
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3 text-sm text-status-warning-text">
<strong>No output types selected.</strong> This order will be created with tracking-only lines —
no rendering will be dispatched until output types are added to the order. You can add
output types later from the order detail page.
</div>
)}
{excludedWithId.length > 0 && (
<div className="rounded-lg border border-border-default bg-status-info-bg px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={createDraftForSkipped}
onChange={(e) => setCreateDraftForSkipped(e.target.checked)}
/>
<div>
<span className="text-sm font-medium text-status-info-text">
Also create a draft for the {excludedWithId.length} skipped product{excludedWithId.length !== 1 ? 's' : ''}
</span>
<p className="text-xs text-status-info-text mt-0.5">
A separate tracking-only draft order will be created for unchecked rows.
Upload STEP files there once available, then assign output types.
</p>
</div>
</label>
</div>
)}
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-content-secondary mb-1">
Order Notes <span className="text-content-muted font-normal">(optional)</span>
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Add any notes for this order\u2026"
/>
</div>
<button
className="btn-primary shrink-0"
onClick={() => finalizeMut.mutate()}
disabled={finalizeMut.isPending || includedWithId.length === 0}
>
<Plus size={16} />
{finalizeMut.isPending
? 'Creating\u2026'
: createDraftForSkipped && excludedWithId.length > 0
? `Create 2 Orders (${includedWithId.length} + ${excludedWithId.length} products)`
: `Create Order (${includedWithId.length} products)`}
</button>
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
</div>
)
}
// ── ValidationDialog ──────────────────────────────────────────────────────
function ValidationDialog({
validation,
onClose,
onSaveAlias,
}: {
validation: ImportValidation | undefined
onClose: () => void
onSaveAlias: (alias: string, suggestion: string) => void
}) {
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set())
const summary = validation?.summary
const isLoading =
!validation || validation.status === 'pending' || validation.status === 'running'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col border"
style={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border-default)' }}
>
{/* Header */}
<div
className="px-6 py-4 border-b flex items-center justify-between"
style={{ borderColor: 'var(--color-border-default)' }}
>
<h2 className="text-lg font-semibold text-content">Import Validation</h2>
<button
onClick={onClose}
className="text-content-muted hover:text-content text-xl leading-none"
>
×
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{isLoading ? (
<div className="flex items-center gap-3 text-content-muted">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<span>Validating import...</span>
</div>
) : (
<>
{/* Traffic light summary */}
{summary && (
<div className="flex gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">{summary.ok} OK</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-yellow-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm font-medium text-yellow-700">
{summary.warnings} Warnings
</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 rounded-lg">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm font-medium text-red-700">
{summary.errors} Errors
</span>
</div>
</div>
)}
{/* Rows with issues */}
{validation.rows &&
validation.rows
.filter((r) => r.issues.length > 0)
.map((row) => (
<div
key={row.row_index}
className="rounded-lg overflow-hidden border"
style={{ borderColor: 'var(--color-border-default)' }}
>
<button
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
row.status === 'error'
? 'bg-red-50'
: row.status === 'warning'
? 'bg-yellow-50'
: ''
}`}
style={row.status !== 'error' && row.status !== 'warning'
? { backgroundColor: 'var(--color-bg-surface-alt)' }
: undefined}
onClick={() =>
setExpandedRows((prev) => {
const next = new Set(prev)
next.has(row.row_index)
? next.delete(row.row_index)
: next.add(row.row_index)
return next
})
}
>
<div className="flex items-center gap-3">
<div
className={`w-2.5 h-2.5 rounded-full ${
row.status === 'error'
? 'bg-red-500'
: row.status === 'warning'
? 'bg-yellow-500'
: 'bg-green-500'
}`}
/>
<span className="text-sm font-medium text-content">
Row {row.row_index + 1}
{row.pim_id ? ` — ${row.pim_id}` : ''}
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
</span>
<span className="text-xs text-content-muted">
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
</span>
</div>
<span className="text-content-muted text-xs">
{expandedRows.has(row.row_index) ? '' : ''}
</span>
</button>
{expandedRows.has(row.row_index) && (
<div
className="border-t divide-y"
style={{ borderColor: 'var(--color-border-default)' }}
>
{row.issues.map((issue: ValidationIssue, i: number) => (
<div
key={i}
className="px-4 py-3 flex items-start justify-between gap-4"
style={{ borderColor: 'var(--color-border-default)' }}
>
<div>
<p className="text-sm text-content">{issue.message}</p>
{issue.suggestion && (
<p className="text-xs text-content-muted mt-0.5">
Suggestion: <em>{issue.suggestion}</em>
</p>
)}
</div>
{issue.type === 'material_suggestion' &&
issue.value &&
issue.suggestion && (
<button
onClick={() =>
onSaveAlias(issue.value!, issue.suggestion!)
}
className="text-xs bg-accent text-white px-3 py-1 rounded whitespace-nowrap"
>
Save as alias
</button>
)}
</div>
))}
</div>
)}
</div>
))}
{validation.rows &&
validation.rows.filter((r) => r.issues.length > 0).length === 0 && (
<p className="text-sm text-green-600 font-medium">
All rows validated successfully.
</p>
)}
</>
)}
</div>
<div
className="px-6 py-4 border-t flex justify-end"
style={{ borderColor: 'var(--color-border-default)' }}
>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-content-secondary rounded-lg border border-border-default hover:bg-surface-hover transition-colors"
>
Close
</button>
</div>
</div>
</div>
)
}