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>
|
||||
|
||||
Reference in New Issue
Block a user