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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
|
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,7 @@ interface EditState {
|
|||||||
is_default: boolean
|
is_default: boolean
|
||||||
sort_order: number
|
sort_order: number
|
||||||
focal_length_mm: number | null
|
focal_length_mm: number | null
|
||||||
|
sensor_width_mm: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_EDIT: EditState = {
|
const EMPTY_EDIT: EditState = {
|
||||||
@@ -30,6 +31,7 @@ const EMPTY_EDIT: EditState = {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
focal_length_mm: null,
|
focal_length_mm: null,
|
||||||
|
sensor_width_mm: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GlobalRenderPositionsPanel() {
|
export default function GlobalRenderPositionsPanel() {
|
||||||
@@ -44,7 +46,7 @@ export default function GlobalRenderPositionsPanel() {
|
|||||||
|
|
||||||
const createMut = useMutation({
|
const createMut = useMutation({
|
||||||
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
|
mutationFn: (body: GlobalRenderPositionCreate) => createGlobalRenderPosition(body),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false) },
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['global-render-positions'] }); setAdding(false); setEditing(null) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
@@ -69,6 +71,7 @@ export default function GlobalRenderPositionsPanel() {
|
|||||||
is_default: pos.is_default,
|
is_default: pos.is_default,
|
||||||
sort_order: pos.sort_order,
|
sort_order: pos.sort_order,
|
||||||
focal_length_mm: pos.focal_length_mm,
|
focal_length_mm: pos.focal_length_mm,
|
||||||
|
sensor_width_mm: pos.sensor_width_mm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,23 +99,101 @@ export default function GlobalRenderPositionsPanel() {
|
|||||||
setAdding(false)
|
setAdding(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotField(label: string, field: keyof Pick<EditState, 'rotation_x' | 'rotation_y' | 'rotation_z'>) {
|
function renderEditFormGrid() {
|
||||||
if (!editing) return null
|
if (!editing) return null
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="grid grid-cols-6 gap-x-4 gap-y-3">
|
||||||
<label className="text-xs text-content-muted">{label}</label>
|
{/* Row 1: Name + Is Default */}
|
||||||
<input
|
<div className="col-span-4 flex flex-col gap-1">
|
||||||
type="number"
|
<label className="text-xs font-medium text-content-muted">Name</label>
|
||||||
step="5"
|
<input
|
||||||
className="input w-20 text-sm"
|
className="input text-sm"
|
||||||
value={editing[field]}
|
placeholder="e.g. Front View"
|
||||||
onChange={(e) => setEditing({ ...editing, [field]: parseFloat(e.target.value) || 0 })}
|
value={editing.name}
|
||||||
/>
|
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Is Default</label>
|
||||||
|
<label className="flex items-center gap-2 h-[34px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editing.is_default}
|
||||||
|
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">Default position</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Rotation X, Y, Z */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Rotation X°</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.rotation_x}
|
||||||
|
onChange={(e) => setEditing({ ...editing, rotation_x: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Rotation Y°</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.rotation_y}
|
||||||
|
onChange={(e) => setEditing({ ...editing, rotation_y: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Rotation Z°</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.rotation_z}
|
||||||
|
onChange={(e) => setEditing({ ...editing, rotation_z: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Focal Length, Sensor Width, Sort Order */}
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Focal Length mm</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
placeholder="50"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.focal_length_mm ?? ''}
|
||||||
|
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Sensor Width mm</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="36"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.sensor_width_mm ?? ''}
|
||||||
|
onChange={(e) => setEditing({ ...editing, sensor_width_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-medium text-content-muted">Sort Order</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input w-24 text-sm"
|
||||||
|
value={editing.sort_order}
|
||||||
|
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <p className="text-sm text-content-muted">Loading…</p>
|
if (isLoading) return <p className="text-sm text-content-muted">Loading...</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -139,147 +220,99 @@ export default function GlobalRenderPositionsPanel() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{positions.map((pos) => {
|
{/* Add new — expandable form at top */}
|
||||||
const isEditingThis = editing && editing.id === pos.id
|
{adding && editing && (
|
||||||
return (
|
<>
|
||||||
<tr key={pos.id} className="border-b border-border-light/50 hover:bg-surface-alt/30">
|
<tr className="border-b border-border-light bg-status-success-bg">
|
||||||
{isEditingThis ? (
|
<td colSpan={8} className="px-6 py-5 border-l-4 border-status-success-text">
|
||||||
<>
|
<div className="text-sm font-medium text-content-secondary mb-3">New Position</div>
|
||||||
<td className="py-1 pr-2">
|
{renderEditFormGrid()}
|
||||||
<input
|
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border-light">
|
||||||
className="input w-32 text-sm"
|
<button className="btn btn-sm" onClick={cancelEdit}>
|
||||||
value={editing!.name}
|
<X size={14} className="mr-1" /> Cancel
|
||||||
onChange={(e) => setEditing({ ...editing!, name: e.target.value })}
|
</button>
|
||||||
/>
|
<button
|
||||||
</td>
|
className="btn btn-sm btn-primary"
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
onClick={saveNew}
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
disabled={createMut.isPending || !editing.name.trim()}
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
>
|
||||||
<td className="py-1 pr-2 text-center">
|
<Check size={14} className="mr-1" /> Create
|
||||||
<input
|
</button>
|
||||||
type="number"
|
</div>
|
||||||
step="1"
|
</td>
|
||||||
placeholder="50"
|
|
||||||
className="input w-16 text-sm"
|
|
||||||
value={editing!.focal_length_mm ?? ''}
|
|
||||||
onChange={(e) => setEditing({ ...editing!, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editing!.is_default}
|
|
||||||
onChange={(e) => setEditing({ ...editing!, is_default: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2 text-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input w-14 text-sm"
|
|
||||||
value={editing!.sort_order}
|
|
||||||
onChange={(e) => setEditing({ ...editing!, sort_order: parseInt(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 flex items-center gap-1">
|
|
||||||
<button className="btn btn-xs btn-primary" onClick={saveEdit} disabled={updateMut.isPending}>
|
|
||||||
<Check size={12} />
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center text-content-muted">
|
|
||||||
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center">
|
|
||||||
{pos.is_default && <span className="text-accent text-xs font-medium">✓</span>}
|
|
||||||
</td>
|
|
||||||
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
|
|
||||||
<td className="py-1.5 flex items-center gap-1">
|
|
||||||
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
|
|
||||||
<button
|
|
||||||
className="btn btn-xs text-blue-500"
|
|
||||||
onClick={() => createMut.mutate({
|
|
||||||
name: `${pos.name} (copy)`,
|
|
||||||
rotation_x: pos.rotation_x,
|
|
||||||
rotation_y: pos.rotation_y,
|
|
||||||
rotation_z: pos.rotation_z,
|
|
||||||
is_default: false,
|
|
||||||
sort_order: pos.sort_order,
|
|
||||||
focal_length_mm: pos.focal_length_mm,
|
|
||||||
sensor_width_mm: pos.sensor_width_mm,
|
|
||||||
})}
|
|
||||||
disabled={createMut.isPending}
|
|
||||||
title="Duplicate"
|
|
||||||
>
|
|
||||||
<Copy size={12} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-xs text-red-500"
|
|
||||||
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
|
|
||||||
disabled={deleteMut.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{positions.map((pos) => {
|
||||||
|
const isEditingThis = !adding && editing !== null && editing.id === pos.id
|
||||||
|
return (
|
||||||
|
<React.Fragment key={pos.id}>
|
||||||
|
{/* Display row — always visible */}
|
||||||
|
<tr className={`border-b border-border-light/50 hover:bg-surface-alt/30 ${isEditingThis ? 'bg-surface-hover' : ''}`}>
|
||||||
|
<td className="py-1.5 pr-3 font-medium">{pos.name}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center text-content-muted">
|
||||||
|
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center">
|
||||||
|
{pos.is_default && <span className="text-accent text-xs font-medium">✓</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
|
||||||
|
<td className="py-1.5 flex items-center gap-1">
|
||||||
|
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs text-blue-500"
|
||||||
|
onClick={() => createMut.mutate({
|
||||||
|
name: `${pos.name} (copy)`,
|
||||||
|
rotation_x: pos.rotation_x,
|
||||||
|
rotation_y: pos.rotation_y,
|
||||||
|
rotation_z: pos.rotation_z,
|
||||||
|
is_default: false,
|
||||||
|
sort_order: pos.sort_order,
|
||||||
|
focal_length_mm: pos.focal_length_mm,
|
||||||
|
sensor_width_mm: pos.sensor_width_mm,
|
||||||
|
})}
|
||||||
|
disabled={createMut.isPending}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs text-red-500"
|
||||||
|
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expandable edit form row */}
|
||||||
|
{isEditingThis && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
|
||||||
|
{renderEditFormGrid()}
|
||||||
|
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-border-light">
|
||||||
|
<button className="btn btn-sm" onClick={cancelEdit}>
|
||||||
|
<X size={14} className="mr-1" /> Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={updateMut.isPending || !editing!.name.trim()}
|
||||||
|
>
|
||||||
|
<Check size={14} className="mr-1" /> Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* New row */}
|
|
||||||
{adding && editing && (
|
|
||||||
<tr className="border-b border-border-light bg-surface-alt/20">
|
|
||||||
<td className="py-1 pr-2">
|
|
||||||
<input
|
|
||||||
className="input w-32 text-sm"
|
|
||||||
placeholder="Name"
|
|
||||||
value={editing.name}
|
|
||||||
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
|
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
|
|
||||||
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
|
|
||||||
<td className="py-1 pr-2 text-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
placeholder="50"
|
|
||||||
className="input w-16 text-sm"
|
|
||||||
value={editing.focal_length_mm ?? ''}
|
|
||||||
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editing.is_default}
|
|
||||||
onChange={(e) => setEditing({ ...editing, is_default: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 pr-2 text-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input w-14 text-sm"
|
|
||||||
value={editing.sort_order}
|
|
||||||
onChange={(e) => setEditing({ ...editing, sort_order: parseInt(e.target.value) || 0 })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="py-1 flex items-center gap-1">
|
|
||||||
<button className="btn btn-xs btn-primary" onClick={saveNew} disabled={createMut.isPending}>
|
|
||||||
<Check size={12} />
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-xs" onClick={cancelEdit}><X size={12} /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Pencil, Trash2, Plus, Check, X } from 'lucide-react'
|
import { Pencil, Trash2, Plus } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
|
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
|
||||||
import type { PricingTier } from '../../api/pricing'
|
import type { PricingTier } from '../../api/pricing'
|
||||||
|
|
||||||
const EMPTY_FORM = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '' }
|
const ALL_CATEGORIES = [
|
||||||
|
{ key: 'default', label: 'default (Global Fallback)' },
|
||||||
|
{ key: 'TRB', label: 'TRB' },
|
||||||
|
{ key: 'Kugellager', label: 'Kugellager' },
|
||||||
|
{ key: 'CRB', label: 'CRB' },
|
||||||
|
{ key: 'Gleitlager', label: 'Gleitlager' },
|
||||||
|
{ key: 'SRB_TORB', label: 'SRB/TORB' },
|
||||||
|
{ key: 'Linear_schiene', label: 'Linear' },
|
||||||
|
{ key: 'Anschlagplatten', label: 'Anschlag' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EMPTY_FORM = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '', is_active: true }
|
||||||
|
|
||||||
export default function PricingTierTable() {
|
export default function PricingTierTable() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [form, setForm] = useState(EMPTY_FORM)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
const [editDraft, setEditDraft] = useState<Partial<PricingTier>>({})
|
const [editDraft, setEditDraft] = useState<Partial<PricingTier> & { _price_str?: string }>({})
|
||||||
|
|
||||||
const { data: tiers, isLoading } = useQuery({
|
const { data: tiers, isLoading } = useQuery({
|
||||||
queryKey: ['pricing-tiers'],
|
queryKey: ['pricing-tiers'],
|
||||||
@@ -26,6 +37,7 @@ export default function PricingTierTable() {
|
|||||||
quality_level: form.quality_level.trim() || 'Normal',
|
quality_level: form.quality_level.trim() || 'Normal',
|
||||||
price_per_item: parseFloat(form.price_per_item),
|
price_per_item: parseFloat(form.price_per_item),
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
|
is_active: form.is_active,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Pricing tier created')
|
toast.success('Pricing tier created')
|
||||||
@@ -37,14 +49,16 @@ export default function PricingTierTable() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const updateMut = useMutation({
|
const updateMut = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> }) =>
|
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> & { _price_str?: string } }) => {
|
||||||
updatePricingTier(id, {
|
const { _price_str, ...rest } = data
|
||||||
category_key: data.category_key,
|
return updatePricingTier(id, {
|
||||||
quality_level: data.quality_level,
|
category_key: rest.category_key,
|
||||||
price_per_item: data.price_per_item != null ? Number(data.price_per_item) : undefined,
|
quality_level: rest.quality_level,
|
||||||
description: data.description !== undefined ? data.description ?? undefined : undefined,
|
price_per_item: rest.price_per_item != null ? Number(rest.price_per_item) : undefined,
|
||||||
is_active: data.is_active,
|
description: rest.description !== undefined ? rest.description ?? undefined : undefined,
|
||||||
}),
|
is_active: rest.is_active,
|
||||||
|
})
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Tier updated')
|
toast.success('Tier updated')
|
||||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||||
@@ -71,6 +85,7 @@ export default function PricingTierTable() {
|
|||||||
price_per_item: tier.price_per_item,
|
price_per_item: tier.price_per_item,
|
||||||
description: tier.description ?? '',
|
description: tier.description ?? '',
|
||||||
is_active: tier.is_active,
|
is_active: tier.is_active,
|
||||||
|
_price_str: String(tier.price_per_item),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,70 +96,175 @@ export default function PricingTierTable() {
|
|||||||
|
|
||||||
const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
|
const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
|
||||||
|
|
||||||
return (
|
/* Shared edit form grid — used by both edit-row and add-row */
|
||||||
<div>
|
function renderEditFormGrid(mode: 'add' | 'edit', tier: PricingTier | null) {
|
||||||
{/* Add form toggle */}
|
const catVal = mode === 'edit' ? (editDraft.category_key ?? tier?.category_key ?? '') : form.category_key
|
||||||
<div className="p-4 border-b border-border-light">
|
const qualVal = mode === 'edit' ? (editDraft.quality_level ?? tier?.quality_level ?? '') : form.quality_level
|
||||||
{showAdd ? (
|
const priceVal = mode === 'edit' ? (editDraft._price_str ?? String(tier?.price_per_item ?? '')) : form.price_per_item
|
||||||
<div className="space-y-3">
|
const descVal = mode === 'edit' ? (editDraft.description ?? tier?.description ?? '') : form.description
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
const activeVal = mode === 'edit' ? (editDraft.is_active ?? tier?.is_active ?? true) : form.is_active
|
||||||
|
|
||||||
|
const setCat = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, category_key: v })) : setForm((f) => ({ ...f, category_key: v }))
|
||||||
|
const setQual = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, quality_level: v })) : setForm((f) => ({ ...f, quality_level: v }))
|
||||||
|
const setPrice = (v: string) => {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
setEditDraft((d) => ({ ...d, _price_str: v, price_per_item: v ? parseFloat(v) : undefined }))
|
||||||
|
} else {
|
||||||
|
setForm((f) => ({ ...f, price_per_item: v }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const setDesc = (v: string) => mode === 'edit' ? setEditDraft((d) => ({ ...d, description: v })) : setForm((f) => ({ ...f, description: v }))
|
||||||
|
const setActive = (v: boolean) => mode === 'edit' ? setEditDraft((d) => ({ ...d, is_active: v })) : setForm((f) => ({ ...f, is_active: v }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Row 1: Category Key, Quality Level, Price per Item */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-secondary mb-1">Category Key</label>
|
||||||
|
<select
|
||||||
|
value={catVal}
|
||||||
|
onChange={(e) => setCat(e.target.value)}
|
||||||
|
className="input-base w-full"
|
||||||
|
>
|
||||||
|
<option value="">-- Select category --</option>
|
||||||
|
{ALL_CATEGORIES.map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-secondary mb-1">Quality Level</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={qualVal}
|
||||||
|
onChange={(e) => setQual(e.target.value)}
|
||||||
|
placeholder="e.g. Normal, Premium"
|
||||||
|
className="input-base w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-secondary mb-1">Price per Item</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-muted text-sm pointer-events-none">€</span>
|
||||||
<input
|
<input
|
||||||
placeholder="Category key (e.g. TRB)"
|
|
||||||
value={form.category_key}
|
|
||||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
|
||||||
className="input-base"
|
|
||||||
title="Product category key this tier applies to (e.g. TRB, Kugellager). Leave empty for the global fallback tier."
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="Quality level (e.g. Normal)"
|
|
||||||
value={form.quality_level}
|
|
||||||
onChange={(e) => setForm({ ...form, quality_level: e.target.value })}
|
|
||||||
className="input-base"
|
|
||||||
title="Quality level label for this tier (e.g. Normal, Premium). Used for display purposes."
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder="€ / item"
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
value={form.price_per_item}
|
value={priceVal}
|
||||||
onChange={(e) => setForm({ ...form, price_per_item: e.target.value })}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
className="input-base"
|
placeholder="0.00"
|
||||||
|
className="input-base w-full pl-7"
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
placeholder="Description (optional)"
|
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
||||||
className="input-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => createMut.mutate()}
|
|
||||||
disabled={!canCreate || createMut.isPending}
|
|
||||||
className="btn-primary text-sm"
|
|
||||||
>
|
|
||||||
{createMut.isPending ? 'Saving…' : 'Add Tier'}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { setShowAdd(false); setForm(EMPTY_FORM) }} className="btn-secondary text-sm">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<button onClick={() => setShowAdd(true)} className="btn-primary text-sm">
|
|
||||||
<Plus size={14} />
|
{/* Row 2: Description (full width) */}
|
||||||
Add New Tier
|
<div>
|
||||||
</button>
|
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
|
||||||
)}
|
<textarea
|
||||||
|
value={descVal}
|
||||||
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
|
placeholder="Optional description for this pricing tier"
|
||||||
|
rows={2}
|
||||||
|
className="input-base w-full resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Active checkbox + Save/Cancel */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-border-light">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-content-secondary cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeVal}
|
||||||
|
onChange={(e) => setActive(e.target.checked)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
cancelEdit()
|
||||||
|
} else {
|
||||||
|
setShowAdd(false)
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
disabled={mode === 'add' ? (!canCreate || createMut.isPending) : updateMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (mode === 'add') {
|
||||||
|
createMut.mutate()
|
||||||
|
} else if (tier) {
|
||||||
|
updateMut.mutate({ id: tier.id, data: editDraft })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'add'
|
||||||
|
? (createMut.isPending ? 'Saving...' : 'Add Tier')
|
||||||
|
: (updateMut.isPending ? 'Saving...' : 'Save Changes')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Add tier toggle button */}
|
||||||
|
<div className="p-4 border-b border-border-light">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setForm(EMPTY_FORM) }}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add New Tier
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-6 text-center text-content-muted text-sm">Loading…</div>
|
<div className="p-6 text-center text-content-muted text-sm">Loading...</div>
|
||||||
) : !tiers || tiers.length === 0 ? (
|
) : !tiers || tiers.length === 0 ? (
|
||||||
<div className="p-6 text-center text-content-muted text-sm">
|
<div className="overflow-x-auto">
|
||||||
No pricing tiers configured. Add one above.
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-surface-alt border-b border-border-default">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
|
||||||
|
<th className="text-right px-4 py-2 font-medium text-content-secondary">€ / Item</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
|
||||||
|
<th className="px-4 py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Add new — expandable form */}
|
||||||
|
{showAdd && (
|
||||||
|
<tr className="border-b border-border-light bg-status-success-bg">
|
||||||
|
<td colSpan={6} className="px-6 py-5 border-l-4 border-status-success-text">
|
||||||
|
<div className="text-sm font-medium text-content-secondary mb-3">New Pricing Tier</div>
|
||||||
|
{renderEditFormGrid('add', null)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="p-6 text-center text-content-muted text-sm">
|
||||||
|
No pricing tiers configured. Add one above.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -159,13 +279,23 @@ export default function PricingTierTable() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Category</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Quality Level</th>
|
||||||
<th className="text-right px-4 py-2 font-medium text-content-secondary">€ / Item</th>
|
<th className="text-right px-4 py-2 font-medium text-content-secondary">€ / Item</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
|
<th className="text-left px-4 py-2 font-medium text-content-secondary">Description</th>
|
||||||
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
|
<th className="text-center px-4 py-2 font-medium text-content-secondary">Active</th>
|
||||||
<th className="px-4 py-2" />
|
<th className="px-4 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
|
{/* Add new — expandable form */}
|
||||||
|
{showAdd && (
|
||||||
|
<tr className="border-b border-border-light bg-status-success-bg">
|
||||||
|
<td colSpan={6} className="px-6 py-5 border-l-4 border-status-success-text">
|
||||||
|
<div className="text-sm font-medium text-content-secondary mb-3">New Pricing Tier</div>
|
||||||
|
{renderEditFormGrid('add', null)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
{[...tiers].sort((a, b) => {
|
{[...tiers].sort((a, b) => {
|
||||||
// Sort 'default' to top
|
// Sort 'default' to top
|
||||||
if (a.category_key === 'default' && b.category_key !== 'default') return -1
|
if (a.category_key === 'default' && b.category_key !== 'default') return -1
|
||||||
@@ -175,16 +305,10 @@ export default function PricingTierTable() {
|
|||||||
const isEditing = editingId === tier.id
|
const isEditing = editingId === tier.id
|
||||||
const isDefault = tier.category_key === 'default'
|
const isDefault = tier.category_key === 'default'
|
||||||
return (
|
return (
|
||||||
<tr key={tier.id} className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''}`}>
|
<React.Fragment key={tier.id}>
|
||||||
<td className="px-4 py-2 font-mono font-medium text-content">
|
{/* Display row — always visible */}
|
||||||
{isEditing ? (
|
<tr className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''} ${isEditing ? 'bg-surface-hover' : ''}`}>
|
||||||
<input
|
<td className="px-4 py-2 font-mono font-medium text-content">
|
||||||
type="text"
|
|
||||||
value={editDraft.category_key ?? tier.category_key}
|
|
||||||
onChange={(e) => setEditDraft((d) => ({ ...d, category_key: e.target.value }))}
|
|
||||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{tier.category_key}
|
{tier.category_key}
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
@@ -193,101 +317,56 @@ export default function PricingTierTable() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-2 text-content-secondary">
|
||||||
<td className="px-4 py-2 text-content-secondary">
|
{tier.quality_level}
|
||||||
{isEditing ? (
|
</td>
|
||||||
<input
|
<td className="px-4 py-2 text-right">
|
||||||
type="text"
|
<span className="font-medium">€ {Number(tier.price_per_item).toFixed(2)}</span>
|
||||||
value={editDraft.quality_level ?? tier.quality_level}
|
</td>
|
||||||
onChange={(e) => setEditDraft((d) => ({ ...d, quality_level: e.target.value }))}
|
<td className="px-4 py-2 text-content-muted">
|
||||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
{tier.description || <span className="text-content-muted">—</span>}
|
||||||
/>
|
</td>
|
||||||
) : (
|
<td className="px-4 py-2 text-center">
|
||||||
tier.quality_level
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={editDraft.price_per_item ?? tier.price_per_item}
|
|
||||||
onChange={(e) => setEditDraft((d) => ({ ...d, price_per_item: parseFloat(e.target.value) }))}
|
|
||||||
className="w-24 px-2 py-1 border border-border-default rounded text-sm text-right focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">€ {Number(tier.price_per_item).toFixed(2)}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-content-muted">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editDraft.description ?? tier.description ?? ''}
|
|
||||||
onChange={(e) => setEditDraft((d) => ({ ...d, description: e.target.value }))}
|
|
||||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
tier.description || <span className="text-content-muted">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editDraft.is_active ?? tier.is_active}
|
|
||||||
onChange={(e) => setEditDraft((d) => ({ ...d, is_active: e.target.checked }))}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
|
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
|
||||||
{tier.is_active ? 'yes' : 'no'}
|
{tier.is_active ? 'yes' : 'no'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</td>
|
||||||
</td>
|
<td className="px-4 py-2">
|
||||||
<td className="px-4 py-2">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<button
|
||||||
{isEditing ? (
|
onClick={() => {
|
||||||
<>
|
if (isEditing) {
|
||||||
<button
|
cancelEdit()
|
||||||
onClick={() => updateMut.mutate({ id: tier.id, data: editDraft })}
|
} else {
|
||||||
disabled={updateMut.isPending}
|
startEdit(tier)
|
||||||
className="p-1 text-status-success-text hover:bg-surface-hover rounded"
|
}
|
||||||
title="Save"
|
}}
|
||||||
>
|
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
|
||||||
<Check size={15} />
|
title={isEditing ? 'Collapse edit form' : 'Edit'}
|
||||||
</button>
|
>
|
||||||
<button
|
<Pencil size={14} />
|
||||||
onClick={cancelEdit}
|
</button>
|
||||||
className="p-1 text-content-muted hover:bg-surface-muted rounded"
|
<button
|
||||||
title="Cancel"
|
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
|
||||||
>
|
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
|
||||||
<X size={15} />
|
title="Delete"
|
||||||
</button>
|
>
|
||||||
</>
|
<Trash2 size={14} />
|
||||||
) : (
|
</button>
|
||||||
<>
|
</div>
|
||||||
<button
|
</td>
|
||||||
onClick={() => startEdit(tier)}
|
</tr>
|
||||||
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
|
|
||||||
title="Edit"
|
{/* Expandable edit form row */}
|
||||||
>
|
{isEditing && (
|
||||||
<Pencil size={14} />
|
<tr>
|
||||||
</button>
|
<td colSpan={6} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
|
||||||
<button
|
{renderEditFormGrid('edit', tier)}
|
||||||
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
|
</td>
|
||||||
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
|
</tr>
|
||||||
title="Delete"
|
)}
|
||||||
>
|
</React.Fragment>
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef } from 'react'
|
import React, { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
|
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
|
||||||
import HelpTooltip from '../HelpTooltip'
|
import HelpTooltip from '../HelpTooltip'
|
||||||
@@ -156,7 +156,279 @@ export default function RenderTemplateTable() {
|
|||||||
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
|
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'px-2 py-1 text-sm border border-border-default rounded bg-surface focus:outline-none focus:ring-1 focus:ring-blue-400'
|
// Render the edit form grid (shared between edit-row and add-row)
|
||||||
|
function renderEditFormGrid(
|
||||||
|
mode: 'edit' | 'add',
|
||||||
|
t: RenderTemplate | null,
|
||||||
|
) {
|
||||||
|
const isEdit = mode === 'edit' && t !== null
|
||||||
|
|
||||||
|
// Value getters
|
||||||
|
const val = (field: keyof typeof EMPTY_FORM | 'is_active' | 'output_type_ids') => {
|
||||||
|
if (isEdit) {
|
||||||
|
if (field === 'name') return editDraft.name ?? t!.name
|
||||||
|
if (field === 'category_key') return editDraft.category_key ?? t!.category_key ?? ''
|
||||||
|
if (field === 'output_type_ids') return editDraft.output_type_ids ?? t!.output_type_ids ?? []
|
||||||
|
if (field === 'target_collection') return editDraft.target_collection ?? t!.target_collection
|
||||||
|
if (field === 'material_replace_enabled') return editDraft.material_replace_enabled ?? t!.material_replace_enabled
|
||||||
|
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
|
||||||
|
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
|
||||||
|
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
|
||||||
|
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
|
||||||
|
return (editDraft as any)[field] ?? (t as any)[field]
|
||||||
|
}
|
||||||
|
return (form as any)[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (field: string, value: any) => {
|
||||||
|
if (isEdit) {
|
||||||
|
setEditDraft({ ...editDraft, [field]: value } as any)
|
||||||
|
} else {
|
||||||
|
setForm({ ...form, [field]: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Row 1: Name | Category | Output Types */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
className="input-sm w-full"
|
||||||
|
placeholder="Template name"
|
||||||
|
value={val('name') as string}
|
||||||
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">Category</label>
|
||||||
|
<select
|
||||||
|
className="input-sm w-full"
|
||||||
|
value={val('category_key') as string}
|
||||||
|
onChange={(e) => set('category_key', e.target.value || (isEdit ? null : ''))}
|
||||||
|
>
|
||||||
|
<option value="">Any (default)</option>
|
||||||
|
{ALL_CATEGORIES.map((c) => (
|
||||||
|
<option key={c.key} value={c.key}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">Output Types</label>
|
||||||
|
{isEdit ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto p-1.5 border border-border-default rounded bg-surface">
|
||||||
|
{outputTypes?.map((ot: OutputType) => {
|
||||||
|
const ids = val('output_type_ids') as string[]
|
||||||
|
const checked = ids.includes(ot.id)
|
||||||
|
return (
|
||||||
|
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
const next = checked
|
||||||
|
? ids.filter((id: string) => id !== ot.id)
|
||||||
|
: [...ids, ot.id]
|
||||||
|
set('output_type_ids', next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{ot.name}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="input-sm w-full"
|
||||||
|
value={(form as any).output_type_id ?? ''}
|
||||||
|
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Any (default)</option>
|
||||||
|
{outputTypes?.map((ot: OutputType) => (
|
||||||
|
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Collection | Checkboxes */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">Collection Name</label>
|
||||||
|
<input
|
||||||
|
className="input-sm w-full"
|
||||||
|
value={val('target_collection') as string}
|
||||||
|
onChange={(e) => set('target_collection', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Mat. Replace
|
||||||
|
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={val('material_replace_enabled') as boolean}
|
||||||
|
onChange={(e) => set('material_replace_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Lighting Only
|
||||||
|
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={val('lighting_only') as boolean}
|
||||||
|
onChange={(e) => set('lighting_only', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">HDR only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Shadow Catcher
|
||||||
|
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={val('shadow_catcher_enabled') as boolean}
|
||||||
|
title="Enable Shadowcatcher collection (Cycles only)"
|
||||||
|
onChange={(e) => set('shadow_catcher_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">Camera Orbit</label>
|
||||||
|
<label className="flex items-center gap-2 mt-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={val('camera_orbit') as boolean}
|
||||||
|
title="Rotate camera around product (better GPU performance)"
|
||||||
|
onChange={(e) => set('camera_orbit', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">Cam orbit</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: .blend File */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="block text-xs font-medium text-content-muted mb-1">.blend File</label>
|
||||||
|
{isEdit ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-content-secondary truncate max-w-xs" title={t!.original_filename}>
|
||||||
|
{t!.original_filename}
|
||||||
|
</span>
|
||||||
|
{templates && templates.filter((o) => o.blend_file_path === t!.blend_file_path).length > 1 && (
|
||||||
|
<span className="text-xs text-blue-500" title="Shared .blend file">∗ shared</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setReuploadId(t!.id); reuploadRef.current?.click() }}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
|
||||||
|
title="Re-upload .blend"
|
||||||
|
>
|
||||||
|
<Upload size={12} /> Re-upload
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/api/render-templates/${t!.id}/download`}
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-1 text-accent hover:bg-surface-hover rounded border border-border-default"
|
||||||
|
title="Download .blend"
|
||||||
|
>
|
||||||
|
<Download size={12} /> Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer text-accent hover:text-accent-hover px-2 py-1 border border-border-default rounded">
|
||||||
|
<Upload size={14} />
|
||||||
|
{addFile ? addFile.name : 'Upload .blend'}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".blend"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { setAddFile(e.target.files?.[0] || null); setCloneBlendFrom('') }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{!addFile && (
|
||||||
|
<select
|
||||||
|
className="input-sm text-xs"
|
||||||
|
value={cloneBlendFrom}
|
||||||
|
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
|
||||||
|
>
|
||||||
|
<option value="">or re-use existing...</option>
|
||||||
|
{templates?.map((tmpl) => (
|
||||||
|
<option key={tmpl.id} value={tmpl.id}>{tmpl.original_filename} ({tmpl.name})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Active + Save/Cancel */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
|
||||||
|
{isEdit ? (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={val('is_active') as boolean}
|
||||||
|
onChange={(e) => set('is_active', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-content-secondary">Active (visible in wizard)</span>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-content-muted">Will be active by default</span>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (isEdit) {
|
||||||
|
setEditingId(null)
|
||||||
|
} else {
|
||||||
|
setShowAdd(false)
|
||||||
|
setForm(EMPTY_FORM)
|
||||||
|
setAddFile(null)
|
||||||
|
setCloneBlendFrom('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
disabled={isEdit ? updateMut.isPending : (!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEdit) {
|
||||||
|
saveEdit()
|
||||||
|
} else {
|
||||||
|
createMut.mutate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEdit ? (updateMut.isPending ? 'Saving...' : 'Save') : (createMut.isPending ? 'Creating...' : 'Create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -189,299 +461,73 @@ export default function RenderTemplateTable() {
|
|||||||
<tr className="bg-surface-alt border-b text-left">
|
<tr className="bg-surface-alt border-b text-left">
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
<th className="px-3 py-2 font-medium">Category</th>
|
<th className="px-3 py-2 font-medium">Category</th>
|
||||||
<th className="px-3 py-2 font-medium">Output Type</th>
|
<th className="px-3 py-2 font-medium">Output Types</th>
|
||||||
<th className="px-3 py-2 font-medium">Collection</th>
|
<th className="px-3 py-2 font-medium">Collection</th>
|
||||||
<th className="px-3 py-2 font-medium">
|
<th className="px-3 py-2 font-medium">Flags</th>
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
Mat. Replace
|
|
||||||
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 font-medium">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
Lighting Only
|
|
||||||
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 font-medium">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
Shadow Catcher
|
|
||||||
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</th>
|
|
||||||
<th className="px-3 py-2 font-medium">.blend File</th>
|
<th className="px-3 py-2 font-medium">.blend File</th>
|
||||||
<th className="px-3 py-2 font-medium">Active</th>
|
<th className="px-3 py-2 font-medium">Active</th>
|
||||||
<th className="px-3 py-2 font-medium w-24">Actions</th>
|
<th className="px-3 py-2 font-medium w-24">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{/* Add row */}
|
{isLoading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-4 text-center text-content-muted">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add new — expandable form */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<tr className="border-b bg-surface-hover/40">
|
<tr className="border-b border-border-light bg-status-success-bg">
|
||||||
<td className="px-3 py-2">
|
<td colSpan={8} className="px-6 py-5 border-l-4 border-status-success-text">
|
||||||
<input
|
<div className="text-sm font-medium text-content-secondary mb-3">New Render Template</div>
|
||||||
className={inputCls + ' w-40'}
|
{renderEditFormGrid('add', null)}
|
||||||
placeholder="Template name"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<select
|
|
||||||
className={inputCls}
|
|
||||||
value={form.category_key}
|
|
||||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Any (default)</option>
|
|
||||||
{ALL_CATEGORIES.map((c) => (
|
|
||||||
<option key={c.key} value={c.key}>{c.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<select
|
|
||||||
className={inputCls}
|
|
||||||
value={form.output_type_id}
|
|
||||||
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Any (default)</option>
|
|
||||||
{outputTypes?.map((ot: OutputType) => (
|
|
||||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<input
|
|
||||||
className={inputCls + ' w-28'}
|
|
||||||
value={form.target_collection}
|
|
||||||
onChange={(e) => setForm({ ...form, target_collection: e.target.value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.material_replace_enabled}
|
|
||||||
onChange={(e) => setForm({ ...form, material_replace_enabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.lighting_only}
|
|
||||||
onChange={(e) => setForm({ ...form, lighting_only: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.shadow_catcher_enabled}
|
|
||||||
title="Enable Shadowcatcher collection (Cycles only)"
|
|
||||||
onChange={(e) => setForm({ ...form, shadow_catcher_enabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.camera_orbit}
|
|
||||||
title="Rotate camera around product (better GPU performance)"
|
|
||||||
onChange={(e) => setForm({ ...form, camera_orbit: e.target.checked })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
|
|
||||||
<Upload size={14} />
|
|
||||||
{addFile ? addFile.name : 'Upload .blend'}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".blend"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => { setAddFile(e.target.files?.[0] || null); setCloneBlendFrom('') }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{!addFile && (
|
|
||||||
<select
|
|
||||||
className={inputCls + ' text-xs w-32'}
|
|
||||||
value={cloneBlendFrom}
|
|
||||||
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
|
|
||||||
>
|
|
||||||
<option value="">or re-use existing…</option>
|
|
||||||
{templates?.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.original_filename} ({t.name})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => createMut.mutate()}
|
|
||||||
disabled={!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending}
|
|
||||||
className="p-1 text-status-success-text hover:bg-surface-hover rounded disabled:opacity-40"
|
|
||||||
title="Create"
|
|
||||||
>
|
|
||||||
<Check size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null); setCloneBlendFrom('') }}
|
|
||||||
className="p-1 text-content-muted hover:bg-surface-hover rounded"
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Template rows */}
|
{/* Template rows */}
|
||||||
{isLoading && (
|
{templates?.map((t) => (
|
||||||
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr>
|
<React.Fragment key={t.id}>
|
||||||
)}
|
{/* Display row — always visible */}
|
||||||
{templates?.map((t) => {
|
<tr className={`border-b border-border-light hover:bg-surface-hover/50 ${editingId === t.id ? 'bg-surface-hover' : ''}`}>
|
||||||
const isEditing = editingId === t.id
|
|
||||||
return (
|
|
||||||
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{isEditing ? (
|
<span className="font-medium">{t.name}</span>
|
||||||
<input
|
|
||||||
className={inputCls + ' w-40'}
|
|
||||||
value={editDraft.name ?? t.name}
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">{t.name}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{isEditing ? (
|
{t.category_key || <span className="text-content-muted">Any</span>}
|
||||||
<select
|
</td>
|
||||||
className={inputCls}
|
<td className="px-3 py-2">
|
||||||
value={editDraft.category_key ?? t.category_key ?? ''}
|
{t.output_type_names && t.output_type_names.length > 0 ? (
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })}
|
<div className="flex flex-wrap gap-1">
|
||||||
>
|
{t.output_type_names.map((name, i) => (
|
||||||
<option value="">Any</option>
|
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
|
||||||
{ALL_CATEGORIES.map((c) => (
|
{name}
|
||||||
<option key={c.key} value={c.key}>{c.label}</option>
|
</span>
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
t.category_key || <span className="text-content-muted">Any</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex flex-col gap-0.5 max-h-32 overflow-y-auto">
|
|
||||||
{outputTypes?.map((ot: OutputType) => {
|
|
||||||
const checked = (editDraft.output_type_ids ?? []).includes(ot.id)
|
|
||||||
return (
|
|
||||||
<label key={ot.id} className="flex items-center gap-1 text-xs cursor-pointer whitespace-nowrap">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
const current = editDraft.output_type_ids ?? []
|
|
||||||
const next = checked
|
|
||||||
? current.filter((id: string) => id !== ot.id)
|
|
||||||
: [...current, ot.id]
|
|
||||||
setEditDraft({ ...editDraft, output_type_ids: next })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{ot.name}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t.output_type_names && t.output_type_names.length > 0 ? (
|
<span className="text-content-muted">Any</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{t.output_type_names.map((name, i) => (
|
|
||||||
<span key={i} className="inline-block text-xs px-1.5 py-0.5 bg-blue-100 text-blue-800 rounded">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-content-muted">Any</span>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{isEditing ? (
|
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
|
||||||
<input
|
|
||||||
className={inputCls + ' w-28'}
|
|
||||||
value={editDraft.target_collection ?? t.target_collection}
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, target_collection: e.target.value })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2">
|
||||||
{isEditing ? (
|
<div className="flex flex-wrap gap-1">
|
||||||
<input
|
{t.material_replace_enabled && (
|
||||||
type="checkbox"
|
<span className="text-status-success-text text-xs font-medium" title="Material Replace">Mat</span>
|
||||||
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled}
|
)}
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })}
|
{t.lighting_only && (
|
||||||
/>
|
<span className="text-status-warning-text text-xs font-medium" title="Lighting Only (HDR)">HDR</span>
|
||||||
) : (
|
)}
|
||||||
t.material_replace_enabled ? (
|
{t.shadow_catcher_enabled && (
|
||||||
<span className="text-status-success-text text-xs font-medium">Yes</span>
|
<span className="text-violet-600 text-xs font-medium" title="Shadow Catcher">Shd</span>
|
||||||
) : (
|
)}
|
||||||
<span className="text-content-muted text-xs">No</span>
|
<span className={`text-xs font-medium ${t.camera_orbit ? 'text-teal-600' : 'text-content-muted'}`} title={t.camera_orbit ? 'Camera Orbit' : 'Object Rotation'}>
|
||||||
)
|
{t.camera_orbit ? 'Cam' : 'Obj'}
|
||||||
)}
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editDraft.lighting_only ?? t.lighting_only}
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, lighting_only: e.target.checked })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.lighting_only ? (
|
|
||||||
<span className="text-status-warning-text text-xs font-medium">HDR</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-content-muted text-xs">—</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editDraft.shadow_catcher_enabled ?? t.shadow_catcher_enabled}
|
|
||||||
title="Enable Shadowcatcher collection (Cycles only)"
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, shadow_catcher_enabled: e.target.checked })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.shadow_catcher_enabled ? (
|
|
||||||
<span className="text-violet-600 text-xs font-medium">On</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-content-muted text-xs">—</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editDraft.camera_orbit ?? t.camera_orbit}
|
|
||||||
title="Rotate camera around product (better GPU performance)"
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, camera_orbit: e.target.checked })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.camera_orbit ? (
|
|
||||||
<span className="text-teal-600 text-xs font-medium">Cam</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-content-muted text-xs">Obj</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -491,74 +537,50 @@ export default function RenderTemplateTable() {
|
|||||||
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
|
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
|
||||||
{t.original_filename}
|
{t.original_filename}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
onClick={() => { setReuploadId(t.id); reuploadRef.current?.click() }}
|
|
||||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
|
||||||
title="Re-upload .blend"
|
|
||||||
>
|
|
||||||
<Upload size={12} />
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href={`/api/render-templates/${t.id}/download`}
|
|
||||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
|
||||||
title="Download .blend"
|
|
||||||
>
|
|
||||||
<Download size={12} />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
{isEditing ? (
|
{t.is_active ? (
|
||||||
<input
|
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
|
||||||
type="checkbox"
|
|
||||||
checked={editDraft.is_active ?? t.is_active}
|
|
||||||
onChange={(e) => setEditDraft({ ...editDraft, is_active: e.target.checked })}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
t.is_active ? (
|
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
|
|
||||||
) : (
|
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{isEditing ? (
|
<div className="flex gap-1">
|
||||||
<div className="flex gap-1">
|
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
|
||||||
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save">
|
<Pencil size={14} />
|
||||||
<Check size={16} />
|
</button>
|
||||||
</button>
|
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
|
||||||
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel">
|
<Copy size={14} />
|
||||||
<X size={16} />
|
</button>
|
||||||
</button>
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
) : (
|
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
|
||||||
<div className="flex gap-1">
|
}}
|
||||||
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
|
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||||
<Pencil size={14} />
|
title="Delete"
|
||||||
</button>
|
>
|
||||||
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
|
<Trash2 size={14} />
|
||||||
<Copy size={14} />
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
|
|
||||||
}}
|
|
||||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
|
||||||
})}
|
{/* Expandable edit form row */}
|
||||||
|
{editingId === t.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-6 py-5 bg-surface-alt border-l-4 border-accent border-b-2 border-b-border-light">
|
||||||
|
{renderEditFormGrid('edit', t)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
|
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="px-3 py-6 text-center text-content-muted">
|
<td colSpan={8} className="px-3 py-6 text-center text-content-muted">
|
||||||
No render templates configured. Click "Add Template" to create one.
|
No render templates configured. Click "Add Template" to create one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
|
import { Receipt, Download, Trash2, Plus, X, ChevronDown } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
|
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
|
||||||
@@ -194,26 +194,29 @@ export default function BillingPage() {
|
|||||||
<tr key={inv.id} className="border-b border-border-default hover:bg-surface-hover transition-colors">
|
<tr key={inv.id} className="border-b border-border-default hover:bg-surface-hover transition-colors">
|
||||||
<td className="px-4 py-3 text-sm font-mono text-content">{inv.invoice_number}</td>
|
<td className="px-4 py-3 text-sm font-mono text-content">{inv.invoice_number}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<select
|
<div className="inline-flex items-center gap-1 border border-transparent hover:border-border-default rounded-full transition-colors cursor-pointer pr-1">
|
||||||
value={inv.status}
|
<select
|
||||||
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
|
value={inv.status}
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
|
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
|
||||||
>
|
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent appearance-none ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
|
||||||
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
|
>
|
||||||
<option key={s} value={s}>{s}</option>
|
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
|
||||||
))}
|
<option key={s} value={s}>{s}</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={12} className="text-content-muted pointer-events-none -ml-0.5" />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.issued_at)}</td>
|
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.issued_at)}</td>
|
||||||
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
|
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
|
||||||
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
|
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
|
||||||
<td className="px-4 py-3 flex items-center gap-1">
|
<td className="px-4 py-3 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
|
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
|
||||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
className="p-2 rounded-lg border border-border-default hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
|
||||||
title="Download PDF"
|
title="Download PDF"
|
||||||
>
|
>
|
||||||
<Download size={15} />
|
<Download size={16} />
|
||||||
</button>
|
</button>
|
||||||
{inv.status === 'draft' && (
|
{inv.status === 'draft' && (
|
||||||
<button
|
<button
|
||||||
@@ -228,10 +231,10 @@ export default function BillingPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="p-1.5 rounded hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
|
className="p-2 rounded-lg border border-border-default hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
|
||||||
title="Delete draft"
|
title="Delete draft"
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -619,26 +619,33 @@ export default function OrderDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(order.lines?.length ?? 0) > 0 && isPrivileged && (
|
{(order.lines?.length ?? 0) > 0 && isPrivileged && (
|
||||||
<div className="flex items-center gap-2 mb-2 px-1">
|
<div className="mb-4 rounded-xl border border-border-default p-4" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
|
||||||
<span className="text-xs text-content-muted">Batch material override:</span>
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<select
|
<div>
|
||||||
className="text-xs border border-border-default rounded px-2 py-1"
|
<p className="text-sm font-medium text-content">Batch Material Override</p>
|
||||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
<p className="text-xs text-content-muted mt-0.5">Apply a single material to all render lines in this order at once.</p>
|
||||||
value=""
|
</div>
|
||||||
onChange={(e) => {
|
<div className="flex items-center gap-2">
|
||||||
const val = e.target.value
|
<select
|
||||||
if (val === '__clear__') batchOverrideMut.mutate(null)
|
className="text-sm border border-border-default rounded-lg px-3 py-1.5"
|
||||||
else if (val) batchOverrideMut.mutate(val)
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
}}
|
value=""
|
||||||
disabled={batchOverrideMut.isPending}
|
onChange={(e) => {
|
||||||
>
|
const val = e.target.value
|
||||||
<option value="">Apply to all lines…</option>
|
if (val === '__clear__') batchOverrideMut.mutate(null)
|
||||||
<option value="__clear__">— Clear all overrides —</option>
|
else if (val) batchOverrideMut.mutate(val)
|
||||||
{orderLibMats.map((m: Material) => (
|
}}
|
||||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
disabled={batchOverrideMut.isPending}
|
||||||
))}
|
>
|
||||||
</select>
|
<option value="">Apply to all lines…</option>
|
||||||
{batchOverrideMut.isPending && <Loader2 size={12} className="animate-spin text-accent" />}
|
<option value="__clear__">— Clear all overrides —</option>
|
||||||
|
{orderLibMats.map((m: Material) => (
|
||||||
|
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{batchOverrideMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -903,6 +910,7 @@ function OrderLineRow({
|
|||||||
const [showInfo, setShowInfo] = useState(false)
|
const [showInfo, setShowInfo] = useState(false)
|
||||||
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
|
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
|
||||||
const [rejectLineReason, setRejectLineReason] = useState('')
|
const [rejectLineReason, setRejectLineReason] = useState('')
|
||||||
|
const [showOverride, setShowOverride] = useState(false)
|
||||||
|
|
||||||
const removeMut = useMutation({
|
const removeMut = useMutation({
|
||||||
mutationFn: () => removeOrderLine(orderId, line.id),
|
mutationFn: () => removeOrderLine(orderId, line.id),
|
||||||
@@ -1028,18 +1036,60 @@ function OrderLineRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isPrivileged && (
|
{isPrivileged && (
|
||||||
<select
|
line.material_override ? (
|
||||||
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
|
<div className="flex items-center gap-1 mt-1">
|
||||||
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
|
<span
|
||||||
value={line.material_override ?? ''}
|
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium cursor-pointer hover:bg-amber-500/20 transition-colors"
|
||||||
onChange={(e) => overrideMut.mutate(e.target.value || null)}
|
onClick={() => setShowOverride(!showOverride)}
|
||||||
title="Material override — apply a single material to all parts for this render"
|
title="Click to change material override"
|
||||||
>
|
>
|
||||||
<option value="">No material override</option>
|
{line.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}
|
||||||
{libMats.map((m: Material) => (
|
</span>
|
||||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
<button
|
||||||
))}
|
onClick={() => overrideMut.mutate(null)}
|
||||||
</select>
|
className="text-content-muted hover:text-red-500 transition-colors"
|
||||||
|
title="Clear override"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
{showOverride && (
|
||||||
|
<select
|
||||||
|
className="text-[10px] border border-border-default rounded px-1 py-0.5 flex-1"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
|
value={line.material_override ?? ''}
|
||||||
|
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<option value="">No material override</option>
|
||||||
|
{libMats.map((m: Material) => (
|
||||||
|
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : showOverride ? (
|
||||||
|
<select
|
||||||
|
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
|
||||||
|
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||||
|
value=""
|
||||||
|
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
|
||||||
|
onBlur={() => setShowOverride(false)}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
<option value="">No material override</option>
|
||||||
|
{libMats.map((m: Material) => (
|
||||||
|
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOverride(true)}
|
||||||
|
className="text-[10px] text-content-muted hover:text-accent mt-1 transition-colors"
|
||||||
|
title="Set material override for this line"
|
||||||
|
>
|
||||||
|
+ override
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -109,24 +109,29 @@ function ScaleControl({
|
|||||||
<p className="text-sm font-medium text-content">{label}</p>
|
<p className="text-sm font-medium text-content">{label}</p>
|
||||||
<p className="text-xs text-content-muted mt-0.5">{description}</p>
|
<p className="text-xs text-content-muted mt-0.5">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<button
|
<div className="flex flex-col items-center gap-1">
|
||||||
onClick={() => setCount((c) => Math.max(0, c - 1))}
|
<span className="text-xs font-medium text-content-muted">Current Scale</span>
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
<button
|
||||||
<Minus size={14} />
|
onClick={() => setCount((c) => Math.max(0, c - 1))}
|
||||||
</button>
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
<span className="w-6 text-center text-sm font-semibold text-content">{count}</span>
|
>
|
||||||
<button
|
<Minus size={14} />
|
||||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
</button>
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
<span className="w-12 text-center text-sm font-semibold text-content">{count}</span>
|
||||||
>
|
<button
|
||||||
<Plus size={14} />
|
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||||
</button>
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => scaleMut.mutate()}
|
onClick={() => scaleMut.mutate()}
|
||||||
disabled={scaleMut.isPending}
|
disabled={scaleMut.isPending}
|
||||||
className="btn-primary text-xs px-3 py-1.5 ml-2"
|
className="btn-primary text-sm px-5 py-2 font-semibold ml-2"
|
||||||
>
|
>
|
||||||
{scaleMut.isPending ? 'Scaling…' : 'Scale'}
|
{scaleMut.isPending ? 'Scaling…' : 'Scale'}
|
||||||
</button>
|
</button>
|
||||||
@@ -202,46 +207,46 @@ function ConcurrencyConfigRow({ config }: { config: WorkerConfig }) {
|
|||||||
<div className="flex items-center gap-6 shrink-0">
|
<div className="flex items-center gap-6 shrink-0">
|
||||||
{/* Min concurrency */}
|
{/* Min concurrency */}
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-xs text-content-muted">Min</span>
|
<span className="text-xs font-medium text-content-muted">Min Concurrency</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMinVal((v) => Math.max(1, v - 1))}
|
onClick={() => setMinVal((v) => Math.max(1, v - 1))}
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
>
|
>
|
||||||
<Minus size={12} />
|
<Minus size={14} />
|
||||||
</button>
|
</button>
|
||||||
<span className="w-6 text-center text-sm font-semibold text-content">{minVal}</span>
|
<span className="w-12 text-center text-sm font-semibold text-content">{minVal}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))}
|
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))}
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Max concurrency */}
|
{/* Max concurrency */}
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-xs text-content-muted">Max</span>
|
<span className="text-xs font-medium text-content-muted">Max Concurrency</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))}
|
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))}
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
>
|
>
|
||||||
<Minus size={12} />
|
<Minus size={14} />
|
||||||
</button>
|
</button>
|
||||||
<span className="w-6 text-center text-sm font-semibold text-content">{maxVal}</span>
|
<span className="w-12 text-center text-sm font-semibold text-content">{maxVal}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMaxVal((v) => Math.min(64, v + 1))}
|
onClick={() => setMaxVal((v) => Math.min(64, v + 1))}
|
||||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => saveMut.mutate()}
|
onClick={() => saveMut.mutate()}
|
||||||
disabled={saveMut.isPending || !isDirty}
|
disabled={saveMut.isPending || !isDirty}
|
||||||
className={`btn-primary text-xs px-3 py-1.5 ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`btn-primary text-sm px-5 py-2 font-semibold ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
{saveMut.isPending ? 'Saving…' : 'Save'}
|
{saveMut.isPending ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,86 +1,123 @@
|
|||||||
# Plan: Duplicate Product Detection
|
# Plan: Full UI/UX Cleanup & Simplification
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
When importing products via Excel or STEP upload, there's no warning when a product already exists with a different STEP file. This can lead to accidental overwrites or confusion. The feature adds non-blocking warnings (yellow badges, toasts) at import time.
|
The OutputTypeTable was refactored from cramped 18-column inline editing to an expandable form row — a dramatic UX improvement. The same pattern should be applied to all other admin tables that suffer from the same problem. Additionally, several pages have UX inconsistencies (mixed editing patterns, tiny inputs, confusing controls) that need cleanup.
|
||||||
|
|
||||||
## Detection Scenarios
|
**Principle**: Tables are for **viewing** data. Editing happens in **expandable rows** or **modals** — never in cramped inline cells.
|
||||||
|
|
||||||
1. **Excel preview**: Product exists in DB with a different STEP file linked → warning icon
|
|
||||||
2. **Excel preview**: Same product in two rows with different `name_cad_modell` → conflict badge
|
|
||||||
3. **STEP upload on product**: Replacing an existing STEP file on a product that has renders → toast warning
|
|
||||||
4. **All warnings are non-blocking** — user can still proceed
|
|
||||||
|
|
||||||
## Affected Files
|
## Affected Files
|
||||||
|
|
||||||
| File | Change |
|
| File | Change | Priority |
|
||||||
|------|--------|
|
|------|--------|----------|
|
||||||
| `backend/app/services/excel_import.py` | Add STEP conflict detection in `preview_excel_rows()` |
|
| `frontend/src/components/admin/RenderTemplateTable.tsx` | Expandable edit row (11 cramped columns) | HIGH |
|
||||||
| `backend/app/api/routers/uploads.py` | Extend preview response with conflict fields |
|
| `frontend/src/components/admin/PricingTierTable.tsx` | Expandable edit row (6 columns, mixed add/edit) | HIGH |
|
||||||
| `backend/app/api/routers/products.py` | Add render-count warning to CAD upload response |
|
| `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx` | Expandable edit row (8 columns, tiny inputs) | MEDIUM |
|
||||||
| `frontend/src/api/uploads.ts` | Update TypeScript interfaces |
|
| `frontend/src/pages/WorkerManagement.tsx` | Larger touch targets, better scale controls | MEDIUM |
|
||||||
| `frontend/src/pages/Upload.tsx` | Display conflict warnings in preview table |
|
| `frontend/src/pages/Billing.tsx` | Fix status dropdown disguised as badge | MEDIUM |
|
||||||
| `frontend/src/api/products.ts` | Add warning fields to CAD upload response type |
|
| `frontend/src/pages/OrderDetail.tsx` | Cleaner line table, material override UX | LOW |
|
||||||
| `frontend/src/pages/ProductDetail.tsx` | Show toast warnings on STEP replacement |
|
|
||||||
|
|
||||||
## Tasks
|
## Tasks (in order)
|
||||||
|
|
||||||
### [ ] Task 1: Backend — STEP conflict detection in Excel preview
|
### [x] Task 1: RenderTemplateTable — expandable edit row
|
||||||
|
|
||||||
- **File**: `backend/app/services/excel_import.py`
|
- **File**: `frontend/src/components/admin/RenderTemplateTable.tsx`
|
||||||
- **What**: In `preview_excel_rows()`, after the product lookup:
|
- **What**: Same pattern as OutputTypeTable refactor:
|
||||||
1. If product exists and has `cad_file_id`, load the CadFile and compare `original_name` (stem) with the Excel row's `name_cad_modell`
|
1. Display row ALWAYS shows (no conditional switching)
|
||||||
2. If they differ → set `step_conflict=True` with details
|
2. Edit form opens as a new `<tr>` below with `colSpan` spanning all columns
|
||||||
3. Track `name_cad_modell` per product key in the `seen` dict
|
3. Grid form inside with labeled fields, grouped logically:
|
||||||
4. If same product appears again with different `name_cad_modell` → set `cad_name_conflict=True`
|
- Row 1: Name, Category, Output Types (multi-select)
|
||||||
- **Also**: Add `selectinload(Product.cad_file)` to `lookup_product()` in `backend/app/domains/products/service.py`
|
- Row 2: Collection name, Material Replace, Lighting Only, Shadow Catcher, Camera Orbit
|
||||||
|
- Row 3: .blend File (upload/download/re-upload with proper spacing)
|
||||||
|
- Row 4: Active toggle + Save/Cancel buttons
|
||||||
|
4. "Add new" form also uses the expandable pattern (button at top opens a full-width form row)
|
||||||
|
5. Use `React.Fragment` for dual-row rendering
|
||||||
|
- **Acceptance gate**: Edit mode shows a well-organized form below the display row; .blend file upload has proper spacing; no horizontal overflow
|
||||||
- **Dependencies**: None
|
- **Dependencies**: None
|
||||||
|
- **Risk**: The .blend file upload flow (upload vs clone) is complex — needs careful preservation
|
||||||
|
|
||||||
### [ ] Task 2: Backend — Extend preview response models
|
### [x] Task 2: PricingTierTable — expandable edit row
|
||||||
|
|
||||||
- **File**: `backend/app/api/routers/uploads.py`
|
- **File**: `frontend/src/components/admin/PricingTierTable.tsx`
|
||||||
- **What**: Add to the preview row dict and response:
|
- **What**: Replace inline cell editing with expandable form row:
|
||||||
- `step_conflict: bool`, `step_conflict_existing_name: str | None`, `step_conflict_excel_name: str | None`
|
1. Display row always visible with read-only values
|
||||||
- `cad_name_conflict: bool`, `cad_name_conflict_other_name: str | None`, `cad_name_conflict_row: int | None`
|
2. Edit form as expandable row below with grid layout:
|
||||||
- Response-level: `step_conflict_count: int`, `cad_name_conflict_count: int`
|
- Row 1: Category Key (select), Quality Level (input), Price per Item (number input)
|
||||||
- **Dependencies**: Task 1
|
- Row 2: Description (textarea, full width)
|
||||||
|
- Row 3: Active toggle + Save/Cancel
|
||||||
### [ ] Task 3: Backend — Render warning on product STEP replacement
|
3. "Add Tier" button opens the same expandable form at the top
|
||||||
|
4. Consistent visual: accent left border on edit row
|
||||||
- **File**: `backend/app/api/routers/products.py`
|
- **Acceptance gate**: No inline cell editing; form has proper labels; description gets full width
|
||||||
- **What**: In `upload_product_cad()`, before replacing cad_file_id:
|
|
||||||
1. Check if product already has a different `cad_file_id`
|
|
||||||
2. Count existing MediaAssets (renders) for this product
|
|
||||||
3. Add `warnings: list[str]` and `existing_render_count: int` to response
|
|
||||||
- **Dependencies**: None
|
- **Dependencies**: None
|
||||||
|
- **Risk**: Low — simple table with few fields
|
||||||
|
|
||||||
### [ ] Task 4: Frontend — Update API types
|
### [x] Task 3: GlobalRenderPositionsPanel — expandable edit row
|
||||||
|
|
||||||
- **File**: `frontend/src/api/uploads.ts`
|
- **File**: `frontend/src/components/admin/GlobalRenderPositionsPanel.tsx`
|
||||||
- **What**: Add conflict fields to `ExcelPreviewRow` and `ExcelPreviewResult` interfaces
|
- **What**: Replace inline cell editing with expandable form row:
|
||||||
- **Also**: `frontend/src/api/products.ts` — add `warnings?: string[]` and `existing_render_count?: number` to `ProductCadUploadResponse`
|
1. Display row keeps compact view (Name, X°, Y°, Z°, Focal, Default, Order)
|
||||||
- **Dependencies**: Tasks 2, 3
|
2. Edit form as expandable row:
|
||||||
|
- Row 1: Name (wide), Is Default (checkbox with label)
|
||||||
|
- Row 2: Rotation X°, Rotation Y°, Rotation Z° (number inputs with proper width)
|
||||||
|
- Row 3: Focal Length mm, Sensor Width mm, Sort Order
|
||||||
|
- Row 4: Save/Cancel buttons
|
||||||
|
3. "Add Position" opens same expandable form
|
||||||
|
4. Wider number inputs (`w-24` instead of `w-16`) for comfortable editing
|
||||||
|
- **Acceptance gate**: Rotation inputs are comfortable to edit; no cramped cells; proper labels
|
||||||
|
- **Dependencies**: None
|
||||||
|
- **Risk**: Low — straightforward table
|
||||||
|
|
||||||
### [ ] Task 5: Frontend — Show conflict warnings in Upload preview
|
### [x] Task 4: WorkerManagement — better scale controls
|
||||||
|
|
||||||
- **File**: `frontend/src/pages/Upload.tsx`
|
- **File**: `frontend/src/pages/WorkerManagement.tsx`
|
||||||
- **What**:
|
- **What**: Improve the concurrency/scale controls:
|
||||||
1. Add StatCards for `step_conflict_count` and `cad_name_conflict_count` (amber color, AlertTriangle icon)
|
1. Replace tiny `w-6` number displays with `w-12` minimum
|
||||||
2. In the preview table rows: yellow warning icon with tooltip for `step_conflict` and `cad_name_conflict`
|
2. Make up/down buttons larger (`p-2` instead of default, `rounded-lg`)
|
||||||
- **Dependencies**: Task 4
|
3. Add proper labels above each control ("Min Concurrency", "Max Concurrency")
|
||||||
|
4. Group the scale controls in a card with clear section header
|
||||||
|
5. Make the "Save" button more prominent (full-width at bottom of card)
|
||||||
|
- **Acceptance gate**: Controls are easy to click; labels are clear; no overflow on mobile
|
||||||
|
- **Dependencies**: None
|
||||||
|
- **Risk**: Low — cosmetic changes only
|
||||||
|
|
||||||
### [ ] Task 6: Frontend — Show warnings on STEP replacement
|
### [x] Task 5: Billing — fix status dropdown UX
|
||||||
|
|
||||||
- **File**: `frontend/src/pages/ProductDetail.tsx`
|
- **File**: `frontend/src/pages/Billing.tsx`
|
||||||
- **What**: In `cadUploadMut.onSuccess`, check response for `warnings` and show `toast.warning()` for each
|
- **What**: Fix the status dropdown that looks like a badge:
|
||||||
- **Dependencies**: Task 4
|
1. Replace the `<select>` styled as a badge with an explicit dropdown button that opens a small popover/menu
|
||||||
|
2. OR: keep the select but add a visible dropdown arrow indicator and border on hover to signal interactivity
|
||||||
|
3. Make action buttons (download, delete) slightly larger with visible borders
|
||||||
|
4. Add tooltips to icon-only action buttons
|
||||||
|
- **Acceptance gate**: Users can tell the status is interactive (not just a badge); action buttons are easy to click
|
||||||
|
- **Dependencies**: None
|
||||||
|
- **Risk**: Low — UI-only changes
|
||||||
|
|
||||||
|
### [x] Task 6: OrderDetail — cleaner material override UX
|
||||||
|
|
||||||
|
- **File**: `frontend/src/pages/OrderDetail.tsx`
|
||||||
|
- **What**: Polish the per-line material override dropdown:
|
||||||
|
1. Move the material override dropdown from inside the "Output Type" column to its own column or a cleaner inline position
|
||||||
|
2. Only show the dropdown on hover or when the line has an override set (reduce visual noise)
|
||||||
|
3. The batch override dropdown above the table should be more visually prominent — card-style with label, not just inline text + select
|
||||||
|
- **Acceptance gate**: Material override is discoverable but not visually noisy; batch override is clearly labeled
|
||||||
|
- **Dependencies**: None
|
||||||
|
- **Risk**: Low — visual adjustment
|
||||||
|
|
||||||
## Migration Check
|
## Migration Check
|
||||||
|
|
||||||
**No** — all detection is computed from existing data.
|
**No** — all changes are frontend-only.
|
||||||
|
|
||||||
## Order
|
## Order Recommendation
|
||||||
|
|
||||||
1. Backend Tasks 1+2 (Excel preview conflicts)
|
Tasks 1-3 can be done in parallel (independent admin components).
|
||||||
2. Backend Task 3 (STEP replacement warning)
|
Tasks 4-6 can be done in parallel (independent pages).
|
||||||
3. Frontend Tasks 4+5+6 (types + UI)
|
|
||||||
|
Recommended execution: Tasks 1-3 first (highest impact), then 4-6.
|
||||||
|
|
||||||
|
## Risks / Open Questions
|
||||||
|
|
||||||
|
1. **RenderTemplateTable .blend upload**: The file upload flow (choose file → upload → or clone from existing) is complex. The expandable form needs to preserve this exact functionality without breaking the upload mutation.
|
||||||
|
|
||||||
|
2. **Consistency with OutputTypeTable**: The expandable form pattern should look identical across all admin tables — same grid spacing, same accent border, same Save/Cancel button placement. Consider extracting a shared `ExpandableEditRow` wrapper if patterns diverge.
|
||||||
|
|
||||||
|
3. **Add-new forms**: Some tables have "Add" as a button that opens a modal-like form at the top, others have an inline row. Standardize: "Add" button at the top → opens expandable form row at the top of the table body (same pattern as edit).
|
||||||
|
|||||||
Reference in New Issue
Block a user