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,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Receipt, Download, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Receipt, Download, Trash2, Plus, X, ChevronDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getInvoices, createInvoice, updateInvoiceStatus, deleteInvoice, downloadInvoicePdf,
|
||||
@@ -194,26 +194,29 @@ export default function BillingPage() {
|
||||
<tr key={inv.id} className="border-b border-border-default hover:bg-surface-hover transition-colors">
|
||||
<td className="px-4 py-3 text-sm font-mono text-content">{inv.invoice_number}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={inv.status}
|
||||
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
|
||||
>
|
||||
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="inline-flex items-center gap-1 border border-transparent hover:border-border-default rounded-full transition-colors cursor-pointer pr-1">
|
||||
<select
|
||||
value={inv.status}
|
||||
onChange={e => statusMutation.mutate({ id: inv.id, status: e.target.value })}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium border-0 cursor-pointer focus:outline-none focus:ring-1 focus:ring-accent appearance-none ${STATUS_COLORS[inv.status] || 'badge-gray'}`}
|
||||
>
|
||||
{['draft', 'sent', 'paid', 'cancelled'].map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={12} className="text-content-muted pointer-events-none -ml-0.5" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.issued_at)}</td>
|
||||
<td className="px-4 py-3 text-sm text-content-secondary">{formatDate(inv.due_at)}</td>
|
||||
<td className="px-4 py-3 text-sm text-content">{formatCurrency(inv.total_net, inv.currency)}</td>
|
||||
<td className="px-4 py-3 flex items-center gap-1">
|
||||
<td className="px-4 py-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => downloadInvoicePdf(inv.id).catch(() => toast.error('PDF download failed'))}
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="p-2 rounded-lg border border-border-default hover:bg-surface-hover text-content-muted hover:text-content transition-colors"
|
||||
title="Download PDF"
|
||||
>
|
||||
<Download size={15} />
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{inv.status === 'draft' && (
|
||||
<button
|
||||
@@ -228,10 +231,10 @@ export default function BillingPage() {
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
|
||||
className="p-2 rounded-lg border border-border-default hover:bg-red-100 text-content-muted hover:text-red-600 transition-colors"
|
||||
title="Delete draft"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -619,26 +619,33 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
|
||||
{(order.lines?.length ?? 0) > 0 && isPrivileged && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="text-xs text-content-muted">Batch material override:</span>
|
||||
<select
|
||||
className="text-xs border border-border-default rounded px-2 py-1"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '__clear__') batchOverrideMut.mutate(null)
|
||||
else if (val) batchOverrideMut.mutate(val)
|
||||
}}
|
||||
disabled={batchOverrideMut.isPending}
|
||||
>
|
||||
<option value="">Apply to all lines…</option>
|
||||
<option value="__clear__">— Clear all overrides —</option>
|
||||
{orderLibMats.map((m: Material) => (
|
||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
{batchOverrideMut.isPending && <Loader2 size={12} className="animate-spin text-accent" />}
|
||||
<div className="mb-4 rounded-xl border border-border-default p-4" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-content">Batch Material Override</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">Apply a single material to all render lines in this order at once.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="text-sm border border-border-default rounded-lg px-3 py-1.5"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
if (val === '__clear__') batchOverrideMut.mutate(null)
|
||||
else if (val) batchOverrideMut.mutate(val)
|
||||
}}
|
||||
disabled={batchOverrideMut.isPending}
|
||||
>
|
||||
<option value="">Apply to all lines…</option>
|
||||
<option value="__clear__">— Clear all overrides —</option>
|
||||
{orderLibMats.map((m: Material) => (
|
||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
{batchOverrideMut.isPending && <Loader2 size={14} className="animate-spin text-accent" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -903,6 +910,7 @@ function OrderLineRow({
|
||||
const [showInfo, setShowInfo] = useState(false)
|
||||
const [rejectLineModalOpen, setRejectLineModalOpen] = useState(false)
|
||||
const [rejectLineReason, setRejectLineReason] = useState('')
|
||||
const [showOverride, setShowOverride] = useState(false)
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: () => removeOrderLine(orderId, line.id),
|
||||
@@ -1028,18 +1036,60 @@ function OrderLineRow({
|
||||
</span>
|
||||
)}
|
||||
{isPrivileged && (
|
||||
<select
|
||||
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
|
||||
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
|
||||
value={line.material_override ?? ''}
|
||||
onChange={(e) => overrideMut.mutate(e.target.value || null)}
|
||||
title="Material override — apply a single material to all parts for this render"
|
||||
>
|
||||
<option value="">No material override</option>
|
||||
{libMats.map((m: Material) => (
|
||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
line.material_override ? (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium cursor-pointer hover:bg-amber-500/20 transition-colors"
|
||||
onClick={() => setShowOverride(!showOverride)}
|
||||
title="Click to change material override"
|
||||
>
|
||||
{line.material_override.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => overrideMut.mutate(null)}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
title="Clear override"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
{showOverride && (
|
||||
<select
|
||||
className="text-[10px] border border-border-default rounded px-1 py-0.5 flex-1"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value={line.material_override ?? ''}
|
||||
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
|
||||
autoFocus
|
||||
>
|
||||
<option value="">No material override</option>
|
||||
{libMats.map((m: Material) => (
|
||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
) : showOverride ? (
|
||||
<select
|
||||
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
|
||||
style={{ backgroundColor: 'var(--color-bg-surface)' }}
|
||||
value=""
|
||||
onChange={(e) => { overrideMut.mutate(e.target.value || null); setShowOverride(false) }}
|
||||
onBlur={() => setShowOverride(false)}
|
||||
autoFocus
|
||||
>
|
||||
<option value="">No material override</option>
|
||||
{libMats.map((m: Material) => (
|
||||
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowOverride(true)}
|
||||
className="text-[10px] text-content-muted hover:text-accent mt-1 transition-colors"
|
||||
title="Set material override for this line"
|
||||
>
|
||||
+ override
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -109,24 +109,29 @@ function ScaleControl({
|
||||
<p className="text-sm font-medium text-content">{label}</p>
|
||||
<p className="text-xs text-content-muted mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.max(0, c - 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-semibold text-content">{count}</span>
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs font-medium text-content-muted">Current Scale</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.max(0, c - 1))}
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-12 text-center text-sm font-semibold text-content">{count}</span>
|
||||
<button
|
||||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => scaleMut.mutate()}
|
||||
disabled={scaleMut.isPending}
|
||||
className="btn-primary text-xs px-3 py-1.5 ml-2"
|
||||
className="btn-primary text-sm px-5 py-2 font-semibold ml-2"
|
||||
>
|
||||
{scaleMut.isPending ? 'Scaling…' : 'Scale'}
|
||||
</button>
|
||||
@@ -202,46 +207,46 @@ function ConcurrencyConfigRow({ config }: { config: WorkerConfig }) {
|
||||
<div className="flex items-center gap-6 shrink-0">
|
||||
{/* Min concurrency */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-content-muted">Min</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-content-muted">Min Concurrency</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setMinVal((v) => Math.max(1, v - 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Minus size={12} />
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-semibold text-content">{minVal}</span>
|
||||
<span className="w-12 text-center text-sm font-semibold text-content">{minVal}</span>
|
||||
<button
|
||||
onClick={() => setMinVal((v) => Math.min(maxVal, v + 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Max concurrency */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-xs text-content-muted">Max</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-content-muted">Max Concurrency</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setMaxVal((v) => Math.max(minVal, v - 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Minus size={12} />
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-semibold text-content">{maxVal}</span>
|
||||
<span className="w-12 text-center text-sm font-semibold text-content">{maxVal}</span>
|
||||
<button
|
||||
onClick={() => setMaxVal((v) => Math.min(64, v + 1))}
|
||||
className="p-1 rounded-md bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
className="p-2 rounded-lg bg-surface-muted hover:bg-surface-hover text-content transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending || !isDirty}
|
||||
className={`btn-primary text-xs px-3 py-1.5 ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
className={`btn-primary text-sm px-5 py-2 font-semibold ${!isDirty ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{saveMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user