5029a94608
- 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>
1640 lines
70 KiB
TypeScript
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>
|
|
)
|
|
}
|