Files
HartOMat/frontend/src/pages/ProductDetail.tsx
T
Hartmut 5029a94608 fix: full-width content area + auto-create MediaAssets on render complete
- Remove max-w-* constraints from all data/table pages so content fills available width after sidebar (Billing, MediaBrowser, OrderDetail, WorkerManagement, WorkerActivity, Materials, Tenants, AssetLibrary, NewProductOrder, ProductDetail)
- Narrow form/settings pages keep their max-width (NotificationSettings, Preferences, NewOrder, Notifications)
- render_order_line_task: create MediaAsset record on render success so results immediately appear in Media Browser without requiring the retroactive import button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:17:17 +01:00

1324 lines
63 KiB
TypeScript

import { useState, useCallback, useEffect, Fragment, useMemo } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropzone } from 'react-dropzone'
import {
ArrowLeft, Pencil, Save, X, Box, Image,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler,
} from 'lucide-react'
import { toast } from 'sonner'
import {
getProduct, updateProduct, uploadProductCad, saveProductCadMaterials, regenerateProduct,
reprocessProduct, reassignMaterialsFromExcel, getProductOrders, getProductRenders,
createRenderPosition, updateRenderPosition, deleteRenderPosition, deleteProductRender,
downloadProductRenders,
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore } from '../store/auth'
import { downloadStl, generateStl, generateGltfGeometry } from '../api/cad'
function CadStatusBadge({ status }: { status: string | null }) {
if (!status) return (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">No STEP</span>
)
const map: Record<string, string> = {
completed: 'bg-status-success-bg text-status-success-text',
processing: 'bg-status-info-bg text-status-info-text animate-pulse',
failed: 'bg-status-error-bg text-status-error-text',
pending: 'bg-status-warning-bg text-status-warning-text',
}
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[status] || 'bg-surface-muted text-content-muted'}`}>
{status}
</span>
)
}
const META_FIELDS: Array<{ key: keyof Product; label: string }> = [
{ key: 'ebene1', label: 'Ebene 1' },
{ key: 'ebene2', label: 'Ebene 2' },
{ key: 'baureihe', label: 'Baureihe' },
{ key: 'produkt_baureihe', label: 'Produkt-Baureihe' },
{ key: 'lagertyp', label: 'Lagertyp' },
{ key: 'name_cad_modell', label: 'Name CAD-Modell' },
]
export default function ProductDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const user = useAuthStore((s) => s.user)
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
const [editMode, setEditMode] = useState(false)
const [draft, setDraft] = useState<Partial<Product>>({})
const [showOrders, setShowOrders] = useState(false)
const [showExcelRow, setShowExcelRow] = useState(false)
const [materialRows, setMaterialRows] = useState<CadPartMaterial[]>([])
const [materialsDirty, setMaterialsDirty] = useState(false)
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
queryFn: () => getProduct(id!),
enabled: !!id,
})
const { data: materialLibrary = [] } = useQuery({
queryKey: ['materials'],
queryFn: listMaterials,
})
useEffect(() => {
if (!product || materialsDirty) return
const parsedNames = product.cad_parsed_objects ?? []
if (parsedNames.length > 0) {
// Build rows from parsed STEP objects, pre-filling any saved material assignments
const savedMap = new Map(
(product.cad_part_materials || []).map((m) => [m.part_name, m.material])
)
setMaterialRows(
parsedNames.map((name) => ({ part_name: name, material: savedMap.get(name) ?? '' }))
)
} else {
// Fallback: show whatever is saved (no parsed objects yet)
setMaterialRows(product.cad_part_materials || [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product?.id, product?.cad_parsed_objects?.length, product?.cad_part_materials.length])
const { data: renders = [] } = useQuery<ProductRender[]>({
queryKey: ['product-renders', id],
queryFn: () => getProductRenders(id!),
enabled: !!id,
})
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchConfirm, setBatchConfirm] = useState(false)
const [batchLoading, setBatchLoading] = useState(false)
const [filterOutputType, setFilterOutputType] = useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = useState(false)
const outputTypeNames = useMemo(() => {
const names = renders.map(r => r.output_type_name).filter((n): n is string => n !== null)
return [...new Set(names)].sort()
}, [renders])
const filteredRenders = useMemo(() => {
if (!filterOutputType) return renders
return renders.filter(r => r.output_type_name === filterOutputType)
}, [renders, filterOutputType])
const toggleSelect = (lineId: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(lineId)) next.delete(lineId)
else next.add(lineId)
return next
})
}
const exitSelectMode = () => {
setSelectMode(false)
setSelectedIds(new Set())
setBatchConfirm(false)
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0 || batchLoading) return
setBatchLoading(true)
try {
await Promise.all([...selectedIds].map(lineId => deleteProductRender(id!, lineId)))
qc.invalidateQueries({ queryKey: ['product-renders', id] })
qc.invalidateQueries({ queryKey: ['product', id] })
toast.success(`${selectedIds.size} render${selectedIds.size > 1 ? 's' : ''} deleted`)
exitSelectMode()
} catch {
toast.error('Failed to delete some renders')
} finally {
setBatchLoading(false)
}
}
const handleDownloadSelected = async () => {
if (selectedIds.size === 0 || downloadLoading) return
setDownloadLoading(true)
try {
const productName = product?.name || product?.pim_id || 'product'
const safe = productName.replace(/[^\w-]/g, '_')
await downloadProductRenders(id!, [...selectedIds], `${safe}_renders.zip`)
toast.success(`Downloading ${selectedIds.size} render${selectedIds.size > 1 ? 's' : ''}`)
} catch {
toast.error('Failed to download renders')
} finally {
setDownloadLoading(false)
}
}
const handleDownloadAll = async () => {
if (filteredRenders.length === 0 || downloadLoading) return
setDownloadLoading(true)
try {
const productName = product?.name || product?.pim_id || 'product'
const safe = productName.replace(/[^\w-]/g, '_')
const suffix = filterOutputType ? `_${filterOutputType.replace(/[^\w-]/g, '_')}` : ''
await downloadProductRenders(id!, filteredRenders.map(r => r.order_line_id), `${safe}${suffix}_renders.zip`)
toast.success(`Downloading ${filteredRenders.length} render${filteredRenders.length > 1 ? 's' : ''}`)
} catch {
toast.error('Failed to download renders')
} finally {
setDownloadLoading(false)
}
}
const deleteRenderMut = useMutation({
mutationFn: (orderLineId: string) => deleteProductRender(id!, orderLineId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['product-renders', id] })
qc.invalidateQueries({ queryKey: ['product', id] })
setPendingDelete(null)
toast.success('Render deleted')
},
onError: () => {
setPendingDelete(null)
toast.error('Failed to delete render')
},
})
const { data: orders, refetch: fetchOrders } = useQuery({
queryKey: ['product-orders', id],
queryFn: () => getProductOrders(id!),
enabled: false,
})
const updateMut = useMutation({
mutationFn: (data: Partial<Product>) => updateProduct(id!, data),
onSuccess: (updated) => {
toast.success('Product updated')
qc.setQueryData(['product', id], updated)
setEditMode(false)
setDraft({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Update failed'),
})
const cadUploadMut = useMutation({
mutationFn: (file: File) => uploadProductCad(id!, file),
onSuccess: () => {
toast.success('STEP file uploaded — processing started')
qc.invalidateQueries({ queryKey: ['product', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
})
const saveMaterialsMut = useMutation({
mutationFn: () => saveProductCadMaterials(id!, materialRows),
onSuccess: (updated) => {
toast.success('Materials saved — thumbnail queued')
qc.setQueryData(['product', id], updated)
setMaterialsDirty(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Save failed'),
})
const regenerateMut = useMutation({
mutationFn: () => regenerateProduct(id!),
onSuccess: () => toast.success('Thumbnail regeneration queued'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const reprocessMut = useMutation({
mutationFn: () => reprocessProduct(id!),
onSuccess: () => {
toast.success('STEP reprocessing queued — part list will refresh when complete')
qc.invalidateQueries({ queryKey: ['product', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const reassignMut = useMutation({
mutationFn: () => reassignMaterialsFromExcel(id!),
onSuccess: (updated) => {
toast.success(`Materials populated from Excel — ${updated.cad_part_materials.length} part(s) assigned`)
qc.setQueryData(['product', id], updated)
setMaterialsDirty(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to reassign materials'),
})
// ── Render Positions ───────────────────────────────────────────────────────
const [showPositions, setShowPositions] = useState(false)
const [positionForm, setPositionForm] = useState<{ name: string; rotation_x: number; rotation_y: number; rotation_z: number } | null>(null)
const [editingPositionId, setEditingPositionId] = useState<string | null>(null)
const addPositionMut = useMutation({
mutationFn: (data: { name: string; rotation_x: number; rotation_y: number; rotation_z: number }) =>
createRenderPosition(id!, { ...data, sort_order: (product?.render_positions?.length ?? 0) }),
onSuccess: () => {
toast.success('Position added')
qc.invalidateQueries({ queryKey: ['product', id] })
setPositionForm(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add position'),
})
const updatePositionMut = useMutation({
mutationFn: ({ posId, data }: { posId: string; data: Partial<RenderPosition> }) =>
updateRenderPosition(id!, posId, data),
onSuccess: () => {
toast.success('Position updated')
qc.invalidateQueries({ queryKey: ['product', id] })
setEditingPositionId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
})
const deletePositionMut = useMutation({
mutationFn: (posId: string) => deleteRenderPosition(id!, posId),
onSuccess: () => {
toast.success('Position deleted')
qc.invalidateQueries({ queryKey: ['product', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
{ label: 'Beauty', rx: 0, ry: 0, rz: 0 },
{ label: '3/4 Front', rx: -15, ry: 45, rz: 0 },
{ label: '3/4 Back', rx: -15, ry: -135, rz: 0 },
]
const onDrop = useCallback(
(files: File[]) => { if (files[0]) cadUploadMut.mutate(files[0]) },
[cadUploadMut],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'application/octet-stream': ['.stp', '.step'] },
multiple: false,
})
if (isLoading) return <div className="p-8 text-content-muted">Loading</div>
if (!product) return <div className="p-8 text-red-500">Product not found</div>
return (
<div className="p-8">
{/* Back */}
<Link to="/products" className="inline-flex items-center gap-1 text-sm text-content-secondary hover:text-content mb-4">
<ArrowLeft size={15} /> Products
</Link>
{/* Header */}
<div className="card p-5 mb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono bg-surface-muted text-content-secondary px-2 py-0.5 rounded">
{product.pim_id}
</span>
{product.category_key && (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
{product.category_key}
</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
product.is_active ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'
}`}>
{product.is_active ? 'active' : 'inactive'}
</span>
</div>
{editMode ? (
<input
className="text-xl font-bold border-b-2 border-accent w-full bg-transparent focus:outline-none"
value={draft.name ?? product.name ?? ''}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
) : (
<h1 className="text-xl font-bold text-content">
{product.name || <span className="text-content-muted italic">No name</span>}
</h1>
)}
</div>
{isPrivileged && (
<div className="flex items-center gap-2 shrink-0">
{editMode ? (
<>
<button
className="btn-primary text-sm"
onClick={() => updateMut.mutate(draft)}
disabled={updateMut.isPending}
>
<Save size={14} /> Save
</button>
<button
className="btn-secondary text-sm"
onClick={() => { setEditMode(false); setDraft({}) }}
>
<X size={14} /> Cancel
</button>
</>
) : (
<button
className="btn-secondary text-sm"
onClick={() => setEditMode(true)}
>
<Pencil size={14} /> Edit
</button>
)}
</div>
)}
</div>
</div>
{/* Meta fields */}
<div className="card p-5 mb-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Product Details</h2>
<div className="grid grid-cols-2 gap-3">
{META_FIELDS.map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-content-muted mb-0.5">{label}</label>
{editMode ? (
<input
className="w-full px-2 py-1.5 border border-border-default rounded text-sm focus:outline-none focus:ring-1 focus:ring-accent"
value={(draft[key] as string) ?? (product[key] as string) ?? ''}
onChange={(e) => setDraft({ ...draft, [key]: e.target.value || null })}
/>
) : (
<p className="text-sm text-content">
{(product[key] as string) || <span className="text-content-muted"></span>}
</p>
)}
</div>
))}
<div className="col-span-2">
<label className="block text-xs text-content-muted mb-0.5">Notes</label>
{editMode ? (
<textarea
rows={2}
className="w-full px-2 py-1.5 border border-border-default rounded text-sm focus:outline-none focus:ring-1 focus:ring-accent"
value={(draft.notes as string) ?? product.notes ?? ''}
onChange={(e) => setDraft({ ...draft, notes: e.target.value || null })}
/>
) : (
<p className="text-sm text-content">{product.notes || <span className="text-content-muted"></span>}</p>
)}
</div>
</div>
{editMode && isPrivileged && (
<div className="mt-3 pt-3 border-t border-border-light flex items-center gap-2">
<label className="text-sm text-content-secondary">Active</label>
<input
type="checkbox"
checked={(draft.is_active ?? product.is_active)}
onChange={(e) => setDraft({ ...draft, is_active: e.target.checked })}
/>
</div>
)}
</div>
{/* Excel Source Row (collapsible) */}
<div className="card mb-4">
<button
className="w-full flex items-center gap-2 p-4 text-sm font-semibold text-content-secondary hover:bg-surface-hover rounded-lg"
onClick={() => setShowExcelRow((v) => !v)}
>
{showExcelRow ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
Excel Source Row
</button>
{showExcelRow && (
<div className="px-4 pb-4 overflow-x-auto">
<table className="text-xs border-collapse min-w-max">
<thead>
<tr className="bg-surface-alt">
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Ebene 1</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Ebene 2</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Baureihe</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">PIM-ID</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Produkt-Baureihe</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Gew. Produkt</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Name CAD-Modell</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Bildnummer</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Lagertyp</th>
<th className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap">Medias</th>
{product.components.map((c: any, i: number) => (
<th key={i} className="border border-border-default px-2 py-1 text-left font-medium text-content-muted whitespace-nowrap" colSpan={2}>
{c.component_type || `Part ${i + 1}`}
</th>
))}
</tr>
{product.components.length > 0 && (
<tr className="bg-surface-alt">
{/* Empty cells for the 10 fixed columns */}
{Array.from({ length: 10 }).map((_, i) => (
<th key={i} className="border border-border-default px-2 py-0.5" />
))}
{product.components.map((_: any, i: number) => (
<Fragment key={i}>
<th className="border border-border-default px-2 py-0.5 text-left font-normal text-content-muted whitespace-nowrap">Part</th>
<th className="border border-border-default px-2 py-0.5 text-left font-normal text-content-muted whitespace-nowrap">Material</th>
</Fragment>
))}
</tr>
)}
</thead>
<tbody>
<tr>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.ebene1 || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.ebene2 || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.baureihe || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap font-mono">{product.pim_id}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.produkt_baureihe || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.name || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.name_cad_modell || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.gewuenschte_bildnummer || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.lagertyp || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{product.medias_rendering === true ? 'Ja' : product.medias_rendering === false ? 'Nein' : '—'}</td>
{product.components.map((c: any, i: number) => (
<Fragment key={i}>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{c.part_name || '—'}</td>
<td className="border border-border-default px-2 py-1 whitespace-nowrap">{c.material || '—'}</td>
</Fragment>
))}
</tr>
</tbody>
</table>
{product.source_excel && (
<p className="text-xs text-content-muted mt-2">Source: {product.source_excel}</p>
)}
</div>
)}
</div>
{/* CAD File section */}
<div className="card p-5 mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-content-secondary">CAD File</h2>
<CadStatusBadge status={product.processing_status} />
</div>
{product.cad_file_id ? (
<div className="space-y-3">
{/* Thumbnail */}
<div className="flex gap-4">
<div className="w-32 h-32 bg-surface-muted rounded border flex items-center justify-center shrink-0 overflow-hidden">
{(product.render_image_url || product.thumbnail_url) ? (
<img
src={product.render_image_url || product.thumbnail_url!}
alt="thumbnail"
className="w-full h-full object-contain"
/>
) : (
<Box size={36} className="text-content-muted" />
)}
</div>
<div className="flex flex-col gap-2 justify-end">
{isPrivileged && (
<>
<div {...getRootProps()} className="cursor-pointer">
<input {...getInputProps()} />
<button className="btn-secondary text-xs" disabled={cadUploadMut.isPending}>
<Upload size={12} />
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
</button>
</div>
<button
className="btn-secondary text-xs"
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
title="Re-render the thumbnail using the current part materials and the active thumbnail renderer — keeps the existing STEP parse data"
>
<RotateCcw size={12} />
{regenerateMut.isPending ? 'Queuing…' : 'Regenerate thumbnail'}
</button>
<button
className="btn-secondary text-xs"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending}
title="Re-run full STEP processing: re-parse part names, regenerate thumbnail and glTF. Use this after re-uploading a STEP file."
>
<RotateCcw size={12} />
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
</button>
</>
)}
{product.cad_file_id && (
<button
className="btn-secondary text-xs"
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
title="Open interactive 3D viewer"
>
<Cuboid size={12} />
View 3D
</button>
)}
{product.cad_file_id && isPrivileged && (
<button
className="btn-secondary text-xs"
onClick={() =>
generateGltfGeometry(product.cad_file_id!)
.then(() => toast.info('GLB geometry export queued'))
.catch(() => toast.error('Failed to queue GLB export'))
}
title="Export geometry-only GLB from cached STL (trimesh, no Blender). Requires STL cache."
>
<Download size={12} />
Generate GLB
</button>
)}
{product.cad_file_id && isPrivileged && (
<div className="flex flex-col gap-1 pt-1 border-t border-border-light">
<p className="text-xs text-content-muted font-medium">STL</p>
{(['low', 'high'] as const).map((q) =>
product.stl_cached.includes(q) ? (
<button
key={q}
className="btn-secondary text-xs"
onClick={() => downloadStl(product.cad_file_id!, q, product.name_cad_modell || product.name || undefined)}
title={q === 'low' ? 'Coarse mesh, tolerance 0.3 mm' : 'Fine mesh, tolerance 0.01 mm'}
>
<Download size={12} /> {q === 'low' ? 'Low' : 'High'} quality
</button>
) : (
<button
key={q}
className="btn-secondary text-xs opacity-60"
onClick={() => generateStl(product.cad_file_id!, q).then(() => toast.info(`STL generation queued (${q} quality)`)).catch(() => toast.error('Failed to queue STL generation'))}
title={`${q === 'low' ? 'Low' : 'High'}-quality STL not cached — click to generate`}
>
<RefreshCw size={12} /> Generate {q === 'low' ? 'Low' : 'High'} quality
</button>
)
)}
</div>
)}
</div>
</div>
{/* Mesh attributes */}
{product.cad_file?.mesh_attributes && Object.keys(product.cad_file.mesh_attributes).length > 0 && (() => {
const mesh_attrs = product.cad_file!.mesh_attributes!
return (
<div className="mt-3 p-3 rounded-md border border-border-default bg-surface-alt">
<p className="text-xs font-semibold text-content-muted mb-2 flex items-center gap-1">
<Ruler size={12} />
Geometry
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{mesh_attrs.volume_mm3 != null && (
<>
<span className="text-content-muted">Volume</span>
<span>{((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³</span>
</>
)}
{mesh_attrs.surface_area_mm2 != null && (
<>
<span className="text-content-muted">Surface</span>
<span>{((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm²</span>
</>
)}
{mesh_attrs.bbox != null && (
<>
<span className="text-content-muted">BBox</span>
<span>
{(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).x?.toFixed(1)} &times;{' '}
{(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).y?.toFixed(1)} &times;{' '}
{(mesh_attrs.bbox as { x?: number; y?: number; z?: number }).z?.toFixed(1)} mm
</span>
</>
)}
{mesh_attrs.suggested_smooth_angle !== undefined && (
<>
<span className="text-content-muted">Sharp angle</span>
<span>{mesh_attrs.suggested_smooth_angle as number}°</span>
</>
)}
</div>
</div>
)
})()}
{/* Material assignments */}
{isPrivileged && (
<div className="pt-3 border-t border-border-light">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-content-secondary">Part Materials</h3>
<div className="flex items-center gap-2">
{(product.cad_parsed_objects?.length ?? 0) > 0 && product.components.length > 0 && (
<button
className="btn-secondary text-xs"
onClick={() => reassignMut.mutate()}
disabled={reassignMut.isPending}
title="Map Excel component materials to CAD parts by position (index-based)"
>
<Wand2 size={11} />
{reassignMut.isPending ? 'Assigning…' : 'Populate from Excel'}
</button>
)}
{materialsDirty && (
<button
className="btn-primary text-xs"
onClick={() => saveMaterialsMut.mutate()}
disabled={saveMaterialsMut.isPending}
>
<Save size={11} />
{saveMaterialsMut.isPending ? 'Saving…' : 'Save & Regenerate'}
</button>
)}
</div>
</div>
{materialRows.length === 0 ? (
<p className="text-xs text-content-muted">
{product.processing_status === 'completed'
? 'No parts found in STEP file.'
: product.processing_status === 'processing' || product.processing_status === 'pending'
? 'STEP file is being processed — part list will appear when complete.'
: 'Click "Re-process STEP" to extract part names from the file.'}
</p>
) : (
<div className="space-y-1.5">
{materialRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-content-secondary w-40 truncate shrink-0" title={row.part_name}>
{row.part_name}
</span>
<div className="flex-1">
<MaterialInput
value={row.material}
onChange={(v) => {
const updated = [...materialRows]
updated[i] = { ...updated[i], material: v }
setMaterialRows(updated)
setMaterialsDirty(true)
}}
library={materialLibrary}
missing={!row.material.trim()}
onOpenWizard={() => {
setWizardTargetIdx(i)
setWizardOpen(true)
}}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
) : (
isPrivileged ? (
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive
? ''
: 'border-border-default hover:border-accent'
}`}
style={isDragActive ? { borderColor: 'var(--color-accent)', backgroundColor: 'rgba(0,0,0,0.03)' } : undefined}
>
<input {...getInputProps()} />
{cadUploadMut.isPending ? (
<div className="text-content-secondary">
<div className="animate-spin w-6 h-6 border-2 border-accent border-t-transparent rounded-full mx-auto mb-2" />
Uploading
</div>
) : (
<>
<Box size={32} className="text-content-muted mx-auto mb-2" />
<p className="text-content-secondary font-medium text-sm">
{isDragActive ? 'Drop the STEP file here' : 'Upload STEP file'}
</p>
<p className="text-content-muted text-xs mt-1">.stp / .step</p>
</>
)}
</div>
) : (
<p className="text-sm text-content-muted">No STEP file uploaded</p>
)
)}
</div>
{/* Renders gallery */}
<div className="card p-5 mb-4">
{/* ── Gallery header ──────────────────────────────────────── */}
<div className="flex items-center gap-2 mb-3 min-h-[26px]">
<h2 className="text-sm font-semibold text-content-secondary flex items-center gap-2">
<Image size={15} /> Renders
{renders.length > 0 && (
<span className="text-xs font-normal text-content-muted">({renders.length})</span>
)}
</h2>
{renders.length > 0 && (
<div className="ml-auto flex items-center gap-2">
{!selectMode ? (
/* Normal mode: Download all + Select button */
<>
<button
disabled={downloadLoading}
onClick={handleDownloadAll}
className="text-xs px-2.5 py-1 rounded border border-border-default text-content-muted hover:border-accent hover:text-accent transition-colors flex items-center gap-1 disabled:opacity-50"
>
{downloadLoading
? <span className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
: <Download size={11} />
}
{filterOutputType ? `Download (${filteredRenders.length})` : 'Download all'}
</button>
<button
onClick={() => setSelectMode(true)}
className="text-xs px-2.5 py-1 rounded border border-border-default text-content-muted hover:border-accent hover:text-accent transition-colors"
>
Select
</button>
</>
) : !batchConfirm ? (
/* Select mode: count + Download + Delete + Cancel */
<>
<span className="text-xs text-content-muted">
{selectedIds.size} of {filteredRenders.length} selected
</span>
<button
onClick={() => {
if (selectedIds.size === filteredRenders.length) setSelectedIds(new Set())
else setSelectedIds(new Set(filteredRenders.map(r => r.order_line_id)))
}}
className="text-xs px-2 py-0.5 rounded border border-border-default text-content-muted hover:bg-surface-hover transition-colors"
>
{selectedIds.size === filteredRenders.length ? 'Deselect all' : 'Select all'}
</button>
{selectedIds.size > 0 && (
<button
disabled={downloadLoading}
onClick={handleDownloadSelected}
className="text-xs px-2.5 py-1 rounded bg-accent-light border border-border-default text-accent hover:bg-surface-hover transition-colors flex items-center gap-1 disabled:opacity-50"
>
{downloadLoading
? <span className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
: <Download size={11} />
}
Download ({selectedIds.size})
</button>
)}
{isPrivileged && selectedIds.size > 0 && (
<button
onClick={() => setBatchConfirm(true)}
className="text-xs px-2.5 py-1 rounded bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 transition-colors flex items-center gap-1"
>
<Trash2 size={11} /> Delete ({selectedIds.size})
</button>
)}
<button
onClick={exitSelectMode}
className="text-xs px-2.5 py-1 rounded border border-border-default text-content-muted hover:bg-surface-hover transition-colors"
>
Cancel
</button>
</>
) : (
/* Confirm batch delete */
<>
<span className="text-xs text-red-600 font-medium">
Delete {selectedIds.size} render{selectedIds.size > 1 ? 's' : ''} permanently?
</span>
<button
disabled={batchLoading}
onClick={() => setBatchConfirm(false)}
className="text-xs px-2.5 py-1 rounded border border-border-default text-content-muted hover:bg-surface-hover disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
disabled={batchLoading}
onClick={handleBatchDelete}
className="text-xs px-2.5 py-1 rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors flex items-center gap-1"
>
{batchLoading && (
<span className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin" />
)}
<Trash2 size={11} /> Delete
</button>
</>
)}
</div>
)}
</div>
{/* ── Output-type filter pills ────────────────────────── */}
{outputTypeNames.length >= 2 && (
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Filter size={13} className="text-content-muted shrink-0" />
<button
onClick={() => setFilterOutputType(null)}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
filterOutputType === null
? 'text-white'
: 'border-border-default text-content-muted hover:border-accent hover:text-accent'
}`}
style={filterOutputType === null ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
All ({renders.length})
</button>
{outputTypeNames.map(name => (
<button
key={name}
onClick={() => setFilterOutputType(filterOutputType === name ? null : name)}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
filterOutputType === name
? 'text-white'
: 'border-border-default text-content-muted hover:border-accent hover:text-accent'
}`}
style={filterOutputType === name ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{name} ({renders.filter(r => r.output_type_name === name).length})
</button>
))}
</div>
)}
{/* ── Grid ───────────────────────────────────────────────── */}
{filteredRenders.length === 0 ? (
<p className="text-sm text-content-muted">
{filterOutputType ? 'No renders match the selected filter' : 'No renders yet'}
</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{filteredRenders.map((r) => {
const isConfirming = pendingDelete === r.order_line_id
const isDeleting = deleteRenderMut.isPending && isConfirming
const isSelected = selectedIds.has(r.order_line_id)
return (
<div
key={r.order_line_id}
onClick={() => selectMode && toggleSelect(r.order_line_id)}
className={`border rounded-lg overflow-hidden bg-surface-alt flex flex-col transition-all ${
selectMode ? 'cursor-pointer' : ''
} ${
isSelected
? 'border-blue-400 ring-2 ring-blue-300'
: 'border-border-default'
}`}
>
{/* ── Media area ───────────────────────────────── */}
{r.is_video ? (
<div className="relative">
<video
src={r.render_url}
controls
className="w-full aspect-video object-contain bg-black"
onClick={(e) => selectMode && e.preventDefault()}
/>
{/* Select mode checkbox overlay for videos */}
{selectMode && (
<div className="absolute top-2 left-2 pointer-events-none">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected ? 'bg-blue-500 border-blue-500' : 'bg-surface border-border-default'
}`}>
{isSelected && <span className="text-white text-xs font-bold"></span>}
</div>
</div>
)}
</div>
) : (
<div className="relative group">
<a
href={r.render_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => selectMode && e.preventDefault()}
>
<img
src={r.render_url}
alt={r.output_type_name || 'Render'}
className={`w-full aspect-square object-contain transition-opacity ${
isConfirming ? 'opacity-30' : isSelected ? 'opacity-80' : 'hover:opacity-90'
}`}
/>
</a>
{/* Select mode: checkbox top-left */}
{selectMode && (
<div className="absolute top-2 left-2 pointer-events-none">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center shadow-sm ${
isSelected ? 'bg-blue-500 border-blue-500' : 'bg-surface border-border-default'
}`}>
{isSelected && <span className="text-white text-xs font-bold"></span>}
</div>
</div>
)}
{/* Normal mode: hover delete trigger */}
{isPrivileged && !selectMode && !isConfirming && (
<button
className="absolute top-2 right-2 p-1.5 rounded-full bg-surface text-content-muted opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all shadow-sm"
title="Delete render"
onClick={(e) => { e.stopPropagation(); setPendingDelete(r.order_line_id) }}
>
<Trash2 size={14} />
</button>
)}
{/* Confirmation overlay */}
{isPrivileged && !selectMode && isConfirming && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-3"
onClick={(e) => e.stopPropagation()}
>
<Trash2 size={20} className="text-red-400" />
<p className="text-content text-xs font-semibold text-center">Delete this render?</p>
<p className="text-content-secondary text-xs text-center leading-tight">File will be permanently removed from disk.</p>
<div className="flex gap-2 mt-1">
<button
disabled={isDeleting}
onClick={() => setPendingDelete(null)}
className="px-3 py-1 text-xs rounded border border-border-default bg-surface text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
Cancel
</button>
<button
disabled={isDeleting}
onClick={() => deleteRenderMut.mutate(r.order_line_id)}
className="px-3 py-1 text-xs rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 flex items-center gap-1"
>
{isDeleting
? <span className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin inline-block" />
: <Trash2 size={11} />
}
Delete
</button>
</div>
</div>
)}
</div>
)}
{/* ── Footer ───────────────────────────────────── */}
<div className="px-2.5 py-2 space-y-1.5 mt-auto">
<div className="flex items-center gap-1.5 flex-wrap">
{r.output_type_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-surface-muted text-content-secondary font-medium">
{r.output_type_name}
</span>
)}
{r.render_backend && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
r.render_backend === 'flamenco' ? 'bg-status-warning-bg text-status-warning-text' : 'bg-status-info-bg text-status-info-text'
}`}>
{r.render_backend}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-content-muted">
<span className="flex items-center gap-1.5">
{r.order_number && <span>{r.order_number}</span>}
{r.completed_at && <span>{new Date(r.completed_at).toLocaleDateString()}</span>}
</span>
{!selectMode && (
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<a
href={r.render_url}
download
className="p-1 rounded text-content-muted hover:text-accent hover:bg-accent-light transition-colors"
title="Download"
>
<Download size={13} />
</a>
{isPrivileged && r.is_video && !isConfirming && (
<button
className="p-1 rounded text-content-muted hover:text-red-500 hover:bg-red-50 transition-colors"
title="Delete render"
onClick={() => setPendingDelete(r.order_line_id)}
>
<Trash2 size={13} />
</button>
)}
</span>
)}
</div>
{/* Video inline confirmation */}
{isPrivileged && !selectMode && r.is_video && isConfirming && (
<div
className="border border-red-200 rounded-md bg-red-50 px-2.5 py-2 flex items-center justify-between gap-2"
onClick={(e) => e.stopPropagation()}
>
<span className="text-xs text-red-700 font-medium">Delete permanently?</span>
<div className="flex gap-1.5">
<button
disabled={isDeleting}
onClick={() => setPendingDelete(null)}
className="px-2 py-0.5 text-xs rounded border border-border-default bg-surface text-content-secondary hover:bg-surface-hover disabled:opacity-50"
>
Cancel
</button>
<button
disabled={isDeleting}
onClick={() => deleteRenderMut.mutate(r.order_line_id)}
className="px-2 py-0.5 text-xs rounded bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 flex items-center gap-1"
>
{isDeleting && <span className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin inline-block" />}
Delete
</button>
</div>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Render Positions */}
{isPrivileged && (
<div className="card mb-4">
<button
className="w-full flex items-center gap-2 p-4 text-sm font-semibold text-content-secondary hover:bg-surface-hover rounded-lg"
onClick={() => setShowPositions((v) => !v)}
>
{showPositions ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
Render Positions
<span className="text-xs font-normal text-content-muted ml-1">
({product.render_positions?.length ?? 0})
</span>
</button>
{showPositions && (
<div className="px-4 pb-4 space-y-3">
{/* Preset buttons */}
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-content-muted">Presets:</span>
{POSITION_PRESETS.map((preset) => (
<button
key={preset.label}
className="text-xs px-2 py-1 rounded border border-border-default text-content-secondary hover:border-accent hover:text-accent transition-colors"
onClick={() => setPositionForm({ name: preset.label, rotation_x: preset.rx, rotation_y: preset.ry, rotation_z: preset.rz })}
>
{preset.label}
</button>
))}
<button
className="ml-auto btn-secondary text-xs"
onClick={() => setPositionForm({ name: '', rotation_x: 0, rotation_y: 0, rotation_z: 0 })}
>
<Plus size={12} /> Add Position
</button>
</div>
{/* Existing positions list */}
{(product.render_positions?.length ?? 0) === 0 && !positionForm && (
<p className="text-xs text-content-muted py-2">No positions defined. Click "Add Position" or a preset above.</p>
)}
{(product.render_positions ?? []).map((pos) => (
<div key={pos.id} className="border border-border-default rounded-lg p-3">
{editingPositionId === pos.id ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
className="flex-1 px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-1 focus:ring-accent"
placeholder="Name"
value={editPositionDraft.name ?? pos.name}
onChange={(e) => setEditPositionDraft((d) => ({ ...d, name: e.target.value }))}
/>
<label className="text-xs text-content-muted flex items-center gap-1">
<input
type="checkbox"
checked={editPositionDraft.is_default ?? pos.is_default}
onChange={(e) => setEditPositionDraft((d) => ({ ...d, is_default: e.target.checked }))}
/>
Default
</label>
</div>
<div className="flex items-center gap-2">
{(['rotation_x', 'rotation_y', 'rotation_z'] as const).map((axis) => (
<label key={axis} className="flex items-center gap-1 text-xs text-content-secondary">
{axis.replace('rotation_', '').toUpperCase()}°
<input
type="number"
className="w-16 px-1.5 py-1 border border-border-default rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent"
value={editPositionDraft[axis] ?? pos[axis]}
onChange={(e) => setEditPositionDraft((d) => ({ ...d, [axis]: parseFloat(e.target.value) || 0 }))}
/>
</label>
))}
</div>
<div className="flex gap-2">
<button
className="btn-primary text-xs"
onClick={() => updatePositionMut.mutate({ posId: pos.id, data: editPositionDraft })}
disabled={updatePositionMut.isPending}
>
<Save size={11} /> Save
</button>
<button className="btn-secondary text-xs" onClick={() => { setEditingPositionId(null); setEditPositionDraft({}) }}>
<X size={11} /> Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-content">{pos.name}</span>
{pos.is_default && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-accent-light text-accent font-medium">default</span>
)}
</div>
<span className="text-xs text-content-muted">
X: {pos.rotation_x}° · Y: {pos.rotation_y}° · Z: {pos.rotation_z}°
</span>
</div>
<button
className="btn-secondary text-xs"
onClick={() => { setEditingPositionId(pos.id); setEditPositionDraft({}) }}
>
<Pencil size={11} /> Edit
</button>
<button
className="text-content-muted hover:text-red-500 transition-colors"
onClick={() => { if (confirm(`Delete position "${pos.name}"?`)) deletePositionMut.mutate(pos.id) }}
disabled={deletePositionMut.isPending}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
))}
{/* Add new position form */}
{positionForm && (
<div className="border border-accent rounded-lg p-3 bg-accent-light space-y-2">
<p className="text-xs font-semibold text-content-secondary">New Position</p>
<input
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-1 focus:ring-accent bg-surface"
placeholder="Name (e.g. Beauty, 3/4 Front)"
value={positionForm.name}
onChange={(e) => setPositionForm((f) => f ? { ...f, name: e.target.value } : f)}
/>
<div className="flex items-center gap-2">
{(['rotation_x', 'rotation_y', 'rotation_z'] as const).map((axis) => (
<label key={axis} className="flex items-center gap-1 text-xs text-content-secondary">
{axis.replace('rotation_', '').toUpperCase()}°
<input
type="number"
className="w-16 px-1.5 py-1 border border-border-default rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent bg-surface"
value={positionForm[axis]}
onChange={(e) => setPositionForm((f) => f ? { ...f, [axis]: parseFloat(e.target.value) || 0 } : f)}
/>
</label>
))}
</div>
<div className="flex gap-2">
<button
className="btn-primary text-xs"
onClick={() => addPositionMut.mutate(positionForm)}
disabled={addPositionMut.isPending || !positionForm.name.trim()}
>
<Save size={11} /> {addPositionMut.isPending ? 'Saving…' : 'Save'}
</button>
<button className="btn-secondary text-xs" onClick={() => setPositionForm(null)}>
<X size={11} /> Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Components table */}
{product.components.length > 0 && (
<div className="card p-5 mb-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Excel Components</h2>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border-light text-left">
<th className="pb-1 font-medium text-content-muted">Part Name</th>
<th className="pb-1 font-medium text-content-muted">Material</th>
<th className="pb-1 font-medium text-content-muted">Type</th>
</tr>
</thead>
<tbody>
{product.components.map((c: any, i: number) => (
<tr key={i} className="border-b border-border-light">
<td className="py-1 pr-3">{c.part_name || '—'}</td>
<td className="py-1 pr-3">{c.material || '—'}</td>
<td className="py-1 text-content-muted">{c.component_type || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Order history */}
<div className="card">
<button
className="w-full flex items-center gap-2 p-4 text-sm font-semibold text-content-secondary hover:bg-surface-hover rounded-lg"
onClick={() => {
if (!showOrders) fetchOrders()
setShowOrders((v) => !v)
}}
>
{showOrders ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
Order History
</button>
{showOrders && (
<div className="px-4 pb-4">
{!orders ? (
<p className="text-sm text-content-muted">Loading</p>
) : orders.length === 0 ? (
<p className="text-sm text-content-muted">No orders reference this product.</p>
) : (
<ul className="space-y-1">
{orders.map((o: any) => (
<li key={o.id}>
<Link
to={`/orders/${o.id}`}
className="text-sm text-accent hover:underline"
>
{o.order_number}
</Link>
<span className="text-xs text-content-muted ml-2">{o.status}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Material Wizard (opened from MaterialInput) */}
<MaterialWizard
open={wizardOpen}
onClose={() => { setWizardOpen(false); setWizardTargetIdx(null) }}
onCreated={(name) => {
if (wizardTargetIdx !== null) {
const updated = [...materialRows]
updated[wizardTargetIdx] = { ...updated[wizardTargetIdx], material: name }
setMaterialRows(updated)
setMaterialsDirty(true)
}
setWizardTargetIdx(null)
}}
/>
</div>
)
}