/** * StepDropzone — Phase 3 * * Accepts one or more .stp/.step files via react-dropzone, uploads each to * POST /api/uploads/step, then calls POST /api/cad/match-to-order to link * matched files to order items by filename. */ import { useState, useCallback } from 'react' import { useDropzone } from 'react-dropzone' import { Upload, CheckCircle, XCircle, Loader2, Link2 } from 'lucide-react' import { toast } from 'sonner' import api from '../../api/client' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface StepUploadResponse { cad_file_id: string original_name: string file_hash: string status: string } interface MatchedItem { item_id: string cad_file_id: string item_name: string cad_name: string } interface MatchToOrderResponse { matched: MatchedItem[] unmatched_cad: string[] unmatched_items: string[] } type FileStatus = 'idle' | 'uploading' | 'done' | 'error' interface FileEntry { file: File status: FileStatus errorMsg?: string cadFileId?: string } interface StepDropzoneProps { orderId: string /** Called after matching completes so the parent can refresh the order */ onMatchComplete?: (result: MatchToOrderResponse) => void } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function StepDropzone({ orderId, onMatchComplete }: StepDropzoneProps) { const [entries, setEntries] = useState([]) const [matching, setMatching] = useState(false) const [matchResult, setMatchResult] = useState(null) // Update a single entry by index const updateEntry = useCallback( (idx: number, patch: Partial) => setEntries((prev) => prev.map((e, i) => (i === idx ? { ...e, ...patch } : e))), [], ) const onDrop = useCallback( async (accepted: File[]) => { if (accepted.length === 0) return // Append new file entries const startIdx = entries.length const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' })) setEntries((prev) => [...prev, ...newEntries]) setMatchResult(null) // Upload each file sequentially to avoid overwhelming the server const uploadedIds: string[] = [] for (let i = 0; i < accepted.length; i++) { const globalIdx = startIdx + i const file = accepted[i] const form = new FormData() form.append('file', file) try { const res = await api.post('/uploads/step', form, { headers: { 'Content-Type': 'multipart/form-data' }, }) const { cad_file_id } = res.data uploadedIds.push(cad_file_id) updateEntry(globalIdx, { status: 'done', cadFileId: cad_file_id }) } catch (err: any) { const msg: string = err?.response?.data?.detail ?? err?.message ?? 'Upload failed' updateEntry(globalIdx, { status: 'error', errorMsg: msg }) toast.error(`${file.name}: ${msg}`) } } // Collect all successful cad_file_ids from this session (including previous uploads) const allSuccessfulIds: string[] = [ ...entries .filter((e) => e.status === 'done' && e.cadFileId) .map((e) => e.cadFileId as string), ...uploadedIds, ] if (allSuccessfulIds.length === 0) return // Match to order setMatching(true) try { const res = await api.post('/cad/match-to-order', { order_id: orderId, cad_file_ids: allSuccessfulIds, }) setMatchResult(res.data) const { matched, unmatched_cad } = res.data if (matched.length > 0) { toast.success(`Matched ${matched.length} file(s) to order items`) } if (unmatched_cad.length > 0) { toast.warning(`${unmatched_cad.length} file(s) could not be matched to any item`) } onMatchComplete?.(res.data) } catch (err: any) { const msg: string = err?.response?.data?.detail ?? err?.message ?? 'Matching failed' toast.error(`CAD matching error: ${msg}`) } finally { setMatching(false) } }, // eslint-disable-next-line react-hooks/exhaustive-deps [entries, orderId, onMatchComplete, updateEntry], ) const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: { 'application/octet-stream': ['.stp', '.step'] }, multiple: true, }) const hasEntries = entries.length > 0 return (
{/* Drop target */}
{isDragActive ? (

Drop STEP files here

) : ( <>

Drag and drop .stp / .step files here

or click to browse

)}
{/* Per-file status list */} {hasEntries && (
    {entries.map((entry, idx) => (
  • {entry.file.name}

    {entry.status === 'error' && (

    {entry.errorMsg}

    )} {entry.status === 'done' && (

    ID: {entry.cadFileId}

    )}
  • ))}
)} {/* Matching spinner */} {matching && (
Matching files to order items...
)} {/* Match result summary */} {matchResult && !matching && (
Matching Results
{matchResult.matched.length > 0 && (

Matched ({matchResult.matched.length})

    {matchResult.matched.map((m) => (
  • {m.cad_name} {m.item_name}
  • ))}
)} {matchResult.unmatched_cad.length > 0 && (

Unmatched CAD files ({matchResult.unmatched_cad.length})

    {matchResult.unmatched_cad.map((id) => (
  • {entries.find((e) => e.cadFileId === id)?.file.name ?? id}
  • ))}
)} {matchResult.matched.length === 0 && matchResult.unmatched_cad.length === 0 && (

No files were processed.

)}
)}
) } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- function FileStatusIcon({ status }: { status: FileStatus }) { if (status === 'uploading') return if (status === 'done') return if (status === 'error') return return
} function StatusLabel({ status }: { status: FileStatus }) { if (status === 'uploading') return Uploading... if (status === 'done') return Uploaded if (status === 'error') return Failed return null }