import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useState } 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 } from 'lucide-react' import { Link } from 'react-router-dom' import api from '../api/client' import ConfirmModal from '../components/ConfirmModal' import HelpTooltip from '../components/HelpTooltip' 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 { useAuthStore } from '../store/auth' import { listPricingTiers } from '../api/pricing' import { listOutputTypes } from '../api/outputTypes' import { listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog, type AssetLibrary, } from '../api/assetLibraries' import { getTenantDefaultDashboard } from '../api/dashboard' import type { WidgetConfig } from '../api/dashboard' import DashboardCustomizeModal from '../components/dashboard/DashboardCustomizeModal' export default function AdminPage() { const qc = useQueryClient() const user = useAuthStore((s) => s.user) const isAdmin = user?.role === 'admin' const [showNewUser, setShowNewUser] = useState(false) const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' }) const [editingTemplateId, setEditingTemplateId] = useState(null) const [priorityNewEntry, setPriorityNewEntry] = useState('') const { data: users } = useQuery({ queryKey: ['admin-users'], queryFn: async () => { const res = await api.get('/admin/users') return res.data as any[] }, }) const { data: templates } = useQuery({ queryKey: ['admin-templates'], queryFn: async () => { const res = await api.get('/templates?include_inactive=true') return res.data as any[] }, }) const createUserMut = useMutation({ mutationFn: (data: typeof newUser) => api.post('/admin/users', data), onSuccess: () => { toast.success('User created') qc.invalidateQueries({ queryKey: ['admin-users'] }) setShowNewUser(false) setNewUser({ email: '', password: '', full_name: '', role: 'client' }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const deleteUserMut = useMutation({ mutationFn: (id: string) => api.delete(`/admin/users/${id}`), onSuccess: () => { toast.success('User deleted') qc.invalidateQueries({ queryKey: ['admin-users'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) type Settings = { thumbnail_renderer: string blender_engine: string blender_cycles_samples: number blender_eevee_samples: number threejs_render_size: number thumbnail_format: string blender_smooth_angle: number cycles_device: string blender_max_concurrent_renders: number render_stall_timeout_minutes: number product_thumbnail_priority: string // JSON array render_backend: string smtp_enabled: boolean smtp_host: string smtp_port: number smtp_user: string smtp_password: string smtp_from_address: string gltf_scale_factor: number gltf_smooth_normals: boolean viewer_max_distance: number viewer_min_distance: number gltf_material_quality: string gltf_pbr_roughness: number gltf_pbr_metallic: number gltf_preview_linear_deflection: number gltf_preview_angular_deflection: number gltf_production_linear_deflection: number gltf_production_angular_deflection: number } const { data: settings } = useQuery({ queryKey: ['admin-settings'], queryFn: async () => { const res = await api.get('/admin/settings') return res.data as Settings }, }) const { data: outputTypes } = useQuery({ queryKey: ['output-types-all'], queryFn: () => listOutputTypes(false), enabled: isAdmin, }) // Local draft for Blender options so the user can change multiple fields before saving const [blenderDraft, setBlenderDraft] = useState>({}) const blender = { ...settings, ...blenderDraft } as Settings const [viewerDraft, setViewerDraft] = useState>({}) const viewer3d = { ...settings, ...viewerDraft } as Settings const [tessellationDraft, setTessellationDraft] = useState>({}) const tess = { ...settings, ...tessellationDraft } as Settings const { data: rendererStatus, refetch: refetchStatus } = useQuery({ queryKey: ['renderer-status'], queryFn: async () => { const res = await api.get('/admin/settings/renderer-status') return res.data as Record }, refetchInterval: 30000, }) const updateSettingsMut = useMutation({ mutationFn: (data: Partial) => api.put('/admin/settings', data), onSuccess: () => { toast.success('Settings saved') qc.invalidateQueries({ queryKey: ['admin-settings'] }) setBlenderDraft({}) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const processUnprocessedMut = useMutation({ mutationFn: () => api.post('/admin/settings/process-unprocessed'), onSuccess: (res) => { toast.success(res.data.message || 'Processing queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const regenerateMut = useMutation({ mutationFn: () => api.post('/admin/settings/regenerate-thumbnails'), onSuccess: (res) => { toast.success(res.data.message || 'Thumbnails re-queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const importMediaAssetsMut = useMutation({ mutationFn: () => api.post('/admin/import-media-assets'), onSuccess: (res) => { toast.success(`Imported: ${res.data.created} created, ${res.data.skipped} skipped`) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'), }) const reextractMetadataMut = useMutation({ mutationFn: () => api.post('/admin/settings/reextract-metadata'), onSuccess: (res) => { toast.success(res.data.message || 'Metadata re-extraction queued') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const recoverStuckMut = useMutation({ mutationFn: () => api.post('/admin/settings/recover-stuck-processing'), onSuccess: (res) => { toast.success(res.data.message || 'Stuck files recovered') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const seedWorkflowsMut = useMutation({ mutationFn: () => api.post('/admin/settings/seed-workflows'), onSuccess: (res) => { toast.success(res.data.message || 'Workflows seeded') qc.invalidateQueries({ queryKey: ['workflows'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const [smtpDraft, setSmtpDraft] = useState>({}) const smtp = { ...settings, ...smtpDraft } as Settings const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} }) const [showTenantDashboardModal, setShowTenantDashboardModal] = useState(false) const { data: tenantDefaultWidgets } = useQuery({ queryKey: ['tenant-default-dashboard'], queryFn: getTenantDefaultDashboard, enabled: isAdmin, staleTime: 300_000, }) return (

Admin

{/* ------------------------------------------------------------------ */} {/* Pricing Summary */} {/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */} {/* Users (admin only) */} {/* ------------------------------------------------------------------ */} {isAdmin &&

Users

{showNewUser && (
setNewUser({ ...newUser, full_name: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" /> setNewUser({ ...newUser, email: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" /> setNewUser({ ...newUser, password: e.target.value })} className="px-3 py-2 border border-border-default rounded-md text-sm" />
)}
{users?.map((user) => (

{user.full_name}

{user.email}

{user.role} {user.is_active ? 'active' : 'inactive'}
))}
} {/* ------------------------------------------------------------------ */} {/* Blender Render Settings (admin only) */} {/* ------------------------------------------------------------------ */} {isAdmin &&

Blender Render Settings

Render quality, performance and thumbnail output options for Blender 5.

{/* ── Render Quality ───────────────────────────────────────────── */}

Render Quality

{/* Engine */}
Render engine {(['cycles', 'eevee'] as const).map((eng) => ( ))}
{/* Cycles device — only relevant for Cycles */} {blender.blender_engine === 'cycles' && (
Cycles device {(['auto', 'gpu', 'cpu'] as const).map((dev) => ( ))}

{blender.cycles_device === 'auto' ? 'Tries OptiX / CUDA / HIP, falls back to CPU if no GPU is available.' : blender.cycles_device === 'gpu' ? 'Always use GPU. Logs a warning if no compatible GPU is found.' : 'Always use CPU — useful for debugging or when GPU is busy.'}

)} {/* Sample counts */}
setBlenderDraft((d) => ({ ...d, blender_cycles_samples: Number(e.target.value) }))} title="Number of Cycles path-tracing samples (1–4096). Higher values = better quality + longer render time. Default: 256" className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Higher = better quality, slower

setBlenderDraft((d) => ({ ...d, blender_eevee_samples: Number(e.target.value) }))} title="EEVEE anti-aliasing sample count (1–1024). Higher values = smoother edges + longer render time. Default: 64" className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Higher = better AA, slower

{/* Smooth by angle */}
Smooth angle setBlenderDraft((d) => ({ ...d, blender_smooth_angle: Number(e.target.value) }))} title="Auto-smooth angle in degrees (0–180°). Faces with dihedral angles below this threshold are shaded smooth; sharper edges stay hard. 30° works well for most mechanical parts." className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" /> °

{(blender.blender_smooth_angle ?? 30) === 0 ? '0° = flat shading on all faces.' : `Faces with edges sharper than ${blender.blender_smooth_angle ?? 30}° stay hard; others smooth. 30° works well for most mechanical parts.`}

{/* ── Performance ──────────────────────────────────────────────── */}

Performance

Max concurrent setBlenderDraft((d) => ({ ...d, blender_max_concurrent_renders: Number(e.target.value) }))} title="Maximum parallel Blender render jobs (1–16). Each job uses ~400 MB RAM. Applied live without restart. Default: 3" className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Max parallel Blender render jobs (1–16). Higher values use more RAM (~400 MB each). Applied live without restart.

Stall timeout setBlenderDraft((d) => ({ ...d, render_stall_timeout_minutes: Number(e.target.value) }))} title="Minutes before a stuck render job is automatically restarted (10–10080). The watchdog checks every 5 minutes. Default: 120" className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-blue-400" />

Minutes before a stuck render job is auto-restarted (10–10080). Checked every 5 min by the watchdog.

{/* Save button — appears when draft has unsaved changes */} {Object.keys(blenderDraft).length > 0 && ( )} {/* ── Output ───────────────────────────────────────────────────── */}

Output

{(['jpg', 'png'] as const).map((fmt) => ( ))}

{settings?.thumbnail_format === 'jpg' ? 'JPEG — ~3–5× smaller files, minimal quality loss at 92% quality.' : 'PNG — lossless, larger files.'}

{/* Product thumbnail priority chain */} {(() => { let priorityList: string[] = ['latest_render', 'cad_thumbnail'] try { const parsed = JSON.parse(settings?.product_thumbnail_priority ?? '["latest_render","cad_thumbnail"]') if (Array.isArray(parsed)) priorityList = parsed } catch {} const savePriority = (list: string[]) => { updateSettingsMut.mutate({ product_thumbnail_priority: JSON.stringify(list) } as any) } const moveUp = (i: number) => { if (i === 0) return const next = [...priorityList] ;[next[i - 1], next[i]] = [next[i], next[i - 1]] savePriority(next) } const moveDown = (i: number) => { if (i === priorityList.length - 1) return const next = [...priorityList] ;[next[i], next[i + 1]] = [next[i + 1], next[i]] savePriority(next) } const remove = (i: number) => savePriority(priorityList.filter((_, j) => j !== i)) const addEntry = () => { if (!priorityNewEntry || priorityList.includes(priorityNewEntry)) return savePriority([...priorityList, priorityNewEntry]) setPriorityNewEntry('') } const entryLabel = (e: string) => e === 'cad_thumbnail' ? 'CAD Thumbnail' : e === 'latest_render' ? 'Latest Render (any type)' : outputTypes?.find((ot) => ot.id === e)?.name ?? `Output type …${e.slice(-8)}` const entryColor = (e: string) => e === 'cad_thumbnail' ? 'bg-surface-alt border-border-default text-content-muted' : e === 'latest_render' ? 'bg-status-info-bg border-border-default text-status-info-text' : 'bg-status-success-bg border-border-default text-status-success-text' // Options not yet in the list const addableOptions = [ ...(['latest_render', 'cad_thumbnail'] as string[]).filter((v) => !priorityList.includes(v)), ...(outputTypes ?? []).filter((ot) => !priorityList.includes(ot.id)).map((ot) => ot.id), ] return (
{priorityList.map((entry, i) => (
{i + 1}
{entryLabel(entry)} {entry !== 'cad_thumbnail' && entry !== 'latest_render' && ( newest completed render )}
))} {addableOptions.length > 0 && (
)}

Sources are tried top to bottom. For specific output types, the newest completed render of that type is used. "CAD Thumbnail" always matches and stops the search.

) })()}
{/* end Output */} {/* ── Service Status ───────────────────────────────────────────── */}

Service Status

{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
{info.available ? : }

{name}

{info.note || (info.available ? 'Online' : 'Offline')}

))} {!rendererStatus && (
Checking service status…
)}
{/* ── Maintenance ──────────────────────────────────────────────── */}

Maintenance

Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.

Queues all pending/failed STEP files for initial processing.

Re-renders thumbnails for all completed CAD files.

Registers existing renders & CAD thumbnails in the Media Browser.

Updates dimensions and edge data for existing files (no re-render).

Creates the 4 standard workflow definitions if they don't exist yet.

} {/* ------------------------------------------------------------------ */} {/* Render Templates (admin/PM) */} {/* ------------------------------------------------------------------ */}

Render Templates

Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.

{/* ------------------------------------------------------------------ */} {/* Asset Libraries */} {/* ------------------------------------------------------------------ */} {/* ------------------------------------------------------------------ */} {/* Output Types */} {/* ------------------------------------------------------------------ */}

Output Types

Define what kinds of outputs orders can request (thumbnails, views, formats).

{/* ------------------------------------------------------------------ */} {/* Pricing Tiers */} {/* ------------------------------------------------------------------ */}

Pricing Tiers

Configure price per rendering item by category and quality level.

{/* ------------------------------------------------------------------ */} {/* E-Mail / SMTP Settings */} {/* ------------------------------------------------------------------ */} {isAdmin && (

E-Mail Notifications (SMTP)

Configure outbound SMTP for email notifications. Enable only when credentials are set.

{smtp.smtp_enabled && ( Active )}
setSmtpDraft(d => ({ ...d, smtp_host: e.target.value }))} placeholder="smtp.example.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_port: parseInt(e.target.value) || 587 }))} className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_user: e.target.value }))} placeholder="user@example.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_password: e.target.value }))} placeholder="••••••••" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
setSmtpDraft(d => ({ ...d, smtp_from_address: e.target.value }))} placeholder="noreply@schaeffler.com" className="w-full px-3 py-1.5 rounded-md border border-border-default text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" />
)} {/* ------------------------------------------------------------------ */} {/* Templates */} {/* ------------------------------------------------------------------ */}

Templates

Click Edit to configure standard fields and component schema for each template.

{templates?.map((t) => { const isEditing = editingTemplateId === t.id return (
{/* Row */}

{t.name}

{t.category_key}

{t.is_active ? 'active' : 'inactive'}
{/* Inline editor panel */} {isEditing && (
setEditingTemplateId(null)} />
)}
) })}
{/* ------------------------------------------------------------------ */} {/* Dashboard Widget Configuration (admin only) */} {/* ------------------------------------------------------------------ */} {isAdmin && (

Dashboard Widget-Konfiguration

Sets the default widget layout for all users of this tenant. Users can customize their own layout individually.

Tenant default:{' '} {tenantDefaultWidgets && tenantDefaultWidgets.length > 0 ? `${tenantDefaultWidgets.length} Widget${tenantDefaultWidgets.length !== 1 ? 's' : ''} configured` : 'No default set yet (system default active)'}

)} {showTenantDashboardModal && ( setShowTenantDashboardModal(false)} tenantMode={true} /> )} {/* ------------------------------------------------------------------ */} {/* 3D Viewer & GLB Export Settings */} {/* ------------------------------------------------------------------ */}

3D Viewer & GLB Export

Settings for the 3D viewer and GLB geometry export

{/* Scale Factor */}
setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))} className="input w-full" />

Default 0.001 converts mm to meters

{/* Camera / Zoom Limits */}
setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))} className="input w-full" />
setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))} className="input w-full" />
{/* PBR Material Quality */}
setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))} className="input w-full" />
setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))} className="input w-full" />
{Object.keys(viewerDraft).length > 0 && ( )}
{/* ------------------------------------------------------------------ */} {/* Tessellation Quality */} {/* ------------------------------------------------------------------ */}

Tessellation Quality

OCC mesh precision for GLB export. Lower values = finer mesh + larger files + slower export.

Preview (Geometry GLB)

setTessellationDraft(d => ({ ...d, gltf_preview_linear_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" /> mm
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" /> rad

Used when clicking "Generate Geometry GLB".

Production (Production GLB)

setTessellationDraft(d => ({ ...d, gltf_production_linear_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" /> mm
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" /> rad

Used when clicking "Generate Production GLB". Smaller = smoother surfaces.

{Object.keys(tessellationDraft).length > 0 && ( )}
{/* ------------------------------------------------------------------ */} {/* Material Library link */} {/* ------------------------------------------------------------------ */}

Material Library

Manage shared materials for CAD part assignments.

Open Material Library →
) } function MaterialLibraryPanel() { return (

Material Library

Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.

Manage Asset Libraries
) } function PricingSummaryCard() { const { data: tiers } = useQuery({ queryKey: ['pricing-tiers'], queryFn: listPricingTiers, }) const { data: outputTypes } = useQuery({ queryKey: ['output-types-admin'], queryFn: () => listOutputTypes(true), }) const defaultTier = tiers?.find((t) => t.category_key === 'default' && t.is_active) const activeTiers = tiers?.filter((t) => t.is_active).length ?? 0 const totalOTs = outputTypes?.length ?? 0 const otsWithTier = outputTypes?.filter((ot) => ot.pricing_tier_id != null).length ?? 0 return (

Pricing Overview

{defaultTier ? `${Number(defaultTier.price_per_item).toFixed(2)}` : '—'}

Global default price

{!defaultTier && (

Not configured

)}

{activeTiers}

Active pricing tiers

{otsWithTier} / {totalOTs}

Output types with explicit tier

{totalOTs - otsWithTier}

Using category default

) } // ── Asset Library Panel ─────────────────────────────────────────────────────── function AssetLibraryPanel() { const qc = useQueryClient() const [showCreate, setShowCreate] = useState(false) const [newName, setNewName] = useState('') const [newDesc, setNewDesc] = useState('') const [newFile, setNewFile] = useState(null) const [expanded, setExpanded] = useState>(new Set()) const { data: libraries = [] } = useQuery({ queryKey: ['asset-libraries'], queryFn: listAssetLibraries, }) const createMut = useMutation({ mutationFn: () => createAssetLibrary({ name: newName, description: newDesc || undefined, blend_file: newFile! }), onSuccess: () => { toast.success('Asset library created') qc.invalidateQueries({ queryKey: ['asset-libraries'] }) setShowCreate(false) setNewName('') setNewDesc('') setNewFile(null) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'), }) const deleteMut = useMutation({ mutationFn: (id: string) => deleteAssetLibrary(id), onSuccess: () => { toast.success('Asset library deleted') qc.invalidateQueries({ queryKey: ['asset-libraries'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'), }) const refreshMut = useMutation({ mutationFn: (id: string) => refreshAssetLibraryCatalog(id), onSuccess: () => { toast.success('Catalog refresh queued') setTimeout(() => qc.invalidateQueries({ queryKey: ['asset-libraries'] }), 3000) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to refresh'), }) const toggle = (id: string) => setExpanded((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n }) return (

Asset Libraries

Upload Blender .blend files containing production materials and node groups.

{showCreate && (
setNewName(e.target.value)} /> setNewDesc(e.target.value)} />
)} {libraries.length === 0 ? (
No asset libraries yet. Upload a .blend file to get started.
) : (
{(libraries as AssetLibrary[]).map((lib) => { const isExpanded = expanded.has(lib.id) const matCount = lib.catalog.materials.length const ngCount = lib.catalog.node_groups.length return (

{lib.name}

{lib.description && (

{lib.description}

)}
{lib.original_filename ?? '—'} {matCount} materials {ngCount} node groups
{isExpanded && (
{matCount > 0 && (

Materials

{lib.catalog.materials.map((m) => ( {m} ))}
)} {ngCount > 0 && (

Node Groups

{lib.catalog.node_groups.map((ng) => ( {ng} ))}
)} {matCount === 0 && ngCount === 0 && (

No assets found. Click "Refresh" to scan the .blend for marked assets.

)}
)}
) })}
)} setConfirmState((s) => ({ ...s, open: false }))} />
) }