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 (
{icon}
{value} {label}

{description}

) } export default function UploadPage() { const navigate = useNavigate() const qc = useQueryClient() // Step 1: Excel parsed (preview only — no products created yet) const [previewResult, setPreviewResult] = useState(null) // Step 2: per-row include toggles const [includedRows, setIncludedRows] = useState>({}) // Step 3: per-row selected output_type_ids const [rowOutputTypes, setRowOutputTypes] = useState>>({}) 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({ 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(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 = {} const rot: Record> = {} 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 (

Upload Order List

Import products from Excel and create a new output job order.

{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */} {step === 1 && (
{uploadMut.isPending ? (
Parsing Excel file\u2026
) : ( <>

{isDragActive ? 'Drop the Excel file here' : 'Drag & drop an Excel order list'}

or click to browse \u2014 .xlsx / .xls

)}
)} {/* ── Step 2: Product Matching Report ─────────────────────────────── */} {step === 2 && previewResult && (
{/* File info header */}

{previewResult.filename}

{previewResult.row_count} rows parsed {previewResult.category_key && <> · Primary category: {previewResult.category_key}}

{/* Import summary — explained stats */}

Preview Summary

No products have been created yet. This is a preview of what will happen when you finalize the order. Each unique Product (Series) in the Excel becomes one product in the library.

} 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" /> } 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" /> } 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" /> } 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" />
{/* Duplicate warning banner */} {previewResult.duplicate_count > 0 && (

{previewResult.duplicate_count} duplicate Product Series row{previewResult.duplicate_count !== 1 ? 's' : ''} detected

Each product is unique — only the first occurrence 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.

)} {/* Row table */}

Row Details

Uncheck rows you don't want to include in the order.

{previewResult.rows.map((row) => { const hasId = !!(row.pim_id || row.produkt_baureihe) return ( ) })}
(!r.pim_id && !r.produkt_baureihe) || includedRows[r.row_index])} onChange={(e) => { const updated: Record = {} 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" /> PIM-ID Series Gew. Produkt Category Status STEP
setIncludedRows({ ...includedRows, [row.row_index]: e.target.checked }) } /> {row.pim_id || } {row.produkt_baureihe || } {row.gewaehltes_produkt || '\u2014'} {row.category_key ? ( {row.category_key} ) : ( )} {!hasId ? ( skipped ) : row.is_duplicate ? ( Duplicate of row {row.duplicate_of_row} ) : row.product_exists ? ( existing ) : ( new (will be created) )} {!hasId ? null : row.has_step ? ( ) : ( )}
)} {/* ── Step 4: Upload STEP Files ────────────────────────────────────── */} {step === 4 && createdOrder && (

Upload STEP Files — {createdOrder.order_number}

Drop one or more .stp / .step 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.

qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })} />
)} {/* ── Validation Dialog ────────────────────────────────────────────── */} {showValidationDialog && ( setShowValidationDialog(false)} onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })} /> )} {/* ── Step 3: Output Type Selection ───────────────────────────────── */} {step === 3 && previewResult && (

Select Output Types

Choose which output types to request for each included product. Leave unchecked to create a tracking-only line.

{outputTypes.length > 0 && (

Select/deselect all rows:

{outputTypes.map((ot) => (
toggleAllOutputType(ot.id, e.target.checked)} />
))} {includedWithId.some((r) => !r.has_step) && ( )}
)}
{outputTypes.map((ot) => ( ))} {includedWithId.map((row) => ( {outputTypes.map((ot) => ( ))} ))}
PIM-ID Product Name STEP {ot.name}
{row.pim_id || '\u2014'} {row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'} {row.has_step ? ( ) : ( )} setRowOutputTypes((prev) => ({ ...prev, [row.row_index]: { ...(prev[row.row_index] || {}), [ot.id]: e.target.checked, }, })) } />
{rowsWithOutputType === 0 && includedWithId.length > 0 && (
No output types selected. 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.
)} {excludedWithId.length > 0 && (
)}