refactor: rename thumbnail_rendering queue to asset_pipeline
The queue handles far more than thumbnails: OCC tessellation, USD master generation, GLB production, order line renders, and workflow renders. asset_pipeline better reflects its role as the render-worker's primary queue. Updated all references in: task decorators, celery_app.py, beat_tasks.py, docker-compose.yml worker command, worker.py MONITORED_QUEUES, admin.py, CLAUDE.md, LEARNINGS.md, Dockerfile, helpTexts.ts, test files, and all .claude/commands/*.md skill files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,18 +25,18 @@ describe('worker API types', () => {
|
||||
test('CeleryWorker interface shape', () => {
|
||||
const worker = {
|
||||
name: 'celery@worker1',
|
||||
queues: ['thumbnail_rendering'],
|
||||
queues: ['asset_pipeline'],
|
||||
active_task_count: 2,
|
||||
active_tasks: [{ name: 'render_still_task', id: 'abc' }],
|
||||
total_tasks_processed: { render_still_task: 42 },
|
||||
}
|
||||
expect(worker.queues).toContain('thumbnail_rendering')
|
||||
expect(worker.queues).toContain('asset_pipeline')
|
||||
expect(worker.active_tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('QueueStatus interface shape', () => {
|
||||
const qs = {
|
||||
queue_depths: { step_processing: 3, thumbnail_rendering: 0 },
|
||||
queue_depths: { step_processing: 3, asset_pipeline: 0 },
|
||||
pending_count: 3,
|
||||
active: [],
|
||||
reserved: [],
|
||||
|
||||
@@ -276,6 +276,18 @@ export async function generateLinesFromItems(
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function rejectOrderLine(
|
||||
orderId: string,
|
||||
lineId: string,
|
||||
reason: string,
|
||||
): Promise<{ rejected: boolean; line_id: string; reason: string }> {
|
||||
const res = await api.post<{ rejected: boolean; line_id: string; reason: string }>(
|
||||
`/orders/${orderId}/lines/${lineId}/reject`,
|
||||
{ reason },
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function rejectOrder(orderId: string, reason: string, notifyClient: boolean = true): Promise<Order> {
|
||||
const res = await api.post<Order>(`/orders/${orderId}/reject`, {
|
||||
reason,
|
||||
|
||||
@@ -537,6 +537,17 @@ export default function ThreeDViewer({
|
||||
const map = glbExtras.partKeyMap as Record<string, string> | undefined
|
||||
if (map && Object.keys(map).length > 0) {
|
||||
setPartKeyMap(map)
|
||||
// Task 2: Stamp userData.partKey on every mesh (fallback for meshes whose
|
||||
// GLB node extras were not populated — e.g. files generated before Task 1).
|
||||
// For new GLBs, Three.js already set userData.partKey from node extras;
|
||||
// the guard `if (obj.userData.partKey) return` avoids overwriting it.
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
if (obj.userData.partKey) return // already set by GLB node extras
|
||||
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
const pk = map[normalized] ?? normalized
|
||||
if (pk) obj.userData.partKey = pk
|
||||
})
|
||||
}
|
||||
|
||||
const names = new Set<string>()
|
||||
@@ -679,8 +690,11 @@ export default function ThreeDViewer({
|
||||
e.stopPropagation()
|
||||
const mesh = e.object as THREE.Mesh
|
||||
const raw = (mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || ''
|
||||
const name = normalizeMeshName(raw) || 'Part'
|
||||
setHoverInfo({ name, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY })
|
||||
const normalized = normalizeMeshName(raw) || 'Part'
|
||||
// Task 3: prefer userData.partKey (set by GLB node extras or Task 2 stamp) over
|
||||
// raw normalized name so tooltip shows canonical slug (e.g. "ring_outer") not OCC name
|
||||
const displayName = (mesh?.userData?.partKey as string | undefined) ?? resolvePartKey(normalized)
|
||||
setHoverInfo({ name: displayName, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY })
|
||||
|
||||
// Restore previous hovered mesh (array-safe)
|
||||
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
|
||||
@@ -703,7 +717,7 @@ export default function ThreeDViewer({
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
|
||||
})
|
||||
}, [showUnassigned])
|
||||
}, [showUnassigned, resolvePartKey])
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
setHoverInfo(null)
|
||||
|
||||
@@ -59,6 +59,41 @@ const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<strin
|
||||
},
|
||||
}
|
||||
|
||||
type NotifGroup =
|
||||
| { kind: 'single'; item: Notification }
|
||||
| { kind: 'batch'; count: number; failed: number; entityId: string | null; latest: Notification; ids: string[] }
|
||||
|
||||
function groupNotifications(items: Notification[]): NotifGroup[] {
|
||||
const result: NotifGroup[] = []
|
||||
let i = 0
|
||||
while (i < items.length) {
|
||||
const n = items[i]
|
||||
const isRender = n.action === 'render.completed' || n.action === 'render.failed'
|
||||
if (isRender) {
|
||||
const t0 = new Date(n.timestamp).getTime()
|
||||
let j = i
|
||||
let done = 0; let failed = 0
|
||||
const batchIds: string[] = []
|
||||
while (j < items.length) {
|
||||
const m = items[j]
|
||||
const isRenderM = m.action === 'render.completed' || m.action === 'render.failed'
|
||||
const tM = new Date(m.timestamp).getTime()
|
||||
if (!isRenderM || m.entity_id !== n.entity_id || Math.abs(tM - t0) > 5 * 60 * 1000) break
|
||||
batchIds.push(m.id)
|
||||
if (m.action === 'render.failed') failed++; else done++
|
||||
j++
|
||||
}
|
||||
if (j - i >= 2) {
|
||||
result.push({ kind: 'batch', count: done, failed, entityId: n.entity_id ?? null, latest: n, ids: batchIds })
|
||||
i = j; continue
|
||||
}
|
||||
}
|
||||
result.push({ kind: 'single', item: n })
|
||||
i++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function relativeTime(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
@@ -182,7 +217,44 @@ export default function NotificationCenter() {
|
||||
{!data?.items.length && (
|
||||
<div className="py-8 text-center text-sm text-content-muted">No notifications</div>
|
||||
)}
|
||||
{data?.items.map((n) => {
|
||||
{data?.items && groupNotifications(data.items).map((group) => {
|
||||
if (group.kind === 'batch') {
|
||||
const { count, failed, entityId, latest, ids } = group
|
||||
const BatchIcon = failed > 0 ? AlertTriangle : CheckCircle
|
||||
const batchColor = failed > 0 ? 'text-red-500' : 'text-status-success-text'
|
||||
const batchLabel = `Render batch: ${count} done${failed > 0 ? `, ${failed} failed` : ''}`
|
||||
return (
|
||||
<button
|
||||
key={`batch-${latest.id}`}
|
||||
onClick={() => {
|
||||
if (!latest.read_at) {
|
||||
ids.forEach((id) => markOneMutation.mutate(id))
|
||||
}
|
||||
if (entityId) navigate(`/orders/${entityId}`)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors border-b border-border-light',
|
||||
!latest.read_at && 'bg-status-info-bg',
|
||||
)}
|
||||
>
|
||||
<BatchIcon size={16} className={clsx('mt-0.5 shrink-0', batchColor)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={clsx('text-sm', !latest.read_at ? 'font-medium text-content' : 'text-content-secondary')}>
|
||||
{batchLabel}
|
||||
</p>
|
||||
{entityId && (
|
||||
<p className="text-xs text-content-muted mt-0.5">Click to view order</p>
|
||||
)}
|
||||
<p className="text-xs text-content-muted mt-0.5">{relativeTime(latest.timestamp)}</p>
|
||||
</div>
|
||||
{!latest.read_at && (
|
||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const n = group.item
|
||||
const cfg = ACTION_CONFIG[n.action] ?? {
|
||||
icon: Bell,
|
||||
label: () => n.action,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const HELP_TEXTS: Record<string, HelpText> = {
|
||||
},
|
||||
'action.regenerate_thumbnails': {
|
||||
title: 'Regenerate All Thumbnails',
|
||||
body: 'Re-renders thumbnails for all STEP files using current renderer settings. Queues every file on the thumbnail_rendering worker.',
|
||||
body: 'Re-renders thumbnails for all STEP files using current renderer settings. Queues every file on the asset_pipeline worker.',
|
||||
warning: 'This queues a large number of tasks. Only run during off-peak hours.',
|
||||
},
|
||||
'action.process_unprocessed': {
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function AdminPage() {
|
||||
|
||||
const [tessellationDraft, setTessellationDraft] = useState<Partial<Settings>>({})
|
||||
const tess = { ...settings, ...tessellationDraft } as Settings
|
||||
const [showAdvancedTess, setShowAdvancedTess] = useState(false)
|
||||
|
||||
const { data: rendererStatus, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['renderer-status'],
|
||||
@@ -1332,6 +1333,7 @@ export default function AdminPage() {
|
||||
/>
|
||||
<span className="text-sm text-content">Apply Laplacian smoothing on export</span>
|
||||
</label>
|
||||
<p className="text-xs text-content-muted mt-1">Smooths surface normals during GLB export for a less faceted look in the 3D viewer.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1348,8 +1350,10 @@ export default function AdminPage() {
|
||||
max="10000"
|
||||
value={viewer3d.viewer_max_distance ?? 50}
|
||||
onChange={e => setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))}
|
||||
title="Maximum camera distance from the model in the 3D viewer (in metres after mm→m conversion). Default: 50"
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-content-muted mt-1">Maximum camera pull-back distance in the 3D viewer (metres).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
||||
@@ -1362,8 +1366,10 @@ export default function AdminPage() {
|
||||
max="1"
|
||||
value={viewer3d.viewer_min_distance ?? 0.001}
|
||||
onChange={e => setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))}
|
||||
title="Minimum camera distance from the model in the 3D viewer (in metres). Default: 0.001. Prevents clipping into the geometry."
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-content-muted mt-1">Closest the camera can zoom in (metres). Prevents clipping through geometry.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1376,11 +1382,13 @@ export default function AdminPage() {
|
||||
<select
|
||||
value={viewer3d.gltf_material_quality ?? 'pbr_colors'}
|
||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_material_quality: e.target.value }))}
|
||||
title="Controls what material data is embedded in exported GLB files. 'None' exports bare geometry; 'PBR Colors' bakes part colours into PBR materials."
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="none">None (geometry only)</option>
|
||||
<option value="pbr_colors">PBR Colors (from part colors)</option>
|
||||
</select>
|
||||
<p className="text-xs text-content-muted mt-1">Material data embedded in exported GLB files.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
||||
@@ -1393,8 +1401,10 @@ export default function AdminPage() {
|
||||
max="1"
|
||||
value={viewer3d.gltf_pbr_roughness ?? 0.4}
|
||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))}
|
||||
title="Surface roughness for GLB PBR materials (0 = mirror-smooth, 1 = fully matte). Default: 0.4 — appropriate for brushed metal."
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-content-muted mt-1">0 = mirror-smooth, 1 = fully matte. Default 0.4 suits brushed metal.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-content-muted block mb-1">
|
||||
@@ -1407,8 +1417,10 @@ export default function AdminPage() {
|
||||
max="1"
|
||||
value={viewer3d.gltf_pbr_metallic ?? 0.6}
|
||||
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))}
|
||||
title="Metallic factor for GLB PBR materials (0 = dielectric/plastic, 1 = fully metallic). Default: 0.6 — suitable for steel parts."
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-content-muted mt-1">0 = plastic/dielectric, 1 = fully metallic. Default 0.6 suits steel parts.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1525,10 +1537,19 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAdvancedTess(v => !v)}
|
||||
className="text-xs text-accent hover:underline flex items-center gap-1 mt-1"
|
||||
>
|
||||
{showAdvancedTess ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
{showAdvancedTess ? 'Hide manual values' : 'Advanced: manual deflection values'}
|
||||
</button>
|
||||
|
||||
{/* Manual inputs */}
|
||||
{showAdvancedTess && (<>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Scene / Viewer</p>
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Scene (USD Master)</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
|
||||
<input
|
||||
@@ -1555,7 +1576,7 @@ export default function AdminPage() {
|
||||
/>
|
||||
<span className="text-sm text-content-muted">rad</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">Used for the 3D viewer (canonical scene). Smaller = smoother surfaces.</p>
|
||||
<p className="text-xs text-content-muted">Used for the USD master + 3D viewer GLB (canonical scene). Smaller = smoother surfaces.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render output</p>
|
||||
@@ -1588,6 +1609,7 @@ export default function AdminPage() {
|
||||
<p className="text-xs text-content-muted">Used for final render output. Smaller = smoother surfaces, larger file sizes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { updateSettingsMut.mutate(tessellationDraft); setTessellationDraft({}) }}
|
||||
|
||||
@@ -504,13 +504,22 @@ export default function MediaBrowserPage() {
|
||||
<span className="text-sm">Loading assets…</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-content-muted gap-3">
|
||||
<Image size={48} className="opacity-25" />
|
||||
<p className="text-sm font-medium">No media assets found.</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Renders will appear here once orders are completed. Try adjusting your filters.
|
||||
</p>
|
||||
</div>
|
||||
(() => {
|
||||
const hasActiveFilters = !!(q || assetType || categoryKey || renderStatus)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3" style={{ color: 'var(--color-content-muted)' }}>
|
||||
<Image size={48} style={{ opacity: 0.25 }} />
|
||||
<p className="text-sm font-medium">
|
||||
{hasActiveFilters ? 'No assets match your filters.' : 'No assets yet — upload a STEP file to get started'}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Try adjusting your search or filter settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{items.map(asset => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useState, useMemo, Fragment } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
ArrowLeft, Send, Trash2,
|
||||
FileBox, AlertTriangle, CheckCircle2, Image as ImageIcon, Unlink,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
XCircle, RotateCw, Info,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder } from '../api/orders'
|
||||
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
|
||||
import type { OrderItem, OrderLine } from '../api/orders'
|
||||
import { listOutputTypes } from '../api/outputTypes'
|
||||
import type { OutputType } from '../api/outputTypes'
|
||||
@@ -827,6 +828,8 @@ function OrderLineRow({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [showInfo, setShowInfo] = useState(false)
|
||||
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
|
||||
const [rejectLineReason, setRejectLineReason] = useState('')
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: () => removeOrderLine(orderId, line.id),
|
||||
@@ -843,7 +846,19 @@ function OrderLineRow({
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
|
||||
})
|
||||
|
||||
const rejectLineMut = useMutation({
|
||||
mutationFn: () => rejectOrderLine(orderId, line.id, rejectLineReason),
|
||||
onSuccess: () => {
|
||||
toast.success('Line rejected')
|
||||
setRejectLineModalOpen(false)
|
||||
setRejectLineReason('')
|
||||
qc.invalidateQueries({ queryKey: ['order', orderId] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
|
||||
})
|
||||
|
||||
const canCancel = isPrivileged && (line.render_status === 'processing' || line.render_status === 'pending') && line.output_type_id
|
||||
const canRejectLine = isPrivileged && line.item_status !== 'rejected'
|
||||
|
||||
const renderStatusColor: Record<string, string> = {
|
||||
pending: 'bg-surface-muted text-content-muted',
|
||||
@@ -1003,7 +1018,21 @@ function OrderLineRow({
|
||||
|
||||
{/* Item Status */}
|
||||
<td className="px-4 py-2">
|
||||
<ItemStatusBadge status={line.item_status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ItemStatusBadge status={line.item_status} />
|
||||
{canRejectLine && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setRejectLineModalOpen(true)
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
title="Reject this line"
|
||||
>
|
||||
<XCircle size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Remove (draft only) */}
|
||||
@@ -1027,6 +1056,61 @@ function OrderLineRow({
|
||||
renderStartedAt={line.render_started_at}
|
||||
renderCompletedAt={line.render_completed_at}
|
||||
/>
|
||||
|
||||
{rejectLineModalOpen && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
||||
onClick={() => { setRejectLineModalOpen(false); setRejectLineReason('') }}
|
||||
>
|
||||
<div
|
||||
className="rounded-xl shadow-2xl border border-border-default w-full max-w-md mx-4 p-6"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<XCircle size={20} className="text-red-500 shrink-0" />
|
||||
<h2 className="text-lg font-semibold text-content">Reject this item?</h2>
|
||||
<button
|
||||
onClick={() => { setRejectLineModalOpen(false); setRejectLineReason('') }}
|
||||
className="ml-auto p-1 text-content-muted hover:text-content transition-colors rounded"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-content-secondary mb-3">
|
||||
Optionally provide a reason for rejecting this line.
|
||||
</p>
|
||||
<textarea
|
||||
value={rejectLineReason}
|
||||
onChange={(e) => setRejectLineReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border-default px-3 py-2 text-sm text-content focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => { setRejectLineModalOpen(false); setRejectLineReason('') }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectLineMut.mutate()}
|
||||
disabled={rejectLineMut.isPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold bg-red-600 hover:bg-red-700 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
{rejectLineMut.isPending ? 'Rejecting…' : 'Confirm Rejection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1611,10 +1695,12 @@ function SourceSpreadsheet({
|
||||
{sorted.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-surface-hover/30 group ${saving === item.id ? 'opacity-60' : ''}`}
|
||||
className={`group ${saving === item.id ? 'opacity-60' : ''}`}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLTableRowElement).style.backgroundColor = 'var(--color-bg-surface-hover)' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLTableRowElement).style.backgroundColor = '' }}
|
||||
>
|
||||
{/* 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">
|
||||
<td className="sticky left-0 z-10 bg-surface py-1.5 px-3 font-mono text-content-muted border-r border-b border-border-light">
|
||||
{item.row_index}
|
||||
</td>
|
||||
|
||||
|
||||
@@ -326,10 +326,19 @@ export default function ProductLibraryPage() {
|
||||
))}
|
||||
</div>
|
||||
) : !products?.length ? (
|
||||
<div className="text-center py-16 text-content-muted">
|
||||
<Library size={48} className="mx-auto mb-3 opacity-30" />
|
||||
<p>No products found</p>
|
||||
<p className="text-sm mt-1">Upload an Excel file to populate the library</p>
|
||||
<div className="text-center py-16" style={{ color: 'var(--color-content-muted)' }}>
|
||||
<Library size={48} className="mx-auto mb-3" style={{ opacity: 0.3 }} />
|
||||
{debouncedSearch || categoryFilter || hasCadFilter || materialsFilter ? (
|
||||
<>
|
||||
<p className="font-medium">No products match your filters.</p>
|
||||
<p className="text-sm mt-1">Try clearing the search or adjusting the filter criteria.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">No products yet</p>
|
||||
<p className="text-sm mt-1">Upload an Excel file to populate the library.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
/* ── Grid view ─────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user