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>
This commit is contained in:
2026-03-15 09:20:45 +01:00
parent 5b92375d86
commit 9a794ff2da
7 changed files with 1013 additions and 784 deletions
@@ -1,4 +1,4 @@
import { useState } from 'react'
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
import {
@@ -19,6 +19,7 @@ interface EditState {
is_default: boolean
sort_order: number
focal_length_mm: number | null
sensor_width_mm: number | null
}
const EMPTY_EDIT: EditState = {
@@ -30,6 +31,7 @@ const EMPTY_EDIT: EditState = {
is_default: false,
sort_order: 0,
focal_length_mm: null,
sensor_width_mm: null,
}
export default function GlobalRenderPositionsPanel() {
@@ -44,7 +46,7 @@ export default function GlobalRenderPositionsPanel() {
const createMut = useMutation({
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false); setEditing(null) },
})
const updateMut = useMutation({
@@ -69,6 +71,7 @@ export default function GlobalRenderPositionsPanel() {
is_default: pos.is_default,
sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm,
sensor_width_mm: pos.sensor_width_mm,
})
}
@@ -96,23 +99,101 @@ export default function GlobalRenderPositionsPanel() {
setAdding(false)
}
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
function renderEditFormGrid() {
if (!editing) return null
return (
<div className="flex flex-col gap-0.5">
<label className="text-xs text-content-muted">{label}</label>
<input
type="number"
step="5"
className="input w-20 text-sm"
value={editing[field]}
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
/>
<div className="grid grid-cols-6 gap-x-4 gap-y-3">
{/* Row 1: Name + Is Default */}
<div className="col-span-4 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Name</label>
<input
className="input text-sm"
placeholder="e.g. Front View"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Is Default</label>
<label className="flex items-center gap-2 h-[34px]">
<input
type="checkbox"
checked={editing.is_default}
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
/>
<span className="text-sm text-content-secondary">Default position</span>
</label>
</div>
{/* Row 2: Rotation X, Y, Z */}
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Rotation X°</label>
<input
type="number"
step="5"
className="input w-24 text-sm"
value={editing.rotation_x}
onChange={(e) => setEditing({ ...editing, rotation_x: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Rotation Y°</label>
<input
type="number"
step="5"
className="input w-24 text-sm"
value={editing.rotation_y}
onChange={(e) => setEditing({ ...editing, rotation_y: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Rotation Z°</label>
<input
type="number"
step="5"
className="input w-24 text-sm"
value={editing.rotation_z}
onChange={(e) => setEditing({ ...editing, rotation_z: parseFloat(e.target.value) || 0 })}
/>
</div>
{/* Row 3: Focal Length, Sensor Width, Sort Order */}
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Focal Length mm</label>
<input
type="number"
step="1"
placeholder="50"
className="input w-24 text-sm"
value={editing.focal_length_mm ?? ''}
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Sensor Width mm</label>
<input
type="number"
step="0.1"
placeholder="36"
className="input w-24 text-sm"
value={editing.sensor_width_mm ?? ''}
onChange={(e) => setEditing({ ...editing, sensor_width_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</div>
<div className="col-span-2 flex flex-col gap-1">
<label className="text-xs font-medium text-content-muted">Sort Order</label>
<input
type="number"
className="input w-24 text-sm"
value={editing.sort_order}
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
)
}
if (isLoading) return <p className="text-sm text-content-muted">Loading</p>
if (isLoading) return <p className="text-sm text-content-muted">Loading...</p>
return (
<div className="space-y-3">
@@ -139,147 +220,99 @@ export default function GlobalRenderPositionsPanel() {
</tr>
</thead>
<tbody>
{positions.map((pos) => {
const isEditingThis = editing && editing.id === pos.id
return (
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
{isEditingThis ? (
<>
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
value={editing!.name}
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
step="1"
placeholder="50"
className="input w-16 text-sm"
value={editing!.focal_length_mm ?? ''}
onChange={(e) => setEditing({ ...editing!, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing!.is_default}
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing!.sort_order}
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</>
) : (
<>
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
</td>
<td className="py-1.5 pr-3 text-center">
{pos.is_default && <span className="text-accent text-xs font-medium"></span>}
</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
<td className="py-1.5 flex items-center gap-1">
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
<button
className="btn btn-xs text-blue-500"
onClick={() => createMut.mutate({
name: `${pos.name} (copy)`,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: false,
sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm,
sensor_width_mm: pos.sensor_width_mm,
})}
disabled={createMut.isPending}
title="Duplicate"
>
<Copy size={12} />
</button>
<button
className="btn btn-xs text-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
disabled={deleteMut.isPending}
>
<Trash2 size={12} />
</button>
</td>
</>
)}
{/* Add new — expandable form at top */}
{adding && editing && (
<>
<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 Position</div>
{renderEditFormGrid()}
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border-light">
<button className="btn btn-sm" onClick={cancelEdit}>
<X size={14} className="mr-1" /> Cancel
</button>
<button
className="btn btn-sm btn-primary"
onClick={saveNew}
disabled={createMut.isPending || !editing.name.trim()}
>
<Check size={14} className="mr-1" /> Create
</button>
</div>
</td>
</tr>
</>
)}
{positions.map((pos) => {
const isEditingThis = !adding && editing !== null && editing.id === pos.id
return (
<React.Fragment key={pos.id}>
{/* Display row — always visible */}
<tr className={`border-b border-border-light/50 hover:bg-surface-alt/30 ${isEditingThis ? 'bg-surface-hover' : ''}`}>
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
</td>
<td className="py-1.5 pr-3 text-center">
{pos.is_default && <span className="text-accent text-xs font-medium">&#10003;</span>}
</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
<td className="py-1.5 flex items-center gap-1">
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
<button
className="btn btn-xs text-blue-500"
onClick={() => createMut.mutate({
name: `${pos.name} (copy)`,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: false,
sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm,
sensor_width_mm: pos.sensor_width_mm,
})}
disabled={createMut.isPending}
title="Duplicate"
>
<Copy size={12} />
</button>
<button
className="btn btn-xs text-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
disabled={deleteMut.isPending}
>
<Trash2 size={12} />
</button>
</td>
</tr>
{/* Expandable edit form row */}
{isEditingThis && (
<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()}
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border-light">
<button className="btn btn-sm" onClick={cancelEdit}>
<X size={14} className="mr-1" /> Cancel
</button>
<button
className="btn btn-sm btn-primary"
onClick={saveEdit}
disabled={updateMut.isPending || !editing!.name.trim()}
>
<Check size={14} className="mr-1" /> Save
</button>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})}
{/* New row */}
{adding && editing && (
<tr className="border-b border-border-light bg-surface-alt/20">
<td className="py-1 pr-2">
<input
className="input w-32 text-sm"
placeholder="Name"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
/>
</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
step="1"
placeholder="50"
className="input w-16 text-sm"
value={editing.focal_length_mm ?? ''}
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
checked={editing.is_default}
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
className="input w-14 text-sm"
value={editing.sort_order}
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
/>
</td>
<td className="py-1 flex items-center gap-1">
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
<Check size={12} />
</button>
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
</td>
</tr>
)}
</tbody>
</table>
</div>
+246 -167
View File
@@ -1,18 +1,29 @@
import { useState } from 'react'
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X } from 'lucide-react'
import { Pencil, Trash2, Plus } from 'lucide-react'
import { toast } from 'sonner'
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
import type { PricingTier } from '../../api/pricing'
const EMPTY_FORM = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '' }
const ALL_CATEGORIES = [
{ key: 'default', label: 'default (Global Fallback)' },
{ 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 = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '', is_active: true }
export default function PricingTierTable() {
const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState<number | null>(null)
const [editDraft, setEditDraft] = useState<Partial<PricingTier>>({})
const [editDraft, setEditDraft] = useState<Partial<PricingTier> & { _price_str?: string }>({})
const { data: tiers, isLoading } = useQuery({
queryKey: ['pricing-tiers'],
@@ -26,6 +37,7 @@ export default function PricingTierTable() {
quality_level: form.quality_level.trim() || 'Normal',
price_per_item: parseFloat(form.price_per_item),
description: form.description.trim() || undefined,
is_active: form.is_active,
}),
onSuccess: () => {
toast.success('Pricing tier created')
@@ -37,14 +49,16 @@ export default function PricingTierTable() {
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> }) =>
updatePricingTier(id, {
category_key: data.category_key,
quality_level: data.quality_level,
price_per_item: data.price_per_item != null ? Number(data.price_per_item) : undefined,
description: data.description !== undefined ? data.description ?? undefined : undefined,
is_active: data.is_active,
}),
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> & { _price_str?: string } }) => {
const { _price_str, ...rest } = data
return updatePricingTier(id, {
category_key: rest.category_key,
quality_level: rest.quality_level,
price_per_item: rest.price_per_item != null ? Number(rest.price_per_item) : undefined,
description: rest.description !== undefined ? rest.description ?? undefined : undefined,
is_active: rest.is_active,
})
},
onSuccess: () => {
toast.success('Tier updated')
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
@@ -71,6 +85,7 @@ export default function PricingTierTable() {
price_per_item: tier.price_per_item,
description: tier.description ?? '',
is_active: tier.is_active,
_price_str: String(tier.price_per_item),
})
}
@@ -81,70 +96,175 @@ export default function PricingTierTable() {
const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
return (
<div>
{/* Add form toggle */}
<div className="p-4 border-b border-border-light">
{showAdd ? (
<div className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
/* Shared edit form grid — used by both edit-row and add-row */
function renderEditFormGrid(mode: 'add' | 'edit', tier: PricingTier | null) {
const catVal = mode === 'edit' ? (editDraft.category_key ?? tier?.category_key ?? '') : form.category_key
const qualVal = mode === 'edit' ? (editDraft.quality_level ?? tier?.quality_level ?? '') : form.quality_level
const priceVal = mode === 'edit' ? (editDraft._price_str ?? String(tier?.price_per_item ?? '')) : form.price_per_item
const descVal = mode === 'edit' ? (editDraft.description ?? tier?.description ?? '') : form.description
const activeVal = mode === 'edit' ? (editDraft.is_active ?? tier?.is_active ?? true) : form.is_active
const setCat = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, category_key: v })) : setForm((f) => ({ ...f, category_key: v }))
const setQual = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, quality_level: v })) : setForm((f) => ({ ...f, quality_level: v }))
const setPrice = (v: string) => {
if (mode === 'edit') {
setEditDraft((d) => ({ ...d, _price_str: v, price_per_item: v ? parseFloat(v) : undefined }))
} else {
setForm((f) => ({ ...f, price_per_item: v }))
}
}
const setDesc = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, description: v })) : setForm((f) => ({ ...f, description: v }))
const setActive = (v: boolean) => mode === 'edit' ? setEditDraft((d) => ({ ...d, is_active: v })) : setForm((f) => ({ ...f, is_active: v }))
return (
<div className="space-y-4">
{/* Row 1: Category Key, Quality Level, Price per Item */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Category Key</label>
<select
value={catVal}
onChange={(e) => setCat(e.target.value)}
className="input-base w-full"
>
<option value="">-- Select category --</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-secondary mb-1">Quality Level</label>
<input
type="text"
value={qualVal}
onChange={(e) => setQual(e.target.value)}
placeholder="e.g. Normal, Premium"
className="input-base w-full"
/>
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Price per Item</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted text-sm pointer-events-none">&#8364;</span>
<input
placeholder="Category key (e.g. TRB)"
value={form.category_key}
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
className="input-base"
title="Product category key this tier applies to (e.g. TRB, Kugellager). Leave empty for the global fallback tier."
/>
<input
placeholder="Quality level (e.g. Normal)"
value={form.quality_level}
onChange={(e) => setForm({ ...form, quality_level: e.target.value })}
className="input-base"
title="Quality level label for this tier (e.g. Normal, Premium). Used for display purposes."
/>
<input
placeholder="€ / item"
type="number"
step="0.01"
min="0"
value={form.price_per_item}
onChange={(e) => setForm({ ...form, price_per_item: e.target.value })}
className="input-base"
value={priceVal}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.00"
className="input-base w-full pl-7"
/>
<input
placeholder="Description (optional)"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
className="input-base"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => createMut.mutate()}
disabled={!canCreate || createMut.isPending}
className="btn-primary text-sm"
>
{createMut.isPending ? 'Saving…' : 'Add Tier'}
</button>
<button onClick={() => { setShowAdd(false); setForm(EMPTY_FORM) }} className="btn-secondary text-sm">
Cancel
</button>
</div>
</div>
) : (
<button onClick={() => setShowAdd(true)} className="btn-primary text-sm">
<Plus size={14} />
Add New Tier
</button>
)}
</div>
{/* Row 2: Description (full width) */}
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
<textarea
value={descVal}
onChange={(e) => setDesc(e.target.value)}
placeholder="Optional description for this pricing tier"
rows={2}
className="input-base w-full resize-y"
/>
</div>
{/* Row 3: Active checkbox + Save/Cancel */}
<div className="flex items-center justify-between pt-2 border-t border-border-light">
<label className="flex items-center gap-2 text-sm text-content-secondary cursor-pointer">
<input
type="checkbox"
checked={activeVal}
onChange={(e) => setActive(e.target.checked)}
className="w-4 h-4"
/>
Active
</label>
<div className="flex gap-2">
<button
className="btn-secondary text-sm"
onClick={() => {
if (mode === 'edit') {
cancelEdit()
} else {
setShowAdd(false)
setForm(EMPTY_FORM)
}
}}
>
Cancel
</button>
<button
className="btn-primary text-sm"
disabled={mode === 'add' ? (!canCreate || createMut.isPending) : updateMut.isPending}
onClick={() => {
if (mode === 'add') {
createMut.mutate()
} else if (tier) {
updateMut.mutate({ id: tier.id, data: editDraft })
}
}}
>
{mode === 'add'
? (createMut.isPending ? 'Saving...' : 'Add Tier')
: (updateMut.isPending ? 'Saving...' : 'Save Changes')
}
</button>
</div>
</div>
</div>
)
}
return (
<div>
{/* Add tier toggle button */}
<div className="p-4 border-b border-border-light">
<button
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setForm(EMPTY_FORM) }}
className="btn-primary text-sm"
>
<Plus size={14} />
Add New Tier
</button>
</div>
{/* Table */}
{isLoading ? (
<div className="p-6 text-center text-content-muted text-sm">Loading</div>
<div className="p-6 text-center text-content-muted text-sm">Loading...</div>
) : !tiers || tiers.length === 0 ? (
<div className="p-6 text-center text-content-muted text-sm">
No pricing tiers configured. Add one above.
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-surface-alt border-b border-border-default">
<tr>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
<th className="text-right px-4 py-2 font-medium text-content-secondary">&#8364; / Item</th>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{/* Add new — expandable form */}
{showAdd && (
<tr className="border-b border-border-light bg-status-success-bg">
<td colSpan={6} className="px-6 py-5 border-l-4 border-status-success-text">
<div className="text-sm font-medium text-content-secondary mb-3">New Pricing Tier</div>
{renderEditFormGrid('add', null)}
</td>
</tr>
)}
<tr>
<td colSpan={6} className="p-6 text-center text-content-muted text-sm">
No pricing tiers configured. Add one above.
</td>
</tr>
</tbody>
</table>
</div>
) : (
<div className="overflow-x-auto">
@@ -159,13 +279,23 @@ export default function PricingTierTable() {
<tr>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
<th className="text-right px-4 py-2 font-medium text-content-secondary"> / Item</th>
<th className="text-right px-4 py-2 font-medium text-content-secondary">&#8364; / Item</th>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{/* Add new — expandable form */}
{showAdd && (
<tr className="border-b border-border-light bg-status-success-bg">
<td colSpan={6} className="px-6 py-5 border-l-4 border-status-success-text">
<div className="text-sm font-medium text-content-secondary mb-3">New Pricing Tier</div>
{renderEditFormGrid('add', null)}
</td>
</tr>
)}
{[...tiers].sort((a, b) => {
// Sort 'default' to top
if (a.category_key === 'default' && b.category_key !== 'default') return -1
@@ -175,16 +305,10 @@ export default function PricingTierTable() {
const isEditing = editingId === tier.id
const isDefault = tier.category_key === 'default'
return (
<tr key={tier.id} className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''}`}>
<td className="px-4 py-2 font-mono font-medium text-content">
{isEditing ? (
<input
type="text"
value={editDraft.category_key ?? tier.category_key}
onChange={(e) => setEditDraft((d) => ({ ...d, category_key: e.target.value }))}
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
/>
) : (
<React.Fragment key={tier.id}>
{/* Display row — always visible */}
<tr className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''} ${isEditing ? 'bg-surface-hover' : ''}`}>
<td className="px-4 py-2 font-mono font-medium text-content">
<div className="flex items-center gap-2">
{tier.category_key}
{isDefault && (
@@ -193,101 +317,56 @@ export default function PricingTierTable() {
</span>
)}
</div>
)}
</td>
<td className="px-4 py-2 text-content-secondary">
{isEditing ? (
<input
type="text"
value={editDraft.quality_level ?? tier.quality_level}
onChange={(e) => setEditDraft((d) => ({ ...d, quality_level: e.target.value }))}
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
/>
) : (
tier.quality_level
)}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<input
type="number"
step="0.01"
min="0"
value={editDraft.price_per_item ?? tier.price_per_item}
onChange={(e) => setEditDraft((d) => ({ ...d, price_per_item: parseFloat(e.target.value) }))}
className="w-24 px-2 py-1 border border-border-default rounded text-sm text-right focus:outline-none focus:border-accent"
/>
) : (
<span className="font-medium"> {Number(tier.price_per_item).toFixed(2)}</span>
)}
</td>
<td className="px-4 py-2 text-content-muted">
{isEditing ? (
<input
type="text"
value={editDraft.description ?? tier.description ?? ''}
onChange={(e) => setEditDraft((d) => ({ ...d, description: e.target.value }))}
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
/>
) : (
tier.description || <span className="text-content-muted"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.is_active ?? tier.is_active}
onChange={(e) => setEditDraft((d) => ({ ...d, is_active: e.target.checked }))}
className="w-4 h-4"
/>
) : (
</td>
<td className="px-4 py-2 text-content-secondary">
{tier.quality_level}
</td>
<td className="px-4 py-2 text-right">
<span className="font-medium">&#8364; {Number(tier.price_per_item).toFixed(2)}</span>
</td>
<td className="px-4 py-2 text-content-muted">
{tier.description || <span className="text-content-muted"></span>}
</td>
<td className="px-4 py-2 text-center">
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
{tier.is_active ? 'yes' : 'no'}
</span>
)}
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
{isEditing ? (
<>
<button
onClick={() => updateMut.mutate({ id: tier.id, data: editDraft })}
disabled={updateMut.isPending}
className="p-1 text-status-success-text hover:bg-surface-hover rounded"
title="Save"
>
<Check size={15} />
</button>
<button
onClick={cancelEdit}
className="p-1 text-content-muted hover:bg-surface-muted rounded"
title="Cancel"
>
<X size={15} />
</button>
</>
) : (
<>
<button
onClick={() => startEdit(tier)}
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
title="Delete"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
if (isEditing) {
cancelEdit()
} else {
startEdit(tier)
}
}}
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
title={isEditing ? 'Collapse edit form' : 'Edit'}
>
<Pencil size={14} />
</button>
<button
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
{/* Expandable edit form row */}
{isEditing && (
<tr>
<td colSpan={6} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
{renderEditFormGrid('edit', tier)}
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react'
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'
@@ -156,7 +156,279 @@ export default function RenderTemplateTable() {
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
}
const inputCls = 'px-2 py-1 text-sm border border-border-default rounded bg-surface focus:outline-none focus:ring-1 focus:ring-blue-400'
// 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>
@@ -189,299 +461,73 @@ export default function RenderTemplateTable() {
<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 Type</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">
<span className="inline-flex items-center gap-1">
Mat. Replace
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Lighting Only
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium">
<span className="inline-flex items-center gap-1">
Shadow Catcher
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
</span>
</th>
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</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>
{/* Add row */}
{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 bg-surface-hover/40">
<td className="px-3 py-2">
<input
className={inputCls + ' w-40'}
placeholder="Template name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</td>
<td className="px-3 py-2">
<select
className={inputCls}
value={form.category_key}
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
>
<option value="">Any (default)</option>
{ALL_CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
</td>
<td className="px-3 py-2">
<select
className={inputCls}
value={form.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>
</td>
<td className="px-3 py-2">
<input
className={inputCls + ' w-28'}
value={form.target_collection}
onChange={(e) => setForm({ ...form, target_collection: e.target.value })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.material_replace_enabled}
onChange={(e) => setForm({ ...form, material_replace_enabled: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.lighting_only}
onChange={(e) => setForm({ ...form, lighting_only: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.shadow_catcher_enabled}
title="Enable Shadowcatcher collection (Cycles only)"
onChange={(e) => setForm({ ...form, shadow_catcher_enabled: e.target.checked })}
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={form.camera_orbit}
title="Rotate camera around product (better GPU performance)"
onChange={(e) => setForm({ ...form, camera_orbit: e.target.checked })}
/>
</td>
<td className="px-3 py-2">
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
<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={inputCls + ' text-xs w-32'}
value={cloneBlendFrom}
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
>
<option value="">or re-use existing</option>
{templates?.map((t) => (
<option key={t.id} value={t.id}>{t.original_filename} ({t.name})</option>
))}
</select>
)}
</div>
</td>
<td />
<td className="px-3 py-2">
<div className="flex gap-1">
<button
onClick={() => createMut.mutate()}
disabled={!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending}
className="p-1 text-status-success-text hover:bg-surface-hover rounded disabled:opacity-40"
title="Create"
>
<Check size={16} />
</button>
<button
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null); setCloneBlendFrom('') }}
className="p-1 text-content-muted hover:bg-surface-hover rounded"
title="Cancel"
>
<X size={16} />
</button>
</div>
<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 */}
{isLoading && (
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr>
)}
{templates?.map((t) => {
const isEditing = editingId === t.id
return (
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
{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">
{isEditing ? (
<input
className={inputCls + ' w-40'}
value={editDraft.name ?? t.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
/>
) : (
<span className="font-medium">{t.name}</span>
)}
<span className="font-medium">{t.name}</span>
</td>
<td className="px-3 py-2">
{isEditing ? (
<select
className={inputCls}
value={editDraft.category_key ?? t.category_key ?? ''}
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })}
>
<option value="">Any</option>
{ALL_CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
{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>
))}
</select>
) : (
t.category_key || <span className="text-content-muted">Any</span>
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="flex flex-col gap-0.5 max-h-32 overflow-y-auto">
{outputTypes?.map((ot: OutputType) => {
const checked = (editDraft.output_type_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 current = editDraft.output_type_ids ?? []
const next = checked
? current.filter((id: string) => id !== ot.id)
: [...current, ot.id]
setEditDraft({ ...editDraft, output_type_ids: next })
}}
/>
{ot.name}
</label>
)
})}
</div>
) : (
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>
)
<span className="text-content-muted">Any</span>
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<input
className={inputCls + ' w-28'}
value={editDraft.target_collection ?? t.target_collection}
onChange={(e) => setEditDraft({ ...editDraft, target_collection: e.target.value })}
/>
) : (
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
)}
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled}
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })}
/>
) : (
t.material_replace_enabled ? (
<span className="text-status-success-text text-xs font-medium">Yes</span>
) : (
<span className="text-content-muted text-xs">No</span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.lighting_only ?? t.lighting_only}
onChange={(e) => setEditDraft({ ...editDraft, lighting_only: e.target.checked })}
/>
) : (
t.lighting_only ? (
<span className="text-status-warning-text text-xs font-medium">HDR</span>
) : (
<span className="text-content-muted text-xs"></span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.shadow_catcher_enabled ?? t.shadow_catcher_enabled}
title="Enable Shadowcatcher collection (Cycles only)"
onChange={(e) => setEditDraft({ ...editDraft, shadow_catcher_enabled: e.target.checked })}
/>
) : (
t.shadow_catcher_enabled ? (
<span className="text-violet-600 text-xs font-medium">On</span>
) : (
<span className="text-content-muted text-xs"></span>
)
)}
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.camera_orbit ?? t.camera_orbit}
title="Rotate camera around product (better GPU performance)"
onChange={(e) => setEditDraft({ ...editDraft, camera_orbit: e.target.checked })}
/>
) : (
t.camera_orbit ? (
<span className="text-teal-600 text-xs font-medium">Cam</span>
) : (
<span className="text-content-muted text-xs">Obj</span>
)
)}
<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">
@@ -491,74 +537,50 @@ export default function RenderTemplateTable() {
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename}
</span>
<button
onClick={() => { setReuploadId(t.id); reuploadRef.current?.click() }}
className="p-0.5 text-accent hover:bg-surface-hover rounded"
title="Re-upload .blend"
>
<Upload size={12} />
</button>
<a
href={`/api/render-templates/${t.id}/download`}
className="p-0.5 text-accent hover:bg-surface-hover rounded"
title="Download .blend"
>
<Download size={12} />
</a>
</div>
</td>
<td className="px-3 py-2 text-center">
{isEditing ? (
<input
type="checkbox"
checked={editDraft.is_active ?? t.is_active}
onChange={(e) => setEditDraft({ ...editDraft, is_active: e.target.checked })}
/>
{t.is_active ? (
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
) : (
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" />
)
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
)}
</td>
<td className="px-3 py-2">
{isEditing ? (
<div className="flex gap-1">
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save">
<Check size={16} />
</button>
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel">
<X size={16} />
</button>
</div>
) : (
<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>
)}
<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={11} className="px-3 py-6 text-center text-content-muted">
<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>