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, DollarSign, Layers, AlertTriangle, Upload, FileBox, Plus, X, LayoutDashboard, Cpu, Zap, AlertCircle, HardDrive, Mail, Monitor, Eye, Box } 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 GlobalRenderPositionsPanel from '../components/admin/GlobalRenderPositionsPanel' import { useAuthStore, isAdmin as checkIsAdmin } 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' import { getGpuProbeResult, triggerGpuProbe } from '../api/worker' import type { GPUProbeResult } from '../api/worker' export default function AdminPage() { const qc = useQueryClient() const user = useAuthStore((s) => s.user) const isAdmin = checkIsAdmin(user) const [showNewUser, setShowNewUser] = useState(false) const [newUser, setNewUser] = useState({ email: '', password: '', full_name: '', role: 'client' }) const [editingUserId, setEditingUserId] = useState(null) const [editUserDraft, setEditUserDraft] = useState<{ full_name: string; role: string; is_active: boolean }>({ full_name: '', role: 'client', is_active: true }) const [editingTemplateId, setEditingTemplateId] = useState(null) 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'), }) 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 blender_cycles_samples: number blender_eevee_samples: number threejs_render_size: number blender_smooth_angle: number cycles_device: string render_backend: string smtp_enabled: boolean smtp_host: string smtp_port: number smtp_user: string smtp_password: string smtp_from_address: string viewer_max_distance: number viewer_min_distance: number scene_linear_deflection: number scene_angular_deflection: number tessellation_engine: string } 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 [showAdvancedTess, setShowAdvancedTess] = useState(false) 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 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) => { toast.success(res.data.message || 'Metadata re-extraction queued') }, 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) => { 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 generateMissingUsdMastersMut = useMutation({ mutationFn: () => api.post('/admin/settings/generate-missing-usd-masters'), onSuccess: (res) => toast.success(res.data.message || 'USD master export queued'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const generateMissingCanonicalScenesMut = useMutation({ mutationFn: () => api.post('/admin/settings/generate-missing-canonical-scenes'), onSuccess: (res) => toast.success(res.data.message || 'Canonical scene export queued'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const regenerateAllCanonicalScenesMut = useMutation({ mutationFn: () => api.post('/admin/settings/regenerate-all-canonical-scenes'), onSuccess: (res) => toast.success(res.data.message || 'All canonical scenes re-queued'), onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), }) const purgeRenderMediaMut = useMutation({ mutationFn: () => api.delete('/admin/settings/purge-render-media'), onSuccess: (res) => toast.success(res.data.message || 'Render media purged'), 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, }) // GPU Probe const [gpuProbeExpanded, setGpuProbeExpanded] = useState(false) const [gpuProbing, setGpuProbing] = useState(false) const gpuPollRef = useRef | null>(null) const { data: gpuProbeResult, refetch: refetchGpuProbe } = useQuery({ queryKey: ['gpu-probe-result'], queryFn: getGpuProbeResult, enabled: isAdmin, refetchInterval: gpuProbing ? 2000 : false, staleTime: 0, }) const handleRunGpuCheck = async () => { if (!isAdmin) return setGpuProbing(true) try { await triggerGpuProbe() // Poll for up to 45 seconds let elapsed = 0 const interval = setInterval(async () => { elapsed += 2 await refetchGpuProbe() if (elapsed >= 45) { clearInterval(interval) setGpuProbing(false) } }, 2000) gpuPollRef.current = interval } catch { toast.error('Failed to trigger GPU check') setGpuProbing(false) } } const gpuStatusBadge = () => { if (!gpuProbeResult) return null const s = gpuProbeResult.status if (s === 'ok') { return ( GPU OK{gpuProbeResult.device_type ? ` (${gpuProbeResult.device_type})` : ''} ) } if (s === 'failed') { return ( CPU Fallback ) } return ( {s === 'error' ? 'Error' : 'Unknown'} ) } type AdminTab = 'overview' | 'users' | 'render-settings' | 'output-types' | 'templates' | 'pricing' | 'libraries' | 'system' const [activeTab, setActiveTab] = useState('overview') // Blender Status (via Celery on render-worker) const { data: blenderStatus, refetch: refetchBlenderStatus, isFetching: blenderStatusFetching } = useQuery({ queryKey: ['blender-status'], queryFn: async () => { const res = await api.get('/admin/settings/renderer-status') return res.data.blender as { available: boolean; blender_bin: string; version: string; error?: string } }, enabled: isAdmin && activeTab === 'system', staleTime: 60_000, }) 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-settings', label: 'Render Settings' }, { id: 'output-types', label: 'Output Types' }, { id: 'templates', label: 'Templates & Positions' }, { id: 'pricing', label: 'Pricing' }, { id: 'libraries', label: 'Libraries' }, { id: 'system', label: 'System Tools' }, ] return (
{/* Tab header */}

Admin

{hasUnsavedChanges && (
Unsaved changes
)}
{TABS.map((tab) => ( ))}
{/* ================================================================== */} {/* Overview */} {/* ================================================================== */} {activeTab === 'overview' && } {/* ================================================================== */} {/* Users */} {/* ================================================================== */} {activeTab === 'users' && 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((u) => (
{editingUserId === u.id ? (
setEditUserDraft((d) => ({ ...d, full_name: e.target.value }))} className="px-3 py-1.5 border border-border-default rounded-md text-sm w-full" />
) : (

{u.full_name}

{u.email}

{u.role} {u.is_active ? 'active' : 'inactive'}
)}
))}
} {/* ================================================================== */} {/* Render Settings */} {/* ================================================================== */} {activeTab === 'render-settings' && isAdmin && <> {/* ── Blender Engine & Quality ──────────────────────────────────── */}

Blender Engine & Quality

Render engine selection, sample counts, smooth angle, and performance tuning for Blender 5.

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

Render Quality

{/* Engine */}
Render engine {(['cycles', 'eevee'] as const).map((eng) => ( ))}
{/* Cycles device */} {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" /> deg

{(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.`}

{/* Save button */} {Object.keys(blenderDraft).length > 0 && ( )}
{/* ── Tessellation Quality ──────────────────────────────────────── */}

Tessellation Quality

Controls how STEP geometry is converted to triangle meshes. Affects both the 3D viewer and Blender renders.

{/* Presets */} {(() => { const PRESETS = [ { label: 'Draft', icon: '***', description: 'Fast preview -- visible faceting on curved surfaces', useCase: 'Quick checks, large assemblies', color: 'border-amber-400', activeColor: 'border-amber-500 ring-2 ring-amber-200', values: { scene_linear_deflection: 0.2, scene_angular_deflection: 0.3 }, }, { label: 'Standard', icon: '---', description: 'Smooth curves, good quality-to-size ratio', useCase: 'Recommended for most parts', color: 'border-blue-400', activeColor: 'border-blue-500 ring-2 ring-blue-200', values: { scene_linear_deflection: 0.1, scene_angular_deflection: 0.1 }, }, { label: 'Fine', icon: '+++', description: 'Near-perfect surfaces, 3-5x larger files', useCase: 'Close-up renders, small precision parts', color: 'border-emerald-400', activeColor: 'border-emerald-500 ring-2 ring-emerald-200', values: { scene_linear_deflection: 0.05, scene_angular_deflection: 0.05 }, }, { label: 'Ultra', icon: '***', description: 'Maximum fidelity, very slow export', useCase: 'Marketing renders, extreme close-ups', color: 'border-purple-400', activeColor: 'border-purple-500 ring-2 ring-purple-200', values: { scene_linear_deflection: 0.02, scene_angular_deflection: 0.02 }, }, ] const isActive = (preset: typeof PRESETS[0]) => tess.scene_linear_deflection === preset.values.scene_linear_deflection && tess.scene_angular_deflection === preset.values.scene_angular_deflection const isCustom = !PRESETS.some(isActive) return (

Quality Presets

{PRESETS.map(preset => ( ))}
{isCustom && (

Current values don't match any preset (custom configuration)

)}
) })()} {/* Tessellation engine selector */}

Tessellation Engine

{[ { value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan-shaped triangles at cylinder seam lines.' }, { value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Uniform mesh -- eliminates fan artifacts on cylindrical parts. 10-30% slower. Recommended for bearings.' }, ].map(opt => ( ))}
{/* Explanation of deflection parameters */}

How deflection values work

Linear deflection (mm)

Maximum allowed distance between the original curved surface and the generated triangles. A value of 0.1 mm means no triangle edge can deviate more than 0.1 mm from the true surface. Lower values produce smoother curves but more triangles.

Angular deflection (rad)

Maximum angle between adjacent triangle normals. Controls how finely curved regions are subdivided. A value of 0.1 rad (~6 deg) means neighboring triangles can differ by at most ~6 deg. Primarily affects small fillets and tight curvatures.

{/* Manual inputs */} {showAdvancedTess && (<>

Scene Deflection

Controls mesh quality for the 3D viewer GLB, USD scene, and Blender renders.

setTessellationDraft(d => ({ ...d, scene_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, scene_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
)}
{Object.keys(tessellationDraft).length > 0 && ( )}
{/* ── 3D Viewer ────────────────────────────────────────────────── */}

3D Viewer

Camera zoom limits for the interactive 3D viewer.

setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))} title="Maximum camera distance from the model in the 3D viewer (in metres after mm to m conversion). Default: 50" className="input w-full" />

Maximum camera pull-back distance in the 3D viewer (metres).

setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))} title="Minimum camera distance from the model in the 3D viewer (in metres). Default: 0.001. Prevents clipping into the geometry." className="input w-full" />

Closest the camera can zoom in (metres). Prevents clipping through geometry.

{Object.keys(viewerDraft).length > 0 && ( )}
} {/* ================================================================== */} {/* Output Types */} {/* ================================================================== */} {activeTab === 'output-types' && <>

Output Types

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

} {/* ================================================================== */} {/* Templates & Positions */} {/* ================================================================== */} {activeTab === 'templates' && <> {/* ── Render Templates ──────────────────────────────────────────── */}

Render Templates

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

{/* ── Global Render Positions ───────────────────────────────────── */} {isAdmin && (

Global Render Positions

Camera rotation presets available to all products. Per-product positions override these.

)} {/* ── Material Library link ─────────────────────────────────────── */}

Material Library

Manage shared materials for CAD part assignments.

Open Material Library
} {/* ================================================================== */} {/* Pricing */} {/* ================================================================== */} {activeTab === 'pricing' && <>

Pricing Tiers

Configure price per rendering item by category and quality level.

} {/* ================================================================== */} {/* Libraries */} {/* ================================================================== */} {activeTab === 'libraries' && <> {/* ── Templates (legacy editor) ─────────────────────────────────── */}

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)} />
)}
) })}
} {/* ================================================================== */} {/* System Tools */} {/* ================================================================== */} {activeTab === 'system' && isAdmin && <> {/* ── Blender Status ─────────────────────────────────────────────── */}

Render Worker Status

Checks Blender availability on the render-worker container via Celery.

{blenderStatus ? ( blenderStatus.available ? ( Blender Available ) : ( Blender Unavailable ) ) : ( Not checked yet )}
{blenderStatus && (
{blenderStatus.version && (
Version {blenderStatus.version}
)} {blenderStatus.blender_bin && (
Binary {blenderStatus.blender_bin}
)} {blenderStatus.error && (
Error {blenderStatus.error}
)}
)}
{/* ── Reprocessing ──────────────────────────────────────────────── */}

Reprocessing

Queue STEP files for reprocessing, re-render thumbnails, or re-extract metadata.

Stuck File Recovery

Resets files stuck in 'processing' for more than 10 minutes to 'failed'. Runs automatically every 5 min.

Process Unprocessed

Queues all pending/failed STEP files for initial processing.

Regenerate Thumbnails

Re-renders thumbnails for all completed CAD files using current Blender settings.

Re-extract CAD Metadata

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

Seed Standard Workflows

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

{/* ── USD / Canonical Scenes ────────────────────────────────────── */}

USD / Canonical Scenes

Manage USD master exports and canonical scene generation for CAD files.

Generate Missing USD Masters

Exports USD canonical scene for all completed CAD files missing one.

Generate Missing Canonical Scenes

Queues geometry GLB + USD master for all completed CAD files missing a canonical scene.

Regenerate All GLB + USD

Re-exports GLB and USD for all completed CAD files, replacing existing assets.

{/* ── Cleanup ───────────────────────────────────────────────────── */}

Cleanup

Import, clean up, or purge media assets and orphaned records.

Import Existing Media

Registers existing renders and CAD thumbnails in the Media Browser.

Clean Up Orphaned Media

Removes DB records for renders whose files no longer exist on disk.

Clean Up Orphaned STEP Files

Removes STEP files, thumbnails, and DB records not linked to any product.

Purge All Stills & Turntables

Deletes all rendered images and animations. Thumbnails, GLBs, and USD files are preserved.

{/* ── GPU Status ────────────────────────────────────────────────── */}

GPU Status

Verify that the render-worker is using the GPU (not CPU fallback).

{gpuProbeExpanded && (
{gpuProbeResult && ( Last checked: {gpuProbeResult.timestamp ? new Date(gpuProbeResult.timestamp).toLocaleString() : '--'} )}
{gpuProbeResult && (
Status {gpuStatusBadge()}
{gpuProbeResult.device_type && (
Device type {gpuProbeResult.device_type}
)} {gpuProbeResult.devices && gpuProbeResult.devices.length > 0 && (
Devices
{gpuProbeResult.devices.map((d: string, i: number) => ( {d} ))}
)} {gpuProbeResult.render_time_s != null && (
Render time {gpuProbeResult.render_time_s.toFixed(2)}s
)} {gpuProbeResult.error && (
Error {gpuProbeResult.error}
)}
)} {!gpuProbeResult && !gpuProbing && (

No probe result yet. Click "Run GPU Check" to trigger a test render.

)}
)}
{/* ── SMTP Settings ─────────────────────────────────────────────── */}

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" />
{/* ── Dashboard Config ──────────────────────────────────────────── */}

Dashboard Configuration

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} /> )} setConfirmState((s) => ({ ...s, open: false }))} />
) } 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 [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} }) 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) => { const name = typeof m === 'string' ? m : m.name return ( {name} ) })}
)} {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 }))} />
) }