feat: initial commit
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Save, FlaskConical, AlertCircle } from 'lucide-react'
|
||||
import { listMaterials, saveCadPartMaterials } from '../../api/materials'
|
||||
import MaterialInput from '../shared/MaterialInput'
|
||||
import MaterialWizard from '../MaterialWizard'
|
||||
|
||||
interface CadPartRow {
|
||||
part_name: string
|
||||
material: string
|
||||
}
|
||||
|
||||
interface ExcelComponent {
|
||||
part_name: string | null
|
||||
material: string | null
|
||||
component_type: string | null
|
||||
column_index: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
itemId: string
|
||||
partNames: string[] // from cad_parsed_objects
|
||||
savedMaterials: CadPartRow[] // from cad_part_materials
|
||||
excelComponents?: ExcelComponent[] // from item.components (Excel data)
|
||||
}
|
||||
|
||||
function normName(s: string) {
|
||||
return s.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export default function CadPartMaterials({ orderId, itemId, partNames, savedMaterials, excelComponents = [] }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [wizardOpen, setWizardOpen] = useState(false)
|
||||
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
|
||||
|
||||
const initRows = (): CadPartRow[] =>
|
||||
partNames.map((name) => {
|
||||
// 1. Use saved value if present
|
||||
const saved = savedMaterials.find((s) => s.part_name === name)
|
||||
if (saved) return { part_name: name, material: saved.material }
|
||||
// 2. Fall back to Excel component data (case-insensitive match)
|
||||
const excelMatch = excelComponents.find(
|
||||
(c) => c.part_name && normName(c.part_name) === normName(name),
|
||||
)
|
||||
return { part_name: name, material: excelMatch?.material ?? '' }
|
||||
})
|
||||
|
||||
const [rows, setRows] = useState<CadPartRow[]>(initRows)
|
||||
|
||||
// Re-sync when props change (e.g. after save or STEP file change)
|
||||
useEffect(() => {
|
||||
setRows(initRows())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [partNames.join(','), savedMaterials.length, excelComponents.length])
|
||||
|
||||
const { data: library = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => saveCadPartMaterials(orderId, itemId, rows.filter((r) => r.material.trim())),
|
||||
onSuccess: () => {
|
||||
toast.success('Materials saved')
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Save failed'),
|
||||
})
|
||||
|
||||
const isDirty = rows.some((r) => {
|
||||
const saved = savedMaterials.find((s) => s.part_name === r.part_name)?.material ?? ''
|
||||
return r.material !== saved
|
||||
})
|
||||
|
||||
const missingCount = rows.filter((r) => !r.material.trim()).length
|
||||
|
||||
const setMaterial = (idx: number, value: string) =>
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, material: value } : r)))
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FlaskConical size={14} className="text-content-muted" />
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
|
||||
CAD Part Materials ({partNames.length})
|
||||
</p>
|
||||
{missingCount > 0 && (
|
||||
<span className="ml-auto flex items-center gap-1 text-xs font-medium text-red-600">
|
||||
<AlertCircle size={12} />
|
||||
{missingCount} missing
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-2 bg-surface-alt border-b border-border-default px-3 py-1.5">
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Part Name</p>
|
||||
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Material</p>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map((row, idx) => {
|
||||
const missing = !row.material.trim()
|
||||
return (
|
||||
<div
|
||||
key={row.part_name}
|
||||
className={`grid grid-cols-2 border-b border-border-light last:border-b-0 ${
|
||||
missing
|
||||
? 'bg-red-50'
|
||||
: idx % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
{missing && <AlertCircle size={12} className="text-red-400 shrink-0" />}
|
||||
<span
|
||||
className={`text-sm font-mono truncate ${missing ? 'text-red-700' : 'text-content'}`}
|
||||
title={row.part_name}
|
||||
>
|
||||
{row.part_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<MaterialInput
|
||||
value={row.material}
|
||||
onChange={(v) => setMaterial(idx, v)}
|
||||
library={library}
|
||||
missing={missing}
|
||||
onOpenWizard={() => {
|
||||
setWizardTargetIdx(idx)
|
||||
setWizardOpen(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(isDirty || missingCount > 0) && (
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
{isDirty && (
|
||||
<button
|
||||
onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
<Save size={14} />
|
||||
{saveMut.isPending ? 'Saving...' : 'Save Materials'}
|
||||
</button>
|
||||
)}
|
||||
{missingCount > 0 && !isDirty && (
|
||||
<p className="text-xs text-red-600 flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
{missingCount} part{missingCount !== 1 ? 's' : ''} have no material assigned
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Material Wizard (opened from MaterialInput) */}
|
||||
<MaterialWizard
|
||||
open={wizardOpen}
|
||||
onClose={() => { setWizardOpen(false); setWizardTargetIdx(null) }}
|
||||
onCreated={(name) => {
|
||||
if (wizardTargetIdx !== null) {
|
||||
setMaterial(wizardTargetIdx, name)
|
||||
}
|
||||
setWizardTargetIdx(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user