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>
+18 -15
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
import { Receipt, Download, Trash2, Plus, X, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
@@ -194,26 +194,29 @@ export default function BillingPage() {
<tr key={inv.id} className="border-b border-border-default hover:bg-surface-hover transition-colors">
<td className="px-4 py-3 text-sm font-mono text-content">{inv.invoice_number}</td>
<td className="px-4 py-3">
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
<div className="inline-flex items-center gap-1 border border-transparent hover:border-border-default rounded-full transition-colors cursor-pointer pr-1">
<select
value={inv.status}
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent appearance-none ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
>
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
<ChevronDown size={12} className="text-content-muted pointer-events-none -ml-0.5" />
</div>
</td>
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.issued_at)}</td>
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
<td className="px-4 py-3 flex items-center gap-1">
<td className="px-4 py-3 flex items-center gap-2">
<button
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
className="p-2 rounded-lg border border-border-default hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
title="Download PDF"
>
<Download size={15} />
<Download size={16} />
</button>
{inv.status === 'draft' && (
<button
@@ -228,10 +231,10 @@ export default function BillingPage() {
},
})
}}
className="p-1.5 rounded hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
className="p-2 rounded-lg border border-border-default hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
title="Delete draft"
>
<Trash2 size={15} />
<Trash2 size={16} />
</button>
)}
</td>
+82 -32
View File
@@ -619,26 +619,33 @@ export default function OrderDetailPage() {
)}
{(order.lines?.length ?? 0) > 0 && isPrivileged && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-xs text-content-muted">Batch material override:</span>
<select
className="text-xs border border-border-default rounded px-2 py-1"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value=""
onChange={(e) => {
const val = e.target.value
if (val === '__clear__') batchOverrideMut.mutate(null)
else if (val) batchOverrideMut.mutate(val)
}}
disabled={batchOverrideMut.isPending}
>
<option value="">Apply to all lines</option>
<option value="__clear__"> Clear all overrides </option>
{orderLibMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
{batchOverrideMut.isPending && <Loader2 size={12} className="animate-spin text-accent" />}
<div className="mb-4 rounded-xl border border-border-default p-4" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="text-sm font-medium text-content">Batch Material Override</p>
<p className="text-xs text-content-muted mt-0.5">Apply a single material to all render lines in this order at once.</p>
</div>
<div className="flex items-center gap-2">
<select
className="text-sm border border-border-default rounded-lg px-3 py-1.5"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value=""
onChange={(e) => {
const val = e.target.value
if (val === '__clear__') batchOverrideMut.mutate(null)
else if (val) batchOverrideMut.mutate(val)
}}
disabled={batchOverrideMut.isPending}
>
<option value="">Apply to all lines</option>
<option value="__clear__"> Clear all overrides </option>
{orderLibMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
{batchOverrideMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />}
</div>
</div>
</div>
)}
@@ -903,6 +910,7 @@ function OrderLineRow({
const [showInfo, setShowInfo] = useState(false)
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
const [rejectLineReason, setRejectLineReason] = useState('')
const [showOverride, setShowOverride] = useState(false)
const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id),
@@ -1028,18 +1036,60 @@ function OrderLineRow({
</span>
)}
{isPrivileged && (
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
value={line.material_override ?? ''}
onChange={(e) => overrideMut.mutate(e.target.value || null)}
title="Material override — apply a single material to all parts for this render"
>
<option value="">No material override</option>
{libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
line.material_override ? (
<div className="flex items-center gap-1 mt-1">
<span
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium cursor-pointer hover:bg-amber-500/20 transition-colors"
onClick={() => setShowOverride(!showOverride)}
title="Click to change material override"
>
{line.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}
</span>
<button
onClick={() => overrideMut.mutate(null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Clear override"
>
<X size={10} />
</button>
{showOverride && (
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5 flex-1"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value={line.material_override ?? ''}
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
autoFocus
>
<option value="">No material override</option>
{libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
)}
</div>
) : showOverride ? (
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value=""
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
onBlur={() => setShowOverride(false)}
autoFocus
>
<option value="">No material override</option>
{libMats.map((m: Material) => (
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
))}
</select>
) : (
<button
onClick={() => setShowOverride(true)}
className="text-[10px] text-content-muted hover:text-accent mt-1 transition-colors"
title="Set material override for this line"
>
+ override
</button>
)
)}
</div>
</td>
+35 -30
View File
@@ -109,24 +109,29 @@ function ScaleControl({
<p className="text-sm font-medium text-content">{label}</p>
<p className="text-xs text-content-muted mt-0.5">{description}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => setCount((c) => Math.max(0, c - 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={14} />
</button>
<span className="w-6 text-center text-sm font-semibold text-content">{count}</span>
<button
onClick={() => setCount((c) => Math.min(20, c + 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={14} />
</button>
<div className="flex items-center gap-3 shrink-0">
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-medium text-content-muted">Current Scale</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => setCount((c) => Math.max(0, c - 1))}
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={14} />
</button>
<span className="w-12 text-center text-sm font-semibold text-content">{count}</span>
<button
onClick={() => setCount((c) => Math.min(20, c + 1))}
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={14} />
</button>
</div>
</div>
<button
onClick={() => scaleMut.mutate()}
disabled={scaleMut.isPending}
className="btn-primary text-xs px-3 py-1.5 ml-2"
className="btn-primary text-sm px-5 py-2 font-semibold ml-2"
>
{scaleMut.isPending ? 'Scaling…' : 'Scale'}
</button>
@@ -202,46 +207,46 @@ function ConcurrencyConfigRow({ config }: { config: WorkerConfig }) {
<div className="flex items-center gap-6 shrink-0">
{/* Min concurrency */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Min</span>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-content-muted">Min Concurrency</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => setMinVal((v) => Math.max(1, v - 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={12} />
<Minus size={14} />
</button>
<span className="w-6 text-center text-sm font-semibold text-content">{minVal}</span>
<span className="w-12 text-center text-sm font-semibold text-content">{minVal}</span>
<button
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={12} />
<Plus size={14} />
</button>
</div>
</div>
{/* Max concurrency */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Max</span>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-content-muted">Max Concurrency</span>
<div className="flex items-center gap-1.5">
<button
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Minus size={12} />
<Minus size={14} />
</button>
<span className="w-6 text-center text-sm font-semibold text-content">{maxVal}</span>
<span className="w-12 text-center text-sm font-semibold text-content">{maxVal}</span>
<button
onClick={() => setMaxVal((v) => Math.min(64, v + 1))}
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={12} />
<Plus size={14} />
</button>
</div>
</div>
<button
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || !isDirty}
className={`btn-primary text-xs px-3 py-1.5 ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
className={`btn-primary text-sm px-5 py-2 font-semibold ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{saveMut.isPending ? 'Saving…' : 'Save'}
</button>
+98 -61
View File
@@ -1,86 +1,123 @@
# Plan: Duplicate Product Detection
# Plan: Full UI/UX Cleanup & Simplification
## Context
When importing products via Excel or STEP upload, there's no warning when a product already exists with a different STEP file. This can lead to accidental overwrites or confusion. The feature adds non-blocking warnings (yellow badges, toasts) at import time.
The OutputTypeTable was refactored from cramped 18-column inline editing to an expandable form row — a dramatic UX improvement. The same pattern should be applied to all other admin tables that suffer from the same problem. Additionally, several pages have UX inconsistencies (mixed editing patterns, tiny inputs, confusing controls) that need cleanup.
## Detection Scenarios
1. **Excel preview**: Product exists in DB with a different STEP file linked → warning icon
2. **Excel preview**: Same product in two rows with different `name_cad_modell` → conflict badge
3. **STEP upload on product**: Replacing an existing STEP file on a product that has renders → toast warning
4. **All warnings are non-blocking** — user can still proceed
**Principle**: Tables are for **viewing** data. Editing happens in **expandable rows** or **modals** — never in cramped inline cells.
## Affected Files
| File | Change |
|------|--------|
| `backend/app/services/excel_import.py` | Add STEP conflict detection in `preview_excel_rows()` |
| `backend/app/api/routers/uploads.py` | Extend preview response with conflict fields |
| `backend/app/api/routers/products.py` | Add render-count warning to CAD upload response |
| `frontend/src/api/uploads.ts` | Update TypeScript interfaces |
| `frontend/src/pages/Upload.tsx` | Display conflict warnings in preview table |
| `frontend/src/api/products.ts` | Add warning fields to CAD upload response type |
| `frontend/src/pages/ProductDetail.tsx` | Show toast warnings on STEP replacement |
| File | Change | Priority |
|------|--------|----------|
| `frontend/src/components/admin/RenderTemplateTable.tsx` | Expandable edit row (11 cramped columns) | HIGH |
| `frontend/src/components/admin/PricingTierTable.tsx` | Expandable edit row (6 columns, mixed add/edit) | HIGH |
| `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx` | Expandable edit row (8 columns, tiny inputs) | MEDIUM |
| `frontend/src/pages/WorkerManagement.tsx` | Larger touch targets, better scale controls | MEDIUM |
| `frontend/src/pages/Billing.tsx` | Fix status dropdown disguised as badge | MEDIUM |
| `frontend/src/pages/OrderDetail.tsx` | Cleaner line table, material override UX | LOW |
## Tasks
## Tasks (in order)
### [ ] Task 1: Backend — STEP conflict detection in Excel preview
### [x] Task 1: RenderTemplateTable — expandable edit row
- **File**: `backend/app/services/excel_import.py`
- **What**: In `preview_excel_rows()`, after the product lookup:
1. If product exists and has `cad_file_id`, load the CadFile and compare `original_name` (stem) with the Excel row's `name_cad_modell`
2. If they differ → set `step_conflict=True` with details
3. Track `name_cad_modell` per product key in the `seen` dict
4. If same product appears again with different `name_cad_modell` → set `cad_name_conflict=True`
- **Also**: Add `selectinload(Product.cad_file)` to `lookup_product()` in `backend/app/domains/products/service.py`
- **File**: `frontend/src/components/admin/RenderTemplateTable.tsx`
- **What**: Same pattern as OutputTypeTable refactor:
1. Display row ALWAYS shows (no conditional switching)
2. Edit form opens as a new `<tr>` below with `colSpan` spanning all columns
3. Grid form inside with labeled fields, grouped logically:
- Row 1: Name, Category, Output Types (multi-select)
- Row 2: Collection name, Material Replace, Lighting Only, Shadow Catcher, Camera Orbit
- Row 3: .blend File (upload/download/re-upload with proper spacing)
- Row 4: Active toggle + Save/Cancel buttons
4. "Add new" form also uses the expandable pattern (button at top opens a full-width form row)
5. Use `React.Fragment` for dual-row rendering
- **Acceptance gate**: Edit mode shows a well-organized form below the display row; .blend file upload has proper spacing; no horizontal overflow
- **Dependencies**: None
- **Risk**: The .blend file upload flow (upload vs clone) is complex — needs careful preservation
### [ ] Task 2: Backend — Extend preview response models
### [x] Task 2: PricingTierTable — expandable edit row
- **File**: `backend/app/api/routers/uploads.py`
- **What**: Add to the preview row dict and response:
- `step_conflict: bool`, `step_conflict_existing_name: str | None`, `step_conflict_excel_name: str | None`
- `cad_name_conflict: bool`, `cad_name_conflict_other_name: str | None`, `cad_name_conflict_row: int | None`
- Response-level: `step_conflict_count: int`, `cad_name_conflict_count: int`
- **Dependencies**: Task 1
### [ ] Task 3: Backend — Render warning on product STEP replacement
- **File**: `backend/app/api/routers/products.py`
- **What**: In `upload_product_cad()`, before replacing cad_file_id:
1. Check if product already has a different `cad_file_id`
2. Count existing MediaAssets (renders) for this product
3. Add `warnings: list[str]` and `existing_render_count: int` to response
- **File**: `frontend/src/components/admin/PricingTierTable.tsx`
- **What**: Replace inline cell editing with expandable form row:
1. Display row always visible with read-only values
2. Edit form as expandable row below with grid layout:
- Row 1: Category Key (select), Quality Level (input), Price per Item (number input)
- Row 2: Description (textarea, full width)
- Row 3: Active toggle + Save/Cancel
3. "Add Tier" button opens the same expandable form at the top
4. Consistent visual: accent left border on edit row
- **Acceptance gate**: No inline cell editing; form has proper labels; description gets full width
- **Dependencies**: None
- **Risk**: Low — simple table with few fields
### [ ] Task 4: Frontend — Update API types
### [x] Task 3: GlobalRenderPositionsPanel — expandable edit row
- **File**: `frontend/src/api/uploads.ts`
- **What**: Add conflict fields to `ExcelPreviewRow` and `ExcelPreviewResult` interfaces
- **Also**: `frontend/src/api/products.ts` — add `warnings?: string[]` and `existing_render_count?: number` to `ProductCadUploadResponse`
- **Dependencies**: Tasks 2, 3
- **File**: `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx`
- **What**: Replace inline cell editing with expandable form row:
1. Display row keeps compact view (Name, X°, Y°, Z°, Focal, Default, Order)
2. Edit form as expandable row:
- Row 1: Name (wide), Is Default (checkbox with label)
- Row 2: Rotation X°, Rotation Y°, Rotation Z° (number inputs with proper width)
- Row 3: Focal Length mm, Sensor Width mm, Sort Order
- Row 4: Save/Cancel buttons
3. "Add Position" opens same expandable form
4. Wider number inputs (`w-24` instead of `w-16`) for comfortable editing
- **Acceptance gate**: Rotation inputs are comfortable to edit; no cramped cells; proper labels
- **Dependencies**: None
- **Risk**: Low — straightforward table
### [ ] Task 5: FrontendShow conflict warnings in Upload preview
### [x] Task 4: WorkerManagementbetter scale controls
- **File**: `frontend/src/pages/Upload.tsx`
- **What**:
1. Add StatCards for `step_conflict_count` and `cad_name_conflict_count` (amber color, AlertTriangle icon)
2. In the preview table rows: yellow warning icon with tooltip for `step_conflict` and `cad_name_conflict`
- **Dependencies**: Task 4
- **File**: `frontend/src/pages/WorkerManagement.tsx`
- **What**: Improve the concurrency/scale controls:
1. Replace tiny `w-6` number displays with `w-12` minimum
2. Make up/down buttons larger (`p-2` instead of default, `rounded-lg`)
3. Add proper labels above each control ("Min Concurrency", "Max Concurrency")
4. Group the scale controls in a card with clear section header
5. Make the "Save" button more prominent (full-width at bottom of card)
- **Acceptance gate**: Controls are easy to click; labels are clear; no overflow on mobile
- **Dependencies**: None
- **Risk**: Low — cosmetic changes only
### [ ] Task 6: Frontend — Show warnings on STEP replacement
### [x] Task 5: Billing — fix status dropdown UX
- **File**: `frontend/src/pages/ProductDetail.tsx`
- **What**: In `cadUploadMut.onSuccess`, check response for `warnings` and show `toast.warning()` for each
- **Dependencies**: Task 4
- **File**: `frontend/src/pages/Billing.tsx`
- **What**: Fix the status dropdown that looks like a badge:
1. Replace the `<select>` styled as a badge with an explicit dropdown button that opens a small popover/menu
2. OR: keep the select but add a visible dropdown arrow indicator and border on hover to signal interactivity
3. Make action buttons (download, delete) slightly larger with visible borders
4. Add tooltips to icon-only action buttons
- **Acceptance gate**: Users can tell the status is interactive (not just a badge); action buttons are easy to click
- **Dependencies**: None
- **Risk**: Low — UI-only changes
### [x] Task 6: OrderDetail — cleaner material override UX
- **File**: `frontend/src/pages/OrderDetail.tsx`
- **What**: Polish the per-line material override dropdown:
1. Move the material override dropdown from inside the "Output Type" column to its own column or a cleaner inline position
2. Only show the dropdown on hover or when the line has an override set (reduce visual noise)
3. The batch override dropdown above the table should be more visually prominent — card-style with label, not just inline text + select
- **Acceptance gate**: Material override is discoverable but not visually noisy; batch override is clearly labeled
- **Dependencies**: None
- **Risk**: Low — visual adjustment
## Migration Check
**No** — all detection is computed from existing data.
**No** — all changes are frontend-only.
## Order
## Order Recommendation
1. Backend Tasks 1+2 (Excel preview conflicts)
2. Backend Task 3 (STEP replacement warning)
3. Frontend Tasks 4+5+6 (types + UI)
Tasks 1-3 can be done in parallel (independent admin components).
Tasks 4-6 can be done in parallel (independent pages).
Recommended execution: Tasks 1-3 first (highest impact), then 4-6.
## Risks / Open Questions
1. **RenderTemplateTable .blend upload**: The file upload flow (choose file → upload → or clone from existing) is complex. The expandable form needs to preserve this exact functionality without breaking the upload mutation.
2. **Consistency with OutputTypeTable**: The expandable form pattern should look identical across all admin tables — same grid spacing, same accent border, same Save/Cancel button placement. Consider extracting a shared `ExpandableEditRow` wrapper if patterns diverge.
3. **Add-new forms**: Some tables have "Add" as a button that opens a modal-like form at the top, others have an inline row. Standardize: "Add" button at the top → opens expandable form row at the top of the table body (same pattern as edit).