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:
+399
-146
@@ -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 & 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 — Go to Order
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
Done — 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 — Go to Order
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => navigate(`/orders/${createdOrder.id}`)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
Done — Go to Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user