Files
HartOMat/frontend/src/components/admin/RenderTemplateTable.tsx
T
Hartmut 9a794ff2da refactor: full UI/UX cleanup — expandable edit rows, better controls, cleaner UX
Admin tables (same pattern as OutputTypeTable):
- RenderTemplateTable: 11 cramped columns → expandable form row with grouped fields,
  boolean flags consolidated into compact badges, .blend upload in proper section
- PricingTierTable: inline cell editing → expandable form with labeled fields,
  shared renderEditFormGrid() for add/edit modes
- GlobalRenderPositionsPanel: tiny rotation inputs → expandable form with w-24 inputs,
  proper labels, sensor_width_mm added to edit form

Page polish:
- WorkerManagement: larger scale controls (p-2 rounded-lg), wider number displays (w-12),
  proper labels, more prominent Save button
- Billing: status select gets visible dropdown indicator (ChevronDown icon),
  hover border to signal interactivity, larger action buttons with borders
- OrderDetail: batch override in proper card with title/description,
  per-line override shows compact "+ override" link (expands on click),
  active overrides show as amber badge with X to clear

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 09:20:45 +01:00

597 lines
25 KiB
TypeScript

import React, { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
import HelpTooltip from '../HelpTooltip'
import { toast } from 'sonner'
import {
listRenderTemplates,
createRenderTemplate,
duplicateRenderTemplate,
updateRenderTemplate,
deleteRenderTemplate,
reuploadBlendFile,
} from '../../api/renderTemplates'
import type { RenderTemplate } from '../../api/renderTemplates'
import { listOutputTypes } from '../../api/outputTypes'
import type { OutputType } from '../../api/outputTypes'
const ALL_CATEGORIES = [
{ key: 'TRB', label: 'TRB' },
{ key: 'Kugellager', label: 'Kugellager' },
{ key: 'CRB', label: 'CRB' },
{ key: 'Gleitlager', label: 'Gleitlager' },
{ key: 'SRB_TORB', label: 'SRB/TORB' },
{ key: 'Linear_schiene', label: 'Linear' },
{ key: 'Anschlagplatten', label: 'Anschlag' },
]
const EMPTY_FORM = {
name: '',
category_key: '' as string,
output_type_id: '' as string,
target_collection: 'Product',
material_replace_enabled: false,
lighting_only: false,
shadow_catcher_enabled: false,
camera_orbit: true,
}
export default function RenderTemplateTable() {
const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState(EMPTY_FORM)
const [addFile, setAddFile] = useState<File | null>(null)
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const reuploadRef = useRef<HTMLInputElement>(null)
const [reuploadId, setReuploadId] = useState<string | null>(null)
const { data: templates, isLoading } = useQuery({
queryKey: ['render-templates'],
queryFn: listRenderTemplates,
})
const { data: outputTypes } = useQuery({
queryKey: ['output-types-admin'],
queryFn: () => listOutputTypes(true),
})
const createMut = useMutation({
mutationFn: () => {
if (!addFile && !cloneBlendFrom) throw new Error('Please select a .blend file or choose an existing one')
const fd = new FormData()
fd.append('name', form.name.trim())
if (addFile) {
fd.append('file', addFile)
} else if (cloneBlendFrom) {
fd.append('clone_blend_from', cloneBlendFrom)
}
fd.append('category_key', form.category_key || '')
fd.append('output_type_id', form.output_type_id || '')
fd.append('target_collection', form.target_collection || 'Product')
fd.append('material_replace_enabled', String(form.material_replace_enabled))
fd.append('lighting_only', String(form.lighting_only))
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
fd.append('camera_orbit', String(form.camera_orbit))
return createRenderTemplate(fd)
},
onSuccess: () => {
toast.success('Render template created')
qc.invalidateQueries({ queryKey: ['render-templates'] })
setForm(EMPTY_FORM)
setAddFile(null)
setCloneBlendFrom('')
setShowAdd(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
updateRenderTemplate(id, data as any),
onSuccess: () => {
toast.success('Template updated')
qc.invalidateQueries({ queryKey: ['render-templates'] })
setEditingId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
})
const deleteMut = useMutation({
mutationFn: deleteRenderTemplate,
onSuccess: () => {
toast.success('Template deleted')
qc.invalidateQueries({ queryKey: ['render-templates'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const reuploadMut = useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => reuploadBlendFile(id, file),
onSuccess: () => {
toast.success('.blend file updated')
qc.invalidateQueries({ queryKey: ['render-templates'] })
setReuploadId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to upload'),
})
const duplicateMut = useMutation({
mutationFn: (t: RenderTemplate) => duplicateRenderTemplate(t.id, {
name: `${t.name} (copy)`,
category_key: t.category_key,
target_collection: t.target_collection,
material_replace_enabled: t.material_replace_enabled,
lighting_only: t.lighting_only,
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
output_type_ids: t.output_type_ids ?? [],
}),
onSuccess: () => {
toast.success('Template duplicated')
qc.invalidateQueries({ queryKey: ['render-templates'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to duplicate'),
})
function startEdit(t: RenderTemplate) {
setEditingId(t.id)
setEditDraft({
name: t.name,
category_key: t.category_key,
output_type_ids: t.output_type_ids ?? [],
target_collection: t.target_collection,
material_replace_enabled: t.material_replace_enabled,
lighting_only: t.lighting_only,
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
is_active: t.is_active,
})
}
function saveEdit() {
if (!editingId) return
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
}
// Render the edit form grid (shared between edit-row and add-row)
function renderEditFormGrid(
mode: 'edit' | 'add',
t: RenderTemplate | null,
) {
const isEdit = mode === 'edit' && t !== null
// Value getters
const val = (field: keyof typeof EMPTY_FORM | 'is_active' | 'output_type_ids') => {
if (isEdit) {
if (field === 'name') return editDraft.name ?? t!.name
if (field === 'category_key') return editDraft.category_key ?? t!.category_key ?? ''
if (field === 'output_type_ids') return editDraft.output_type_ids ?? t!.output_type_ids ?? []
if (field === 'target_collection') return editDraft.target_collection ?? t!.target_collection
if (field === 'material_replace_enabled') return editDraft.material_replace_enabled ?? t!.material_replace_enabled
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
return (editDraft as any)[field] ?? (t as any)[field]
}
return (form as any)[field]
}
const set = (field: string, value: any) => {
if (isEdit) {
setEditDraft({ ...editDraft, [field]: value } as any)
} else {
setForm({ ...form, [field]: value })
}
}
return (
<>
{/* Row 1: Name | Category | Output Types */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Name</label>
<input
className="input-sm w-full"
placeholder="Template name"
value={val('name') as string}
onChange={(e) => set('name', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Category</label>
<select
className="input-sm w-full"
value={val('category_key') as string}
onChange={(e) => set('category_key', e.target.value || (isEdit ? null : ''))}
>
<option value="">Any (default)</option>
{ALL_CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Output Types</label>
{isEdit ? (
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto p-1.5 border border-border-default rounded bg-surface">
{outputTypes?.map((ot: OutputType) => {
const ids = val('output_type_ids') as string[]
const checked = ids.includes(ot.id)
return (
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={checked}
onChange={() => {
const next = checked
? ids.filter((id: string) => id !== ot.id)
: [...ids, ot.id]
set('output_type_ids', next)
}}
/>
{ot.name}
</label>
)
})}
</div>
) : (
<select
className="input-sm w-full"
value={(form as any).output_type_id ?? ''}
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
>
<option value="">Any (default)</option>
{outputTypes?.map((ot: OutputType) => (
<option key={ot.id} value={ot.id}>{ot.name}</option>
))}
</select>
)}
</div>
</div>
{/* Row 2: Collection | Checkboxes */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Collection Name</label>
<input
className="input-sm w-full"
value={val('target_collection') as string}
onChange={(e) => set('target_collection', e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">
<span className="inline-flex items-center gap-1">
Mat. Replace
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
</span>
</label>
<label className="flex items-center gap-2 mt-1">
<input
type="checkbox"
checked={val('material_replace_enabled') as boolean}
onChange={(e) => set('material_replace_enabled', e.target.checked)}
/>
<span className="text-sm text-content-secondary">Enabled</span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">
<span className="inline-flex items-center gap-1">
Lighting Only
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
</span>
</label>
<label className="flex items-center gap-2 mt-1">
<input
type="checkbox"
checked={val('lighting_only') as boolean}
onChange={(e) => set('lighting_only', e.target.checked)}
/>
<span className="text-sm text-content-secondary">HDR only</span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">
<span className="inline-flex items-center gap-1">
Shadow Catcher
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
</span>
</label>
<label className="flex items-center gap-2 mt-1">
<input
type="checkbox"
checked={val('shadow_catcher_enabled') as boolean}
title="Enable Shadowcatcher collection (Cycles only)"
onChange={(e) => set('shadow_catcher_enabled', e.target.checked)}
/>
<span className="text-sm text-content-secondary">Enabled</span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-content-muted mb-1">Camera Orbit</label>
<label className="flex items-center gap-2 mt-1">
<input
type="checkbox"
checked={val('camera_orbit') as boolean}
title="Rotate camera around product (better GPU performance)"
onChange={(e) => set('camera_orbit', e.target.checked)}
/>
<span className="text-sm text-content-secondary">Cam orbit</span>
</label>
</div>
</div>
{/* Row 3: .blend File */}
<div className="mt-4">
<label className="block text-xs font-medium text-content-muted mb-1">.blend File</label>
{isEdit ? (
<div className="flex items-center gap-3">
<span className="text-sm text-content-secondary truncate max-w-xs" title={t!.original_filename}>
{t!.original_filename}
</span>
{templates && templates.filter((o) => o.blend_file_path === t!.blend_file_path).length > 1 && (
<span className="text-xs text-blue-500" title="Shared .blend file">&#8727; shared</span>
)}
<button
onClick={() => { setReuploadId(t!.id); reuploadRef.current?.click() }}
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
title="Re-upload .blend"
>
<Upload size={12} /> Re-upload
</button>
<a
href={`/api/render-templates/${t!.id}/download`}
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
title="Download .blend"
>
<Download size={12} /> Download
</a>
</div>
) : (
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-sm cursor-pointer text-accent hover:text-accent-hover px-2 py-1 border border-border-default rounded">
<Upload size={14} />
{addFile ? addFile.name : 'Upload .blend'}
<input
ref={fileInputRef}
type="file"
accept=".blend"
className="hidden"
onChange={(e) => { setAddFile(e.target.files?.[0] || null); setCloneBlendFrom('') }}
/>
</label>
{!addFile && (
<select
className="input-sm text-xs"
value={cloneBlendFrom}
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
>
<option value="">or re-use existing...</option>
{templates?.map((tmpl) => (
<option key={tmpl.id} value={tmpl.id}>{tmpl.original_filename} ({tmpl.name})</option>
))}
</select>
)}
</div>
)}
</div>
{/* Row 4: Active + Save/Cancel */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
{isEdit ? (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={val('is_active') as boolean}
onChange={(e) => set('is_active', e.target.checked)}
/>
<span className="text-sm text-content-secondary">Active (visible in wizard)</span>
</label>
) : (
<span className="text-sm text-content-muted">Will be active by default</span>
)}
<div className="flex gap-2">
<button
className="btn-secondary text-sm"
onClick={() => {
if (isEdit) {
setEditingId(null)
} else {
setShowAdd(false)
setForm(EMPTY_FORM)
setAddFile(null)
setCloneBlendFrom('')
}
}}
>
Cancel
</button>
<button
className="btn-primary text-sm"
disabled={isEdit ? updateMut.isPending : (!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending)}
onClick={() => {
if (isEdit) {
saveEdit()
} else {
createMut.mutate()
}
}}
>
{isEdit ? (updateMut.isPending ? 'Saving...' : 'Save') : (createMut.isPending ? 'Creating...' : 'Create')}
</button>
</div>
</div>
</>
)
}
return (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-content">Render Templates</h3>
<button
onClick={() => setShowAdd(!showAdd)}
className="flex items-center gap-1 text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
<Plus size={14} /> Add Template
</button>
</div>
{/* Hidden file inputs */}
<input
ref={reuploadRef}
type="file"
accept=".blend"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file && reuploadId) reuploadMut.mutate({ id: reuploadId, file })
e.target.value = ''
}}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-surface-alt border-b text-left">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Category</th>
<th className="px-3 py-2 font-medium">Output Types</th>
<th className="px-3 py-2 font-medium">Collection</th>
<th className="px-3 py-2 font-medium">Flags</th>
<th className="px-3 py-2 font-medium">.blend File</th>
<th className="px-3 py-2 font-medium">Active</th>
<th className="px-3 py-2 font-medium w-24">Actions</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={8} className="px-3 py-4 text-center text-content-muted">Loading...</td>
</tr>
)}
{/* Add new — expandable form */}
{showAdd && (
<tr className="border-b border-border-light bg-status-success-bg">
<td colSpan={8} className="px-6 py-5 border-l-4 border-status-success-text">
<div className="text-sm font-medium text-content-secondary mb-3">New Render Template</div>
{renderEditFormGrid('add', null)}
</td>
</tr>
)}
{/* Template rows */}
{templates?.map((t) => (
<React.Fragment key={t.id}>
{/* Display row — always visible */}
<tr className={`border-b border-border-light hover:bg-surface-hover/50 ${editingId === t.id ? 'bg-surface-hover' : ''}`}>
<td className="px-3 py-2">
<span className="font-medium">{t.name}</span>
</td>
<td className="px-3 py-2">
{t.category_key || <span className="text-content-muted">Any</span>}
</td>
<td className="px-3 py-2">
{t.output_type_names && t.output_type_names.length > 0 ? (
<div className="flex flex-wrap gap-1">
{t.output_type_names.map((name, i) => (
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
{name}
</span>
))}
</div>
) : (
<span className="text-content-muted">Any</span>
)}
</td>
<td className="px-3 py-2">
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{t.material_replace_enabled && (
<span className="text-status-success-text text-xs font-medium" title="Material Replace">Mat</span>
)}
{t.lighting_only && (
<span className="text-status-warning-text text-xs font-medium" title="Lighting Only (HDR)">HDR</span>
)}
{t.shadow_catcher_enabled && (
<span className="text-violet-600 text-xs font-medium" title="Shadow Catcher">Shd</span>
)}
<span className={`text-xs font-medium ${t.camera_orbit ? 'text-teal-600' : 'text-content-muted'}`} title={t.camera_orbit ? 'Camera Orbit' : 'Object Rotation'}>
{t.camera_orbit ? 'Cam' : 'Obj'}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{templates && templates.filter((o) => o.blend_file_path === t.blend_file_path).length > 1 && (
<span className="text-xs text-blue-500" title="Shared .blend file">&#8727;</span>
)}
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename}
</span>
</div>
</td>
<td className="px-3 py-2 text-center">
{t.is_active ? (
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
) : (
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
)}
</td>
<td className="px-3 py-2">
<div className="flex gap-1">
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
<Pencil size={14} />
</button>
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
<Copy size={14} />
</button>
<button
onClick={() => {
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
}}
className="p-1 text-red-500 hover:bg-red-50 rounded"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
{/* Expandable edit form row */}
{editingId === t.id && (
<tr>
<td colSpan={8} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
{renderEditFormGrid('edit', t)}
</td>
</tr>
)}
</React.Fragment>
))}
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
<tr>
<td colSpan={8} className="px-3 py-6 text-center text-content-muted">
No render templates configured. Click "Add Template" to create one.
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-content-muted">
Templates define pre-designed .blend studio setups. When rendering, the system matches templates by Category + Output Type with fallback cascade.
</p>
</div>
)
}