Files
HartOMat/frontend/src/pages/OrderDetail.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

1640 lines
70 KiB
TypeScript

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useState, useMemo, Fragment } from 'react'
import {
ArrowLeft, Send, Trash2,
FileBox, AlertTriangle, CheckCircle2, Image as ImageIcon, Unlink,
RotateCcw, LayoutList, LayoutGrid, X,
ChevronDown, ChevronUp, ChevronsUpDown,
Search, SlidersHorizontal, FileSpreadsheet, Box,
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders } from '../api/orders'
import type { OrderItem, OrderLine } from '../api/orders'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import { useAuthStore } from '../store/auth'
import StepDropzone from '../components/upload/StepDropzone'
import CadPartMaterials from '../components/orders/CadPartMaterials'
import LiveRenderLog from '../components/LiveRenderLog'
// ── Filter / sort types ───────────────────────────────────────────────────────
type SortKey = 'row_index' | 'name_cad_modell' | 'baureihe' | 'item_status'
type SortDir = 'asc' | 'desc'
interface TableFilters {
search: string
itemStatus: '' | 'pending' | 'approved' | 'rejected'
stepStatus: '' | 'with_step' | 'missing_step'
rendering: '' | 'rendering' | 'non_rendering'
}
const EMPTY_FILTERS: TableFilters = {
search: '',
itemStatus: '',
stepStatus: '',
rendering: '',
}
function countActiveFilters(f: TableFilters): number {
return (
(f.search.trim() !== '' ? 1 : 0) +
(f.itemStatus !== '' ? 1 : 0) +
(f.stepStatus !== '' ? 1 : 0) +
(f.rendering !== '' ? 1 : 0)
)
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function OrderDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const user = useAuthStore((s) => s.user)
const [galleryView, setGalleryView] = useState(false)
const [gallerySelected, setGallerySelected] = useState<any | null>(null)
const [genLinesOpen, setGenLinesOpen] = useState(false)
const [genLinesSelected, setGenLinesSelected] = useState<Record<string, boolean>>({})
const [isDownloading, setIsDownloading] = useState(false)
// Table state
const [filters, setFilters] = useState<TableFilters>(EMPTY_FILTERS)
const [sortKey, setSortKey] = useState<SortKey>('row_index')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data: order, isLoading } = useQuery({
queryKey: ['order', id],
queryFn: () => getOrder(id!),
})
const submitMut = useMutation({
mutationFn: () => submitOrder(id!),
onSuccess: () => {
toast.success('Order submitted')
qc.invalidateQueries({ queryKey: ['order', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Submit failed'),
})
const deleteMut = useMutation({
mutationFn: () => deleteOrder(id!),
onSuccess: () => {
toast.success('Order deleted')
navigate('/orders')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const dispatchMut = useMutation({
mutationFn: () => dispatchRenders(id!),
onSuccess: (data) => {
toast.success(`${data.dispatched} render${data.dispatched !== 1 ? 's' : ''} dispatched`)
qc.invalidateQueries({ queryKey: ['order', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Dispatch failed'),
})
const cancelAllMut = useMutation({
mutationFn: () => cancelOrderRenders(id!),
onSuccess: (data) => {
toast.success(`${data.cancelled} render${data.cancelled !== 1 ? 's' : ''} cancelled`)
if (data.errors?.length) toast.warning(`Some cancellations had issues: ${data.errors.join('; ')}`)
qc.invalidateQueries({ queryKey: ['order', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const { data: allOutputTypes = [] } = useQuery<OutputType[]>({
queryKey: ['output-types'],
queryFn: () => listOutputTypes(false),
enabled: genLinesOpen,
})
const generateLinesMut = useMutation({
mutationFn: () => {
const typeIds = Object.entries(genLinesSelected).filter(([, v]) => v).map(([k]) => k)
return generateLinesFromItems(id!, typeIds)
},
onSuccess: (data) => {
toast.success(`${data.created} line${data.created !== 1 ? 's' : ''} created`)
if (data.no_product_count > 0) {
toast.warning(`${data.no_product_count} item${data.no_product_count !== 1 ? 's' : ''} could not be matched to a product`)
}
if (data.no_step_count > 0) {
toast.warning(`${data.no_step_count} product${data.no_step_count !== 1 ? 's' : ''} have no STEP file — upload STEP files before dispatching renders`)
}
setGenLinesOpen(false)
setGenLinesSelected({})
qc.invalidateQueries({ queryKey: ['order', id] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to generate lines'),
})
const splitMut = useMutation({
mutationFn: () => splitMissingStep(id!),
onSuccess: (data) => {
const total = data.moved_item_count + data.moved_line_count
toast.success(
`Draft order ${data.new_order_number} created — ${total} item${total !== 1 ? 's' : ''} without STEP moved there`,
)
qc.invalidateQueries({ queryKey: ['order', id] })
qc.invalidateQueries({ queryKey: ['orders'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Split failed'),
})
if (isLoading) return <div className="p-8 text-center text-content-muted">Loading</div>
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>
const canSubmit = order.status === 'draft'
const canDelete = order.status === 'draft' || order.status === 'rejected'
const isDraft = order.status === 'draft'
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
const rp = order.render_progress
const hasRetryable = rp && (rp.pending > 0 || rp.failed > 0 || (rp as any).cancelled > 0)
const canDispatch = isPrivileged && (order.status === 'processing' || order.status === 'submitted' || order.status === 'completed')
const hasActiveRenders = rp && (rp.processing > 0 || rp.pending > 0)
const canCancelRenders = isPrivileged && order.status === 'processing' && hasActiveRenders
const hasCompletedRenders = rp && rp.completed > 0
async function handleDownloadRenders() {
setIsDownloading(true)
try {
await downloadOrderRenders(id!, order!.order_number)
} catch (e: any) {
toast.error(e.response?.data?.detail || 'Download failed')
} finally {
setIsDownloading(false)
}
}
// Items marked for rendering require a STEP file before submission
const renderingItems = order.items.filter((i: any) => i.medias_rendering)
const missingStep = renderingItems.filter((i: any) => !i.cad_file_id)
// Order lines with an output type also require the product to have a STEP file
const missingStepLines = (order.lines ?? []).filter(
(l: any) => l.output_type_id && !l.product?.cad_file_id,
)
const allRenderingHaveStep = missingStep.length === 0 && missingStepLines.length === 0
const totalMissingStep = missingStep.length + missingStepLines.length
// Allow admin/PM to generate lines for orders that have items but no lines
const canGenerateLines = isPrivileged
&& (isDraft || order.status === 'submitted')
&& (order.lines?.length ?? 0) === 0
&& (order.items?.length ?? 0) > 0
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(key)
setSortDir('asc')
}
}
function updateFilter<K extends keyof TableFilters>(key: K, value: TableFilters[K]) {
setFilters((prev) => ({ ...prev, [key]: value }))
}
const activeFilterCount = countActiveFilters(filters)
return (
<div className="p-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6 flex-wrap">
<Link to="/orders" className="btn-secondary">
<ArrowLeft size={16} />
Back
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-content">{order.order_number}</h1>
<p className="text-sm text-content-secondary mt-0.5">
{order.items.length} items · Created {new Date(order.created_at).toLocaleString('de-DE')}
</p>
</div>
<StatusBadge status={order.status} />
{order.estimated_price != null && (
<div className="text-sm text-content-secondary">
Estimated: <span className="font-semibold">{Number(order.estimated_price).toFixed(2)}</span>
</div>
)}
{canDelete && (
<button
onClick={() => { if (confirm('Delete this order?')) deleteMut.mutate() }}
className="btn-danger"
disabled={deleteMut.isPending}
>
<Trash2 size={16} />
Delete
</button>
)}
{canCancelRenders && (
<button
onClick={() => {
if (confirm(`Cancel all ${rp!.processing + rp!.pending} active render(s)?`))
cancelAllMut.mutate()
}}
className="px-3 py-2 rounded-lg text-sm font-medium border border-border-default text-status-warning-text bg-status-warning-bg hover:bg-surface-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
disabled={cancelAllMut.isPending}
>
<StopCircle size={16} />
{cancelAllMut.isPending ? 'Cancelling…' : 'Cancel Renders'}
</button>
)}
{hasCompletedRenders && (
<button
onClick={handleDownloadRenders}
className="px-3 py-2 rounded-lg text-sm font-medium border border-border-default text-status-info-text bg-status-info-bg hover:bg-surface-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
disabled={isDownloading}
>
<Download size={16} />
{isDownloading ? 'Downloading…' : `Download Renders (${rp!.completed})`}
</button>
)}
{canDispatch && (
<button
onClick={() => dispatchMut.mutate()}
className="btn-secondary"
disabled={dispatchMut.isPending}
>
{order.status === 'completed' ? <RefreshCw size={16} /> : rp && rp.failed > 0 ? <RefreshCw size={16} /> : <Play size={16} />}
{dispatchMut.isPending
? 'Dispatching…'
: order.status === 'completed'
? 'Re-submit Renders'
: rp && rp.failed > 0
? 'Retry Failed'
: 'Dispatch Renders'}
</button>
)}
{canSubmit && (
<button
onClick={() => submitMut.mutate()}
className="btn-primary"
disabled={submitMut.isPending || !allRenderingHaveStep}
title={!allRenderingHaveStep ? `${totalMissingStep} item${totalMissingStep !== 1 ? 's' : ''} still need a STEP file` : undefined}
>
<Send size={16} />
{submitMut.isPending ? 'Submitting…' : 'Submit Order'}
</button>
)}
</div>
{/* Draft action banner */}
{isDraft && (
<div className="card p-4 mb-6 bg-accent-light border-accent">
{allRenderingHaveStep ? (
<div className="flex items-center gap-4">
<div className="flex-1">
<p className="text-sm font-semibold text-content">
This order is a draft all rendering items have STEP files attached.
</p>
<p className="text-xs text-content-secondary mt-0.5">Ready to submit.</p>
</div>
<button
onClick={() => submitMut.mutate()}
className="px-5 py-2.5 rounded-lg text-sm font-semibold bg-accent text-white hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
disabled={submitMut.isPending}
>
<Send size={16} />
{submitMut.isPending ? 'Submitting…' : 'Submit Order'}
</button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-semibold text-content">
{totalMissingStep} item{totalMissingStep !== 1 ? 's' : ''} still need a STEP file
</p>
<p className="text-xs text-content-secondary mt-0.5">
Upload the missing STEP files below, or move these items to a separate draft order so you can submit the rest now.
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => {
if (confirm(`Move ${totalMissingStep} item${totalMissingStep !== 1 ? 's' : ''} without STEP to a new draft order and submit this one?`))
splitMut.mutate()
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border border-border-default text-status-warning-text bg-status-warning-bg hover:bg-surface-hover transition-colors disabled:opacity-50"
disabled={splitMut.isPending}
>
<Scissors size={15} />
{splitMut.isPending ? 'Moving…' : `Move ${totalMissingStep} item${totalMissingStep !== 1 ? 's' : ''} without STEP to draft`}
</button>
<button
onClick={() => submitMut.mutate()}
className="px-5 py-2.5 rounded-lg text-sm font-semibold bg-accent text-white hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
disabled={submitMut.isPending || !allRenderingHaveStep}
title={`${totalMissingStep} item${totalMissingStep !== 1 ? 's' : ''} still need a STEP file`}
>
<Send size={16} />
{submitMut.isPending ? 'Submitting…' : 'Submit Order'}
</button>
</div>
</div>
)}
</div>
)}
{/* Missing STEP file warning — only for rendering items */}
{isDraft && !allRenderingHaveStep && (
<div className="card p-4 mb-6 bg-status-warning-bg border-border-default flex items-start gap-3">
<AlertTriangle size={18} className="text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-status-warning-text">
{missingStep.length} rendering item{missingStep.length > 1 ? 's' : ''} still need a STEP file
</p>
<p className="text-xs text-status-warning-text mt-0.5">
Upload the corresponding .stp files below matched automatically by filename.
Submission is blocked until all items marked for rendering have a CAD file.
</p>
<div className="mt-1 flex flex-wrap gap-1">
{missingStep.slice(0, 8).map((item: any) => (
<span key={item.id} className="text-xs font-mono bg-status-warning-bg text-status-warning-text px-1.5 py-0.5 rounded">
{item.name_cad_modell || `row ${item.row_index}`}
</span>
))}
{missingStep.length > 8 && (
<span className="text-xs text-amber-600">+{missingStep.length - 8} more</span>
)}
</div>
</div>
</div>
)}
{/* All rendering items have STEP files */}
{isDraft && allRenderingHaveStep && renderingItems.length > 0 && (
<div className="card p-4 mb-6 bg-status-success-bg border-border-default flex items-center gap-3">
<CheckCircle2 size={18} className="text-green-500 shrink-0" />
<p className="text-sm text-status-success-text font-medium">
All {renderingItems.length} rendering item{renderingItems.length > 1 ? 's' : ''} have a STEP file attached ready to submit.
</p>
</div>
)}
{order.notes && (
<div className="card p-4 mb-6 bg-status-info-bg border-border-default">
<p className="text-sm text-status-info-text">{order.notes}</p>
</div>
)}
{/* Render progress bar */}
{rp && rp.total > 0 && (order.status === 'processing' || order.status === 'completed') && (
<div className="card p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-sm font-semibold text-content-secondary">Render Progress</h3>
<span className="text-xs text-content-muted">
{rp.completed}/{rp.total} completed
{rp.processing > 0 && <span className="text-status-info-text ml-1">({rp.processing} rendering)</span>}
{rp.failed > 0 && <span className="text-red-500 ml-1">({rp.failed} failed)</span>}
{(rp as any).cancelled > 0 && <span className="text-orange-500 ml-1">({(rp as any).cancelled} cancelled)</span>}
</span>
{order.status === 'processing' && rp.processing > 0 && (
<Loader2 size={14} className="animate-spin text-amber-500 ml-auto" />
)}
</div>
<div className="flex h-2.5 rounded-full overflow-hidden bg-surface-muted">
{rp.completed > 0 && (
<div
className="bg-green-500 transition-all duration-500"
style={{ width: `${(rp.completed / rp.total) * 100}%` }}
/>
)}
{rp.processing > 0 && (
<div
className="bg-blue-500 transition-all duration-500"
style={{ width: `${(rp.processing / rp.total) * 100}%` }}
/>
)}
{rp.failed > 0 && (
<div
className="bg-red-400 transition-all duration-500"
style={{ width: `${(rp.failed / rp.total) * 100}%` }}
/>
)}
{(rp as any).cancelled > 0 && (
<div
className="bg-orange-300 transition-all duration-500"
style={{ width: `${((rp as any).cancelled / rp.total) * 100}%` }}
/>
)}
</div>
</div>
)}
{/* STEP upload section — only for draft orders */}
{isDraft && (
<div className="card p-6 mb-6">
<div className="flex items-center gap-2 mb-1">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">Upload STEP Files</h2>
</div>
<p className="text-sm text-content-secondary mb-4">
Drop one or multiple .stp / .step files they are matched to items by filename stem (case-insensitive).
</p>
<StepDropzone
orderId={order.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', id] })}
/>
</div>
)}
{/* Source spreadsheet — reference data, collapsed by default after submission */}
{order.source_excel != null && (
<SourceSpreadsheet
items={order.items}
sourceExcel={order.source_excel}
isDraft={isDraft}
defaultOpen={isDraft}
orderId={order.id}
onItemUpdated={() => qc.invalidateQueries({ queryKey: ['order', id] })}
/>
)}
{/* ── Product Output Lines ─────────────────────────────────────────────────
Shown ABOVE the Order Items table so the primary "what was ordered"
view (product + output type + render status) is immediately visible */}
{((order.lines?.length ?? 0) > 0 || canGenerateLines) && (
<div className="card mb-6">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">
Product Output Lines ({order.lines?.length ?? 0})
</h2>
{canGenerateLines && (
<button
className="btn-secondary text-xs flex items-center gap-1.5"
onClick={() => setGenLinesOpen((v) => !v)}
>
<Wand2 size={13} />
{genLinesOpen ? 'Cancel' : 'Assign Output Types…'}
</button>
)}
</div>
{/* Generate lines panel */}
{canGenerateLines && genLinesOpen && (
<div className="p-4 border-b border-border-default bg-status-warning-bg">
<p className="text-sm text-status-warning-text mb-3">
This order has <strong>{order.items?.length ?? 0} items</strong> but no output lines.
Select one or more output types to generate renderable lines for all matching products:
</p>
{allOutputTypes.length === 0 ? (
<p className="text-sm text-content-muted">Loading output types</p>
) : (
<div className="flex flex-wrap gap-3 mb-3">
{allOutputTypes.map((ot: OutputType) => (
<label key={ot.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
<input
type="checkbox"
checked={!!genLinesSelected[ot.id]}
onChange={(e) =>
setGenLinesSelected((prev) => ({ ...prev, [ot.id]: e.target.checked }))
}
/>
{ot.name}
</label>
))}
</div>
)}
<button
className="btn-primary text-sm"
disabled={generateLinesMut.isPending || !Object.values(genLinesSelected).some(Boolean)}
onClick={() => generateLinesMut.mutate()}
>
<Plus size={14} />
{generateLinesMut.isPending ? 'Generating…' : 'Generate Lines'}
</button>
</div>
)}
{(order.lines?.length ?? 0) > 0 && (
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary w-12">Thumb</th>
<th className="px-4 py-2 font-medium text-content-secondary">Product</th>
<th className="px-4 py-2 font-medium text-content-secondary">Output Type</th>
<th className="px-4 py-2 font-medium text-content-secondary">Backend</th>
<th className="px-4 py-2 font-medium text-content-secondary text-right">Price</th>
<th className="px-4 py-2 font-medium text-content-secondary">Render</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
{isDraft && <th className="px-4 py-2 font-medium text-content-secondary w-10" />}
</tr>
</thead>
<tbody>
{order.lines.map((line: OrderLine) => (
<OrderLineRow
key={line.id}
line={line}
orderId={order.id}
isDraft={isDraft}
isPrivileged={isPrivileged}
onRemoved={() => qc.invalidateQueries({ queryKey: ['order', id] })}
/>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Order items */}
<div className="card">
{/* Toolbar */}
<div className="p-4 border-b border-border-default space-y-3">
{/* Top row: title + view toggle */}
<div className="flex items-center gap-3">
<h2 className="font-semibold text-content">
Order Items ({order.items.length})
</h2>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => setGalleryView(false)}
className={`p-1.5 rounded ${!galleryView ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'}`}
title="Table view"
>
<LayoutList size={16} />
</button>
<button
onClick={() => setGalleryView(true)}
className={`p-1.5 rounded ${galleryView ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'}`}
title="Gallery view"
>
<LayoutGrid size={16} />
</button>
</div>
</div>
{/* Filter bar — only in table view */}
{!galleryView && (
<div className="flex flex-wrap items-center gap-2">
{/* Text search */}
<div className="relative flex-1 min-w-[180px]">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input
type="text"
placeholder="Search name, Baureihe, Ebene…"
value={filters.search}
onChange={(e) => updateFilter('search', e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{/* Item status filter */}
<select
value={filters.itemStatus}
onChange={(e) => updateFilter('itemStatus', e.target.value as TableFilters['itemStatus'])}
className="py-1.5 pl-2 pr-7 text-sm border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-accent bg-surface"
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
{/* STEP status filter */}
<select
value={filters.stepStatus}
onChange={(e) => updateFilter('stepStatus', e.target.value as TableFilters['stepStatus'])}
className="py-1.5 pl-2 pr-7 text-sm border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-accent bg-surface"
>
<option value="">All STEP</option>
<option value="with_step">With STEP</option>
<option value="missing_step">Missing STEP</option>
</select>
{/* Rendering filter */}
<select
value={filters.rendering}
onChange={(e) => updateFilter('rendering', e.target.value as TableFilters['rendering'])}
className="py-1.5 pl-2 pr-7 text-sm border border-border-default rounded-md focus:outline-none focus:ring-2 focus:ring-accent bg-surface"
>
<option value="">All rendering</option>
<option value="rendering">Rendering only</option>
<option value="non_rendering">Non-rendering</option>
</select>
{/* Active filter count + clear */}
{activeFilterCount > 0 && (
<button
onClick={() => setFilters(EMPTY_FILTERS)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm text-content-secondary bg-surface-muted hover:bg-surface-hover rounded-md transition-colors"
>
<X size={13} />
Clear
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
{activeFilterCount}
</span>
</button>
)}
{activeFilterCount === 0 && (
<div className="flex items-center gap-1 text-xs text-content-muted ml-1">
<SlidersHorizontal size={13} />
<span>Filter</span>
</div>
)}
</div>
)}
</div>
{galleryView ? (
<>
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{order.items.map((item: any) => (
<GalleryCard
key={item.id}
item={item}
isDraft={isDraft}
onClick={() => setGallerySelected(item)}
/>
))}
</div>
{gallerySelected && (
<GalleryModal
item={gallerySelected}
orderId={order.id}
isDraft={isDraft}
onClose={() => setGallerySelected(null)}
onUnlink={() => { setGallerySelected(null); qc.invalidateQueries({ queryKey: ['order', id] }) }}
onRerender={() => qc.invalidateQueries({ queryKey: ['order', id] })}
/>
)}
</>
) : (
<OrderItemsTable
items={order.items}
orderId={order.id}
isDraft={isDraft}
filters={filters}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
expandedId={expandedId}
onExpand={setExpandedId}
onUnlink={() => qc.invalidateQueries({ queryKey: ['order', id] })}
/>
)}
</div>
</div>
)
}
// ── Order Line Row ────────────────────────────────────────────────────────────
function OrderLineRow({
line, orderId, isDraft, isPrivileged, onRemoved,
}: {
line: OrderLine
orderId: string
isDraft: boolean
isPrivileged: boolean
onRemoved: () => void
}) {
const qc = useQueryClient()
const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id),
onSuccess: onRemoved,
onError: (e: any) => toast.error(e.response?.data?.detail || 'Remove failed'),
})
const cancelMut = useMutation({
mutationFn: () => cancelLineRender(orderId, line.id),
onSuccess: () => {
toast.success('Render cancelled')
qc.invalidateQueries({ queryKey: ['order', orderId] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const canCancel = isPrivileged && (line.render_status === 'processing' || line.render_status === 'pending') && line.output_type_id
const renderStatusColor: Record<string, string> = {
pending: 'bg-surface-muted text-content-muted',
processing: 'bg-status-info-bg text-status-info-text',
completed: 'bg-status-success-bg text-status-success-text',
failed: 'bg-status-error-bg text-status-error-text',
cancelled: 'bg-status-warning-bg text-status-warning-text',
}
return (
<tr className="border-b border-border-light hover:bg-surface-hover">
{/* Thumbnail */}
<td className="px-4 py-2">
{line.thumbnail_url ? (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
) : (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center">
<Box size={16} className="text-content-muted" />
</div>
)}
</td>
{/* Product */}
<td className="px-4 py-2">
<Link
to={`/products/${line.product_id}`}
className="font-medium text-accent hover:underline"
onClick={(e) => e.stopPropagation()}
>
{line.product.name || line.product.pim_id}
</Link>
<span className="text-xs text-content-muted font-mono">{line.product.pim_id}</span>
</td>
{/* Output Type + Position */}
<td className="px-4 py-2">
<div className="flex flex-col gap-1">
{line.output_type ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium w-fit">
{line.output_type.name}
</span>
) : (
<span className="text-xs text-content-muted italic">tracking only</span>
)}
{line.render_position_name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium w-fit">
{line.render_position_name}
</span>
)}
</div>
</td>
{/* Backend */}
<td className="px-4 py-2">
{line.render_backend_used ? (
line.render_backend_used === 'flamenco' && line.flamenco_job_id ? (
<a
href="http://localhost:8080"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium hover:bg-surface-hover transition-colors"
>
Flamenco <ExternalLink size={9} />
</a>
) : line.render_backend_used === 'celery' ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium">
Celery
</span>
) : (
<span className="text-xs text-content-muted">{line.render_backend_used}</span>
)
) : (
<span className="text-xs text-content-muted"></span>
)}
</td>
{/* Price */}
<td className="px-4 py-2 text-right">
{line.unit_price != null ? (
<span className="text-sm font-medium text-content-secondary">{line.unit_price.toFixed(2)}</span>
) : (
<span className="text-xs text-content-muted"></span>
)}
</td>
{/* Render Status */}
<td className="px-4 py-2">
{line.output_type_id ? (
<div>
<div className="flex items-center gap-1.5">
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${renderStatusColor[line.render_status] || 'bg-surface-muted text-content-muted'}`}>
{line.render_status === 'processing' && <Loader2 size={10} className="animate-spin" />}
{line.render_status}
</span>
{canCancel && (
<button
onClick={(e) => {
e.stopPropagation()
cancelMut.mutate()
}}
disabled={cancelMut.isPending}
className="text-content-muted hover:text-orange-500 transition-colors"
title="Cancel this render"
>
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
</div>
<LiveRenderLog
orderLineId={line.id}
isActive={line.render_status === 'processing'}
compact
/>
</div>
) : (
<span className="text-xs text-content-muted"></span>
)}
</td>
{/* Item Status */}
<td className="px-4 py-2">
<ItemStatusBadge status={line.item_status} />
</td>
{/* Remove (draft only) */}
{isDraft && (
<td className="px-4 py-2">
<button
className="text-content-muted hover:text-red-500"
onClick={() => { if (confirm('Remove this line?')) removeMut.mutate() }}
disabled={removeMut.isPending}
title="Remove this output line from the order"
>
<X size={14} />
</button>
</td>
)}
</tr>
)
}
// ── Table component ───────────────────────────────────────────────────────────
interface OrderItemsTableProps {
items: OrderItem[]
orderId: string
isDraft: boolean
filters: TableFilters
sortKey: SortKey
sortDir: SortDir
onSort: (key: SortKey) => void
expandedId: string | null
onExpand: (id: string | null) => void
onUnlink: () => void
}
function OrderItemsTable({
items, orderId, isDraft, filters, sortKey, sortDir, onSort,
expandedId, onExpand, onUnlink,
}: OrderItemsTableProps) {
const filtered = useMemo(() => {
let result = [...items]
// Text search
if (filters.search.trim()) {
const q = filters.search.trim().toLowerCase()
result = result.filter((item) =>
[item.name_cad_modell, item.baureihe, item.ebene1, item.ebene2]
.some((v) => v?.toLowerCase().includes(q))
)
}
// Item status
if (filters.itemStatus) {
result = result.filter((item) => item.item_status === filters.itemStatus)
}
// STEP status
if (filters.stepStatus === 'with_step') {
result = result.filter((item) => !!item.cad_file_id)
} else if (filters.stepStatus === 'missing_step') {
result = result.filter((item) => !item.cad_file_id)
}
// Rendering filter
if (filters.rendering === 'rendering') {
result = result.filter((item) => item.medias_rendering === true)
} else if (filters.rendering === 'non_rendering') {
result = result.filter((item) => item.medias_rendering !== true)
}
// Sort
result.sort((a, b) => {
let av: string | number = ''
let bv: string | number = ''
if (sortKey === 'row_index') { av = a.row_index; bv = b.row_index }
else if (sortKey === 'name_cad_modell') { av = a.name_cad_modell ?? ''; bv = b.name_cad_modell ?? '' }
else if (sortKey === 'baureihe') { av = a.baureihe ?? ''; bv = b.baureihe ?? '' }
else if (sortKey === 'item_status') { av = a.item_status ?? ''; bv = b.item_status ?? '' }
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return result
}, [items, filters, sortKey, sortDir])
if (filtered.length === 0) {
return (
<div className="p-12 text-center text-content-muted text-sm">
No items match the current filters.
</div>
)
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-surface-alt border-b border-border-default">
<tr>
<SortableTh label="#" sortKey="row_index" current={sortKey} dir={sortDir} onSort={onSort} className="w-12 text-center" />
<th className="py-2.5 px-3 text-left text-xs font-semibold text-content-secondary uppercase tracking-wide w-10">
Img
</th>
<SortableTh label="CAD Model" sortKey="name_cad_modell" current={sortKey} dir={sortDir} onSort={onSort} className="min-w-[160px]" />
<SortableTh label="Baureihe" sortKey="baureihe" current={sortKey} dir={sortDir} onSort={onSort} className="min-w-[110px]" />
<th className="py-2.5 px-3 text-left text-xs font-semibold text-content-secondary uppercase tracking-wide">Ebene 1</th>
<th className="py-2.5 px-3 text-left text-xs font-semibold text-content-secondary uppercase tracking-wide">Ebene 2</th>
<th className="py-2.5 px-3 text-left text-xs font-semibold text-content-secondary uppercase tracking-wide">Lagertyp</th>
<th className="py-2.5 px-3 text-center text-xs font-semibold text-content-secondary uppercase tracking-wide">Rendering</th>
<th className="py-2.5 px-3 text-center text-xs font-semibold text-content-secondary uppercase tracking-wide">Parts</th>
<th className="py-2.5 px-3 text-center text-xs font-semibold text-content-secondary uppercase tracking-wide">STEP</th>
<SortableTh label="Status" sortKey="item_status" current={sortKey} dir={sortDir} onSort={onSort} className="text-center" />
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{filtered.map((item) => (
<ItemTableRow
key={item.id}
item={item}
orderId={orderId}
isDraft={isDraft}
expanded={expandedId === item.id}
onExpand={() => onExpand(expandedId === item.id ? null : item.id)}
onUnlink={onUnlink}
/>
))}
</tbody>
</table>
</div>
)
}
// ── Sortable table header ─────────────────────────────────────────────────────
function SortableTh({
label, sortKey: key, current, dir, onSort, className = '',
}: {
label: string
sortKey: SortKey
current: SortKey
dir: SortDir
onSort: (k: SortKey) => void
className?: string
}) {
const active = current === key
return (
<th
className={`py-2.5 px-3 text-left text-xs font-semibold text-content-secondary uppercase tracking-wide cursor-pointer select-none hover:text-content transition-colors ${className}`}
onClick={() => onSort(key)}
>
<span className="flex items-center gap-1">
{label}
{active ? (
dir === 'asc' ? <ChevronUp size={12} className="text-accent" /> : <ChevronDown size={12} className="text-accent" />
) : (
<ChevronsUpDown size={12} className="text-content-muted" />
)}
</span>
</th>
)
}
// ── Table row + expand panel ──────────────────────────────────────────────────
function ItemTableRow({
item, orderId, isDraft, expanded, onExpand, onUnlink,
}: {
item: OrderItem
orderId: string
isDraft: boolean
expanded: boolean
onExpand: () => void
onUnlink: () => void
}) {
const hasStep = !!item.cad_file_id
const [imgError, setImgError] = useState(false)
const [thumbVersion, setThumbVersion] = useState(0)
const unlinkMut = useMutation({
mutationFn: () => unlinkCadFile(orderId, item.id),
onSuccess: () => { toast.success('CAD file removed'); onUnlink() },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Remove failed'),
})
const rerenderMut = useMutation({
mutationFn: () => regenerateItemThumbnail(orderId, item.id),
onSuccess: () => {
toast.success('Thumbnail re-render queued — refresh in a moment')
setTimeout(() => setThumbVersion((v) => v + 1), 8000)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-render failed'),
})
const thumbSrc = `/api/cad/${item.cad_file_id}/thumbnail${thumbVersion > 0 ? `?v=${thumbVersion}` : ''}`
return (
<>
<tr
onClick={onExpand}
className={`cursor-pointer transition-colors hover:bg-surface-hover ${expanded ? 'bg-accent-light' : ''}`}
>
{/* Row # */}
<td className="py-2.5 px-3 text-center text-content-muted text-xs font-mono">{item.row_index}</td>
{/* Thumbnail 40px */}
<td className="py-2 px-3">
<div className="w-10 h-10 rounded border border-border-default bg-surface-alt flex items-center justify-center overflow-hidden shrink-0">
{hasStep && !imgError ? (
<img
src={thumbSrc}
alt="thumb"
className="w-full h-full object-contain"
onError={() => setImgError(true)}
/>
) : (
<ImageIcon size={16} className="text-content-muted" />
)}
</div>
</td>
{/* CAD Model */}
<td className="py-2.5 px-3 font-mono text-xs text-content max-w-[200px]">
<p className="truncate" title={item.name_cad_modell ?? undefined}>{item.name_cad_modell || '—'}</p>
</td>
{/* Baureihe */}
<td className="py-2.5 px-3 text-content-secondary text-xs max-w-[130px]">
<p className="truncate">{item.baureihe || '—'}</p>
</td>
{/* Ebene 1 */}
<td className="py-2.5 px-3 text-content-secondary text-xs max-w-[120px]">
<p className="truncate">{item.ebene1 || '—'}</p>
</td>
{/* Ebene 2 */}
<td className="py-2.5 px-3 text-content-secondary text-xs max-w-[120px]">
<p className="truncate">{item.ebene2 || '—'}</p>
</td>
{/* Lagertyp */}
<td className="py-2.5 px-3 text-content-secondary text-xs max-w-[110px]">
<p className="truncate">{item.lagertyp || '—'}</p>
</td>
{/* Rendering */}
<td className="py-2.5 px-3 text-center">
{item.medias_rendering
? <span className="text-xs font-medium text-accent">Yes</span>
: <span className="text-xs text-content-muted">No</span>}
</td>
{/* Components count */}
<td className="py-2.5 px-3 text-center text-xs text-content-secondary">
{(item.components?.length ?? 0) || '—'}
</td>
{/* STEP status */}
<td className="py-2.5 px-3 text-center">
{hasStep ? (
<span className="badge badge-green">STEP</span>
) : (
<span
className={`inline-block w-2 h-2 rounded-full ${
item.medias_rendering ? 'bg-amber-400' : 'bg-gray-300'
}`}
title={item.medias_rendering ? 'Missing STEP (required)' : 'No STEP (optional)'}
/>
)}
</td>
{/* Item status */}
<td className="py-2.5 px-3 text-center">
<ItemStatusBadge status={item.item_status} />
</td>
</tr>
{/* Expanded detail panel */}
{expanded && (
<tr>
<td colSpan={11} className="bg-surface-alt border-t border-b border-border-light px-0 py-0">
<div className="px-6 py-5">
<div className="flex gap-6">
{/* Thumbnail */}
<div className="shrink-0">
<p className="text-xs text-content-muted mb-1">CAD Thumbnail</p>
{hasStep ? (
<div className="flex flex-col items-start gap-2">
<ThumbnailPreview cadFileId={item.cad_file_id!} version={thumbVersion} />
<div className="flex items-center gap-3">
<button
onClick={(e) => { e.stopPropagation(); rerenderMut.mutate() }}
disabled={rerenderMut.isPending}
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover disabled:opacity-50"
title="Re-render thumbnail with current part colours"
>
<RotateCcw size={12} className={rerenderMut.isPending ? 'animate-spin' : ''} />
{rerenderMut.isPending ? 'Queuing…' : 'Re-render'}
</button>
{isDraft && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm('Remove this STEP file from the item?')) unlinkMut.mutate()
}}
disabled={unlinkMut.isPending}
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
title="Remove STEP file"
>
<Unlink size={12} />
{unlinkMut.isPending ? 'Removing…' : 'Remove STEP'}
</button>
)}
</div>
</div>
) : (
<div className="w-32 h-32 rounded border border-dashed border-border-default bg-surface-muted flex flex-col items-center justify-center text-content-muted">
<ImageIcon size={24} className="mb-1" />
<span className="text-xs text-center px-2">No STEP file yet</span>
</div>
)}
</div>
<div className="flex-1 grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<Field label="Ebene 1" value={item.ebene1} />
<Field label="PIM-ID" value={item.pim_id} />
<Field label="Produkt / Baureihe" value={item.produkt_baureihe} />
<Field label="Gewähltes Produkt" value={item.gewaehltes_produkt} />
<Field label="Bildnummer" value={item.gewuenschte_bildnummer} />
<Field label="Lagertyp" value={item.lagertyp} />
<Field label="Medias Rendering" value={item.medias_rendering?.toString() ?? '—'} />
</div>
</div>
{item.components && item.components.length > 0 && (
<div className="mt-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
Components ({item.components.length})
</p>
<div className="overflow-x-auto">
<table className="text-xs w-full">
<thead>
<tr className="text-content-muted">
<th className="text-left py-1 pr-4 font-medium">#</th>
<th className="text-left py-1 pr-4 font-medium">Type</th>
<th className="text-left py-1 pr-4 font-medium">Part Name</th>
<th className="text-left py-1 font-medium">Material</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{item.components.map((c: any, i: number) => (
<tr key={i}>
<td className="py-1 pr-4 text-content-muted">{i + 1}</td>
<td className="py-1 pr-4 text-content-secondary truncate max-w-[160px]">{c.component_type || '—'}</td>
<td className="py-1 pr-4 font-mono text-content truncate max-w-[200px]">{c.part_name || '—'}</td>
<td className="py-1 text-content-secondary">{c.material || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* CAD part material assignments */}
{(item as any).cad_parsed_objects && (item as any).cad_parsed_objects.length > 0 && (
<CadPartMaterials
orderId={orderId}
itemId={item.id}
partNames={(item as any).cad_parsed_objects}
savedMaterials={(item as any).cad_part_materials ?? []}
excelComponents={item.components}
/>
)}
</div>
</td>
</tr>
)}
</>
)
}
// ── Reusable display helpers ──────────────────────────────────────────────────
function ThumbnailPreview({ cadFileId, version = 0 }: { cadFileId: string; version?: number }) {
const [imgError, setImgError] = useState(false)
const src = `/api/cad/${cadFileId}/thumbnail${version > 0 ? `?v=${version}` : ''}`
if (imgError) {
return (
<div className="w-32 h-32 rounded border border-dashed border-border-default bg-surface-muted flex flex-col items-center justify-center text-content-muted">
<ImageIcon size={24} className="mb-1" />
<span className="text-xs text-center px-2">No preview</span>
</div>
)
}
return (
<div className="relative shrink-0 cursor-zoom-in group">
{/* Small thumbnail */}
<img
src={src}
alt="CAD thumbnail"
className="w-32 h-32 object-contain rounded border border-border-default bg-surface"
onError={() => setImgError(true)}
/>
{/* Full-res hover overlay */}
<div className="absolute left-full top-1/2 -translate-y-1/2 z-50
invisible group-hover:visible pl-4">
{/* Arrow pointing left */}
<div className="absolute left-4 top-1/2 -translate-y-1/2 -ml-px
border-8 border-transparent border-r-white drop-shadow-sm" />
<div className="rounded-xl border border-border-default bg-surface shadow-2xl p-2 w-[520px]">
<img
src={src}
alt="CAD preview"
className="w-full h-auto rounded-lg object-contain"
/>
<p className="text-[10px] text-content-muted text-center mt-1 pb-0.5">Full resolution preview</p>
</div>
</div>
</div>
)
}
function Field({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div>
<p className="text-xs text-content-muted">{label}</p>
<p className="text-content">{value || '—'}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'badge-gray', submitted: 'badge-blue', processing: 'badge-yellow',
completed: 'badge-green', rejected: 'badge-red',
}
return <span className={`badge ${map[status] ?? 'badge-gray'}`}>{status}</span>
}
function ItemStatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
pending: 'badge-gray', approved: 'badge-green', rejected: 'badge-red',
}
return <span className={`badge ${map[status] ?? 'badge-gray'}`}>{status}</span>
}
// ── Gallery view ─────────────────────────────────────────────────────────────
function GalleryCard({ item, isDraft, onClick }: { item: any; isDraft: boolean; onClick: () => void }) {
const hasStep = !!item.cad_file_id
const [imgError, setImgError] = useState(false)
return (
<div
onClick={onClick}
className="cursor-pointer rounded-lg border border-border-default hover:border-accent bg-surface overflow-hidden transition-colors group"
>
<div className="aspect-square bg-surface-alt relative overflow-hidden">
{hasStep && !imgError ? (
<img
src={`/api/cad/${item.cad_file_id}/thumbnail`}
alt={item.name_cad_modell || 'thumbnail'}
className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-200"
onError={() => setImgError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-content-muted">
<ImageIcon size={32} />
</div>
)}
{/* Status dot */}
{isDraft && (
<span
className={`absolute top-1.5 right-1.5 w-2.5 h-2.5 rounded-full border-2 border-white ${
hasStep ? 'bg-green-400' : item.medias_rendering ? 'bg-amber-400' : 'bg-gray-300'
}`}
/>
)}
</div>
<div className="p-2 border-t border-border-light">
<p className="text-xs font-mono truncate text-content-secondary" title={item.name_cad_modell || undefined}>
{item.name_cad_modell || '—'}
</p>
<div className="flex items-center gap-1 mt-1">
<ItemStatusBadge status={item.item_status} />
{hasStep && <span className="badge badge-green text-xs">STEP</span>}
</div>
</div>
</div>
)
}
// ── Source Spreadsheet ────────────────────────────────────────────────────────
const STD_COLS: { key: keyof OrderItem; label: string; editable?: boolean }[] = [
{ key: 'ebene1', label: 'Ebene 1', editable: true },
{ key: 'ebene2', label: 'Ebene 2', editable: true },
{ key: 'baureihe', label: 'Baureihe', editable: true },
{ key: 'pim_id', label: 'PIM-ID', editable: true },
{ key: 'produkt_baureihe', label: 'Produkt/Baureihe',editable: true },
{ key: 'gewaehltes_produkt', label: 'Gew. Produkt', editable: true },
{ key: 'name_cad_modell', label: 'CAD-Modell', editable: true },
{ key: 'gewuenschte_bildnummer', label: 'Bildnummer', editable: true },
{ key: 'lagertyp', label: 'Lagertyp', editable: true },
]
function SourceSpreadsheet({
items,
sourceExcel,
isDraft,
defaultOpen = true,
orderId,
onItemUpdated,
}: {
items: OrderItem[]
sourceExcel: string | null | undefined
isDraft?: boolean
defaultOpen?: boolean
orderId?: string
onItemUpdated?: () => void
}) {
const [open, setOpen] = useState(defaultOpen)
const [saving, setSaving] = useState<string | null>(null) // itemId being saved
const sorted = useMemo(() => [...items].sort((a, b) => a.row_index - b.row_index), [items])
const maxComponents = Math.max(0, ...sorted.map((i) => i.components?.length ?? 0))
const fileName = sourceExcel
? sourceExcel.replace(/\\/g, '/').split('/').pop() ?? sourceExcel
: null
async function saveField(itemId: string, field: keyof OrderItem, value: string | boolean | null) {
if (!orderId) return
setSaving(itemId)
try {
await patchOrderItem(orderId, itemId, { [field]: value })
onItemUpdated?.()
} catch {
toast.error('Failed to save change')
} finally {
setSaving(null)
}
}
return (
<div className="card mb-6 overflow-hidden">
{/* Collapsible header */}
<button
className="w-full flex items-center gap-2 px-4 py-3 hover:bg-surface-hover transition-colors"
onClick={() => setOpen((v) => !v)}
>
<FileSpreadsheet size={16} className="text-accent shrink-0" />
<span className="font-semibold text-content text-sm">Source Spreadsheet</span>
{fileName && (
<span className="text-xs text-content-muted font-mono ml-1 truncate max-w-[300px]">{fileName}</span>
)}
<span className="ml-auto flex items-center gap-2 text-xs text-content-muted shrink-0">
{isDraft && <span className="text-accent font-medium">editable</span>}
{sorted.length} row{sorted.length !== 1 ? 's' : ''}
{open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
</button>
{open && (
<div className="overflow-x-auto border-t border-border-light">
<table className="text-xs whitespace-nowrap border-separate border-spacing-0">
<thead>
<tr>
<th className="sticky left-0 z-20 bg-surface-alt py-2 px-3 text-left font-semibold text-content-secondary uppercase tracking-wide border-b border-r border-border-default min-w-[40px]">
#
</th>
{/* Rendering toggle column */}
<th className="bg-surface-alt py-2 px-3 text-center font-semibold text-content-secondary uppercase tracking-wide border-b border-border-default min-w-[80px]">
Rendering
</th>
{STD_COLS.map((c) => (
<th key={c.key} className="bg-surface-alt py-2 px-3 text-left font-semibold text-content-secondary uppercase tracking-wide border-b border-border-default">
{c.label}
</th>
))}
{Array.from({ length: maxComponents }, (_, i) => (
<Fragment key={`ch-${i}`}>
<th className="bg-surface-alt py-2 px-3 text-left font-semibold text-content-secondary uppercase tracking-wide border-b border-l border-border-default">
Bauteil {i + 1}
</th>
<th className="bg-surface-alt py-2 px-3 text-left font-semibold text-content-secondary uppercase tracking-wide border-b border-border-default">
Material {i + 1}
</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{sorted.map((item) => (
<tr
key={item.id}
className={`hover:bg-surface-hover/30 group ${saving === item.id ? 'opacity-60' : ''}`}
>
{/* Row # */}
<td className="sticky left-0 z-10 bg-surface group-hover:bg-surface-hover/30 py-1.5 px-3 font-mono text-content-muted border-r border-b border-border-light">
{item.row_index}
</td>
{/* Rendering toggle */}
<td className="py-1.5 px-3 text-center border-b border-border-light">
{isDraft ? (
<button
onClick={() => saveField(item.id, 'medias_rendering', !item.medias_rendering)}
disabled={saving === item.id}
className={`inline-flex items-center justify-center w-5 h-5 rounded border-2 transition-colors ${
item.medias_rendering
? 'text-white'
: 'border-border-default bg-surface hover:border-accent'
}`}
style={item.medias_rendering ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
title={item.medias_rendering ? 'Click to disable rendering' : 'Click to enable rendering'}
>
{item.medias_rendering && (
<svg viewBox="0 0 10 8" className="w-3 h-3 fill-current">
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
) : (
<span className={item.medias_rendering ? 'text-accent font-medium' : 'text-content-muted'}>
{item.medias_rendering ? 'Yes' : 'No'}
</span>
)}
</td>
{/* Text fields */}
{STD_COLS.map((c) => {
const raw = (item as any)[c.key]
const display = raw ?? ''
return (
<td key={c.key} className="py-0.5 px-1 border-b border-border-light">
{isDraft && c.editable ? (
<input
type="text"
defaultValue={display}
onBlur={(e) => {
const newVal = e.target.value.trim() || null
if (newVal !== (raw ?? null)) {
saveField(item.id, c.key, newVal)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
}}
className="w-full min-w-[120px] max-w-[200px] px-1.5 py-0.5 text-xs text-content-secondary rounded border border-transparent hover:border-border-default focus:border-accent focus:outline-none focus:bg-surface bg-transparent"
/>
) : (
<span className="block px-1.5 py-0.5 max-w-[200px] truncate text-content-secondary" title={display || undefined}>
{display || '—'}
</span>
)}
</td>
)
})}
{/* Component columns (read-only) */}
{Array.from({ length: maxComponents }, (_, i) => {
const comp = (item.components as any[])?.[i]
return (
<Fragment key={`cv-${i}`}>
<td className="py-1.5 px-3 font-mono text-content-secondary border-b border-l border-border-light">
<span className="block max-w-[200px] truncate" title={comp?.part_name ?? undefined}>
{comp?.part_name || '—'}
</span>
</td>
<td className="py-1.5 px-3 text-content-secondary border-b border-border-light">
<span className="block max-w-[140px] truncate" title={comp?.material ?? undefined}>
{comp?.material || '—'}
</span>
</td>
</Fragment>
)
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
// ── Gallery modal ─────────────────────────────────────────────────────────────
function GalleryModal({
item, orderId, isDraft, onClose, onUnlink, onRerender,
}: {
item: any; orderId: string; isDraft: boolean
onClose: () => void; onUnlink: () => void; onRerender: () => void
}) {
const hasStep = !!item.cad_file_id
const [thumbVersion, setThumbVersion] = useState(0)
const unlinkMut = useMutation({
mutationFn: () => unlinkCadFile(orderId, item.id),
onSuccess: () => { toast.success('CAD file removed'); onUnlink() },
onError: (e: any) => toast.error(e.response?.data?.detail || 'Remove failed'),
})
const rerenderMut = useMutation({
mutationFn: () => regenerateItemThumbnail(orderId, item.id),
onSuccess: () => {
toast.success('Re-render queued — refreshing in a moment')
setTimeout(() => { setThumbVersion((v) => v + 1); onRerender() }, 8000)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-render failed'),
})
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
className="bg-surface rounded-2xl shadow-2xl max-w-lg w-full overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-border-default">
<p className="font-mono font-semibold text-content flex-1 truncate">
{item.name_cad_modell || `Row ${item.row_index}`}
</p>
<ItemStatusBadge status={item.item_status} />
<button onClick={onClose} className="text-content-muted hover:text-content-secondary p-1">
<X size={18} />
</button>
</div>
{/* Thumbnail */}
<div className="p-4 bg-surface-alt flex items-center justify-center min-h-[280px]">
{hasStep ? (
<img
src={`/api/cad/${item.cad_file_id}/thumbnail${thumbVersion > 0 ? `?v=${thumbVersion}` : ''}`}
alt="CAD preview"
className="max-h-64 object-contain rounded-lg"
/>
) : (
<div className="flex flex-col items-center text-content-muted py-8">
<ImageIcon size={48} className="mb-2" />
<p className="text-sm">No STEP file attached</p>
</div>
)}
</div>
{/* Fields */}
<div className="px-5 py-3 grid grid-cols-2 gap-x-6 gap-y-1 text-sm border-t border-border-light">
<Field label="Baureihe" value={item.baureihe} />
<Field label="Ebene 2" value={item.ebene2} />
<Field label="Lagertyp" value={item.lagertyp} />
<Field label="Rendering" value={item.medias_rendering?.toString() ?? '—'} />
<Field label="Components" value={String(item.components?.length ?? 0)} />
<Field label="PIM-ID" value={item.pim_id} />
</div>
{/* Actions */}
{hasStep && (
<div className="px-5 py-3 border-t border-border-light flex items-center gap-3">
<button
onClick={() => rerenderMut.mutate()}
disabled={rerenderMut.isPending}
className="btn-secondary text-sm"
>
<RotateCcw size={14} className={rerenderMut.isPending ? 'animate-spin' : ''} />
{rerenderMut.isPending ? 'Queuing…' : 'Re-render'}
</button>
{isDraft && (
<button
onClick={() => { if (confirm('Remove this STEP file?')) unlinkMut.mutate() }}
disabled={unlinkMut.isPending}
className="text-sm text-red-500 hover:text-red-700 flex items-center gap-1 ml-auto"
>
<Unlink size={14} />
{unlinkMut.isPending ? 'Removing…' : 'Remove STEP'}
</button>
)}
</div>
)}
</div>
</div>
)
}