feat: initial commit
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Trash2, Pencil, Check, X } from 'lucide-react'
|
||||
import { listMaterials, createMaterial, updateMaterial, deleteMaterial } from '../../api/materials'
|
||||
import type { Material } from '../../api/materials'
|
||||
|
||||
export default function MaterialLibrary() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
|
||||
const { data: materials = [] } = useQuery({
|
||||
queryKey: ['materials'],
|
||||
queryFn: listMaterials,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createMaterial({ name: newName.trim(), description: newDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material added')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setShowAdd(false)
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add material'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (id: string) => updateMaterial(id, { name: editName.trim(), description: editDesc.trim() || undefined }),
|
||||
onSuccess: () => {
|
||||
toast.success('Material updated')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteMaterial,
|
||||
onSuccess: () => {
|
||||
toast.success('Material deleted')
|
||||
qc.invalidateQueries({ queryKey: ['materials'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const startEdit = (mat: Material) => {
|
||||
setEditingId(mat.id)
|
||||
setEditName(mat.name)
|
||||
setEditDesc(mat.description ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-default flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Material Library</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Shared materials available when assigning CAD part materials.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
|
||||
<Plus size={16} /> Add Material
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="p-4 border-b border-border-light bg-surface-alt flex gap-3 items-end flex-wrap">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. Steel 100Cr6"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
|
||||
<input
|
||||
placeholder="e.g. Bearing steel, hardened"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!newName.trim() || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{materials.length === 0 ? (
|
||||
<div className="p-8 text-center text-content-muted text-sm">
|
||||
No materials yet. Add the first one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{materials.map((mat) => (
|
||||
<div key={mat.id} className="flex items-center px-6 py-3 gap-3">
|
||||
{editingId === mat.id ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="flex-1 px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Description"
|
||||
className="flex-1 px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateMut.mutate(mat.id)}
|
||||
disabled={!editName.trim() || updateMut.isPending}
|
||||
className="text-status-success-text hover:text-status-success-text"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="text-content-muted hover:text-content-secondary" title="Cancel">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-content">{mat.name}</p>
|
||||
{mat.description && (
|
||||
<p className="text-xs text-content-muted">{mat.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => startEdit(mat)} className="text-content-muted hover:text-content-secondary" title="Edit">
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete material "${mat.name}"?`)) deleteMut.mutate(mat.id)
|
||||
}}
|
||||
className="text-content-muted hover:text-red-500"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Trash2, Plus, Check, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listPricingTiers, createPricingTier, updatePricingTier, deletePricingTier } from '../../api/pricing'
|
||||
import type { PricingTier } from '../../api/pricing'
|
||||
|
||||
const EMPTY_FORM = { category_key: '', quality_level: 'Normal', price_per_item: '', description: '' }
|
||||
|
||||
export default function PricingTierTable() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Partial<PricingTier>>({})
|
||||
|
||||
const { data: tiers, isLoading } = useQuery({
|
||||
queryKey: ['pricing-tiers'],
|
||||
queryFn: listPricingTiers,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () =>
|
||||
createPricingTier({
|
||||
category_key: form.category_key.trim(),
|
||||
quality_level: form.quality_level.trim() || 'Normal',
|
||||
price_per_item: parseFloat(form.price_per_item),
|
||||
description: form.description.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Pricing tier created')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
setForm(EMPTY_FORM)
|
||||
setShowAdd(false)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create tier'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<PricingTier> }) =>
|
||||
updatePricingTier(id, {
|
||||
category_key: data.category_key,
|
||||
quality_level: data.quality_level,
|
||||
price_per_item: data.price_per_item != null ? Number(data.price_per_item) : undefined,
|
||||
description: data.description !== undefined ? data.description ?? undefined : undefined,
|
||||
is_active: data.is_active,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Tier updated')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
setEditingId(null)
|
||||
setEditDraft({})
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update tier'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: number) => deletePricingTier(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Tier deleted')
|
||||
qc.invalidateQueries({ queryKey: ['pricing-tiers'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete tier'),
|
||||
})
|
||||
|
||||
function startEdit(tier: PricingTier) {
|
||||
setEditingId(tier.id)
|
||||
setEditDraft({
|
||||
category_key: tier.category_key,
|
||||
quality_level: tier.quality_level,
|
||||
price_per_item: tier.price_per_item,
|
||||
description: tier.description ?? '',
|
||||
is_active: tier.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setEditDraft({})
|
||||
}
|
||||
|
||||
const canCreate = form.category_key.trim() !== '' && form.price_per_item !== '' && !isNaN(parseFloat(form.price_per_item))
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Add form toggle */}
|
||||
<div className="p-4 border-b border-border-light">
|
||||
{showAdd ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<input
|
||||
placeholder="Category key (e.g. TRB)"
|
||||
value={form.category_key}
|
||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
||||
className="input-base"
|
||||
title="Product category key this tier applies to (e.g. TRB, Kugellager). Leave empty for the global fallback tier."
|
||||
/>
|
||||
<input
|
||||
placeholder="Quality level (e.g. Normal)"
|
||||
value={form.quality_level}
|
||||
onChange={(e) => setForm({ ...form, quality_level: e.target.value })}
|
||||
className="input-base"
|
||||
title="Quality level label for this tier (e.g. Normal, Premium). Used for display purposes."
|
||||
/>
|
||||
<input
|
||||
placeholder="€ / item"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={form.price_per_item}
|
||||
onChange={(e) => setForm({ ...form, price_per_item: e.target.value })}
|
||||
className="input-base"
|
||||
/>
|
||||
<input
|
||||
placeholder="Description (optional)"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="input-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!canCreate || createMut.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{createMut.isPending ? 'Saving…' : 'Add Tier'}
|
||||
</button>
|
||||
<button onClick={() => { setShowAdd(false); setForm(EMPTY_FORM) }} className="btn-secondary text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-primary text-sm">
|
||||
<Plus size={14} />
|
||||
Add New Tier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-content-muted text-sm">Loading…</div>
|
||||
) : !tiers || tiers.length === 0 ? (
|
||||
<div className="p-6 text-center text-content-muted text-sm">
|
||||
No pricing tiers configured. Add one above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
{/* Warning if no default tier */}
|
||||
{!tiers.some((t) => t.category_key === 'default') && (
|
||||
<div className="mx-4 mt-3 mb-1 px-3 py-2 rounded-lg bg-status-warning-bg border border-border-default text-status-warning-text text-xs">
|
||||
No global default tier configured. Orders without a category-specific tier will have no price.
|
||||
</div>
|
||||
)}
|
||||
<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 className="divide-y divide-border-light">
|
||||
{[...tiers].sort((a, b) => {
|
||||
// Sort 'default' to top
|
||||
if (a.category_key === 'default' && b.category_key !== 'default') return -1
|
||||
if (b.category_key === 'default' && a.category_key !== 'default') return 1
|
||||
return 0
|
||||
}).map((tier) => {
|
||||
const isEditing = editingId === tier.id
|
||||
const isDefault = tier.category_key === 'default'
|
||||
return (
|
||||
<tr key={tier.id} className={`hover:bg-surface-hover transition-colors ${isDefault ? 'bg-status-warning-bg' : ''}`}>
|
||||
<td className="px-4 py-2 font-mono font-medium text-content">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.category_key ?? tier.category_key}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, category_key: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{tier.category_key}
|
||||
{isDefault && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-status-warning-bg text-status-warning-text font-medium font-sans" title="This is the global fallback pricing tier — used when no category-specific tier matches. The 'default' category key identifies this tier.">
|
||||
Global Fallback
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-content-secondary">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.quality_level ?? tier.quality_level}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, quality_level: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
tier.quality_level
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editDraft.price_per_item ?? tier.price_per_item}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, price_per_item: parseFloat(e.target.value) }))}
|
||||
className="w-24 px-2 py-1 border border-border-default rounded text-sm text-right focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">€ {Number(tier.price_per_item).toFixed(2)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-content-muted">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editDraft.description ?? tier.description ?? ''}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
className="w-full px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
tier.description || <span className="text-content-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.is_active ?? tier.is_active}
|
||||
onChange={(e) => setEditDraft((d) => ({ ...d, is_active: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<span className={`badge ${tier.is_active ? 'badge-green' : 'badge-gray'}`}>
|
||||
{tier.is_active ? 'yes' : 'no'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateMut.mutate({ id: tier.id, data: editDraft })}
|
||||
disabled={updateMut.isPending}
|
||||
className="p-1 text-status-success-text hover:bg-surface-hover rounded"
|
||||
title="Save"
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1 text-content-muted hover:bg-surface-muted rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startEdit(tier)}
|
||||
className="p-1 text-content-muted hover:text-accent hover:bg-surface-hover rounded"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Delete ${tier.category_key} / ${tier.quality_level}?`)) deleteMut.mutate(tier.id) }}
|
||||
className="p-1 text-content-muted hover:text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
listRenderTemplates,
|
||||
createRenderTemplate,
|
||||
updateRenderTemplate,
|
||||
deleteRenderTemplate,
|
||||
reuploadBlendFile,
|
||||
} from '../../api/renderTemplates'
|
||||
import type { RenderTemplate } from '../../api/renderTemplates'
|
||||
import { listOutputTypes } from '../../api/outputTypes'
|
||||
import type { OutputType } from '../../api/outputTypes'
|
||||
|
||||
const ALL_CATEGORIES = [
|
||||
{ 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 = {
|
||||
name: '',
|
||||
category_key: '' as string,
|
||||
output_type_id: '' as string,
|
||||
target_collection: 'Product',
|
||||
material_replace_enabled: false,
|
||||
lighting_only: false,
|
||||
shadow_catcher_enabled: false,
|
||||
camera_orbit: true,
|
||||
}
|
||||
|
||||
export default function RenderTemplateTable() {
|
||||
const qc = useQueryClient()
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [addFile, setAddFile] = useState<File | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const reuploadRef = useRef<HTMLInputElement>(null)
|
||||
const [reuploadId, setReuploadId] = useState<string | null>(null)
|
||||
|
||||
const { data: templates, isLoading } = useQuery({
|
||||
queryKey: ['render-templates'],
|
||||
queryFn: listRenderTemplates,
|
||||
})
|
||||
|
||||
const { data: outputTypes } = useQuery({
|
||||
queryKey: ['output-types-admin'],
|
||||
queryFn: () => listOutputTypes(true),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!addFile) throw new Error('Please select a .blend file')
|
||||
const fd = new FormData()
|
||||
fd.append('name', form.name.trim())
|
||||
fd.append('file', addFile)
|
||||
fd.append('category_key', form.category_key || '')
|
||||
fd.append('output_type_id', form.output_type_id || '')
|
||||
fd.append('target_collection', form.target_collection || 'Product')
|
||||
fd.append('material_replace_enabled', String(form.material_replace_enabled))
|
||||
fd.append('lighting_only', String(form.lighting_only))
|
||||
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
|
||||
fd.append('camera_orbit', String(form.camera_orbit))
|
||||
return createRenderTemplate(fd)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Render template created')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setForm(EMPTY_FORM)
|
||||
setAddFile(null)
|
||||
setShowAdd(false)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
updateRenderTemplate(id, data as any),
|
||||
onSuccess: () => {
|
||||
toast.success('Template updated')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteRenderTemplate,
|
||||
onSuccess: () => {
|
||||
toast.success('Template deleted')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const reuploadMut = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => reuploadBlendFile(id, file),
|
||||
onSuccess: () => {
|
||||
toast.success('.blend file updated')
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setReuploadId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to upload'),
|
||||
})
|
||||
|
||||
function startEdit(t: RenderTemplate) {
|
||||
setEditingId(t.id)
|
||||
setEditDraft({
|
||||
name: t.name,
|
||||
category_key: t.category_key,
|
||||
output_type_id: t.output_type_id,
|
||||
target_collection: t.target_collection,
|
||||
material_replace_enabled: t.material_replace_enabled,
|
||||
lighting_only: t.lighting_only,
|
||||
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
||||
camera_orbit: t.camera_orbit,
|
||||
is_active: t.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingId) return
|
||||
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'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-content">Render Templates</h3>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="flex items-center gap-1 text-sm px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
<Plus size={14} /> Add Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={reuploadRef}
|
||||
type="file"
|
||||
accept=".blend"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file && reuploadId) reuploadMut.mutate({ id: reuploadId, file })
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-surface-alt border-b text-left">
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Category</th>
|
||||
<th className="px-3 py-2 font-medium">Output Type</th>
|
||||
<th className="px-3 py-2 font-medium">Collection</th>
|
||||
<th className="px-3 py-2 font-medium">Mat. Replace</th>
|
||||
<th className="px-3 py-2 font-medium">Lighting Only</th>
|
||||
<th className="px-3 py-2 font-medium" title="Enable Shadowcatcher collection (Cycles only)">Shadow Catcher</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">Active</th>
|
||||
<th className="px-3 py-2 font-medium w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Add row */}
|
||||
{showAdd && (
|
||||
<tr className="border-b bg-surface-hover/40">
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className={inputCls + ' w-40'}
|
||||
placeholder="Template name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={form.category_key}
|
||||
onChange={(e) => setForm({ ...form, category_key: e.target.value })}
|
||||
>
|
||||
<option value="">Any (default)</option>
|
||||
{ALL_CATEGORIES.map((c) => (
|
||||
<option key={c.key} value={c.key}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
className={inputCls}
|
||||
value={form.output_type_id}
|
||||
onChange={(e) => setForm({ ...form, output_type_id: e.target.value })}
|
||||
>
|
||||
<option value="">Any (default)</option>
|
||||
{outputTypes?.map((ot: OutputType) => (
|
||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className={inputCls + ' w-28'}
|
||||
value={form.target_collection}
|
||||
onChange={(e) => setForm({ ...form, target_collection: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.material_replace_enabled}
|
||||
onChange={(e) => setForm({ ...form, material_replace_enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.lighting_only}
|
||||
onChange={(e) => setForm({ ...form, lighting_only: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.shadow_catcher_enabled}
|
||||
title="Enable Shadowcatcher collection (Cycles only)"
|
||||
onChange={(e) => setForm({ ...form, shadow_catcher_enabled: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.camera_orbit}
|
||||
title="Rotate camera around product (better GPU performance)"
|
||||
onChange={(e) => setForm({ ...form, camera_orbit: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
|
||||
<Upload size={14} />
|
||||
{addFile ? addFile.name : 'Choose .blend'}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".blend"
|
||||
className="hidden"
|
||||
onChange={(e) => setAddFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td />
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={!form.name.trim() || !addFile || 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) }}
|
||||
className="p-1 text-content-muted hover:bg-surface-hover rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Template rows */}
|
||||
{isLoading && (
|
||||
<tr><td colSpan={11} className="px-3 py-4 text-center text-content-muted">Loading...</td></tr>
|
||||
)}
|
||||
{templates?.map((t) => {
|
||||
const isEditing = editingId === t.id
|
||||
return (
|
||||
<tr key={t.id} className="border-b hover:bg-surface-hover/50">
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={inputCls + ' w-40'}
|
||||
value={editDraft.name ?? t.name}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">{t.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<select
|
||||
className={inputCls}
|
||||
value={editDraft.category_key ?? t.category_key ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, category_key: e.target.value || null })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{ALL_CATEGORIES.map((c) => (
|
||||
<option key={c.key} value={c.key}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
t.category_key || <span className="text-content-muted">Any</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<select
|
||||
className={inputCls}
|
||||
value={editDraft.output_type_id ?? t.output_type_id ?? ''}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, output_type_id: e.target.value || null })}
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{outputTypes?.map((ot: OutputType) => (
|
||||
<option key={ot.id} value={ot.id}>{ot.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
t.output_type_name || <span className="text-content-muted">Any</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={inputCls + ' w-28'}
|
||||
value={editDraft.target_collection ?? t.target_collection}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, target_collection: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<code className="text-xs bg-surface-muted px-1 rounded">{t.target_collection}</code>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.material_replace_enabled ?? t.material_replace_enabled}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, material_replace_enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.material_replace_enabled ? (
|
||||
<span className="text-status-success-text text-xs font-medium">Yes</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">No</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.lighting_only ?? t.lighting_only}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, lighting_only: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.lighting_only ? (
|
||||
<span className="text-status-warning-text text-xs font-medium">HDR</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">—</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.shadow_catcher_enabled ?? t.shadow_catcher_enabled}
|
||||
title="Enable Shadowcatcher collection (Cycles only)"
|
||||
onChange={(e) => setEditDraft({ ...editDraft, shadow_catcher_enabled: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.shadow_catcher_enabled ? (
|
||||
<span className="text-violet-600 text-xs font-medium">On</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">—</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.camera_orbit ?? t.camera_orbit}
|
||||
title="Rotate camera around product (better GPU performance)"
|
||||
onChange={(e) => setEditDraft({ ...editDraft, camera_orbit: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.camera_orbit ? (
|
||||
<span className="text-teal-600 text-xs font-medium">Cam</span>
|
||||
) : (
|
||||
<span className="text-content-muted text-xs">Obj</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
|
||||
{t.original_filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setReuploadId(t.id); reuploadRef.current?.click() }}
|
||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
||||
title="Re-upload .blend"
|
||||
>
|
||||
<Upload size={12} />
|
||||
</button>
|
||||
<a
|
||||
href={`/api/render-templates/${t.id}/download`}
|
||||
className="p-0.5 text-accent hover:bg-surface-hover rounded"
|
||||
title="Download .blend"
|
||||
>
|
||||
<Download size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDraft.is_active ?? t.is_active}
|
||||
onChange={(e) => setEditDraft({ ...editDraft, is_active: e.target.checked })}
|
||||
/>
|
||||
) : (
|
||||
t.is_active ? (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" title="Active" />
|
||||
) : (
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-surface-muted" title="Inactive" />
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isEditing ? (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={saveEdit} className="p-1 text-status-success-text hover:bg-surface-hover rounded" title="Save">
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="p-1 text-content-muted hover:bg-surface-muted rounded" title="Cancel">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
|
||||
}}
|
||||
className="p-1 text-red-500 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isLoading && (!templates || templates.length === 0) && !showAdd && (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-3 py-6 text-center text-content-muted">
|
||||
No render templates configured. Click "Add Template" to create one.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-content-muted">
|
||||
Templates define pre-designed .blend studio setups. When rendering, the system matches templates by Category + Output Type with fallback cascade.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Save, Plus, Trash2, ChevronUp, ChevronDown,
|
||||
GripVertical, Eye, EyeOff, ToggleLeft, ToggleRight,
|
||||
} from 'lucide-react'
|
||||
import api from '../../api/client'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Visibility = 'required' | 'optional' | 'hidden'
|
||||
|
||||
interface StdField {
|
||||
key: string
|
||||
label: string
|
||||
visibility: Visibility
|
||||
}
|
||||
|
||||
interface CompPair {
|
||||
component_type: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
category_key: string
|
||||
description?: string | null
|
||||
is_active: boolean
|
||||
standard_fields: any
|
||||
component_schema: any
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All canonical standard field definitions (maps to DB columns in order_items)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_FIELD_DEFS: { key: string; defaultLabel: string }[] = [
|
||||
{ key: 'ebene1', defaultLabel: 'Ebene 1' },
|
||||
{ key: 'ebene2', defaultLabel: 'Ebene 2' },
|
||||
{ key: 'baureihe', defaultLabel: 'Baureihe' },
|
||||
{ key: 'pim_id', defaultLabel: 'PIM-ID' },
|
||||
{ key: 'produkt_baureihe', defaultLabel: 'Produkt / Baureihe' },
|
||||
{ key: 'gewaehltes_produkt', defaultLabel: 'Gewähltes Produkt' },
|
||||
{ key: 'name_cad_modell', defaultLabel: 'Name CAD-Modell' },
|
||||
{ key: 'gewuenschte_bildnummer',defaultLabel: 'Gewünschte Bildnummer' },
|
||||
{ key: 'lagertyp', defaultLabel: 'Lagertyp' },
|
||||
{ key: 'medias_rendering', defaultLabel: 'Medias Rendering' },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeFields(raw: any): StdField[] {
|
||||
// New array format: [{key, label, visibility}]
|
||||
if (Array.isArray(raw) && raw.length > 0 && raw[0].key) {
|
||||
const existing = new Map(raw.map((f: StdField) => [f.key, f]))
|
||||
// Preserve saved order, then append any missing canonical fields
|
||||
const ordered: StdField[] = raw.filter((f: StdField) =>
|
||||
ALL_FIELD_DEFS.some((d) => d.key === f.key),
|
||||
)
|
||||
ALL_FIELD_DEFS.forEach(({ key, defaultLabel }) => {
|
||||
if (!existing.has(key)) {
|
||||
ordered.push({ key, label: defaultLabel, visibility: 'optional' })
|
||||
}
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
// Legacy dict format {"0": {label, required}} or empty — use canonical defaults
|
||||
return ALL_FIELD_DEFS.map(({ key, defaultLabel }) => ({
|
||||
key,
|
||||
label: defaultLabel,
|
||||
visibility: 'optional' as Visibility,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizePairs(raw: any): CompPair[] {
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw.pairs)) return raw.pairs.map((p: any) => ({
|
||||
component_type: p.component_type ?? p.part_name ?? '',
|
||||
required: p.required ?? false,
|
||||
}))
|
||||
if (Array.isArray(raw)) return raw.map((p: any) => ({
|
||||
component_type: p.component_type ?? p.part_name ?? '',
|
||||
required: p.required ?? false,
|
||||
}))
|
||||
return []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function moveItem<T>(arr: T[], from: number, to: number): T[] {
|
||||
const next = [...arr]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(to, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
const VIS_STYLES: Record<Visibility, string> = {
|
||||
required: 'bg-accent text-white',
|
||||
optional: 'bg-blue-500 text-white',
|
||||
hidden: 'bg-surface-muted text-content-secondary',
|
||||
}
|
||||
|
||||
function VisibilityToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Visibility
|
||||
onChange: (v: Visibility) => void
|
||||
}) {
|
||||
const cycle: Visibility[] = ['required', 'optional', 'hidden']
|
||||
const labels: Record<Visibility, string> = { required: 'Required', optional: 'Optional', hidden: 'Hidden' }
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(cycle[(cycle.indexOf(value) + 1) % 3])}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${VIS_STYLES[value]}`}
|
||||
title="Click to cycle: Required → Optional → Hidden"
|
||||
>
|
||||
{labels[value]}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TemplateEditor({
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
template: Template
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [isActive, setIsActive] = useState(template.is_active)
|
||||
const [fields, setFields] = useState<StdField[]>(() => normalizeFields(template.standard_fields))
|
||||
const [pairs, setPairs] = useState<CompPair[]>(() => normalizePairs(template.component_schema))
|
||||
const [showHidden, setShowHidden] = useState(false)
|
||||
const [newFieldKey, setNewFieldKey] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setName(template.name)
|
||||
setDescription(template.description ?? '')
|
||||
setIsActive(template.is_active)
|
||||
setFields(normalizeFields(template.standard_fields))
|
||||
setPairs(normalizePairs(template.component_schema))
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () =>
|
||||
api.patch(`/templates/${template.id}`, {
|
||||
name,
|
||||
description: description || null,
|
||||
is_active: isActive,
|
||||
standard_fields: fields,
|
||||
component_schema: { pairs },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Template saved')
|
||||
qc.invalidateQueries({ queryKey: ['admin-templates'] })
|
||||
qc.invalidateQueries({ queryKey: ['templates'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to save'),
|
||||
})
|
||||
|
||||
// ---- Standard fields helpers ----
|
||||
function updateField(i: number, patch: Partial<StdField>) {
|
||||
setFields((f) => f.map((x, j) => (j === i ? { ...x, ...patch } : x)))
|
||||
}
|
||||
function removeField(i: number) {
|
||||
// Don't delete — mark hidden so it stays in DB but hidden in UI
|
||||
updateField(i, { visibility: 'hidden' })
|
||||
}
|
||||
const hiddenKeys = new Set(fields.filter((f) => f.visibility === 'hidden').map((f) => f.key))
|
||||
const availableToAdd = ALL_FIELD_DEFS.filter((d) => hiddenKeys.has(d.key))
|
||||
|
||||
function restoreField(key: string) {
|
||||
setFields((f) =>
|
||||
f.map((x) => (x.key === key ? { ...x, visibility: 'optional' } : x)),
|
||||
)
|
||||
setNewFieldKey('')
|
||||
}
|
||||
|
||||
// ---- Component pair helpers ----
|
||||
function updatePair(i: number, patch: Partial<CompPair>) {
|
||||
setPairs((p) => p.map((x, j) => (j === i ? { ...x, ...patch } : x)))
|
||||
}
|
||||
function addPair() {
|
||||
setPairs((p) => [...p, { component_type: '', required: false }])
|
||||
}
|
||||
function removePair(i: number) {
|
||||
setPairs((p) => p.filter((_, j) => j !== i))
|
||||
}
|
||||
|
||||
// ---- Visible fields for rendering ----
|
||||
const visibleFields = showHidden ? fields : fields.filter((f) => f.visibility !== 'hidden')
|
||||
|
||||
// ---- Shared styles ----
|
||||
const ROW = 'flex items-center gap-2 px-3 py-2 rounded-lg border border-border-light bg-surface-alt group'
|
||||
const ICON_BTN = 'p-1 rounded text-content-muted hover:text-content-secondary hover:bg-surface transition-colors disabled:opacity-30'
|
||||
const INPUT = 'flex-1 min-w-0 text-sm bg-transparent border-b border-transparent focus:border-accent focus:outline-none py-0.5 text-content'
|
||||
|
||||
return (
|
||||
<div className="border border-border-default rounded-xl bg-surface shadow-sm">
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Header */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-default bg-surface-alt rounded-t-xl gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
{/* Editable name */}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-base font-semibold text-content bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
|
||||
placeholder="Template name"
|
||||
/>
|
||||
{/* Category key (read-only) + active toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-content-muted font-mono">{template.category_key}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full transition-colors ${
|
||||
isActive ? 'bg-status-success-bg text-status-success-text' : 'bg-surface-muted text-content-muted'
|
||||
}`}
|
||||
>
|
||||
{isActive ? <ToggleRight size={13} /> : <ToggleLeft size={13} />}
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-xs text-content-muted bg-transparent border-b border-transparent focus:border-accent focus:outline-none w-full"
|
||||
placeholder="Description (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => saveMut.mutate()}
|
||||
disabled={saveMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||
>
|
||||
<Save size={14} />
|
||||
{saveMut.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-8">
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Standard Fields */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
|
||||
Standard Fields
|
||||
</h3>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Rename, reorder, and set visibility for each column. Hidden fields are excluded from forms.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-content-muted hover:text-content px-2 py-1 rounded border border-border-default hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{showHidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
{showHidden ? 'Hide hidden' : `Show hidden (${hiddenKeys.size})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{visibleFields.map((field, i) => {
|
||||
// Real index in fields array (needed for moveItem / updateField)
|
||||
const realIdx = fields.indexOf(field)
|
||||
const isHidden = field.visibility === 'hidden'
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`${ROW} ${isHidden ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={realIdx === 0}
|
||||
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx - 1))}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={realIdx === fields.length - 1}
|
||||
onClick={() => setFields((f) => moveItem(f, realIdx, realIdx + 1))}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GripVertical size={13} className="text-content-muted shrink-0" />
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(realIdx, { label: e.target.value })}
|
||||
className={INPUT}
|
||||
placeholder="Field label"
|
||||
/>
|
||||
|
||||
{/* Key badge */}
|
||||
<span className="hidden md:block text-xs text-content-muted font-mono w-48 shrink-0 truncate">
|
||||
{field.key}
|
||||
</span>
|
||||
|
||||
{/* Visibility */}
|
||||
<VisibilityToggle
|
||||
value={field.visibility}
|
||||
onChange={(v) => updateField(realIdx, { visibility: v })}
|
||||
/>
|
||||
|
||||
{/* Hide button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
|
||||
onClick={() => removeField(realIdx)}
|
||||
aria-label="Hide field"
|
||||
title="Hide this field"
|
||||
>
|
||||
<EyeOff size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Restore hidden field */}
|
||||
{availableToAdd.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<select
|
||||
value={newFieldKey}
|
||||
onChange={(e) => setNewFieldKey(e.target.value)}
|
||||
className="text-xs border border-border-default rounded px-2 py-1.5 text-content-secondary focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">Restore a hidden field…</option>
|
||||
{availableToAdd.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{fields.find((f) => f.key === d.key)?.label || d.defaultLabel}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{newFieldKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restoreField(newFieldKey)}
|
||||
className="text-xs px-2 py-1.5 rounded bg-accent text-white hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* Component Schema */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-content-secondary uppercase tracking-wide">
|
||||
Component Schema
|
||||
</h3>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Define the expected component types that appear as column pairs in the Excel file (cols 11+).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{pairs.length === 0 && (
|
||||
<p className="text-sm text-content-muted italic px-3">No component types defined.</p>
|
||||
)}
|
||||
{pairs.map((pair, i) => (
|
||||
<div key={i} className={ROW}>
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={i === 0}
|
||||
onClick={() => setPairs((p) => moveItem(p, i, i - 1))}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ICON_BTN}
|
||||
disabled={i === pairs.length - 1}
|
||||
onClick={() => setPairs((p) => moveItem(p, i, i + 1))}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GripVertical size={13} className="text-content-muted shrink-0" />
|
||||
|
||||
{/* Index badge */}
|
||||
<span className="text-xs text-content-muted font-mono w-6 text-center shrink-0">
|
||||
{i + 1}
|
||||
</span>
|
||||
|
||||
{/* Component type name */}
|
||||
<input
|
||||
value={pair.component_type}
|
||||
onChange={(e) => updatePair(i, { component_type: e.target.value })}
|
||||
placeholder="Component type name"
|
||||
className={INPUT}
|
||||
/>
|
||||
|
||||
{/* Required toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updatePair(i, { required: !pair.required })}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium shrink-0 transition-colors ${
|
||||
pair.required
|
||||
? 'text-white'
|
||||
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
style={pair.required ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
title="Toggle required"
|
||||
>
|
||||
{pair.required ? 'Required' : 'Optional'}
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className={`${ICON_BTN} hover:text-red-500 hover:bg-red-50`}
|
||||
onClick={() => removePair(i)}
|
||||
aria-label="Delete component"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPair}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-accent hover:text-accent-hover px-3 py-1.5 rounded border border-border-default hover:bg-accent-light transition-colors"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add component type
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user