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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react' import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
import { import {
@@ -19,6 +19,7 @@ interface EditState {
is_default: boolean is_default: boolean
sort_order: number sort_order: number
focal_length_mm: number | null focal_length_mm: number | null
sensor_width_mm: number | null
} }
const EMPTY_EDIT: EditState = { const EMPTY_EDIT: EditState = {
@@ -30,6 +31,7 @@ const EMPTY_EDIT: EditState = {
is_default: false, is_default: false,
sort_order: 0, sort_order: 0,
focal_length_mm: null, focal_length_mm: null,
sensor_width_mm: null,
} }
export default function GlobalRenderPositionsPanel() { export default function GlobalRenderPositionsPanel() {
@@ -44,7 +46,7 @@ export default function GlobalRenderPositionsPanel() {
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body), 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({ const updateMut = useMutation({
@@ -69,6 +71,7 @@ export default function GlobalRenderPositionsPanel() {
is_default: pos.is_default, is_default: pos.is_default,
sort_order: pos.sort_order, sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm, focal_length_mm: pos.focal_length_mm,
sensor_width_mm: pos.sensor_width_mm,
}) })
} }
@@ -96,23 +99,101 @@ export default function GlobalRenderPositionsPanel() {
setAdding(false) setAdding(false)
} }
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) { function renderEditFormGrid() {
if (!editing) return null if (!editing) return null
return ( return (
<div className="flex flex-col gap-0.5"> <div className="grid grid-cols-6 gap-x-4 gap-y-3">
<label className="text-xs text-content-muted">{label}</label> {/* Row 1: Name + Is Default */}
<input <div className="col-span-4 flex flex-col gap-1">
type="number" <label className="text-xs font-medium text-content-muted">Name</label>
step="5" <input
className="input w-20 text-sm" className="input text-sm"
value={editing[field]} placeholder="e.g. Front View"
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })} 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> </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 ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -139,147 +220,99 @@ export default function GlobalRenderPositionsPanel() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{positions.map((pos) => { {/* Add new — expandable form at top */}
const isEditingThis = editing && editing.id === pos.id {adding && editing && (
return ( <>
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30"> <tr className="border-b border-border-light bg-status-success-bg">
{isEditingThis ? ( <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>
<td className="py-1 pr-2"> {renderEditFormGrid()}
<input <div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border-light">
className="input w-32 text-sm" <button className="btn btn-sm" onClick={cancelEdit}>
value={editing!.name} <X size={14} className="mr-1" /> Cancel
onChange={(e) => setEditing({ ...editing!, name: e.target.value })} </button>
/> <button
</td> className="btn btn-sm btn-primary"
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td> onClick={saveNew}
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td> disabled={createMut.isPending || !editing.name.trim()}
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td> >
<td className="py-1 pr-2 text-center"> <Check size={14} className="mr-1" /> Create
<input </button>
type="number" </div>
step="1" </td>
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>
</>
)}
</tr> </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> </tbody>
</table> </table>
</div> </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 { 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 { toast } from 'sonner'
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing' import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
import type { PricingTier } 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() { export default function PricingTierTable() {
const qc = useQueryClient() const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState(EMPTY_FORM) const [form, setForm] = useState(EMPTY_FORM)
const [editingId, setEditingId] = useState<number | null>(null) 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({ const { data: tiers, isLoading } = useQuery({
queryKey: ['pricing-tiers'], queryKey: ['pricing-tiers'],
@@ -26,6 +37,7 @@ export default function PricingTierTable() {
quality_level: form.quality_level.trim() || 'Normal', quality_level: form.quality_level.trim() || 'Normal',
price_per_item: parseFloat(form.price_per_item), price_per_item: parseFloat(form.price_per_item),
description: form.description.trim() || undefined, description: form.description.trim() || undefined,
is_active: form.is_active,
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Pricing tier created') toast.success('Pricing tier created')
@@ -37,14 +49,16 @@ export default function PricingTierTable() {
}) })
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> }) => mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> & { _price_str?: string } }) => {
updatePricingTier(id, { const { _price_str, ...rest } = data
category_key: data.category_key, return updatePricingTier(id, {
quality_level: data.quality_level, category_key: rest.category_key,
price_per_item: data.price_per_item != null ? Number(data.price_per_item) : undefined, quality_level: rest.quality_level,
description: data.description !== undefined ? data.description ?? undefined : undefined, price_per_item: rest.price_per_item != null ? Number(rest.price_per_item) : undefined,
is_active: data.is_active, description: rest.description !== undefined ? rest.description ?? undefined : undefined,
}), is_active: rest.is_active,
})
},
onSuccess: () => { onSuccess: () => {
toast.success('Tier updated') toast.success('Tier updated')
qc.invalidateQueries({ queryKey: ['pricing-tiers'] }) qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
@@ -71,6 +85,7 @@ export default function PricingTierTable() {
price_per_item: tier.price_per_item, price_per_item: tier.price_per_item,
description: tier.description ?? '', description: tier.description ?? '',
is_active: tier.is_active, 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)) const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
return ( /* Shared edit form grid — used by both edit-row and add-row */
<div> function renderEditFormGrid(mode: 'add' | 'edit', tier: PricingTier | null) {
{/* Add form toggle */} const catVal = mode === 'edit' ? (editDraft.category_key ?? tier?.category_key ?? '') : form.category_key
<div className="p-4 border-b border-border-light"> const qualVal = mode === 'edit' ? (editDraft.quality_level ?? tier?.quality_level ?? '') : form.quality_level
{showAdd ? ( const priceVal = mode === 'edit' ? (editDraft._price_str ?? String(tier?.price_per_item ?? '')) : form.price_per_item
<div className="space-y-3"> const descVal = mode === 'edit' ? (editDraft.description ?? tier?.description ?? '') : form.description
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> 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 <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" type="number"
step="0.01" step="0.01"
min="0" min="0"
value={form.price_per_item} value={priceVal}
onChange={(e) => setForm({ ...form, price_per_item: e.target.value })} onChange={(e) => setPrice(e.target.value)}
className="input-base" 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>
</div> </div>
) : ( </div>
<button onClick={() => setShowAdd(true)} className="btn-primary text-sm">
<Plus size={14} /> {/* Row 2: Description (full width) */}
Add New Tier <div>
</button> <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> </div>
{/* Table */} {/* Table */}
{isLoading ? ( {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 ? ( ) : !tiers || tiers.length === 0 ? (
<div className="p-6 text-center text-content-muted text-sm"> <div className="overflow-x-auto">
No pricing tiers configured. Add one above. <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>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -159,13 +279,23 @@ export default function PricingTierTable() {
<tr> <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">Category</th>
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</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-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="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
<th className="px-4 py-2" /> <th className="px-4 py-2" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-light"> <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) => { {[...tiers].sort((a, b) => {
// Sort 'default' to top // Sort 'default' to top
if (a.category_key === 'default' && b.category_key !== 'default') return -1 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 isEditing = editingId === tier.id
const isDefault = tier.category_key === 'default' const isDefault = tier.category_key === 'default'
return ( return (
<tr key={tier.id} className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''}`}> <React.Fragment key={tier.id}>
<td className="px-4 py-2 font-mono font-medium text-content"> {/* Display row — always visible */}
{isEditing ? ( <tr className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''} ${isEditing ? 'bg-surface-hover' : ''}`}>
<input <td className="px-4 py-2 font-mono font-medium text-content">
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"
/>
) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{tier.category_key} {tier.category_key}
{isDefault && ( {isDefault && (
@@ -193,101 +317,56 @@ export default function PricingTierTable() {
</span> </span>
)} )}
</div> </div>
)} </td>
</td> <td className="px-4 py-2 text-content-secondary">
<td className="px-4 py-2 text-content-secondary"> {tier.quality_level}
{isEditing ? ( </td>
<input <td className="px-4 py-2 text-right">
type="text" <span className="font-medium">&#8364; {Number(tier.price_per_item).toFixed(2)}</span>
value={editDraft.quality_level ?? tier.quality_level} </td>
onChange={(e) => setEditDraft((d) => ({ ...d, quality_level: e.target.value }))} <td className="px-4 py-2 text-content-muted">
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">
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"
/>
) : (
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}> <span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
{tier.is_active ? 'yes' : 'no'} {tier.is_active ? 'yes' : 'no'}
</span> </span>
)} </td>
</td> <td className="px-4 py-2">
<td className="px-4 py-2"> <div className="flex items-center justify-end gap-1">
<div className="flex items-center justify-end gap-1"> <button
{isEditing ? ( onClick={() => {
<> if (isEditing) {
<button cancelEdit()
onClick={() => updateMut.mutate({ id: tier.id, data: editDraft })} } else {
disabled={updateMut.isPending} startEdit(tier)
className="p-1 text-status-success-text hover:bg-surface-hover rounded" }
title="Save" }}
> className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
<Check size={15} /> title={isEditing ? 'Collapse edit form' : 'Edit'}
</button> >
<button <Pencil size={14} />
onClick={cancelEdit} </button>
className="p-1 text-content-muted hover:bg-surface-muted rounded" <button
title="Cancel" 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"
<X size={15} /> title="Delete"
</button> >
</> <Trash2 size={14} />
) : ( </button>
<> </div>
<button </td>
onClick={() => startEdit(tier)} </tr>
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
title="Edit" {/* Expandable edit form row */}
> {isEditing && (
<Pencil size={14} /> <tr>
</button> <td colSpan={6} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
<button {renderEditFormGrid('edit', tier)}
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }} </td>
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded" </tr>
title="Delete" )}
> </React.Fragment>
<Trash2 size={14} />
</button>
</>
)}
</div>
</td>
</tr>
) )
})} })}
</tbody> </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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react' import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
import HelpTooltip from '../HelpTooltip' import HelpTooltip from '../HelpTooltip'
@@ -156,7 +156,279 @@ export default function RenderTemplateTable() {
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> }) 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 ( return (
<div> <div>
@@ -189,299 +461,73 @@ export default function RenderTemplateTable() {
<tr className="bg-surface-alt border-b text-left"> <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">Name</th>
<th className="px-3 py-2 font-medium">Category</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">Collection</th>
<th className="px-3 py-2 font-medium"> <th className="px-3 py-2 font-medium">Flags</th>
<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">.blend File</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">Active</th>
<th className="px-3 py-2 font-medium w-24">Actions</th> <th className="px-3 py-2 font-medium w-24">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <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 && ( {showAdd && (
<tr className="border-b bg-surface-hover/40"> <tr className="border-b border-border-light bg-status-success-bg">
<td className="px-3 py-2"> <td colSpan={8} className="px-6 py-5 border-l-4 border-status-success-text">
<input <div className="text-sm font-medium text-content-secondary mb-3">New Render Template</div>
className={inputCls + ' w-40'} {renderEditFormGrid('add', null)}
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>
</td> </td>
</tr> </tr>
)} )}
{/* Template rows */} {/* Template rows */}
{isLoading && ( {templates?.map((t) => (
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr> <React.Fragment key={t.id}>
)} {/* Display row — always visible */}
{templates?.map((t) => { <tr className={`border-b border-border-light hover:bg-surface-hover/50 ${editingId === t.id ? 'bg-surface-hover' : ''}`}>
const isEditing = editingId === t.id
return (
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
<td className="px-3 py-2"> <td className="px-3 py-2">
{isEditing ? ( <span className="font-medium">{t.name}</span>
<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>
)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{isEditing ? ( {t.category_key || <span className="text-content-muted">Any</span>}
<select </td>
className={inputCls} <td className="px-3 py-2">
value={editDraft.category_key ?? t.category_key ?? ''} {t.output_type_names && t.output_type_names.length > 0 ? (
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })} <div className="flex flex-wrap gap-1">
> {t.output_type_names.map((name, i) => (
<option value="">Any</option> <span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
{ALL_CATEGORIES.map((c) => ( {name}
<option key={c.key} value={c.key}>{c.label}</option> </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> </div>
) : ( ) : (
t.output_type_names && t.output_type_names.length > 0 ? ( <span className="text-content-muted">Any</span>
<div className="flex flex-wrap gap-1">
{t.output_type_names.map((name, i) => (
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
{name}
</span>
))}
</div>
) : (
<span className="text-content-muted">Any</span>
)
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{isEditing ? ( <code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
<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>
)}
</td> </td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2">
{isEditing ? ( <div className="flex flex-wrap gap-1">
<input {t.material_replace_enabled && (
type="checkbox" <span className="text-status-success-text text-xs font-medium" title="Material Replace">Mat</span>
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled} )}
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })} {t.lighting_only && (
/> <span className="text-status-warning-text text-xs font-medium" title="Lighting Only (HDR)">HDR</span>
) : ( )}
t.material_replace_enabled ? ( {t.shadow_catcher_enabled && (
<span className="text-status-success-text text-xs font-medium">Yes</span> <span className="text-violet-600 text-xs font-medium" title="Shadow Catcher">Shd</span>
) : ( )}
<span className="text-content-muted text-xs">No</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>
</td> </div>
<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> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center gap-1"> <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}> <span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename} {t.original_filename}
</span> </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> </div>
</td> </td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
{isEditing ? ( {t.is_active ? (
<input <span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
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-surface-muted" title="Inactive" />
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
) : (
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
)
)} )}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{isEditing ? ( <div className="flex gap-1">
<div className="flex gap-1"> <button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save"> <Pencil size={14} />
<Check size={16} /> </button>
</button> <button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel"> <Copy size={14} />
<X size={16} /> </button>
</button> <button
</div> onClick={() => {
) : ( if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
<div className="flex gap-1"> }}
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit"> className="p-1 text-red-500 hover:bg-red-50 rounded"
<Pencil size={14} /> title="Delete"
</button> >
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate"> <Trash2 size={14} />
<Copy size={14} /> </button>
</button> </div>
<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> </td>
</tr> </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 && ( {!isLoading && (!templates || templates.length === 0) && !showAdd && (
<tr> <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. No render templates configured. Click "Add Template" to create one.
</td> </td>
</tr> </tr>
+18 -15
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' 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 { toast } from 'sonner'
import { import {
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf, 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"> <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 text-sm font-mono text-content">{inv.invoice_number}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<select <div className="inline-flex items-center gap-1 border border-transparent hover:border-border-default rounded-full transition-colors cursor-pointer pr-1">
value={inv.status} <select
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })} value={inv.status}
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'}`} 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> {['draft', 'sent', 'paid', 'cancelled'].map(s => (
))} <option key={s} value={s}>{s}</option>
</select> ))}
</select>
<ChevronDown size={12} className="text-content-muted pointer-events-none -ml-0.5" />
</div>
</td> </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.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-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 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 <button
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))} 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" title="Download PDF"
> >
<Download size={15} /> <Download size={16} />
</button> </button>
{inv.status === 'draft' && ( {inv.status === 'draft' && (
<button <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" title="Delete draft"
> >
<Trash2 size={15} /> <Trash2 size={16} />
</button> </button>
)} )}
</td> </td>
+82 -32
View File
@@ -619,26 +619,33 @@ export default function OrderDetailPage() {
)} )}
{(order.lines?.length ?? 0) > 0 && isPrivileged && ( {(order.lines?.length ?? 0) > 0 && isPrivileged && (
<div className="flex items-center gap-2 mb-2 px-1"> <div className="mb-4 rounded-xl border border-border-default p-4" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
<span className="text-xs text-content-muted">Batch material override:</span> <div className="flex items-center justify-between gap-4 flex-wrap">
<select <div>
className="text-xs border border-border-default rounded px-2 py-1" <p className="text-sm font-medium text-content">Batch Material Override</p>
style={{ backgroundColor: 'var(--color-bg-surface)' }} <p className="text-xs text-content-muted mt-0.5">Apply a single material to all render lines in this order at once.</p>
value="" </div>
onChange={(e) => { <div className="flex items-center gap-2">
const val = e.target.value <select
if (val === '__clear__') batchOverrideMut.mutate(null) className="text-sm border border-border-default rounded-lg px-3 py-1.5"
else if (val) batchOverrideMut.mutate(val) style={{ backgroundColor: 'var(--color-bg-surface)' }}
}} value=""
disabled={batchOverrideMut.isPending} onChange={(e) => {
> const val = e.target.value
<option value="">Apply to all lines</option> if (val === '__clear__') batchOverrideMut.mutate(null)
<option value="__clear__"> Clear all overrides </option> else if (val) batchOverrideMut.mutate(val)
{orderLibMats.map((m: Material) => ( }}
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> disabled={batchOverrideMut.isPending}
))} >
</select> <option value="">Apply to all lines</option>
{batchOverrideMut.isPending && <Loader2 size={12} className="animate-spin text-accent" />} <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> </div>
)} )}
@@ -903,6 +910,7 @@ function OrderLineRow({
const [showInfo, setShowInfo] = useState(false) const [showInfo, setShowInfo] = useState(false)
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false) const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
const [rejectLineReason, setRejectLineReason] = useState('') const [rejectLineReason, setRejectLineReason] = useState('')
const [showOverride, setShowOverride] = useState(false)
const removeMut = useMutation({ const removeMut = useMutation({
mutationFn: () => removeOrderLine(orderId, line.id), mutationFn: () => removeOrderLine(orderId, line.id),
@@ -1028,18 +1036,60 @@ function OrderLineRow({
</span> </span>
)} )}
{isPrivileged && ( {isPrivileged && (
<select line.material_override ? (
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1" <div className="flex items-center gap-1 mt-1">
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }} <span
value={line.material_override ?? ''} 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"
onChange={(e) => overrideMut.mutate(e.target.value || null)} onClick={() => setShowOverride(!showOverride)}
title="Material override — apply a single material to all parts for this render" title="Click to change material override"
> >
<option value="">No material override</option> {line.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}
{libMats.map((m: Material) => ( </span>
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option> <button
))} onClick={() => overrideMut.mutate(null)}
</select> 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> </div>
</td> </td>
+35 -30
View File
@@ -109,24 +109,29 @@ function ScaleControl({
<p className="text-sm font-medium text-content">{label}</p> <p className="text-sm font-medium text-content">{label}</p>
<p className="text-xs text-content-muted mt-0.5">{description}</p> <p className="text-xs text-content-muted mt-0.5">{description}</p>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<button <div className="flex flex-col items-center gap-1">
onClick={() => setCount((c) => Math.max(0, c - 1))} <span className="text-xs font-medium text-content-muted">Current Scale</span>
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors" <div className="flex items-center gap-1.5">
> <button
<Minus size={14} /> onClick={() => setCount((c) => Math.max(0, c - 1))}
</button> className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
<span className="w-6 text-center text-sm font-semibold text-content">{count}</span> >
<button <Minus size={14} />
onClick={() => setCount((c) => Math.min(20, c + 1))} </button>
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors" <span className="w-12 text-center text-sm font-semibold text-content">{count}</span>
> <button
<Plus size={14} /> onClick={() => setCount((c) => Math.min(20, c + 1))}
</button> className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
>
<Plus size={14} />
</button>
</div>
</div>
<button <button
onClick={() => scaleMut.mutate()} onClick={() => scaleMut.mutate()}
disabled={scaleMut.isPending} 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'} {scaleMut.isPending ? 'Scaling…' : 'Scale'}
</button> </button>
@@ -202,46 +207,46 @@ function ConcurrencyConfigRow({ config }: { config: WorkerConfig }) {
<div className="flex items-center gap-6 shrink-0"> <div className="flex items-center gap-6 shrink-0">
{/* Min concurrency */} {/* Min concurrency */}
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Min</span> <span className="text-xs font-medium text-content-muted">Min Concurrency</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<button <button
onClick={() => setMinVal((v) => Math.max(1, v - 1))} 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> </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 <button
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))} 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> </button>
</div> </div>
</div> </div>
{/* Max concurrency */} {/* Max concurrency */}
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<span className="text-xs text-content-muted">Max</span> <span className="text-xs font-medium text-content-muted">Max Concurrency</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<button <button
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))} 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> </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 <button
onClick={() => setMaxVal((v) => Math.min(64, v + 1))} 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> </button>
</div> </div>
</div> </div>
<button <button
onClick={() => saveMut.mutate()} onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || !isDirty} 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'} {saveMut.isPending ? 'Saving…' : 'Save'}
</button> </button>
+98 -61
View File
@@ -1,86 +1,123 @@
# Plan: Duplicate Product Detection # Plan: Full UI/UX Cleanup & Simplification
## Context ## 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 **Principle**: Tables are for **viewing** data. Editing happens in **expandable rows** or **modals** — never in cramped inline cells.
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
## Affected Files ## Affected Files
| File | Change | | File | Change | Priority |
|------|--------| |------|--------|----------|
| `backend/app/services/excel_import.py` | Add STEP conflict detection in `preview_excel_rows()` | | `frontend/src/components/admin/RenderTemplateTable.tsx` | Expandable edit row (11 cramped columns) | HIGH |
| `backend/app/api/routers/uploads.py` | Extend preview response with conflict fields | | `frontend/src/components/admin/PricingTierTable.tsx` | Expandable edit row (6 columns, mixed add/edit) | HIGH |
| `backend/app/api/routers/products.py` | Add render-count warning to CAD upload response | | `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx` | Expandable edit row (8 columns, tiny inputs) | MEDIUM |
| `frontend/src/api/uploads.ts` | Update TypeScript interfaces | | `frontend/src/pages/WorkerManagement.tsx` | Larger touch targets, better scale controls | MEDIUM |
| `frontend/src/pages/Upload.tsx` | Display conflict warnings in preview table | | `frontend/src/pages/Billing.tsx` | Fix status dropdown disguised as badge | MEDIUM |
| `frontend/src/api/products.ts` | Add warning fields to CAD upload response type | | `frontend/src/pages/OrderDetail.tsx` | Cleaner line table, material override UX | LOW |
| `frontend/src/pages/ProductDetail.tsx` | Show toast warnings on STEP replacement |
## 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` - **File**: `frontend/src/components/admin/RenderTemplateTable.tsx`
- **What**: In `preview_excel_rows()`, after the product lookup: - **What**: Same pattern as OutputTypeTable refactor:
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` 1. Display row ALWAYS shows (no conditional switching)
2. If they differ → set `step_conflict=True` with details 2. Edit form opens as a new `<tr>` below with `colSpan` spanning all columns
3. Track `name_cad_modell` per product key in the `seen` dict 3. Grid form inside with labeled fields, grouped logically:
4. If same product appears again with different `name_cad_modell` → set `cad_name_conflict=True` - Row 1: Name, Category, Output Types (multi-select)
- **Also**: Add `selectinload(Product.cad_file)` to `lookup_product()` in `backend/app/domains/products/service.py` - 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 - **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` - **File**: `frontend/src/components/admin/PricingTierTable.tsx`
- **What**: Add to the preview row dict and response: - **What**: Replace inline cell editing with expandable form row:
- `step_conflict: bool`, `step_conflict_existing_name: str | None`, `step_conflict_excel_name: str | None` 1. Display row always visible with read-only values
- `cad_name_conflict: bool`, `cad_name_conflict_other_name: str | None`, `cad_name_conflict_row: int | None` 2. Edit form as expandable row below with grid layout:
- Response-level: `step_conflict_count: int`, `cad_name_conflict_count: int` - Row 1: Category Key (select), Quality Level (input), Price per Item (number input)
- **Dependencies**: Task 1 - Row 2: Description (textarea, full width)
- Row 3: Active toggle + Save/Cancel
### [ ] Task 3: Backend — Render warning on product STEP replacement 3. "Add Tier" button opens the same expandable form at the top
4. Consistent visual: accent left border on edit row
- **File**: `backend/app/api/routers/products.py` - **Acceptance gate**: No inline cell editing; form has proper labels; description gets full width
- **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
- **Dependencies**: None - **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` - **File**: `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx`
- **What**: Add conflict fields to `ExcelPreviewRow` and `ExcelPreviewResult` interfaces - **What**: Replace inline cell editing with expandable form row:
- **Also**: `frontend/src/api/products.ts` — add `warnings?: string[]` and `existing_render_count?: number` to `ProductCadUploadResponse` 1. Display row keeps compact view (Name, X°, Y°, Z°, Focal, Default, Order)
- **Dependencies**: Tasks 2, 3 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` - **File**: `frontend/src/pages/WorkerManagement.tsx`
- **What**: - **What**: Improve the concurrency/scale controls:
1. Add StatCards for `step_conflict_count` and `cad_name_conflict_count` (amber color, AlertTriangle icon) 1. Replace tiny `w-6` number displays with `w-12` minimum
2. In the preview table rows: yellow warning icon with tooltip for `step_conflict` and `cad_name_conflict` 2. Make up/down buttons larger (`p-2` instead of default, `rounded-lg`)
- **Dependencies**: Task 4 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` - **File**: `frontend/src/pages/Billing.tsx`
- **What**: In `cadUploadMut.onSuccess`, check response for `warnings` and show `toast.warning()` for each - **What**: Fix the status dropdown that looks like a badge:
- **Dependencies**: Task 4 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 ## 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) Tasks 1-3 can be done in parallel (independent admin components).
2. Backend Task 3 (STEP replacement warning) Tasks 4-6 can be done in parallel (independent pages).
3. Frontend Tasks 4+5+6 (types + UI)
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).