Files
HartOMat/frontend/src/pages/Admin.tsx
T
Hartmut 10d05bd2e7 feat(phase7.1): add HelpTooltip system with contextual help icons
- New HelpTooltip component: hover-triggered floating panel, themed via
  CSS variables, supports top/right/bottom/left positioning, no deps
- New helpTexts.ts registry: 14 entries covering render settings,
  admin actions, template fields, and wizard fields
- Admin.tsx: tooltips on Cycles/EEVEE samples, smooth angle, regenerate
  thumbnails, process unprocessed
- RenderTemplateTable.tsx: tooltips on material replace, lighting only,
  shadow catcher column headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 20:16:42 +01:00

1536 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null>(null)
const [priorityNewEntry, setPriorityNewEntry] = useState<string>('')
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<Partial<Settings>>({})
const blender = { ...settings, ...blenderDraft } as Settings
const [viewerDraft, setViewerDraft] = useState<Partial<Settings>>({})
const viewer3d = { ...settings, ...viewerDraft } as Settings
const [tessellationDraft, setTessellationDraft] = useState<Partial<Settings>>({})
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<string, { available: boolean; note: string; url: string | null }>
},
refetchInterval: 30000,
})
const updateSettingsMut = useMutation({
mutationFn: (data: Partial<Settings>) => 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<Partial<Settings>>({})
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<WidgetConfig[]>({
queryKey: ['tenant-default-dashboard'],
queryFn: getTenantDefaultDashboard,
enabled: isAdmin,
staleTime: 300_000,
})
return (
<div className="p-8 space-y-8">
<h1 className="text-2xl font-bold text-content">Admin</h1>
{/* ------------------------------------------------------------------ */}
{/* Pricing Summary */}
{/* ------------------------------------------------------------------ */}
<PricingSummaryCard />
{/* ------------------------------------------------------------------ */}
{/* Users (admin only) */}
{/* ------------------------------------------------------------------ */}
{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">
<UserPlus size={16} />New User
</button>
</div>
{showNewUser && (
<div className="p-4 border-b border-border-light bg-surface-alt">
<div className="grid grid-cols-2 gap-3 mb-3">
<input
placeholder="Full name"
value={newUser.full_name}
onChange={(e) => setNewUser({ ...newUser, full_name: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<input
placeholder="Email"
type="email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<input
placeholder="Password"
type="password"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
/>
<select
value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="px-3 py-2 border border-border-default rounded-md text-sm"
>
<option value="client">Client</option>
<option value="project_manager">Project Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button
onClick={() => createUserMut.mutate(newUser)}
disabled={createUserMut.isPending}
className="btn-primary"
>
{createUserMut.isPending ? 'Creating...' : 'Create User'}
</button>
</div>
)}
<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 ${user.role === 'admin' ? '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>
</div>
))}
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Blender Render Settings (admin only) */}
{/* ------------------------------------------------------------------ */}
{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" />
<div>
<h2 className="font-semibold text-content">Blender Render Settings</h2>
<p className="text-xs text-content-muted mt-0.5">
Render quality, performance and thumbnail output options for Blender 5.
</p>
</div>
</div>
<button
onClick={() => refetchStatus()}
className="text-content-muted hover:text-content-secondary transition-colors"
title="Refresh service status"
>
<RefreshCw size={15} />
</button>
</div>
<div className="p-6 space-y-6">
{/* ── Render Quality ───────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Render Quality</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
{/* Engine */}
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Render engine</span>
{(['cycles', 'eevee'] as const).map((eng) => (
<button
key={eng}
onClick={() => setBlenderDraft((d) => ({ ...d, blender_engine: eng }))}
title={
eng === 'cycles'
? 'Cycles — path-tracing engine; photorealistic results but slower (affected by sample count)'
: 'EEVEE Next — real-time rasteriser; very fast but less physically accurate'
}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.blender_engine === eng
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{eng === 'cycles' ? 'Cycles (ray tracing)' : 'EEVEE Next (real-time)'}
</button>
))}
</div>
{/* Cycles device — only relevant for Cycles */}
{blender.blender_engine === 'cycles' && (
<div className="flex items-center gap-6 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Cycles device</span>
{(['auto', 'gpu', 'cpu'] as const).map((dev) => (
<button
key={dev}
onClick={() => setBlenderDraft((d) => ({ ...d, cycles_device: dev }))}
title={
dev === 'auto'
? 'Auto — tries OptiX / CUDA / HIP first; falls back to CPU if no compatible GPU is found'
: dev === 'gpu'
? 'GPU only — always render on GPU; logs a warning if no compatible GPU is available'
: 'CPU only — always render on CPU; useful for debugging or when the GPU is busy'
}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
blender.cycles_device === dev
? 'bg-blue-600 text-white border-blue-600'
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
}`}
>
{dev === 'auto' ? 'Auto (GPU → CPU)' : dev === 'gpu' ? 'GPU only' : 'CPU only'}
</button>
))}
<p className="text-xs text-content-muted">
{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.'}
</p>
</div>
)}
{/* Sample counts */}
<div className="grid grid-cols-2 gap-4 max-w-sm">
<div>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
Cycles samples
<HelpTooltip helpKey="setting.blender_cycles_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={4096} step={32}
value={blender.blender_cycles_samples ?? 256}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_cycles_samples: Number(e.target.value) }))}
title="Number of Cycles path-tracing samples (14096). 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"
/>
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
</div>
<div>
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
EEVEE samples
<HelpTooltip helpKey="setting.blender_eevee_samples" position="top" size={12} />
</label>
<input
type="number"
min={1} max={1024} step={16}
value={blender.blender_eevee_samples ?? 64}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_eevee_samples: Number(e.target.value) }))}
title="EEVEE anti-aliasing sample count (11024). 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"
/>
<p className="text-xs text-content-muted mt-0.5">Higher = better AA, slower</p>
</div>
</div>
{/* Smooth by angle */}
<div className="flex items-center gap-4 flex-wrap">
<span className="flex items-center gap-1 text-sm font-medium text-content-secondary w-28 shrink-0">
Smooth angle
<HelpTooltip helpKey="setting.blender_smooth_angle" size={12} />
</span>
<input
type="number"
min={0} max={180} step={5}
value={blender.blender_smooth_angle ?? 30}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_smooth_angle: Number(e.target.value) }))}
title="Auto-smooth angle in degrees (0180°). 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"
/>
<span className="text-sm text-content-muted">°</span>
<p className="text-xs text-content-muted">
{(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.`}
</p>
</div>
</div>
</div>
{/* ── Performance ──────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Performance</p>
<div className="rounded-lg border border-border-default bg-surface-alt p-4 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Max concurrent</span>
<input
type="number"
min={1} max={16} step={1}
value={blender.blender_max_concurrent_renders ?? 3}
onChange={(e) => setBlenderDraft((d) => ({ ...d, blender_max_concurrent_renders: Number(e.target.value) }))}
title="Maximum parallel Blender render jobs (116). 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"
/>
<p className="text-xs text-content-muted">
Max parallel Blender render jobs (116). Higher values use more RAM (~400 MB each). Applied live without restart.
</p>
</div>
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Stall timeout</span>
<input
type="number"
min={10} max={10080} step={10}
value={blender.render_stall_timeout_minutes ?? 120}
onChange={(e) => setBlenderDraft((d) => ({ ...d, render_stall_timeout_minutes: Number(e.target.value) }))}
title="Minutes before a stuck render job is automatically restarted (1010080). 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"
/>
<p className="text-xs text-content-muted">
Minutes before a stuck render job is auto-restarted (1010080). Checked every 5 min by the watchdog.
</p>
</div>
</div>
</div>
{/* Save button — appears when draft has unsaved changes */}
{Object.keys(blenderDraft).length > 0 && (
<button
onClick={() => updateSettingsMut.mutate(blenderDraft)}
disabled={updateSettingsMut.isPending}
className="btn-primary text-sm"
>
{updateSettingsMut.isPending ? 'Saving…' : 'Save Settings'}
</button>
)}
{/* ── Output ───────────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Output</p>
<div className="flex items-center gap-4 flex-wrap">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28">Thumbnail format</label>
{(['jpg', 'png'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => updateSettingsMut.mutate({ thumbnail_format: fmt })}
disabled={updateSettingsMut.isPending}
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
settings?.thumbnail_format === fmt
? 'text-white'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={settings?.thumbnail_format === fmt ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{fmt === 'jpg' ? 'JPEG (smaller)' : 'PNG (lossless)'}
</button>
))}
<p className="text-xs text-content-muted">
{settings?.thumbnail_format === 'jpg'
? 'JPEG — ~35× smaller files, minimal quality loss at 92% quality.'
: 'PNG — lossless, larger files.'}
</p>
</div>
{/* 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 (
<div className="flex items-start gap-4 pt-1">
<label className="text-sm font-medium text-content-secondary shrink-0 w-28 pt-1">Product thumbnail:</label>
<div className="flex flex-col gap-2 min-w-[280px]">
{priorityList.map((entry, i) => (
<div
key={entry + i}
className={`flex items-center gap-2 border rounded-lg px-3 py-2 ${entryColor(entry)}`}
>
<span className="text-xs font-mono text-content-muted w-4 shrink-0">{i + 1}</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{entryLabel(entry)}</span>
{entry !== 'cad_thumbnail' && entry !== 'latest_render' && (
<span className="text-xs text-content-muted">newest completed render</span>
)}
</div>
<button
disabled={i === 0 || updateSettingsMut.isPending}
onClick={() => moveUp(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move up"
>
<ChevronUp size={14} />
</button>
<button
disabled={i === priorityList.length - 1 || updateSettingsMut.isPending}
onClick={() => moveDown(i)}
className="p-0.5 rounded hover:bg-surface-hover disabled:opacity-30 text-content-muted"
title="Move down"
>
<ChevronDown size={14} />
</button>
<button
disabled={updateSettingsMut.isPending}
onClick={() => remove(i)}
className="p-0.5 rounded hover:bg-status-error-bg text-content-muted hover:text-red-600"
title="Remove"
>
<X size={14} />
</button>
</div>
))}
{addableOptions.length > 0 && (
<div className="flex items-center gap-2">
<select
value={priorityNewEntry}
onChange={(e) => setPriorityNewEntry(e.target.value)}
className="flex-1 px-2 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
>
<option value="">Add entry</option>
{addableOptions.map((v) => (
<option key={v} value={v}>{entryLabel(v)}</option>
))}
</select>
<button
disabled={!priorityNewEntry || updateSettingsMut.isPending}
onClick={addEntry}
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1 disabled:opacity-40"
>
<Plus size={13} /> Add
</button>
</div>
)}
<p className="text-xs text-content-muted">
Sources are tried top to bottom. For specific output types, the <span className="font-medium">newest completed render</span> of that type is used. "CAD Thumbnail" always matches and stops the search.
</p>
</div>
</div>
)
})()}
</div>{/* end Output */}
{/* ── Service Status ───────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Service Status</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{rendererStatus && Object.entries(rendererStatus).map(([name, info]) => (
<div
key={name}
className={`rounded-lg border p-3 flex items-start gap-2.5 ${
info.available ? 'border-border-default bg-status-success-bg' : 'border-border-default bg-surface-alt'
}`}
>
{info.available
? <CheckCircle2 size={16} className="text-green-500 shrink-0 mt-0.5" />
: <XCircle size={16} className="text-content-muted shrink-0 mt-0.5" />
}
<div className="min-w-0">
<p className="text-sm font-semibold text-content capitalize">{name}</p>
<p className="text-xs text-content-muted truncate">{info.note || (info.available ? 'Online' : 'Offline')}</p>
</div>
</div>
))}
{!rendererStatus && (
<div className="flex items-center gap-2 text-xs text-content-muted p-2">
<Clock size={13} /> Checking service status
</div>
)}
</div>
</div>
{/* ── Maintenance ──────────────────────────────────────────────── */}
<div className="space-y-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Maintenance</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<button
onClick={() => recoverStuckMut.mutate()}
disabled={recoverStuckMut.isPending}
className="btn-secondary text-sm w-full justify-start border-amber-400/40 text-amber-600 hover:bg-amber-50"
title="Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'. Runs automatically every 5 min."
>
<RefreshCw size={14} className={recoverStuckMut.isPending ? 'animate-spin' : ''} />
{recoverStuckMut.isPending ? 'Recovering…' : 'Recover Stuck Processing'}
</button>
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => processUnprocessedMut.mutate()}
disabled={processUnprocessedMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Queue all pending and failed STEP files that have never been successfully processed"
>
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
</button>
<HelpTooltip helpKey="action.process_unprocessed" position="left" />
</div>
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<button
onClick={() => regenerateMut.mutate()}
disabled={regenerateMut.isPending}
className="btn-secondary text-sm flex-1 justify-start"
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
>
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
</button>
<HelpTooltip helpKey="action.regenerate_thumbnails" position="left" />
</div>
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => importMediaAssetsMut.mutate()}
disabled={importMediaAssetsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create MediaAsset records for all existing CAD thumbnails and order line renders"
>
<RefreshCw size={14} className={importMediaAssetsMut.isPending ? 'animate-spin' : ''} />
{importMediaAssetsMut.isPending ? 'Importing…' : 'Import Existing Media'}
</button>
<p className="text-xs text-content-muted">Registers existing renders &amp; CAD thumbnails in the Media Browser.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
disabled={reextractMetadataMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Re-extract OCC bounding box and sharp-edge data for all completed CAD files"
>
<RefreshCw size={14} className={reextractMetadataMut.isPending ? 'animate-spin' : ''} />
{reextractMetadataMut.isPending ? 'Queueing…' : 'Re-extract CAD Metadata'}
</button>
<p className="text-xs text-content-muted">Updates dimensions and edge data for existing files (no re-render).</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => seedWorkflowsMut.mutate()}
disabled={seedWorkflowsMut.isPending}
className="btn-secondary text-sm w-full justify-start"
title="Create standard workflow definitions (Still Cycles/EEVEE, Turntable, Multi-Angle) if not yet present"
>
<RefreshCw size={14} className={seedWorkflowsMut.isPending ? 'animate-spin' : ''} />
{seedWorkflowsMut.isPending ? 'Seeding…' : 'Seed Standard Workflows'}
</button>
<p className="text-xs text-content-muted">Creates the 4 standard workflow definitions if they don't exist yet.</p>
</div>
</div>
</div>
</div>
</div>}
{/* ------------------------------------------------------------------ */}
{/* Render Templates (admin/PM) */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Render Templates</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload .blend studio setups matched by Category + Output Type. Geometry is imported into the template at render time.
</p>
</div>
</div>
<div className="p-4">
<RenderTemplateTable />
</div>
<div className="border-t border-border-light p-4">
<MaterialLibraryPanel />
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Asset Libraries */}
{/* ------------------------------------------------------------------ */}
<AssetLibraryPanel />
{/* ------------------------------------------------------------------ */}
{/* Output Types */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Output Types</h2>
<p className="text-xs text-content-muted mt-0.5">
Define what kinds of outputs orders can request (thumbnails, views, formats).
</p>
</div>
</div>
<OutputTypeTable />
</div>
{/* ------------------------------------------------------------------ */}
{/* Pricing Tiers */}
{/* ------------------------------------------------------------------ */}
<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>
<h2 className="font-semibold text-content">Pricing Tiers</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure price per rendering item by category and quality level.
</p>
</div>
</div>
<PricingTierTable />
</div>
{/* ------------------------------------------------------------------ */}
{/* E-Mail / SMTP Settings */}
{/* ------------------------------------------------------------------ */}
{isAdmin && (
<div className="card">
<div className="p-4 border-b border-border-default">
<h2 className="font-semibold text-content">E-Mail Notifications (SMTP)</h2>
<p className="text-xs text-content-muted mt-0.5">
Configure outbound SMTP for email notifications. Enable only when credentials are set.
</p>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={smtp.smtp_enabled ?? false}
onChange={(e) => setSmtpDraft(d => ({ ...d, smtp_enabled: e.target.checked }))}
className="w-4 h-4 rounded"
/>
<span className="text-sm font-medium text-content">Enable email sending</span>
</label>
{smtp.smtp_enabled && (
<span className="badge badge-green text-xs">Active</span>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">SMTP Host</label>
<input
type="text"
value={smtp.smtp_host ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Port</label>
<input
type="number"
value={smtp.smtp_port ?? 587}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Username</label>
<input
type="text"
value={smtp.smtp_user ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={smtp.smtp_password ?? ''}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-content-secondary mb-1">From Address</label>
<input
type="email"
value={smtp.smtp_from_address ?? ''}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={() => { updateSettingsMut.mutate(smtpDraft as any); setSmtpDraft({}) }}
disabled={updateSettingsMut.isPending || Object.keys(smtpDraft).length === 0}
className="btn-primary text-sm"
>
Save SMTP Settings
</button>
</div>
</div>
)}
{/* ------------------------------------------------------------------ */}
{/* Templates */}
{/* ------------------------------------------------------------------ */}
<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">
Click Edit to configure standard fields and component schema for each template.
</p>
</div>
<div className="divide-y divide-border-light">
{templates?.map((t) => {
const isEditing = editingTemplateId === t.id
return (
<div key={t.id}>
{/* Row */}
<div className="flex items-center px-6 py-3 gap-3">
<button
onClick={() => setEditingTemplateId(isEditing ? null : t.id)}
className="text-content-muted hover:text-content-secondary transition-colors shrink-0"
aria-label={isEditing ? 'Collapse editor' : 'Expand editor'}
title={isEditing ? 'Collapse template editor' : 'Expand template editor'}
>
{isEditing ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content">{t.name}</p>
<p className="text-xs text-content-muted font-mono">{t.category_key}</p>
</div>
<span className={`badge ${t.is_active ? 'badge-green' : 'badge-gray'}`}>
{t.is_active ? 'active' : 'inactive'}
</span>
<button
onClick={() => setEditingTemplateId(isEditing ? null : t.id)}
className="flex items-center gap-1.5 px-3 py-1 rounded-md border border-border-default text-xs text-content-secondary hover:bg-surface-hover hover:border-accent hover:text-accent transition-colors"
>
<Pencil size={12} />
{isEditing ? 'Close' : 'Edit'}
</button>
</div>
{/* Inline editor panel */}
{isEditing && (
<div className="px-6 pb-6">
<TemplateEditor
template={t}
onClose={() => setEditingTemplateId(null)}
/>
</div>
)}
</div>
)
})}
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Dashboard Widget Configuration (admin only) */}
{/* ------------------------------------------------------------------ */}
{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" />
<div>
<h2 className="font-semibold text-content">Dashboard Widget-Konfiguration</h2>
<p className="text-xs text-content-muted mt-0.5">
Sets the default widget layout for all users of this tenant. Users can customize their own layout individually.
</p>
</div>
</div>
<div className="p-4 flex items-center gap-4">
<div className="flex-1">
<p className="text-sm text-content-secondary">
Tenant default:{' '}
<span className="font-medium text-content">
{tenantDefaultWidgets && tenantDefaultWidgets.length > 0
? `${tenantDefaultWidgets.length} Widget${tenantDefaultWidgets.length !== 1 ? 's' : ''} configured`
: 'No default set yet (system default active)'}
</span>
</p>
</div>
<button
onClick={() => setShowTenantDashboardModal(true)}
className="btn-secondary text-sm flex items-center gap-2"
>
<LayoutDashboard size={14} />
Edit Tenant Default Dashboard
</button>
</div>
</div>
)}
{showTenantDashboardModal && (
<DashboardCustomizeModal
currentWidgets={tenantDefaultWidgets ?? []}
onClose={() => setShowTenantDashboardModal(false)}
tenantMode={true}
/>
)}
{/* ------------------------------------------------------------------ */}
{/* 3D Viewer & GLB Export Settings */}
{/* ------------------------------------------------------------------ */}
<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">
Settings for the 3D viewer and GLB geometry export
</p>
</div>
<div className="p-4 space-y-4">
{/* Scale Factor */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Scale Factor (mm→m)
</label>
<input
type="number"
step="0.0001"
min="0.0001"
max="1"
value={viewer3d.gltf_scale_factor ?? 0.001}
onChange={e => setViewerDraft(d => ({ ...d, gltf_scale_factor: parseFloat(e.target.value) }))}
className="input w-full"
/>
<p className="text-xs text-content-muted mt-0.5">Default 0.001 converts mm to meters</p>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Smooth Normals
</label>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={viewer3d.gltf_smooth_normals ?? true}
onChange={e => setViewerDraft(d => ({ ...d, gltf_smooth_normals: e.target.checked }))}
className="w-4 h-4"
/>
<span className="text-sm text-content">Apply Laplacian smoothing on export</span>
</label>
</div>
</div>
{/* Camera / Zoom Limits */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Max Zoom-Out Distance
</label>
<input
type="number"
step="1"
min="1"
max="10000"
value={viewer3d.viewer_max_distance ?? 50}
onChange={e => setViewerDraft(d => ({ ...d, viewer_max_distance: parseFloat(e.target.value) }))}
className="input w-full"
/>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
Min Zoom-In Distance
</label>
<input
type="number"
step="0.001"
min="0.0001"
max="1"
value={viewer3d.viewer_min_distance ?? 0.001}
onChange={e => setViewerDraft(d => ({ ...d, viewer_min_distance: parseFloat(e.target.value) }))}
className="input w-full"
/>
</div>
</div>
{/* PBR Material Quality */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
GLB Material Mode
</label>
<select
value={viewer3d.gltf_material_quality ?? 'pbr_colors'}
onChange={e => setViewerDraft(d => ({ ...d, gltf_material_quality: e.target.value }))}
className="input w-full"
>
<option value="none">None (geometry only)</option>
<option value="pbr_colors">PBR Colors (from part colors)</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Roughness (01)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_roughness ?? 0.4}
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_roughness: parseFloat(e.target.value) }))}
className="input w-full"
/>
</div>
<div>
<label className="text-sm font-medium text-content-muted block mb-1">
PBR Metallic (01)
</label>
<input
type="number"
step="0.05"
min="0"
max="1"
value={viewer3d.gltf_pbr_metallic ?? 0.6}
onChange={e => setViewerDraft(d => ({ ...d, gltf_pbr_metallic: parseFloat(e.target.value) }))}
className="input w-full"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
updateSettingsMut.mutate(viewerDraft)
setViewerDraft({})
}}
disabled={Object.keys(viewerDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save 3D Settings
</button>
{Object.keys(viewerDraft).length > 0 && (
<button
onClick={() => setViewerDraft({})}
className="btn-secondary"
>
Reset
</button>
)}
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Tessellation Quality */}
{/* ------------------------------------------------------------------ */}
<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">
OCC mesh precision for GLB export. Lower values = finer mesh + larger files + slower export.
</p>
</div>
<div className="p-4 space-y-6">
<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>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.01"
min="0.001"
max="10"
value={tess.gltf_preview_linear_deflection ?? 0.1}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
max="1.5"
value={tess.gltf_preview_angular_deflection ?? 0.5}
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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Geometry GLB".</p>
</div>
<div className="space-y-4">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide">Production (Production GLB)</p>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Linear deflection</label>
<input
type="number"
step="0.005"
min="0.001"
max="10"
value={tess.gltf_production_linear_deflection ?? 0.03}
onChange={e => 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"
/>
<span className="text-sm text-content-muted">mm</span>
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-content-secondary w-36 shrink-0">Angular deflection</label>
<input
type="number"
step="0.05"
min="0.05"
max="1.5"
value={tess.gltf_production_angular_deflection ?? 0.2}
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"
/>
<span className="text-sm text-content-muted">rad</span>
</div>
<p className="text-xs text-content-muted">Used when clicking "Generate Production GLB". Smaller = smoother surfaces.</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => { updateSettingsMut.mutate(tessellationDraft); setTessellationDraft({}) }}
disabled={Object.keys(tessellationDraft).length === 0 || updateSettingsMut.isPending}
className="btn-primary disabled:opacity-40"
>
Save Tessellation Settings
</button>
{Object.keys(tessellationDraft).length > 0 && (
<button onClick={() => setTessellationDraft({})} className="btn-secondary">Reset</button>
)}
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Material Library link */}
{/* ------------------------------------------------------------------ */}
<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">
Manage shared materials for CAD part assignments.
</p>
</div>
<Link to="/materials" className="btn-secondary text-sm">
Open Material Library →
</Link>
</div>
</div>
)
}
function MaterialLibraryPanel() {
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-content-secondary">Material Library</h4>
<p className="text-xs text-content-muted">
Materials for "Material Replace" are now managed via Asset Libraries. The active asset library's materials are used at render time.
</p>
<Link
to="/asset-libraries"
className="inline-flex items-center gap-1 text-sm text-accent hover:text-accent-hover"
>
<Layers size={14} />
Manage Asset Libraries
</Link>
</div>
)
}
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 (
<div className="card">
<div className="p-4 border-b border-border-default flex items-center gap-2">
<DollarSign size={16} className="text-content-muted" />
<h2 className="font-semibold text-content">Pricing Overview</h2>
</div>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-content">
{defaultTier ? `${Number(defaultTier.price_per_item).toFixed(2)}` : '—'}
</p>
<p className="text-xs text-content-muted">Global default price</p>
{!defaultTier && (
<p className="text-xs text-amber-600 flex items-center justify-center gap-1 mt-1">
<AlertTriangle size={10} /> Not configured
</p>
)}
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{activeTiers}</p>
<p className="text-xs text-content-muted">Active pricing tiers</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{otsWithTier} / {totalOTs}</p>
<p className="text-xs text-content-muted">Output types with explicit tier</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-content">{totalOTs - otsWithTier}</p>
<p className="text-xs text-content-muted">Using category default</p>
</div>
</div>
</div>
)
}
// ── 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<File | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(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 (
<div className="card">
<div className="p-4 border-b border-border-light flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers size={16} className="text-content-muted" />
<div>
<h2 className="font-semibold text-content">Asset Libraries</h2>
<p className="text-xs text-content-muted mt-0.5">
Upload Blender .blend files containing production materials and node groups.
</p>
</div>
</div>
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
<Plus size={14} />New Library
</button>
</div>
{showCreate && (
<div className="p-4 border-b border-border-light bg-surface-alt space-y-3">
<div className="flex gap-3">
<input
className="input flex-1"
placeholder="Library name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<input
className="input flex-1"
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<label className="btn-secondary cursor-pointer">
<Upload size={14} />
{newFile ? newFile.name : 'Choose .blend file'}
<input
type="file"
accept=".blend"
className="hidden"
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
/>
</label>
<button
className="btn-primary"
disabled={!newName || !newFile || createMut.isPending}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? 'Creating…' : 'Create'}
</button>
<button className="btn-secondary" onClick={() => setShowCreate(false)}>
<X size={14} />
</button>
</div>
</div>
)}
{libraries.length === 0 ? (
<div className="p-8 text-center text-content-muted text-sm">
No asset libraries yet. Upload a .blend file to get started.
</div>
) : (
<div className="divide-y divide-border-light">
{(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 (
<div key={lib.id}>
<div className="p-4 flex items-center gap-3">
<button
onClick={() => toggle(lib.id)}
className="text-content-muted hover:text-content"
>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium text-content text-sm">{lib.name}</p>
{lib.description && (
<p className="text-xs text-content-muted">{lib.description}</p>
)}
</div>
<span className="text-xs text-content-muted">
{lib.original_filename ?? '—'}
</span>
<span className="badge-neutral text-xs">{matCount} materials</span>
<span className="badge-neutral text-xs">{ngCount} node groups</span>
<button
className="btn-secondary text-xs"
onClick={() => refreshMut.mutate(lib.id)}
disabled={refreshMut.isPending}
title="Re-scan catalog from .blend"
>
<RefreshCw size={12} />Refresh
</button>
<button
className="btn-danger text-xs"
onClick={() => {
setConfirmState({
open: true,
title: 'Delete Asset Library',
message: `Delete "${lib.name}"?`,
onConfirm: () => {
deleteMut.mutate(lib.id)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}}
>
<Trash2 size={12} />
</button>
</div>
{isExpanded && (
<div className="px-10 pb-4 space-y-3">
{matCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Materials</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.materials.map((m) => (
<span key={m} className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
{m}
</span>
))}
</div>
</div>
)}
{ngCount > 0 && (
<div>
<p className="text-xs font-medium text-content-muted mb-1">Node Groups</p>
<div className="flex flex-wrap gap-1">
{lib.catalog.node_groups.map((ng) => (
<span key={ng} className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{ng}
</span>
))}
</div>
</div>
)}
{matCount === 0 && ngCount === 0 && (
<p className="text-xs text-content-muted italic">
No assets found. Click "Refresh" to scan the .blend for marked assets.
</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
</div>
)
}