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:
@@ -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">✓</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>
|
||||
|
||||
@@ -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">€</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">€ / 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">€ / 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">€ {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">∗ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: Frontend — Show conflict warnings in Upload preview
|
||||
### [x] Task 4: WorkerManagement — better 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).
|
||||
|
||||
Reference in New Issue
Block a user