a70cb55d01
- workflow_builder.py: fix broken stubs, add render_order_line_still_task
(resolves step_path from DB instead of passing order_line_id as step_path)
- domains/rendering/tasks.py: add render_order_line_still_task,
export_gltf_for_order_line_task, export_blend_for_order_line_task,
generate_gltf_geometry_task (trimesh STL→GLB, no Blender needed)
- tasks/step_tasks.py: add generate_gltf_geometry_task for CadFile GLB export
- cad router: POST /{id}/generate-gltf-geometry endpoint (admin/PM)
- worker router: GET /celery-workers + POST /scale (docker compose subprocess)
- Dockerfile: pip install -e "[dev]" to enable pytest
- docker-compose.yml: docker socket + compose file mount on backend
- ThreeDViewer.tsx: mode toggle (geometry/production), wireframe, env presets,
download buttons (GLB + .blend)
- CadPreview.tsx: load gltf_geometry/gltf_production/blend_production assets
from MediaAsset table and pass URLs to ThreeDViewer
- ProductDetail.tsx: "View 3D" button → /cad/:id, "Generate GLB" button
- media router/service: cad_file_id filter on GET /api/media
- WorkerManagement.tsx: new page with worker status, queue depth, scale controls
- App.tsx + Layout.tsx: /workers route + sidebar link (admin/PM)
- tests: test_rendering_service.py, test_orders_service.py (backend)
- tests: WorkerActivity.test.tsx, WorkerManagement.test.tsx (frontend)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1281 lines
61 KiB
TypeScript
1281 lines
61 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,
|
|
} 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 max-w-4xl mx-auto">
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
)
|
|
}
|