Files
HartOMat/frontend/src/pages/Upload.tsx
T
Hartmut c0ea60d984 fix: resolve open risks — invoice race condition, SMTP config, workflow seeds
- billing/service.py: pg_advisory_xact_lock on invoice_number_seq per year
  → prevents duplicate INV-YYYY-NNNN under concurrent requests
- admin.py: SMTP settings in system_settings (smtp_host/port/user/password/
  from_address/enabled) with GET+PUT support; seed-workflows endpoint creates
  4 standard workflow definitions (still-cycles, still-eevee, turntable,
  multi-angle) idempotently
- notifications/service.py: send_email_notification_stub now sends real
  SMTP email via smtplib when smtp_enabled=true in system_settings
- Admin.tsx: SMTP settings panel (host/port/user/password/from + enable
  toggle, save button); Seed Standard Workflows maintenance button
- Upload.tsx: fix TS error — title→aria-label on Lucide icons
- Admin.tsx Settings type: add render_backend/flamenco_* fields (TS fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:15:45 +01:00

858 lines
38 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,
} 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'
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>
{/* ── 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>Produkt (Baureihe)</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 Baureihe 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 Baureihe"
description="Same Produkt-Baureihe appears multiple times. Pre-unchecked — only first occurrence imported."
color="bg-status-warning-bg border-border-default text-status-warning-text"
/>
</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 Produkt-Baureihe 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 Baureihe 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">Baureihe</th>
<th className="px-4 py-2 font-medium text-content-secondary"
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
>Gew. Produkt</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 Baureihe — 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 Produkt-Baureihe — 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">
{!hasId ? null : 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>
</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 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>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── 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>
)}
</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="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Import Validation</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 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-gray-500">
<div className="w-5 h-5 border-2 border-blue-500 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="border border-gray-200 rounded-lg overflow-hidden"
>
<button
className={`w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors ${
row.status === 'error'
? 'bg-red-50'
: row.status === 'warning'
? 'bg-yellow-50'
: 'bg-white'
}`}
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-gray-700">
Row {row.row_index + 1}
{row.pim_id ? ` — ${row.pim_id}` : ''}
{row.produkt_baureihe ? ` (${row.produkt_baureihe})` : ''}
</span>
<span className="text-xs text-gray-400">
{row.issues.length} issue{row.issues.length !== 1 ? 's' : ''}
</span>
</div>
<span className="text-gray-400 text-xs">
{expandedRows.has(row.row_index) ? '' : ''}
</span>
</button>
{expandedRows.has(row.row_index) && (
<div className="border-t border-gray-100 divide-y divide-gray-50">
{row.issues.map((issue: ValidationIssue, i: number) => (
<div
key={i}
className="px-4 py-3 flex items-start justify-between gap-4"
>
<div>
<p className="text-sm text-gray-700">{issue.message}</p>
{issue.suggestion && (
<p className="text-xs text-gray-500 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-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 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 border-gray-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</div>
)
}