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 ( No STEP ) const map: Record = { 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 ( {status} ) } 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>({}) const [showOrders, setShowOrders] = useState(false) const [showExcelRow, setShowExcelRow] = useState(false) const [materialRows, setMaterialRows] = useState([]) const [materialsDirty, setMaterialsDirty] = useState(false) const [wizardOpen, setWizardOpen] = useState(false) const [wizardTargetIdx, setWizardTargetIdx] = useState(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({ queryKey: ['product-renders', id], queryFn: () => getProductRenders(id!), enabled: !!id, }) const [pendingDelete, setPendingDelete] = useState(null) const [selectMode, setSelectMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [batchConfirm, setBatchConfirm] = useState(false) const [batchLoading, setBatchLoading] = useState(false) const [filterOutputType, setFilterOutputType] = useState(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) => 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(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 }) => 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>({}) 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
Loading…
if (!product) return
Product not found
return (
{/* Back */} Products {/* Header */}
{product.pim_id} {product.category_key && ( {product.category_key} )} {product.is_active ? 'active' : 'inactive'}
{editMode ? ( setDraft({ ...draft, name: e.target.value })} /> ) : (

{product.name || No name}

)}
{isPrivileged && (
{editMode ? ( <> ) : ( )}
)}
{/* Meta fields */}

Product Details

{META_FIELDS.map(({ key, label }) => (
{editMode ? ( setDraft({ ...draft, [key]: e.target.value || null })} /> ) : (

{(product[key] as string) || }

)}
))}
{editMode ? (