feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan

Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:40:36 +01:00
parent 202b06a026
commit ca62319688
70 changed files with 6551 additions and 1130 deletions
+399 -146
View File
@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState, useRef } from 'react'
import { toast } from 'sonner'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap } from 'lucide-react'
import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Settings, RefreshCw, CheckCircle2, XCircle, Clock, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import ConfirmModal from '../components/ConfirmModal'
@@ -10,6 +10,7 @@ import TemplateEditor from '../components/admin/TemplateEditor'
import PricingTierTable from '../components/admin/PricingTierTable'
import OutputTypeTable from '../components/admin/OutputTypeTable'
import RenderTemplateTable from '../components/admin/RenderTemplateTable'
import GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel'
import { useAuthStore, isAdmin as checkIsAdmin } from '../store/auth'
import { listPricingTiers } from '../api/pricing'
import { listOutputTypes } from '../api/outputTypes'
@@ -29,6 +30,8 @@ export default function AdminPage() {
const isAdmin = checkIsAdmin(user)
const [showNewUser, setShowNewUser] = useState(false)
const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' })
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true })
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
@@ -68,6 +71,17 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const updateUserMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: { full_name: string; role: string; is_active: boolean } }) =>
api.patch(`/admin/users/${id}`, data),
onSuccess: () => {
toast.success('User updated')
qc.invalidateQueries({ queryKey: ['admin-users'] })
setEditingUserId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
type Settings = {
thumbnail_renderer: string
blender_engine: string
@@ -167,6 +181,15 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
})
const cleanupOrphanedMut = useMutation({
mutationFn: () => api.post('/media/cleanup-orphaned'),
onSuccess: (res) => {
toast.success(`Cleanup done: ${res.data.deleted} orphaned records deleted (${res.data.checked} checked)`)
qc.invalidateQueries({ queryKey: ['media-browser'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const reextractMetadataMut = useMutation({
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
onSuccess: (res) => {
@@ -175,6 +198,14 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const cleanupOrphanedCadMut = useMutation({
mutationFn: () => api.post('/admin/settings/cleanup-orphaned-cad-files'),
onSuccess: (res) => {
toast.success(`Deleted ${res.data.deleted_records} orphaned CAD records, freed ${res.data.freed_mb} MB`)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cleanup failed'),
})
const recoverStuckMut = useMutation({
mutationFn: () => api.post('/admin/settings/recover-stuck-processing'),
onSuccess: (res) => {
@@ -265,19 +296,65 @@ export default function AdminPage() {
)
}
type AdminTab = 'overview' | 'users' | 'render' | 'pricing' | 'libraries' | 'config'
const [activeTab, setActiveTab] = useState<AdminTab>('overview')
const hasUnsavedChanges =
Object.keys(blenderDraft).length > 0 ||
Object.keys(viewerDraft).length > 0 ||
Object.keys(tessellationDraft).length > 0 ||
Object.keys(smtpDraft).length > 0
const TABS: { id: AdminTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'render', label: 'Render' },
{ id: 'pricing', label: 'Pricing' },
{ id: 'libraries', label: 'Libraries' },
{ id: 'config', label: 'Config' },
]
return (
<div>
{/* Tab header */}
<div className="px-8 pt-6 pb-0 bg-surface border-b border-border-default sticky top-0 z-10">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{hasUnsavedChanges && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300">
<AlertCircle size={14} />
Unsaved changes
</div>
)}
</div>
<div className="flex gap-1 -mb-px">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-accent text-accent'
: 'border-transparent text-content-secondary hover:text-content hover:border-border-default'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
<PricingSummaryCard />
{activeTab === 'overview' && <PricingSummaryCard />}
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'users' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<h2 className="font-semibold text-content">Users</h2>
<button onClick={() => setShowNewUser(!showNewUser)} className="btn-primary">
@@ -329,35 +406,102 @@ export default function AdminPage() {
)}
<div className="divide-y divide-border-light">
{users?.map((user) => (
<div key={user.id} className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{user.full_name}</p>
<p className="text-xs text-content-muted">{user.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(user) ? 'badge-green' : 'badge-gray'}`}>
{user.role}
</span>
<span className={`badge mr-4 ${user.is_active ? 'badge-green' : 'badge-red'}`}>
{user.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${user.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(user.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
{users?.map((u) => (
<div key={u.id}>
{editingUserId === u.id ? (
<div className="px-6 py-3 bg-surface-alt space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-content-muted block mb-1">Full name</label>
<input
value={editUserDraft.full_name}
onChange={(e) => setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
/>
</div>
<div>
<label className="text-xs text-content-muted block mb-1">Role</label>
<select
value={editUserDraft.role}
onChange={(e) => setEditUserDraft((d) => ({ ...d, role: e.target.value }))}
className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
<option value="global_admin">Global Admin</option>
<option value="tenant_admin">Tenant Admin</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-content cursor-pointer">
<input
type="checkbox"
checked={editUserDraft.is_active}
onChange={(e) => setEditUserDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="rounded"
/>
Active
</label>
<div className="flex gap-2 ml-auto">
<button
onClick={() => setEditingUserId(null)}
className="btn-secondary text-sm"
>
Cancel
</button>
<button
onClick={() => updateUserMut.mutate({ id: u.id, data: editUserDraft })}
disabled={updateUserMut.isPending}
className="btn-primary text-sm"
>
{updateUserMut.isPending ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center px-6 py-3">
<div className="flex-1">
<p className="text-sm font-medium text-content">{u.full_name}</p>
<p className="text-xs text-content-muted">{u.email}</p>
</div>
<span className={`badge mr-4 ${checkIsAdmin(u) ? 'badge-green' : 'badge-gray'}`}>
{u.role}
</span>
<span className={`badge mr-4 ${u.is_active ? 'badge-green' : 'badge-red'}`}>
{u.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => {
setEditingUserId(u.id)
setEditUserDraft({ full_name: u.full_name, role: u.role, is_active: u.is_active })
}}
className="text-content-muted hover:text-accent transition-colors mr-3"
title="Edit user"
>
<Pencil size={16} />
</button>
<button
onClick={() => {
setConfirmState({
open: true,
title: 'Delete User',
message: `Delete user "${u.email}"? This cannot be undone.`,
onConfirm: () => {
deleteUserMut.mutate(u.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
className="text-content-muted hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 size={16} />
</button>
</div>
)}
</div>
))}
</div>
@@ -366,7 +510,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && <div className="card">
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
@@ -788,6 +932,34 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => cleanupOrphanedMut.mutate()}
disabled={cleanupOrphanedMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Find and delete all MediaAsset DB records whose backing file is missing on disk"
>
<Trash2 size={14} className={cleanupOrphanedMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedMut.isPending ? 'Checking files…' : 'Clean Up Orphaned Media'}
</button>
<p className="text-xs text-content-muted">Removes DB records for renders whose files no longer exist on disk.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => {
if (window.confirm('Delete all orphaned STEP files (not linked to any product)? This cannot be undone.')) {
cleanupOrphanedCadMut.mutate()
}
}}
disabled={cleanupOrphanedCadMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Delete STEP files and thumbnails that are no longer linked to any product"
>
<Trash2 size={14} className={cleanupOrphanedCadMut.isPending ? 'animate-spin' : ''} />
{cleanupOrphanedCadMut.isPending ? 'Deleting…' : 'Clean Up Orphaned STEP Files'}
</button>
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
@@ -817,10 +989,28 @@ export default function AdminPage() {
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Global Render Positions (admin only) */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Settings size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Global Render Positions</h2>
<p className="text-xs text-content-muted mt-0.5">
Camera rotation presets available to all products. Per-product positions override these.
</p>
</div>
</div>
<div className="p-4">
<GlobalRenderPositionsPanel />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<FileBox size={16} className="text-content-muted" />
<div>
@@ -836,17 +1026,17 @@ export default function AdminPage() {
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{activeTab === 'libraries' && <AssetLibraryPanel />}
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-light flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
@@ -857,12 +1047,12 @@ export default function AdminPage() {
</div>
</div>
<OutputTypeTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'pricing' && <div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<div>
@@ -873,12 +1063,12 @@ export default function AdminPage() {
</div>
</div>
<PricingTierTable />
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
@@ -966,7 +1156,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'libraries' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1017,12 +1207,12 @@ export default function AdminPage() {
)
})}
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
{activeTab === 'config' && isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<LayoutDashboard size={16} className="text-content-muted" />
@@ -1066,7 +1256,7 @@ export default function AdminPage() {
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">3D Viewer & GLB Export</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1205,12 +1395,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<div className="card">
{activeTab === 'render' && <div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">Tessellation Quality</h2>
<p className="text-sm text-content-muted mt-0.5">
@@ -1218,6 +1408,58 @@ export default function AdminPage() {
</p>
</div>
<div className="p-4 space-y-6">
{/* Presets */}
{(() => {
const PRESETS = [
{
label: 'Draft',
description: 'Fast export, visible faceting on large curves',
color: 'border-amber-400 text-amber-700',
values: { gltf_preview_linear_deflection: 0.2, gltf_preview_angular_deflection: 0.3, gltf_production_linear_deflection: 0.05, gltf_production_angular_deflection: 0.1 },
},
{
label: 'Standard',
description: 'Smooth curves, no fan artifacts recommended',
color: 'border-blue-400 text-blue-700',
values: { gltf_preview_linear_deflection: 0.1, gltf_preview_angular_deflection: 0.1, gltf_production_linear_deflection: 0.03, gltf_production_angular_deflection: 0.05 },
},
{
label: 'Fine',
description: 'Maximum quality, very large files, slow export',
color: 'border-emerald-400 text-emerald-700',
values: { gltf_preview_linear_deflection: 0.05, gltf_preview_angular_deflection: 0.05, gltf_production_linear_deflection: 0.01, gltf_production_angular_deflection: 0.02 },
},
]
const isActive = (preset: typeof PRESETS[0]) =>
tess.gltf_preview_linear_deflection === preset.values.gltf_preview_linear_deflection &&
tess.gltf_preview_angular_deflection === preset.values.gltf_preview_angular_deflection &&
tess.gltf_production_linear_deflection === preset.values.gltf_production_linear_deflection &&
tess.gltf_production_angular_deflection === preset.values.gltf_production_angular_deflection
return (
<div>
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">Presets</p>
<div className="flex gap-3">
{PRESETS.map(preset => (
<button
key={preset.label}
onClick={() => setTessellationDraft(preset.values)}
className={`flex-1 p-3 rounded-lg border-2 text-left transition-colors ${isActive(preset) ? preset.color + ' bg-opacity-10' : 'border-border-default text-content hover:border-blue-300'}`}
style={isActive(preset) ? { backgroundColor: 'var(--color-bg-surface-alt)' } : undefined}
>
<div className="font-semibold text-sm">{preset.label}</div>
<div className="text-xs text-content-muted mt-0.5">{preset.description}</div>
<div className="text-xs font-mono text-content-secondary mt-1 space-y-0.5">
<div>preview: {preset.values.gltf_preview_angular_deflection} rad / {preset.values.gltf_preview_linear_deflection} mm</div>
<div>prod: {preset.values.gltf_production_angular_deflection} rad / {preset.values.gltf_production_linear_deflection} mm</div>
</div>
</button>
))}
</div>
</div>
)
})()}
{/* Manual inputs */}
<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">Preview (Geometry GLB)</p>
@@ -1238,10 +1480,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.01"
min="0.01"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
value={tess.gltf_preview_angular_deflection ?? 0.1}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_preview_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1268,10 +1510,10 @@ export default function AdminPage() {
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
step="0.005"
min="0.005"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
value={tess.gltf_production_angular_deflection ?? 0.05}
onChange={e => setTessellationDraft(d => ({ ...d, gltf_production_angular_deflection: parseFloat(e.target.value) }))}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400"
/>
@@ -1293,12 +1535,12 @@ export default function AdminPage() {
)}
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
<div className="card p-5 flex items-center justify-between">
{activeTab === 'render' && <div className="card p-5 flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">Material Library</h2>
<p className="text-xs text-content-muted mt-0.5">
@@ -1308,7 +1550,106 @@ export default function AdminPage() {
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library →
</Link>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* GPU Status */}
{/* ------------------------------------------------------------------ */}
{activeTab === 'render' && isAdmin && (
<div className="card">
<button
className="w-full p-5 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-3">
<Zap size={18} className="text-content-secondary" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Verify that the render-worker is using the GPU (not CPU fallback).
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-5 pb-5 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="btn-primary flex items-center gap-2"
>
{gpuProbing ? (
<><RefreshCw size={14} className="animate-spin" /> Running probe…</>
) : (
<><Zap size={14} /> Run GPU Check</>
)}
</button>
{gpuProbeResult && (
<span className="text-xs text-content-muted">
Last checked: {new Date(gpuProbeResult.timestamp).toLocaleString()}
</span>
)}
</div>
{gpuProbeResult && (
<div className="bg-surface-alt rounded-md p-4 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-xs text-content">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Devices</span>
<div className="space-y-0.5">
{gpuProbeResult.devices.map((d: string, i: number) => (
<span key={i} className="block text-xs text-content">{d}</span>
))}
</div>
</div>
)}
{gpuProbeResult.render_time_s != null && (
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Render time</span>
<span className="text-xs text-content">{gpuProbeResult.render_time_s.toFixed(2)}s</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-xs font-semibold text-content-secondary w-28 shrink-0">Error</span>
<span className="text-xs text-status-error-text font-mono">{gpuProbeResult.error}</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-xs text-content-muted">No probe result yet. Click "Run GPU Check" to trigger a test render.</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
</div>
)
}
@@ -1393,6 +1734,7 @@ function AssetLibraryPanel() {
const [newDesc, setNewDesc] = useState('')
const [newFile, setNewFile] = useState<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: libraries = [] } = useQuery({
queryKey: ['asset-libraries'],
@@ -1586,95 +1928,6 @@ function AssetLibraryPanel() {
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* GPU Status (admin only) */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<button
className="w-full p-4 flex items-center justify-between text-left"
onClick={() => setGpuProbeExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<Zap size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">GPU Status</h2>
<p className="text-xs text-content-muted mt-0.5">
Check Blender GPU availability on the render worker
</p>
</div>
</div>
<div className="flex items-center gap-3">
{gpuStatusBadge()}
{gpuProbeResult?.probed_at && (
<span className="text-xs text-content-muted">
Last checked: {Math.round((Date.now() - new Date(gpuProbeResult.probed_at).getTime()) / 60000)} min ago
</span>
)}
{gpuProbeExpanded ? <ChevronUp size={16} className="text-content-muted" /> : <ChevronDown size={16} className="text-content-muted" />}
</div>
</button>
{gpuProbeExpanded && (
<div className="px-6 pb-6 space-y-4 border-t border-border-default pt-4">
<div className="flex items-center gap-3">
<button
onClick={handleRunGpuCheck}
disabled={gpuProbing}
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{gpuProbing
? <RefreshCw size={14} className="animate-spin" />
: <Zap size={14} />
}
{gpuProbing ? 'Checking…' : 'Run GPU Check'}
</button>
{gpuProbing && (
<span className="text-xs text-content-muted">
Polling for result (up to 45s)
</span>
)}
</div>
{gpuProbeResult && (
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Status</span>
{gpuStatusBadge()}
</div>
{gpuProbeResult.device_type && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Device type</span>
<span className="text-content font-mono text-xs">{gpuProbeResult.device_type}</span>
</div>
)}
{gpuProbeResult.error && (
<div className="flex items-start gap-2">
<span className="text-content-secondary w-28 shrink-0">Error</span>
<span className="text-status-error-text text-xs">{gpuProbeResult.error}</span>
</div>
)}
{gpuProbeResult.probed_at && (
<div className="flex items-center gap-2">
<span className="text-content-secondary w-28 shrink-0">Probed at</span>
<span className="text-content-muted text-xs">
{new Date(gpuProbeResult.probed_at).toLocaleString()}
</span>
</div>
)}
</div>
)}
{!gpuProbeResult && !gpuProbing && (
<p className="text-sm text-content-muted">
No probe result yet. Click "Run GPU Check" to trigger a check on the render worker.
</p>
)}
</div>
)}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
+5 -5
View File
@@ -19,10 +19,10 @@ const formatDate = (iso: string | null) =>
iso ? new Date(iso).toLocaleDateString('de-DE') : '—'
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-100 text-blue-700',
paid: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
draft: 'badge-gray',
sent: 'badge-blue',
paid: 'badge-green',
cancelled: 'badge-red',
}
// ── New Invoice Modal ─────────────────────────────────────────────────────
@@ -197,7 +197,7 @@ export default function BillingPage() {
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'bg-gray-100 text-gray-700'}`}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
+24 -9
View File
@@ -1,15 +1,18 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useLocation } from 'react-router-dom'
import { toast } from 'sonner'
import { Eye, EyeOff } from 'lucide-react'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
export default function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -17,7 +20,8 @@ export default function LoginPage() {
try {
const res = await api.post('/auth/login', { email, password })
setAuth(res.data.access_token, res.data.user)
navigate('/')
const returnTo = (location.state as any)?.from || '/'
navigate(returnTo)
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Login failed')
} finally {
@@ -50,13 +54,24 @@ export default function LoginPage() {
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full"
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-secondary transition-colors"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Signing in...' : 'Sign In'}
+1 -1
View File
@@ -560,7 +560,7 @@ function SourceBadge({ source }: { source: string }) {
}
if (source === 'cad_import') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
<span className="badge-purple">
CAD import
</span>
)
+323 -52
View File
@@ -3,9 +3,11 @@ import { useQuery } from '@tanstack/react-query'
import {
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
CheckSquare, Square, X, ZoomIn, Archive,
} from 'lucide-react'
import {
getMediaAssets,
zipDownloadAssets,
} from '../api/media'
import type { MediaAssetItem, MediaAssetType } from '../api/media'
@@ -22,24 +24,27 @@ const formatBytes = (bytes: number | null) => {
}
const TYPE_COLORS: Partial<Record<MediaAssetType, string>> = {
thumbnail: 'bg-gray-100 text-gray-700',
still: 'bg-blue-100 text-blue-700',
turntable: 'bg-purple-100 text-purple-700',
stl_low: 'bg-yellow-100 text-yellow-700',
stl_high: 'bg-orange-100 text-orange-700',
gltf_geometry: 'bg-green-100 text-green-700',
gltf_production: 'bg-emerald-100 text-emerald-700',
blend_production: 'bg-pink-100 text-pink-700',
thumbnail: 'badge-gray',
still: 'badge-blue',
turntable: 'badge-purple',
stl_low: 'badge-yellow',
stl_high: 'badge-orange',
gltf_geometry: 'badge-green',
gltf_production: 'badge-teal',
blend_production: 'badge-purple',
}
const ASSET_TYPES = [
{ value: '', label: 'All types' },
const ASSET_TYPES_MEDIA = [
{ value: '', label: 'All media' },
{ value: 'still', label: 'Still' },
{ value: 'turntable', label: 'Turntable' },
{ value: 'thumbnail', label: 'Thumbnail' },
]
const ASSET_TYPES_TECHNICAL = [
{ value: 'gltf_geometry', label: 'glTF Geometry' },
{ value: 'gltf_production', label: 'glTF Production' },
{ value: 'blend_production', label: 'Blend Production' },
{ value: 'blend_production', label: 'Blend (.blend)' },
{ value: 'stl_low', label: 'STL Low' },
{ value: 'stl_high', label: 'STL High' },
]
@@ -67,67 +72,201 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100]
// ── TypeIcon ──────────────────────────────────────────────────────────────────
function TypeIcon({ type }: { type: MediaAssetType }) {
if (type === 'still' || type === 'thumbnail') return <Image size={32} className="text-content-muted" />
if (type === 'turntable') return <Film size={32} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={32} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={32} className="text-content-muted" />
return <Layers size={32} className="text-content-muted" />
function TypeIcon({ type, size = 32 }: { type: MediaAssetType; size?: number }) {
if (type === 'still' || type === 'thumbnail') return <Image size={size} className="text-content-muted" />
if (type === 'turntable') return <Film size={size} className="text-content-muted" />
if (type === 'stl_low' || type === 'stl_high') return <Box size={size} className="text-content-muted" />
if (type === 'gltf_geometry' || type === 'gltf_production') return <FileCode2 size={size} className="text-content-muted" />
return <Layers size={size} className="text-content-muted" />
}
// ── Lightbox ──────────────────────────────────────────────────────────────────
function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => void }) {
const isVideo = asset.asset_type === 'turntable'
// No-auth thumbnail endpoint serves image/video directly (no Bearer token needed)
const mediaSrc = `/api/media/${asset.id}/thumbnail`
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.88)' }}
onClick={onClose}
>
{/* Close button */}
<button
className="absolute top-4 right-4 p-2 rounded-full text-white transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.15)' }}
onClick={onClose}
title="Close (Esc)"
>
<X size={20} />
</button>
{/* Media */}
<div
className="max-w-5xl max-h-[80vh] w-full mx-6 flex items-center justify-center"
onClick={e => e.stopPropagation()}
>
{isVideo ? (
<video
src={mediaSrc}
controls
autoPlay
loop
className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
/>
) : asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.product_name ?? asset.asset_type}
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
/>
) : (
<div className="flex flex-col items-center gap-4 text-white opacity-60">
<TypeIcon type={asset.asset_type} size={64} />
<p className="text-sm">No preview available</p>
</div>
)}
</div>
{/* Caption */}
<div
className="absolute bottom-0 left-0 right-0 px-6 py-4 text-sm text-white"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between max-w-5xl mx-auto">
<div className="space-y-0.5">
{asset.product_name && <p className="font-medium">{asset.product_name}</p>}
<p className="text-xs opacity-70">
{asset.asset_type}
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
{asset.product_baureihe && ` · ${asset.product_baureihe}`}
{formatBytes(asset.file_size_bytes) && ` · ${formatBytes(asset.file_size_bytes)}`}
</p>
</div>
{asset.download_url && (
<a
href={asset.download_url}
download
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md font-medium transition-colors"
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={e => e.stopPropagation()}
>
<Download size={13} />
Download
</a>
)}
</div>
</div>
</div>
)
}
// ── AssetCard ─────────────────────────────────────────────────────────────────
function AssetCard({ asset }: { asset: MediaAssetItem }) {
interface AssetCardProps {
asset: MediaAssetItem
selected: boolean
onToggleSelect: (id: string) => void
onPreview: (asset: MediaAssetItem) => void
}
function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProps) {
const isImage = asset.asset_type === 'still' || asset.asset_type === 'thumbnail'
const isVideo = asset.asset_type === 'turntable'
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'bg-gray-100 text-gray-700'
// Images need a resolved thumbnail_url; videos always have the no-auth endpoint available
const isPreviewable = isVideo || (isImage && !!asset.thumbnail_url)
const typeBadge = TYPE_COLORS[asset.asset_type] ?? 'badge-gray'
const sizeStr = formatBytes(asset.file_size_bytes)
const handleDownload = () => {
if (asset.download_url) window.open(asset.download_url, '_blank')
}
return (
<div
className="rounded-lg border border-border-default overflow-hidden flex flex-col"
className={`rounded-lg border overflow-hidden flex flex-col relative group transition-all ${
selected ? 'border-accent ring-2 ring-accent ring-offset-1' : 'border-border-default'
}`}
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{/* Select checkbox — top-left, always shown when selected, hover otherwise */}
<button
className={`absolute top-2 left-2 z-10 rounded p-0.5 transition-all ${
selected
? 'text-accent opacity-100'
: 'text-white opacity-0 group-hover:opacity-100'
}`}
style={{ backgroundColor: selected ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.45)' }}
onClick={e => { e.stopPropagation(); onToggleSelect(asset.id) }}
title={selected ? 'Deselect' : 'Select'}
>
{selected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
{/* Preview area */}
<div
className="w-full h-40 flex items-center justify-center overflow-hidden"
className="w-full h-40 flex items-center justify-center overflow-hidden relative cursor-pointer"
style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}
onClick={() => isPreviewable ? onPreview(asset) : onToggleSelect(asset.id)}
>
{isImage && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-contain p-2"
/>
) : isVideo && asset.thumbnail_url ? (
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="w-full h-full object-cover opacity-80"
<div className="w-full h-full p-2 flex items-center justify-center">
<img
src={asset.thumbnail_url}
alt={asset.asset_type}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isVideo ? (
<video
src={`/api/media/${asset.id}/thumbnail`}
preload="metadata"
muted
loop
playsInline
className="w-full h-full object-cover"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<TypeIcon type={asset.asset_type} />
)}
{/* Preview hover overlay */}
{isPreviewable && (
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
{isVideo
? <Film size={24} className="text-white" />
: <ZoomIn size={24} className="text-white" />
}
</div>
)}
</div>
{/* Info */}
<div className="p-3 flex-1 flex flex-col gap-1">
<div className="flex items-center justify-between gap-1">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge}`}>
<span className={typeBadge}>
{asset.asset_type}
</span>
{asset.download_url && (
<button
onClick={handleDownload}
<a
href={asset.download_url}
download
className="p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title="Download"
onClick={e => e.stopPropagation()}
>
<Download size={14} />
</button>
</a>
)}
</div>
{asset.product_name && (
@@ -138,6 +277,21 @@ function AssetCard({ asset }: { asset: MediaAssetItem }) {
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
)}
{(asset.product_baureihe || asset.product_lagertyp) && (
<p className="text-xs text-content-muted truncate" title={[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}>
{[asset.product_baureihe, asset.product_lagertyp].filter(Boolean).join(' · ')}
</p>
)}
{(asset.product_ebene1 || asset.product_ebene2) && (
<p className="text-xs truncate" style={{ color: 'var(--color-content-subtle, #9ca3af)' }} title={[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}>
{[asset.product_ebene1, asset.product_ebene2].filter(Boolean).join(' ')}
</p>
)}
{(asset.product_name_cad_modell || asset.product_produkt_baureihe) && (
<p className="text-xs text-content-muted font-mono truncate" title={asset.product_name_cad_modell ?? asset.product_produkt_baureihe ?? ''}>
{asset.product_name_cad_modell ?? asset.product_produkt_baureihe}
</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1 text-xs text-content-muted">
<span>{formatDate(asset.created_at)}</span>
{sizeStr && <span>· {sizeStr}</span>}
@@ -167,21 +321,38 @@ export default function MediaBrowserPage() {
const [assetType, setAssetType] = useState('')
const [categoryKey, setCategoryKey] = useState('')
const [renderStatus, setRenderStatus] = useState('')
const [showTechnical, setShowTechnical] = useState(false)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(50)
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set())
const [zipping, setZipping] = useState(false)
// Lightbox
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
const q = useDebounce(searchInput, 300)
// Reset to page 1 when any filter changes
useEffect(() => { setPage(1) }, [q, assetType, categoryKey, renderStatus, pageSize])
// Reset to page 1 when any filter changes; clear selection on page/filter change
useEffect(() => { setPage(1); setSelected(new Set()) }, [q, assetType, categoryKey, renderStatus, showTechnical, pageSize])
useEffect(() => { setSelected(new Set()) }, [page])
// When switching off technical view, clear any technical type selection
useEffect(() => {
if (!showTechnical && ASSET_TYPES_TECHNICAL.some(t => t.value === assetType)) {
setAssetType('')
}
}, [showTechnical, assetType])
const { data, isLoading, isFetching } = useQuery({
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, page, pageSize }],
queryKey: ['media-browser', { q, assetType, categoryKey, renderStatus, showTechnical, page, pageSize }],
queryFn: () => getMediaAssets({
q: q || undefined,
asset_type: assetType || undefined,
category_key: categoryKey || undefined,
render_status: renderStatus || undefined,
exclude_technical: !showTechnical && !assetType ? true : undefined,
page,
page_size: pageSize,
}),
@@ -192,8 +363,41 @@ export default function MediaBrowserPage() {
const total = data?.total ?? 0
const pages = data?.pages ?? 1
const allSelected = items.length > 0 && items.every(i => selected.has(i.id))
function toggleSelect(id: string) {
setSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
function toggleSelectAll() {
if (allSelected) {
setSelected(new Set())
} else {
setSelected(new Set(items.map(i => i.id)))
}
}
async function handleZipDownload() {
if (selected.size === 0) return
setZipping(true)
try {
await zipDownloadAssets(Array.from(selected))
} finally {
setZipping(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Lightbox */}
{previewAsset && (
<Lightbox asset={previewAsset} onClose={() => setPreviewAsset(null)} />
)}
{/* Sticky filter bar */}
<div
className="sticky top-0 z-20 px-6 py-4 border-b border-border-default"
@@ -213,7 +417,7 @@ export default function MediaBrowserPage() {
<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 product name or PIM-ID..."
placeholder="Search name, PIM-ID, Baureihe, Ebene…"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-8 pr-3 py-1.5 text-sm border border-border-default rounded-md focus:outline-none focus:ring-1 focus:ring-accent w-64"
@@ -228,9 +432,26 @@ export default function MediaBrowserPage() {
className="text-sm border border-border-default rounded-md px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-accent"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
{ASSET_TYPES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{ASSET_TYPES_MEDIA.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
{showTechnical && (
<>
<option disabled></option>
{ASSET_TYPES_TECHNICAL.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</>
)}
</select>
{/* Technical files toggle */}
<label className="flex items-center gap-1.5 text-sm text-content-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={showTechnical}
onChange={e => setShowTechnical(e.target.checked)}
className="rounded"
/>
Technical files
</label>
{/* Category */}
<select
value={categoryKey}
@@ -252,12 +473,26 @@ export default function MediaBrowserPage() {
</select>
</div>
{/* Results count + loading indicator */}
<div className="flex items-center gap-2 mt-2 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{/* Results count + select-all */}
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-3 text-xs text-content-muted">
{isFetching && <Loader2 size={12} className="animate-spin" />}
<span>
{total === 0 ? 'No assets' : `${total.toLocaleString()} asset${total !== 1 ? 's' : ''}`}
</span>
{items.length > 0 && (
<button
onClick={toggleSelectAll}
className="flex items-center gap-1 hover:text-content transition-colors"
>
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
{allSelected ? 'Deselect all' : 'Select all on page'}
</button>
)}
</div>
{selected.size > 0 && (
<span className="text-xs text-accent font-medium">{selected.size} selected</span>
)}
</div>
</div>
@@ -279,14 +514,50 @@ export default function MediaBrowserPage() {
) : (
<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 => (
<AssetCard key={asset.id} asset={asset} />
<AssetCard
key={asset.id}
asset={asset}
selected={selected.has(asset.id)}
onToggleSelect={toggleSelect}
onPreview={setPreviewAsset}
/>
))}
</div>
)}
</div>
{/* Floating selection action bar */}
{selected.size > 0 && (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<span className="text-sm font-medium text-content">
{selected.size} file{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-border-default" />
<button
onClick={handleZipDownload}
disabled={zipping}
className="flex items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-hover transition-colors disabled:opacity-50"
>
{zipping
? <><Loader2 size={14} className="animate-spin" /> Preparing</>
: <><Archive size={14} /> Download ZIP</>
}
</button>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} />
Clear
</button>
</div>
)}
{/* Pagination footer */}
{(total > 0) && (
{total > 0 && (
<div
className="border-t border-border-default px-6 py-3 flex items-center justify-between gap-4"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
+182 -30
View File
@@ -10,7 +10,9 @@ import { listProducts } from '../api/products'
import { listOutputTypes } from '../api/outputTypes'
import { createOrder } from '../api/orders'
import { estimatePrice } from '../api/pricing'
import { listGlobalRenderPositions } from '../api/renderPositions'
import type { Product, RenderPosition } from '../api/products'
import type { GlobalRenderPosition } from '../api/renderPositions'
import type { OutputType } from '../api/outputTypes'
const formatCurrency = (amount: number) =>
@@ -32,6 +34,8 @@ type WizardStep = 1 | 2 | 3
type OutputSelections = Record<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
// Maps product_id → Set of global_render_position_id
type GlobalPositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
@@ -41,6 +45,7 @@ export default function NewProductOrderPage() {
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [globalPositionSelections, setGlobalPositionSelections] = useState<GlobalPositionSelections>({})
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -62,14 +67,26 @@ export default function NewProductOrderPage() {
enabled: step >= 2,
})
function initPositionsForProduct(product: Product) {
const { data: allGlobalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
function initPositionsForProduct(product: Product, globals: GlobalRenderPosition[] = []) {
// Pre-select all per-product positions (if any)
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.id)),
}))
}
// Always pre-select all global positions for every product
if (globals.length > 0) {
setGlobalPositionSelections((gs) => ({
...gs,
[product.id]: new Set(globals.map((g) => g.id)),
}))
}
}
function toggleProduct(product: Product) {
@@ -84,7 +101,7 @@ export default function NewProductOrderPage() {
return next
})
if (willSelect) {
initPositionsForProduct(product)
initPositionsForProduct(product, allGlobalPositions)
}
}
@@ -98,7 +115,7 @@ export default function NewProductOrderPage() {
;(products ?? []).forEach((p) => next.set(p.id, p))
return next
})
toInit.forEach(initPositionsForProduct)
toInit.forEach((p) => initPositionsForProduct(p, allGlobalPositions))
}
function deselectAllFiltered() {
@@ -180,7 +197,7 @@ export default function NewProductOrderPage() {
})
}
// Union of all unique position names across selected products that have positions
// Union of all unique per-product position names across selected products that have per-product positions
const globalPositionNames = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
@@ -195,6 +212,30 @@ export default function NewProductOrderPage() {
return result
}, [selectedProducts])
// Global positions apply to all selected products
const anyProductUsesGlobalPositions = selectedProducts.size > 0
function toggleGlobalPositionForAll(gpId: string) {
// Count how many selected products have this global position selected
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gpId)) selectedCount++
}
if (eligibleCount === 0) return
const shouldSelect = selectedCount < eligibleCount
setGlobalPositionSelections((prev) => {
const next = { ...prev }
for (const [productId] of selectedProducts) {
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(gpId)
else set.delete(gpId)
next[productId] = set
}
return next
})
}
function togglePositionGlobal(positionName: string) {
// Count how many products have this position name and how many have it selected
let compatibleCount = 0
@@ -221,47 +262,61 @@ export default function NewProductOrderPage() {
})
}
// Build flat list of order lines for review (Step 3)
// Each (product, outputType, position?) triple becomes one line.
// Build flat list of order lines for review (Step 3).
// Each (product, outputType, position) triple becomes one line.
// Global positions apply to ALL products; per-product positions are additional.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
globalPosition: GlobalRenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
for (const posId of selectedPosIds) {
const pos = product.render_positions!.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos })
}
}
const selectedPosIds = positionSelections[productId] || new Set()
const selectedGlobalIds = globalPositionSelections[productId] || new Set()
const hasAny = selectedPosIds.size > 0 || selectedGlobalIds.size > 0
if (!hasAny) {
// No position selected — one unpositioned line
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null, globalPosition: null })
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
// One line per selected global position
for (const gpId of selectedGlobalIds) {
const gp = allGlobalPositions.find((g) => g.id === gpId)
if (gp) lines.push({ key: `${productId}-${otId}-g${gpId}`, product, outputType: ot, position: null, globalPosition: gp })
}
// One line per selected per-product position
for (const posId of selectedPosIds) {
const pos = product.render_positions?.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos, globalPosition: null })
}
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
}, [selectedProducts, outputSelections, positionSelections, globalPositionSelections, allOutputTypes, allGlobalPositions])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
function removeLine(productId: string, outputTypeId: string, positionId: string | null, globalPositionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
return { ...prev, [productId]: set }
})
} else if (globalPositionId) {
setGlobalPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(globalPositionId)
return { ...prev, [productId]: set }
})
} else {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
@@ -322,6 +377,7 @@ export default function NewProductOrderPage() {
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
global_render_position_id: l.globalPosition?.id ?? null,
})),
})
toast.success(`Draft order ${result.order_number} created — review and submit`)
@@ -502,7 +558,7 @@ export default function NewProductOrderPage() {
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0) && (
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0 || (anyProductUsesGlobalPositions && allGlobalPositions.length > 0)) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
@@ -555,10 +611,10 @@ export default function NewProductOrderPage() {
</div>
)}
{/* Perspectives row */}
{/* Perspectives row — per-product positions (for products that have them) */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</p>
<p className="text-xs text-content-muted mb-1.5">Perspectives (custom)</p>
<div className="flex flex-wrap gap-2">
{globalPositionNames.map((posName) => {
let compatibleCount = 0
@@ -597,6 +653,47 @@ export default function NewProductOrderPage() {
</div>
</div>
)}
{/* Perspectives row — global positions (for products without custom positions) */}
{anyProductUsesGlobalPositions && allGlobalPositions.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives (global)</p>
<div className="flex flex-wrap gap-2">
{allGlobalPositions.map((gp) => {
const eligibleCount = selectedProducts.size
let selectedCount = 0
for (const [productId] of selectedProducts) {
if (globalPositionSelections[productId]?.has(gp.id)) selectedCount++
}
const allSel = selectedCount === eligibleCount && eligibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={gp.id}
onClick={() => toggleGlobalPositionForAll(gp.id)}
title={`${selectedCount} / ${eligibleCount} product${eligibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{gp.name}
{gp.is_default && !allSel && <span className="text-xs opacity-60"></span>}
{selectedProducts.size > 1 && eligibleCount > 0 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{eligibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
@@ -610,6 +707,13 @@ export default function NewProductOrderPage() {
onToggle={(otId) => toggleOutputType(product.id, otId)}
selectedPositions={positionSelections[product.id] || new Set()}
onTogglePosition={(posId) => togglePosition(product.id, posId)}
globalPositions={allGlobalPositions}
selectedGlobalPositions={globalPositionSelections[product.id] || new Set()}
onToggleGlobalPosition={(gpId) => setGlobalPositionSelections((prev) => {
const set = new Set(prev[product.id] || [])
if (set.has(gpId)) set.delete(gpId); else set.add(gpId)
return { ...prev, [product.id]: set }
})}
/>
))}
</div>
@@ -685,9 +789,9 @@ export default function NewProductOrderPage() {
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.name}
</span>
<span className="badge-purple">{line.position.name}</span>
) : line.globalPosition ? (
<span className="badge-purple opacity-70" title="Global position">{line.globalPosition.name}</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
@@ -706,7 +810,7 @@ export default function NewProductOrderPage() {
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null)}
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null, line.globalPosition?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
@@ -771,6 +875,9 @@ function ProductOutputRow({
onToggle,
selectedPositions,
onTogglePosition,
globalPositions,
selectedGlobalPositions,
onToggleGlobalPosition,
}: {
product: Product
compatibleTypes: OutputType[]
@@ -778,6 +885,9 @@ function ProductOutputRow({
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
globalPositions: GlobalRenderPosition[]
selectedGlobalPositions: Set<string>
onToggleGlobalPosition: (gpId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
@@ -852,11 +962,11 @@ function ProductOutputRow({
</div>
)}
{/* Render position toggles — only shown if product has positions */}
{/* Per-product custom positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Render Positions</p>
<p className="text-xs font-medium text-content-muted">Custom Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
@@ -895,6 +1005,48 @@ function ProductOutputRow({
</div>
</div>
)}
{/* Global position toggles — always shown for all products */}
{globalPositions.length > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Perspectives</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => globalPositions.forEach((g) => !selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => globalPositions.forEach((g) => selectedGlobalPositions.has(g.id) && onToggleGlobalPosition(g.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{globalPositions.map((gp) => {
const active = selectedGlobalPositions.has(gp.id)
return (
<button
key={gp.id}
onClick={() => onToggleGlobalPosition(gp.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{gp.name}
{gp.is_default && <span className="text-xs opacity-60 ml-0.5"></span>}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
+20
View File
@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom'
import { Home, FileQuestion } from 'lucide-react'
export default function NotFoundPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
<div className="w-20 h-20 rounded-full bg-surface-muted flex items-center justify-center mb-6">
<FileQuestion size={36} className="text-content-muted" />
</div>
<h1 className="text-3xl font-bold text-content mb-2">Page not found</h1>
<p className="text-content-secondary mb-8 max-w-sm">
The page you're looking for doesn't exist or has been moved.
</p>
<Link to="/" className="btn-primary">
<Home size={16} />
Go to Dashboard
</Link>
</div>
)
}
+48 -8
View File
@@ -6,9 +6,9 @@ import {
FileBox, AlertTriangle, CheckCircle2, Image as ImageIcon, Unlink,
RotateCcw, LayoutList, LayoutGrid, X,
ChevronDown, ChevronUp, ChevronsUpDown,
Search, SlidersHorizontal, FileSpreadsheet, Box,
Search, SlidersHorizontal, FileSpreadsheet, Box, Film,
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
XCircle, RotateCw,
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'
@@ -19,6 +19,7 @@ import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivilege
import StepDropzone from '../components/upload/StepDropzone'
import CadPartMaterials from '../components/orders/CadPartMaterials'
import LiveRenderLog from '../components/LiveRenderLog'
import RenderInfoModal from '../components/renders/RenderInfoModal'
// ── Filter / sort types ───────────────────────────────────────────────────────
@@ -825,6 +826,8 @@ function OrderLineRow({
onRemoved: () => void
}) {
const qc = useQueryClient()
const [showInfo, setShowInfo] = useState(false)
const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id),
onSuccess: onRemoved,
@@ -855,11 +858,28 @@ function OrderLineRow({
{/* 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"
/>
(() => {
const isVideo = /\.(mp4|webm|mov)$/i.test(line.thumbnail_url)
return isVideo ? (
<video
src={line.thumbnail_url}
preload="metadata"
muted
loop
playsInline
className="w-10 h-10 object-cover rounded border bg-surface-alt"
title="Hover to preview"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
/>
) : (
<img
src={line.thumbnail_url}
alt={line.product.name || ''}
className="w-10 h-10 object-contain rounded border bg-surface"
/>
)
})()
) : (line.render_status === 'processing' || line.render_status === 'pending') ? (
<div className="w-10 h-10 rounded border border-dashed border-border-default bg-surface-alt flex items-center justify-center animate-pulse">
<Loader2 size={16} className="text-accent animate-spin" />
@@ -894,7 +914,7 @@ function OrderLineRow({
<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">
<span className="badge-purple w-fit">
{line.render_position_name}
</span>
)}
@@ -957,6 +977,18 @@ function OrderLineRow({
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
{line.render_log && (
<button
onClick={(e) => {
e.stopPropagation()
setShowInfo(true)
}}
className="text-content-muted hover:text-accent transition-colors"
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
<LiveRenderLog
orderLineId={line.id}
@@ -987,6 +1019,14 @@ function OrderLineRow({
</button>
</td>
)}
<RenderInfoModal
open={showInfo}
onClose={() => setShowInfo(false)}
title={line.output_type?.name ?? 'Render Info'}
renderLog={line.render_log}
renderStartedAt={line.render_started_at}
renderCompletedAt={line.render_completed_at}
/>
</tr>
)
}
+83 -7
View File
@@ -42,7 +42,7 @@ export default function OrdersPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [view, setView] = useState<'kanban' | 'list'>('kanban')
const [view, setView] = useState<'kanban' | 'list'>(() => window.innerWidth < 768 ? 'list' : 'kanban')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selectedStatuses, setSelectedStatuses] = useState<Set<Status>>(new Set())
@@ -52,6 +52,13 @@ export default function OrdersPage() {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Auto-switch to list view on narrow screens
useEffect(() => {
const handler = () => { if (window.innerWidth < 768) setView('list') }
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
// Debounce the search input (400 ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@@ -163,10 +170,15 @@ export default function OrdersPage() {
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
const ordersMap = Object.fromEntries(orders.map((o) => [o.id, o]))
const submittedCount = ids.filter((id) => ordersMap[id]?.status === 'submitted').length
const message = submittedCount > 0
? `⚠️ ${submittedCount} of ${ids.length} selected order${ids.length > 1 ? 's' : ''} ${submittedCount === 1 ? 'has' : 'have'} been submitted and may be processing. Delete anyway?`
: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`
setConfirmState({
open: true,
title: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}`,
message: 'This cannot be undone.',
message,
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
@@ -323,10 +335,12 @@ export default function OrdersPage() {
</div>
{/* ── Content ──────────────────────────────────────────────────────── */}
{isLoading ? (
{isLoading && !isSearchMode && orders.length === 0 ? (
view === 'kanban' ? <KanbanSkeleton /> : <ListSkeleton />
) : isLoading && isSearchMode ? (
<div className="flex-1 flex items-center justify-center text-content-muted">
<Loader2 size={24} className="animate-spin mr-2" />
{isSearchMode ? 'Searching…' : 'Loading orders…'}
Searching
</div>
) : isSearchMode ? (
<SearchResultsView
@@ -370,9 +384,10 @@ export default function OrdersPage() {
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
flex items-center gap-3 px-5 py-3
bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10">
<div
className="fixed bottom-6 z-50 flex items-center gap-3 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} order{selected.size > 1 ? 's' : ''} selected
</span>
@@ -399,6 +414,67 @@ export default function OrdersPage() {
)
}
// ── Skeleton loaders ──────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-6 my-4 card overflow-hidden animate-pulse">
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5">
<div className="h-3 w-3 bg-surface-muted rounded" />
<div className="h-3 w-24 bg-surface-muted rounded" />
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-3 w-14 bg-surface-muted rounded" />
<div className="h-3 w-16 bg-surface-muted rounded" />
</div>
<div className="divide-y divide-border-light">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3 gap-x-4">
<div className="h-3.5 w-3.5 bg-surface-muted rounded" />
<div className="space-y-1.5">
<div className="h-3.5 w-32 bg-surface-muted rounded" />
<div className="h-2.5 w-48 bg-surface-muted rounded opacity-60" />
</div>
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-5 w-16 bg-surface-muted rounded-full" />
<div className="h-3 w-14 bg-surface-muted rounded" />
</div>
))}
</div>
</div>
</div>
)
}
function KanbanSkeleton() {
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full">
{['bg-gray-400', 'bg-blue-400', 'bg-amber-400'].map((color, ci) => (
<div key={ci} className="flex flex-col w-72 min-w-[272px] animate-pulse">
<div className={`${color} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<div className="h-4 w-4 bg-white/40 rounded" />
<div className="h-3.5 w-20 bg-white/40 rounded" />
<div className="ml-auto h-5 w-6 bg-white/30 rounded-full" />
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 space-y-2 min-h-[120px]">
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="bg-surface rounded-lg p-3 border border-border-default border-l-4 border-l-border-default">
<div className="h-3.5 w-24 bg-surface-muted rounded mb-2" />
<div className="flex gap-3">
<div className="h-3 w-16 bg-surface-muted rounded" />
<div className="h-3 w-20 bg-surface-muted rounded" />
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
// ── Search results view ───────────────────────────────────────────────────────
function SearchResultsView({
+95 -35
View File
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useDropzone } from 'react-dropzone'
import {
ArrowLeft, Pencil, Save, X, Box, Image,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2,
RotateCcw, RefreshCw, Upload, ChevronDown, ChevronRight, Wand2, Download, Plus, Trash2, Filter, Cuboid, Ruler, Loader2, Info, Play,
} from 'lucide-react'
import { toast } from 'sonner'
import {
@@ -15,12 +15,15 @@ import {
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
import { listGlobalRenderPositions } from '../api/renderPositions'
import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, generateGltfProduction, resetStuckProcessing } from '../api/cad'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
@@ -57,6 +60,16 @@ function GlbDownloadButton({
}
}
// Always show generating state first — hides stale download button while task runs
if (isGenerating) {
return (
<div className="flex items-center gap-1.5 text-xs text-content-secondary px-2 py-1.5 rounded border border-border-light" style={{ backgroundColor: 'var(--color-bg-surface-alt)' }}>
<Loader2 size={12} className="animate-spin shrink-0 text-accent" />
<span>Generating {label}</span>
</div>
)
}
if (url) {
return (
<div className="flex gap-1 w-full">
@@ -73,12 +86,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs px-2 shrink-0"
onClick={onGenerate}
disabled={isGenerating}
title={`Re-generate ${label}`}
>
{isGenerating
? <Loader2 size={12} className="animate-spin" />
: <RotateCcw size={12} />}
<RotateCcw size={12} />
</button>
</div>
)
@@ -88,12 +98,9 @@ function GlbDownloadButton({
<button
className="btn-secondary text-xs w-full justify-start"
onClick={onGenerate}
disabled={isGenerating}
title={title}
>
{isGenerating
? <><Loader2 size={12} className="animate-spin" />Queuing</>
: <><Download size={12} />Generate {label}</>}
<Download size={12} />Generate {label}
</button>
)
}
@@ -139,6 +146,7 @@ export default function ProductDetailPage() {
const [materialsDirty, setMaterialsDirty] = useState(false)
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const [showCadInfo, setShowCadInfo] = useState(false)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
@@ -178,13 +186,23 @@ export default function ProductDetailPage() {
staleTime: 0,
})
const [productionGlbGenerating, setProductionGlbGenerating] = useState(false)
const { data: productionGlbAssets = [] } = useQuery({
queryKey: ['media-assets', cadFileId, 'gltf_production'],
queryFn: () => getMediaAssets({ cad_file_id: cadFileId!, asset_types: ['gltf_production'] }),
enabled: !!cadFileId,
staleTime: 0,
refetchInterval: productionGlbGenerating ? 3000 : false,
})
// Stop polling once the freshly-generated asset has arrived
useEffect(() => {
if (productionGlbGenerating && productionGlbAssets.length > 0) {
setProductionGlbGenerating(false)
}
}, [productionGlbAssets, productionGlbGenerating])
const geometryGlbUrl = geometryGlbAssets[0]?.download_url ?? null
const productionGlbUrl = productionGlbAssets[0]?.download_url ?? null
@@ -343,7 +361,9 @@ export default function ProductDetailPage() {
mutationFn: () => generateGltfProduction(product!.cad_file_id!),
onSuccess: () => {
toast.info('Production GLB export queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
setProductionGlbGenerating(true)
// Remove stale asset immediately so the button doesn't show an outdated download
qc.removeQueries({ queryKey: ['media-assets', cadFileId, 'gltf_production'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue production GLB export'),
})
@@ -415,11 +435,10 @@ export default function ProductDetailPage() {
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
const POSITION_PRESETS = [
{ label: 'Beauty', rx: 0, ry: 0, rz: 0 },
{ label: '3/4 Front', rx: -15, ry: 45, rz: 0 },
{ label: '3/4 Back', rx: -15, ry: -135, rz: 0 },
]
const { data: globalPositions = [] } = useQuery({
queryKey: ['global-render-positions'],
queryFn: listGlobalRenderPositions,
})
const onDrop = useCallback(
(files: File[]) => { if (files[0]) cadUploadMut.mutate(files[0]) },
@@ -647,11 +666,22 @@ export default function ProductDetailPage() {
{/* Two-column: viewer left, actions right */}
<div className="flex gap-4 items-start">
{/* Left: Inline 3D Viewer */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 relative group">
<InlineCadViewer
cadFileId={product.cad_file_id}
thumbnailUrl={product.render_image_url || product.thumbnail_url}
initialPartMaterials={convertCadPartMaterials(product.cad_part_materials ?? [])}
/>
{product.cad_render_log && (
<button
onClick={() => setShowCadInfo(true)}
className="absolute bottom-1 right-1 p-1 rounded text-white transition-colors"
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
title="Render info"
>
<Info size={12} />
</button>
)}
</div>
{/* Right: Action buttons */}
@@ -720,7 +750,7 @@ export default function ProductDetailPage() {
url={productionGlbUrl}
filename={`${product.name ?? product.pim_id}_production.glb`}
onGenerate={() => generateProductionGlbMut.mutate()}
isGenerating={generateProductionGlbMut.isPending}
isGenerating={generateProductionGlbMut.isPending || productionGlbGenerating}
title="Export production GLB with PBR materials via Blender"
/>
</div>
@@ -770,7 +800,7 @@ export default function ProductDetailPage() {
<div className="space-y-1.5">
{materialRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-content-secondary w-40 truncate shrink-0" title={row.part_name}>
<span className="text-xs text-content-secondary w-64 truncate shrink-0" title={row.part_name}>
{row.part_name}
</span>
<div className="flex-1">
@@ -994,13 +1024,24 @@ export default function ProductDetailPage() {
>
{/* ── Media area ───────────────────────────────── */}
{r.is_video ? (
<div className="relative">
<div className="relative group/video">
<video
src={r.render_url}
controls
preload="metadata"
muted
loop
playsInline
className="w-full aspect-video object-contain bg-black"
onMouseEnter={(e) => { e.currentTarget.play().catch(() => {}) }}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0 }}
onClick={(e) => selectMode && e.preventDefault()}
/>
{/* Play hint — visible until first hover */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none group-hover/video:opacity-0 transition-opacity">
<div className="w-10 h-10 rounded-full bg-black/40 flex items-center justify-center">
<Play size={18} className="text-white ml-0.5" />
</div>
</div>
{/* Select mode checkbox overlay for videos */}
{selectMode && (
<div className="absolute top-2 left-2 pointer-events-none">
@@ -1093,6 +1134,11 @@ export default function ProductDetailPage() {
{r.output_type_name}
</span>
)}
{r.render_position_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">
{r.render_position_name}
</span>
)}
{r.render_backend && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
r.render_backend === 'flamenco' ? 'bg-status-warning-bg text-status-warning-text' : 'bg-status-info-bg text-status-info-text'
@@ -1179,29 +1225,36 @@ export default function ProductDetailPage() {
</button>
{showPositions && (
<div className="px-4 pb-4 space-y-3">
{/* Preset buttons */}
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-content-muted">Presets:</span>
{POSITION_PRESETS.map((preset) => (
<button
key={preset.label}
className="text-xs px-2 py-1 rounded border border-border-default text-content-secondary hover:border-accent hover:text-accent transition-colors"
onClick={() => setPositionForm({ name: preset.label, rotation_x: preset.rx, rotation_y: preset.ry, rotation_z: preset.rz })}
>
{preset.label}
</button>
))}
{/* Global positions (read-only reference) */}
{globalPositions.length > 0 && (
<div className="pt-1">
<p className="text-xs text-content-muted mb-1">Global positions (shared across all products):</p>
<div className="flex flex-wrap gap-1.5">
{globalPositions.map((gp) => (
<span
key={gp.id}
className="text-xs px-2 py-0.5 rounded-full border border-border-default text-content-muted"
title={`X: ${gp.rotation_x}° · Y: ${gp.rotation_y}° · Z: ${gp.rotation_z}°`}
>
{gp.name}
{gp.is_default && <span className="ml-1 text-accent"></span>}
</span>
))}
</div>
</div>
)}
<div className="flex justify-end pt-1">
<button
className="ml-auto btn-secondary text-xs"
className="btn-secondary text-xs"
onClick={() => setPositionForm({ name: '', rotation_x: 0, rotation_y: 0, rotation_z: 0 })}
>
<Plus size={12} /> Add Position
<Plus size={12} /> Add custom position
</button>
</div>
{/* Existing positions list */}
{(product.render_positions?.length ?? 0) === 0 && !positionForm && (
<p className="text-xs text-content-muted py-2">No positions defined. Click "Add Position" or a preset above.</p>
<p className="text-xs text-content-muted py-2">No custom positions defined. Global positions apply automatically.</p>
)}
{(product.render_positions ?? []).map((pos) => (
<div key={pos.id} className="border border-border-default rounded-lg p-3">
@@ -1398,6 +1451,13 @@ export default function ProductDetailPage() {
setWizardTargetIdx(null)
}}
/>
<RenderInfoModal
open={showCadInfo}
onClose={() => setShowCadInfo(false)}
title="CAD Thumbnail"
renderLog={product.cad_render_log}
/>
</div>
)
}
+71 -13
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
LayoutGrid, List, Trash2, X,
LayoutGrid, List, Trash2, X, ArrowUpDown,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products'
@@ -77,6 +77,7 @@ function ProductCard({ product, onClick, selected, onSelect }: {
src={product.render_image_url || product.thumbnail_url!}
alt={product.name || product.pim_id}
className="h-full w-full object-contain"
loading="lazy"
/>
) : (
<Box size={48} className="text-content-muted" />
@@ -114,18 +115,26 @@ function ProductCard({ product, onClick, selected, onSelect }: {
export default function ProductLibraryPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [hasCadFilter, setHasCadFilter] = useState<string>('')
const [materialsFilter, setMaterialsFilter] = useState('')
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
const { data: products, isLoading } = useQuery({
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
// Debounce search input (300ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 300)
return () => clearTimeout(t)
}, [searchInput])
const { data: rawProducts, isLoading } = useQuery({
queryKey: ['products', { debouncedSearch, categoryFilter, hasCadFilter, materialsFilter }],
queryFn: () => listProducts({
q: search || undefined,
q: debouncedSearch || undefined,
category_key: categoryFilter || undefined,
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
materials_filter: materialsFilter || undefined,
@@ -133,6 +142,16 @@ export default function ProductLibraryPage() {
}),
})
// Client-side sort
const products = useMemo(() => {
if (!rawProducts) return rawProducts
return [...rawProducts].sort((a, b) => {
if (sortBy === 'name') return (a.name || '').localeCompare(b.name || '')
if (sortBy === 'status') return (a.processing_status || '').localeCompare(b.processing_status || '')
return (a.pim_id || '').localeCompare(b.pim_id || '')
})
}, [rawProducts, sortBy])
// ── Selection helpers ──────────────────────────────────────────────────
const toggleOne = (id: string) => {
setSelected((prev) => {
@@ -186,7 +205,7 @@ export default function ProductLibraryPage() {
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : 'Loading\u2026'}
{products ? `${products.length} products` : isLoading ? 'Loading…' : '0 products'}
</p>
</div>
</div>
@@ -226,11 +245,19 @@ export default function ProductLibraryPage() {
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by PIM-ID or name\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Search by PIM-ID or name"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full pl-9 pr-8 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
>
<X size={13} />
</button>
)}
</div>
<select
@@ -266,11 +293,38 @@ export default function ProductLibraryPage() {
<option value="complete"> All materials assigned</option>
<option value="incomplete"> Incomplete materials</option>
</select>
<div className="flex items-center gap-1.5 ml-auto text-sm text-content-secondary shrink-0">
<ArrowUpDown size={13} className="text-content-muted" />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="border-0 bg-transparent text-sm text-content-secondary focus:outline-none cursor-pointer pr-1"
>
<option value="pim_id">Sort: PIM ID</option>
<option value="name">Sort: Name</option>
<option value="status">Sort: Status</option>
</select>
</div>
</div>
{/* Content */}
{isLoading ? (
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 animate-pulse">
{Array.from({ length: 12 }, (_, i) => (
<div key={i} className="card overflow-hidden">
<div className="h-40 bg-surface-muted" />
<div className="p-3 space-y-2">
<div className="h-4 w-16 bg-surface-muted rounded" />
<div className="h-4 w-32 bg-surface-muted rounded" />
<div className="flex gap-2">
<div className="h-4 w-14 bg-surface-muted rounded-full" />
<div className="h-4 w-20 bg-surface-muted rounded" />
</div>
</div>
</div>
))}
</div>
) : !products?.length ? (
<div className="text-center py-16 text-content-muted">
<Library size={48} className="mx-auto mb-3 opacity-30" />
@@ -337,6 +391,7 @@ export default function ProductLibraryPage() {
src={product.render_image_url || product.thumbnail_url!}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<Box size={18} className="text-content-muted" />
@@ -379,7 +434,10 @@ export default function ProductLibraryPage() {
{/* ── Floating action bar ───────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
<div
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<span className="text-sm font-medium">
{selected.size} selected
</span>
+63 -56
View File
@@ -13,6 +13,8 @@ import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
import StepDropzone from '../components/upload/StepDropzone'
import StepIndicator from '../components/shared/StepIndicator'
import HelpTooltip from '../components/HelpTooltip'
function StatCard({ icon, value, label, description, color }: {
icon: React.ReactNode
@@ -238,6 +240,8 @@ export default function UploadPage() {
</p>
</div>
<StepIndicator step={step} total={4} labels={['Upload', 'Review', 'Configure', 'STEP Files']} />
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
{step === 1 && (
<div
@@ -379,9 +383,12 @@ export default function UploadPage() {
</th>
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Series</th>
<th className="px-4 py-2 font-medium text-content-secondary"
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
>Gew. Produkt</th>
<th className="px-4 py-2 font-medium text-content-secondary">
<span className="flex items-center gap-1">
Gew. Produkt
<HelpTooltip help={{ title: 'Gew. Produkt', body: 'Gewähltes Produkt the specific product variant selected in the Excel file.' }} />
</span>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
@@ -477,59 +484,6 @@ export default function UploadPage() {
</div>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
@@ -684,6 +638,59 @@ export default function UploadPage() {
</div>
</div>
)}
{/* ── Validation Dialog ────────────────────────────────────────────── */}
{showValidationDialog && (
<ValidationDialog
validation={validationData}
onClose={() => setShowValidationDialog(false)}
onSaveAlias={(alias, suggestion) => saveAlias.mutate({ alias, materialName: suggestion })}
/>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
</div>
)
}
+42 -9
View File
@@ -4,7 +4,7 @@ import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
Trash2, Ban, ListOrdered, FileCode2,
Trash2, Ban, ListOrdered, FileCode2, ChevronUp,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
@@ -12,6 +12,7 @@ import {
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
import ConfirmModal from '../components/ConfirmModal'
type UnifiedEvent =
| { kind: 'cad'; ts: number; entry: CadActivityEntry }
@@ -20,6 +21,7 @@ type UnifiedEvent =
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [logExpanded, setLogExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
@@ -94,9 +96,18 @@ export default function WorkerActivityPage() {
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
{isLoading && events.length === 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{[0,1,2,3].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="w-5 h-5 rounded-full bg-surface-muted animate-pulse shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 bg-surface-muted animate-pulse rounded w-2/3" />
<div className="h-2.5 bg-surface-muted animate-pulse rounded w-1/3" />
</div>
<div className="w-16 h-3 bg-surface-muted animate-pulse rounded" />
</div>
))}
</div>
)}
@@ -127,6 +138,7 @@ export default function WorkerActivityPage() {
)}
</div>
)}
</div>
)
}
@@ -232,6 +244,7 @@ function firstArg(task: QueueTask): string {
function QueuePanel() {
const qc = useQueryClient()
const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false)
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
@@ -285,9 +298,7 @@ function QueuePanel() {
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
setPurgeConfirmOpen(true)
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
@@ -395,6 +406,14 @@ function QueuePanel() {
</div>
)}
</div>
<ConfirmModal
open={purgeConfirmOpen}
title="Purge Queue"
message={`Purge all ${totalPending} pending task${totalPending !== 1 ? 's' : ''} from the queue? This cannot be undone.`}
onConfirm={() => { purgeMut.mutate(); setPurgeConfirmOpen(false) }}
onCancel={() => setPurgeConfirmOpen(false)}
/>
</div>
)
}
@@ -577,8 +596,12 @@ function KV({ label, value, mono, highlight }: {
}
function BlenderLog({ lines }: { lines: string[] }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<div>
<div
className={`bg-gray-900 rounded-md overflow-auto transition-all ${expanded ? 'max-h-[600px]' : 'max-h-64'}`}
>
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
@@ -592,6 +615,16 @@ function BlenderLog({ lines }: { lines: string[] }) {
)
})}
</pre>
</div>
{lines.length > 20 && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-1 flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary transition-colors"
>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
{expanded ? 'Collapse log' : `Expand log (${lines.length} lines)`}
</button>
)}
</div>
)
}
@@ -610,7 +643,7 @@ function RendererBadge({ log }: { log: RenderLog }) {
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
<span className="badge-purple text-xs px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)