273 lines
9.7 KiB
TypeScript
273 lines
9.7 KiB
TypeScript
/**
|
|
* 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<FileEntry[]>([])
|
|
const [matching, setMatching] = useState(false)
|
|
const [matchResult, setMatchResult] = useState<MatchToOrderResponse | null>(null)
|
|
|
|
// Update a single entry by index
|
|
const updateEntry = useCallback(
|
|
(idx: number, patch: Partial<FileEntry>) =>
|
|
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<StepUploadResponse>('/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<MatchToOrderResponse>('/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 (
|
|
<div className="space-y-4">
|
|
{/* Drop target */}
|
|
<div
|
|
{...getRootProps()}
|
|
className={[
|
|
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors',
|
|
isDragActive
|
|
? 'border-green-500 bg-status-success-bg'
|
|
: 'border-border-default hover:border-border-default bg-surface-alt',
|
|
].join(' ')}
|
|
>
|
|
<input {...getInputProps()} />
|
|
<Upload size={32} className="mx-auto mb-3 text-content-muted" />
|
|
{isDragActive ? (
|
|
<p className="text-green-600 font-medium">Drop STEP files here</p>
|
|
) : (
|
|
<>
|
|
<p className="text-content-secondary font-medium">
|
|
Drag and drop .stp / .step files here
|
|
</p>
|
|
<p className="text-sm text-content-muted mt-1">or click to browse</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Per-file status list */}
|
|
{hasEntries && (
|
|
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
|
|
{entries.map((entry, idx) => (
|
|
<li key={idx} className="flex items-center gap-3 px-4 py-3">
|
|
<FileStatusIcon status={entry.status} />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-content truncate">
|
|
{entry.file.name}
|
|
</p>
|
|
{entry.status === 'error' && (
|
|
<p className="text-xs text-red-500 mt-0.5">{entry.errorMsg}</p>
|
|
)}
|
|
{entry.status === 'done' && (
|
|
<p className="text-xs text-content-muted mt-0.5">
|
|
ID: {entry.cadFileId}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<StatusLabel status={entry.status} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Matching spinner */}
|
|
{matching && (
|
|
<div className="flex items-center gap-2 text-sm text-content-secondary">
|
|
<Loader2 size={15} className="animate-spin" />
|
|
Matching files to order items...
|
|
</div>
|
|
)}
|
|
|
|
{/* Match result summary */}
|
|
{matchResult && !matching && (
|
|
<div className="rounded-lg border border-border-default bg-surface p-4 space-y-3">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
|
|
<Link2 size={15} />
|
|
Matching Results
|
|
</div>
|
|
|
|
{matchResult.matched.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium text-status-success-text mb-1">
|
|
Matched ({matchResult.matched.length})
|
|
</p>
|
|
<ul className="space-y-1">
|
|
{matchResult.matched.map((m) => (
|
|
<li key={m.item_id} className="flex items-center gap-2 text-xs">
|
|
<CheckCircle size={13} className="text-green-500 shrink-0" />
|
|
<span className="font-mono text-content-secondary truncate">{m.cad_name}</span>
|
|
<span className="text-content-muted">→</span>
|
|
<span className="text-content-secondary truncate">{m.item_name}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{matchResult.unmatched_cad.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium text-status-warning-text mb-1">
|
|
Unmatched CAD files ({matchResult.unmatched_cad.length})
|
|
</p>
|
|
<ul className="space-y-0.5">
|
|
{matchResult.unmatched_cad.map((id) => (
|
|
<li key={id} className="text-xs text-content-secondary font-mono truncate">
|
|
{entries.find((e) => e.cadFileId === id)?.file.name ?? id}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{matchResult.matched.length === 0 && matchResult.unmatched_cad.length === 0 && (
|
|
<p className="text-xs text-content-muted">No files were processed.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sub-components
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function FileStatusIcon({ status }: { status: FileStatus }) {
|
|
if (status === 'uploading') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
|
|
if (status === 'done') return <CheckCircle size={16} className="text-green-500 shrink-0" />
|
|
if (status === 'error') return <XCircle size={16} className="text-red-500 shrink-0" />
|
|
return <div className="w-4 h-4 rounded-full bg-surface-muted shrink-0" />
|
|
}
|
|
|
|
function StatusLabel({ status }: { status: FileStatus }) {
|
|
if (status === 'uploading') return <span className="text-xs text-blue-500">Uploading...</span>
|
|
if (status === 'done') return <span className="text-xs text-green-600">Uploaded</span>
|
|
if (status === 'error') return <span className="text-xs text-red-500">Failed</span>
|
|
return null
|
|
}
|