feat: initial commit
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import { ParsedRow, ParsedComponent, ParsedExcelResponse } from '../../api/uploads'
|
||||
|
||||
interface Props {
|
||||
parsed: ParsedExcelResponse
|
||||
rows: ParsedRow[]
|
||||
onChange: (rows: ParsedRow[]) => void
|
||||
}
|
||||
|
||||
const STANDARD_FIELDS: { key: keyof ParsedRow; label: string; width: number; mono?: boolean }[] = [
|
||||
{ key: 'ebene1', label: 'Ebene 1', width: 140 },
|
||||
{ key: 'ebene2', label: 'Ebene 2', width: 120 },
|
||||
{ key: 'baureihe', label: 'Baureihe', width: 160 },
|
||||
{ key: 'pim_id', label: 'PIM-ID', width: 110 },
|
||||
{ key: 'produkt_baureihe', label: 'Produkt-Baureihe', width: 150 },
|
||||
{ key: 'gewaehltes_produkt', label: 'Gewähltes Produkt', width: 150 },
|
||||
{ key: 'name_cad_modell', label: 'CAD-Modell', width: 190, mono: true },
|
||||
{ key: 'gewuenschte_bildnummer', label: 'Bildnummer', width: 170, mono: true },
|
||||
{ key: 'lagertyp', label: 'Lagertyp', width: 100 },
|
||||
]
|
||||
|
||||
export default function ExcelSpreadsheet({ parsed, rows, onChange }: Props) {
|
||||
const maxComps = Math.max(0, ...rows.map((r) => r.components.length))
|
||||
|
||||
function updateField(ri: number, field: keyof ParsedRow, value: string | boolean | null) {
|
||||
const next = rows.map((r, i) => (i === ri ? { ...r, [field]: value } : r))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateComp(ri: number, ci: number, field: keyof ParsedComponent, value: string) {
|
||||
const next = rows.map((r, i) => {
|
||||
if (i !== ri) return r
|
||||
const comps = r.components.map((c, j) =>
|
||||
j === ci ? { ...c, [field]: value || null } : c,
|
||||
)
|
||||
// If the row doesn't have this component slot yet, pad it
|
||||
while (comps.length <= ci) {
|
||||
comps.push({ part_name: null, material: null, component_type: null, column_index: 11 + comps.length * 2 })
|
||||
}
|
||||
comps[ci] = { ...comps[ci], [field]: value || null }
|
||||
return { ...r, components: comps }
|
||||
})
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const cell =
|
||||
'w-full px-2 py-1 text-xs bg-transparent border-0 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-accent rounded focus:bg-surface'
|
||||
const th =
|
||||
'px-2 py-2 text-left text-xs font-semibold text-content-secondary whitespace-nowrap bg-surface-alt border-b border-r border-border-default sticky top-0 z-10'
|
||||
const td = 'border-b border-r border-border-light p-0'
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">
|
||||
{parsed.template_name || parsed.category_key} — {rows.length} rows
|
||||
</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">Click any cell to edit before creating the order</p>
|
||||
</div>
|
||||
<span className="badge badge-blue">{maxComps} component columns</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto" style={{ maxHeight: '65vh' }}>
|
||||
<table className="text-sm border-collapse" style={{ minWidth: 'max-content' }}>
|
||||
<thead>
|
||||
{/* Group header row */}
|
||||
<tr>
|
||||
<th className={`${th} text-center`} colSpan={1}>#</th>
|
||||
<th className={`${th} text-center bg-status-info-bg`} colSpan={STANDARD_FIELDS.length}>
|
||||
Standard Fields
|
||||
</th>
|
||||
<th className={`${th} text-center`}>Rendering</th>
|
||||
{Array.from({ length: maxComps }, (_, i) => (
|
||||
<th key={i} className={`${th} text-center bg-status-warning-bg`} colSpan={2}>
|
||||
Component {i + 1}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* Field name row */}
|
||||
<tr>
|
||||
<th className={`${th} text-content-muted`}>#</th>
|
||||
{STANDARD_FIELDS.map((f) => (
|
||||
<th key={f.key} className={`${th} bg-status-info-bg`} style={{ minWidth: f.width }}>
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
<th className={`${th} text-center`} style={{ minWidth: 72 }}>
|
||||
Rendering
|
||||
</th>
|
||||
{Array.from({ length: maxComps }, (_, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 180 }}>
|
||||
Part Name
|
||||
</th>
|
||||
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 110 }}>
|
||||
Material
|
||||
</th>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={row.row_index} className={ri % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'}>
|
||||
{/* Row number */}
|
||||
<td className={`${td} px-2 py-1.5 text-xs text-content-muted font-mono text-right`}>
|
||||
{row.row_index}
|
||||
</td>
|
||||
|
||||
{/* Standard text fields */}
|
||||
{STANDARD_FIELDS.map((f) => (
|
||||
<td key={f.key} className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={(row[f.key] as string | null) ?? ''}
|
||||
onChange={(e) => updateField(ri, f.key, e.target.value || null)}
|
||||
className={`${cell} ${f.mono ? 'font-mono' : ''}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
|
||||
{/* Rendering checkbox */}
|
||||
<td className={`${td} text-center`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.medias_rendering ?? false}
|
||||
onChange={(e) => updateField(ri, 'medias_rendering', e.target.checked)}
|
||||
className="w-3.5 h-3.5"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Component pairs */}
|
||||
{Array.from({ length: maxComps }, (_, ci) => {
|
||||
const comp = row.components[ci]
|
||||
return (
|
||||
<React.Fragment key={ci}>
|
||||
<td className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={comp?.part_name ?? ''}
|
||||
onChange={(e) => updateComp(ri, ci, 'part_name', e.target.value)}
|
||||
className={`${cell} font-mono`}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<input
|
||||
type="text"
|
||||
value={comp?.material ?? ''}
|
||||
onChange={(e) => updateComp(ri, ci, 'material', e.target.value)}
|
||||
className={cell}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* StepPreUpload — STEP file uploader used during order creation (before an
|
||||
* order ID exists). Files are uploaded immediately to /api/uploads/step so
|
||||
* we have cad_file_ids ready. Client-side filename matching gives the user
|
||||
* live feedback on which Excel rows already have a STEP file.
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, CheckCircle, XCircle, Loader2, FileBox } 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
|
||||
}
|
||||
|
||||
type FileStatus = 'uploading' | 'done' | 'error'
|
||||
|
||||
interface FileEntry {
|
||||
file: File
|
||||
status: FileStatus
|
||||
errorMsg?: string
|
||||
cadFileId?: string
|
||||
}
|
||||
|
||||
export interface StepUploadState {
|
||||
ids: string[] // cad_file_ids of successfully uploaded files
|
||||
names: string[] // original_names of successfully uploaded files
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** name_cad_modell values from parsed rows — used for match preview */
|
||||
itemNames: string[]
|
||||
/** Called whenever the set of successfully uploaded files changes */
|
||||
onUpdate: (state: StepUploadState) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normStem(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\.(step|stp)$/i, '')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function StepPreUpload({ itemNames, onUpdate }: Props) {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
|
||||
const getSuccessState = (updated: FileEntry[]): StepUploadState => ({
|
||||
ids: updated.filter((e) => e.status === 'done' && e.cadFileId).map((e) => e.cadFileId!),
|
||||
names: updated.filter((e) => e.status === 'done').map((e) => e.file.name),
|
||||
})
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (accepted: File[]) => {
|
||||
if (accepted.length === 0) return
|
||||
|
||||
const startIdx = entries.length
|
||||
const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' }))
|
||||
const merged = [...entries, ...newEntries]
|
||||
setEntries(merged)
|
||||
|
||||
let working = [...merged]
|
||||
|
||||
for (let i = 0; i < accepted.length; i++) {
|
||||
const idx = 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' },
|
||||
})
|
||||
working = working.map((e, j) =>
|
||||
j === idx ? { ...e, status: 'done', cadFileId: res.data.cad_file_id } : e,
|
||||
)
|
||||
} catch (err: any) {
|
||||
const msg: string = err?.response?.data?.detail ?? err?.message ?? 'Upload failed'
|
||||
working = working.map((e, j) =>
|
||||
j === idx ? { ...e, status: 'error', errorMsg: msg } : e,
|
||||
)
|
||||
toast.error(`${file.name}: ${msg}`)
|
||||
}
|
||||
setEntries([...working])
|
||||
}
|
||||
|
||||
onUpdate(getSuccessState(working))
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[entries, onUpdate],
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { 'application/octet-stream': ['.stp', '.step'] },
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
// Client-side match preview
|
||||
const uploadedStems = new Set(
|
||||
entries.filter((e) => e.status === 'done').map((e) => normStem(e.file.name)),
|
||||
)
|
||||
const matched = itemNames.filter((n) => uploadedStems.has(normStem(n)))
|
||||
const missing = itemNames.filter((n) => !uploadedStems.has(normStem(n)))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Match status bar */}
|
||||
{itemNames.length > 0 && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-status-success-text">
|
||||
<CheckCircle size={15} className="shrink-0" />
|
||||
<span><strong>{matched.length}</strong> matched</span>
|
||||
</div>
|
||||
{missing.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
<FileBox size={15} className="shrink-0" />
|
||||
<span><strong>{missing.length}</strong> still need a STEP file</span>
|
||||
</div>
|
||||
)}
|
||||
{missing.length === 0 && (
|
||||
<span className="text-status-success-text font-medium">All items covered ✓</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={[
|
||||
'border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors',
|
||||
isDragActive
|
||||
? 'border-accent bg-status-success-bg'
|
||||
: 'border-border-default hover:border-border-default bg-surface-alt',
|
||||
].join(' ')}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={28} className="mx-auto mb-2 text-content-muted" />
|
||||
{isDragActive ? (
|
||||
<p className="text-accent font-medium">Drop STEP files here</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-content-secondary font-medium">Drag & drop .stp / .step files</p>
|
||||
<p className="text-sm text-content-muted mt-1">or click to browse — multiple files at once</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uploaded file list */}
|
||||
{entries.length > 0 && (
|
||||
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
|
||||
{entries.map((entry, idx) => {
|
||||
const stem = normStem(entry.file.name)
|
||||
const isMatched = itemNames.some((n) => normStem(n) === stem)
|
||||
return (
|
||||
<li key={idx} className="flex items-center gap-3 px-4 py-2.5">
|
||||
{entry.status === 'uploading' && (
|
||||
<Loader2 size={15} className="animate-spin text-blue-500 shrink-0" />
|
||||
)}
|
||||
{entry.status === 'done' && (
|
||||
<CheckCircle size={15} className={isMatched ? 'text-green-500 shrink-0' : 'text-amber-400 shrink-0'} />
|
||||
)}
|
||||
{entry.status === 'error' && (
|
||||
<XCircle size={15} className="text-red-500 shrink-0" />
|
||||
)}
|
||||
<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">{entry.errorMsg}</p>
|
||||
)}
|
||||
{entry.status === 'done' && !isMatched && (
|
||||
<p className="text-xs text-amber-600">No matching row in Excel</p>
|
||||
)}
|
||||
</div>
|
||||
{entry.status === 'uploading' && (
|
||||
<span className="text-xs text-blue-500 shrink-0">Uploading…</span>
|
||||
)}
|
||||
{entry.status === 'done' && (
|
||||
<span className={`text-xs shrink-0 ${isMatched ? 'text-green-600' : 'text-amber-500'}`}>
|
||||
{isMatched ? 'Matched' : 'Unmatched'}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Missing items list */}
|
||||
{missing.length > 0 && entries.some((e) => e.status === 'done') && (
|
||||
<div className="rounded-lg border border-border-default bg-status-warning-bg p-3">
|
||||
<p className="text-xs font-semibold text-status-warning-text mb-1.5">
|
||||
Still missing ({missing.length}):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{missing.slice(0, 12).map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="text-xs font-mono bg-status-warning-bg text-status-warning-text px-1.5 py-0.5 rounded border border-border-default"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{missing.length > 12 && (
|
||||
<span className="text-xs text-status-warning-text">+{missing.length - 12} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user