feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
@@ -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>
)
}