feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Terminal, ChevronDown, ChevronUp } from 'lucide-react'
import { getRenderLog } from '../api/worker'
import type { RenderLogEntry } from '../api/worker'
const LEVEL_COLORS: Record<string, string> = {
info: 'text-gray-300',
error: 'text-red-400',
success: 'text-green-400',
warn: 'text-yellow-400',
}
/**
* Live render log panel — polls Redis-backed log entries every 2s.
* Shows a compact terminal-style output for a render job.
*
* Always does an initial fetch to check if entries exist (so failed jobs
* still show their log). Polls only when isActive.
*/
export default function LiveRenderLog({
orderLineId,
isActive,
compact = false,
}: {
orderLineId: string
/** Whether the render is still processing — enables polling */
isActive: boolean
/** Compact mode (inline, no border) for table rows */
compact?: boolean
}) {
const [expanded, setExpanded] = useState(isActive)
const scrollRef = useRef<HTMLDivElement>(null)
// Always fetch once to probe for existing entries; poll only when active
const { data } = useQuery({
queryKey: ['render-log', orderLineId],
queryFn: () => getRenderLog(orderLineId),
refetchInterval: isActive ? 2000 : false,
})
const entries: RenderLogEntry[] = data?.entries ?? []
const hasEntries = entries.length > 0
// Auto-scroll to bottom when new entries arrive
useEffect(() => {
if (scrollRef.current && isActive) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [entries.length, isActive])
// Auto-expand when active
useEffect(() => {
if (isActive) setExpanded(true)
}, [isActive])
// Nothing to show at all
if (!hasEntries && !isActive) return null
if (compact) {
return (
<div className="mt-1">
<button
onClick={() => setExpanded((v) => !v)}
className="text-[10px] text-gray-400 hover:text-gray-600 flex items-center gap-1"
>
<Terminal size={10} />
Log ({entries.length})
{expanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
{expanded && hasEntries && (
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="120px" />
)}
</div>
)
}
return (
<div className="mt-2">
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 mb-1"
>
<Terminal size={12} />
<span className="font-medium">Render Log</span>
<span className="text-gray-400">({entries.length} entries)</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<LogPanel entries={entries} isActive={isActive} scrollRef={scrollRef} maxHeight="200px" />
)}
</div>
)
}
function LogPanel({
entries,
isActive,
scrollRef,
maxHeight,
}: {
entries: RenderLogEntry[]
isActive: boolean
scrollRef: React.RefObject<HTMLDivElement | null>
maxHeight: string
}) {
return (
<div
ref={scrollRef}
className="bg-gray-900 rounded-md p-2 overflow-y-auto font-mono text-[11px] leading-relaxed"
style={{ maxHeight }}
>
{entries.map((entry, i) => (
<div key={i} className={`flex gap-2 ${LEVEL_COLORS[entry.level] || LEVEL_COLORS.info}`}>
<span className="text-gray-500 shrink-0 select-none">{entry.t}</span>
<span>{entry.msg}</span>
</div>
))}
{isActive && entries.length > 0 && (
<div className="text-gray-500 animate-pulse">...</div>
)}
{entries.length === 0 && (
<div className="text-gray-600 italic">No log entries yet</div>
)}
</div>
)
}
+307
View File
@@ -0,0 +1,307 @@
import { useState, useEffect, useMemo } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { X, ChevronLeft, ChevronRight, Wrench, Paintbrush, Shapes, FlaskConical, HelpCircle } from 'lucide-react'
import { createMaterial, getNextCode } from '../api/materials'
interface Props {
open: boolean
onClose: () => void
onCreated?: (name: string) => void
}
const MATERIAL_TYPES = [
{ code: '01', label: 'Metals', icon: Wrench, color: 'bg-slate-100 text-slate-700 border-slate-300', activeColor: 'bg-slate-600 text-white border-slate-600' },
{ code: '02', label: 'Coatings', icon: Paintbrush, color: 'bg-status-info-bg text-status-info-text border-border-default', activeColor: 'bg-blue-600 text-white border-blue-600' },
{ code: '03', label: 'Non-metals', icon: Shapes, color: 'bg-status-warning-bg text-status-warning-text border-border-default', activeColor: 'bg-amber-600 text-white border-amber-600' },
{ code: '04', label: 'Compounds', icon: FlaskConical, color: 'bg-purple-50 text-purple-700 border-purple-300', activeColor: 'bg-purple-600 text-white border-purple-600' },
{ code: '05', label: 'Misc', icon: HelpCircle, color: 'bg-surface-alt text-content-secondary border-border-default', activeColor: 'bg-gray-600 text-white border-gray-600' },
] as const
const SUBTYPE_PRESETS: Record<string, Array<{ code: string; label: string }>> = {
'01': [
{ code: '01', label: 'Steel' },
{ code: '02', label: 'Niro' },
{ code: '03', label: 'Tin' },
{ code: '04', label: 'Aluminium' },
{ code: '05', label: 'Brass' },
{ code: '06', label: 'Bronze' },
],
'02': [
{ code: '01', label: 'Durotect' },
{ code: '02', label: 'Coat' },
],
'03': [
{ code: '01', label: 'Elastomer' },
{ code: '02', label: 'Plastic (opaque)' },
{ code: '03', label: 'Plastic (translucent)' },
{ code: '04', label: 'TPU' },
{ code: '05', label: 'Ceramic' },
],
'04': [
{ code: '01', label: 'E-series' },
{ code: '02', label: 'Elgo-series' },
{ code: '03', label: 'PTFE / GFK' },
],
'05': [],
}
export default function MaterialWizard({ open, onClose, onCreated }: Props) {
const qc = useQueryClient()
const [step, setStep] = useState(1)
const [typeCode, setTypeCode] = useState('')
const [subTypeCode, setSubTypeCode] = useState('')
const [customSubType, setCustomSubType] = useState('')
const [consecutive, setConsecutive] = useState<number | null>(null)
const [nameParts, setNameParts] = useState('')
const [description, setDescription] = useState('')
const [loadingCode, setLoadingCode] = useState(false)
const effectiveSubType = subTypeCode || customSubType
// Reset on open
useEffect(() => {
if (open) {
setStep(1)
setTypeCode('')
setSubTypeCode('')
setCustomSubType('')
setConsecutive(null)
setNameParts('')
setDescription('')
}
}, [open])
// Fetch next consecutive number when type + subtype are set
useEffect(() => {
if (!typeCode || !effectiveSubType || effectiveSubType.length !== 2) {
setConsecutive(null)
return
}
const prefix = typeCode + effectiveSubType
setLoadingCode(true)
getNextCode(prefix)
.then((res) => setConsecutive(res.next_consecutive))
.catch(() => setConsecutive(1))
.finally(() => setLoadingCode(false))
}, [typeCode, effectiveSubType])
const fullCode = useMemo(() => {
if (!typeCode || !effectiveSubType || consecutive === null) return null
return `${typeCode}${effectiveSubType}${String(consecutive).padStart(2, '0')}`
}, [typeCode, effectiveSubType, consecutive])
const sanitizedName = nameParts
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9\-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
const fullMaterialName = fullCode && sanitizedName
? `SCHAEFFLER_${fullCode}_${sanitizedName}`
: null
const schaefflerCodeInt = fullCode ? parseInt(fullCode, 10) : null
const createMut = useMutation({
mutationFn: () =>
createMaterial({
name: fullMaterialName!,
description: description.trim() || undefined,
source: 'manual',
schaeffler_code: schaefflerCodeInt,
}),
onSuccess: () => {
toast.success('Material created')
qc.invalidateQueries({ queryKey: ['materials'] })
if (onCreated && fullMaterialName) onCreated(fullMaterialName)
onClose()
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create material'),
})
if (!open) return null
const nameValid = sanitizedName.length >= 2
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div
className="bg-surface rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default">
<div>
<h2 className="text-lg font-semibold text-content">Schaeffler Material Wizard</h2>
<p className="text-xs text-content-muted mt-0.5">Step {step} of 3</p>
</div>
<button onClick={onClose} className="text-content-muted hover:text-content-secondary">
<X size={20} />
</button>
</div>
{/* Steps */}
<div className="px-6 py-5 min-h-[260px]">
{step === 1 && (
<div>
<p className="text-sm font-medium text-content-secondary mb-4">Select material type:</p>
<div className="grid grid-cols-2 gap-3">
{MATERIAL_TYPES.map((t) => {
const Icon = t.icon
const active = typeCode === t.code
return (
<button
key={t.code}
onClick={() => {
setTypeCode(t.code)
setSubTypeCode('')
setCustomSubType('')
setStep(2)
}}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border-2 transition-all text-left ${
active ? t.activeColor : t.color + ' hover:shadow-md'
}`}
>
<Icon size={20} />
<div>
<p className="text-sm font-semibold">{t.label}</p>
<p className={`text-xs ${active ? 'opacity-80' : 'opacity-60'}`}>Code {t.code}</p>
</div>
</button>
)
})}
</div>
</div>
)}
{step === 2 && (
<div>
<p className="text-sm font-medium text-content-secondary mb-3">
Sub-type for <span className="font-bold">{MATERIAL_TYPES.find((t) => t.code === typeCode)?.label}</span>:
</p>
{(SUBTYPE_PRESETS[typeCode] ?? []).length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{SUBTYPE_PRESETS[typeCode]!.map((st) => (
<button
key={st.code}
onClick={() => {
setSubTypeCode(st.code)
setCustomSubType('')
}}
className={`px-3 py-1.5 rounded-full text-sm border transition-all ${
subTypeCode === st.code
? 'text-white'
: 'bg-surface text-content-secondary border-border-default hover:border-accent'
}`}
style={subTypeCode === st.code ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{st.label} ({st.code})
</button>
))}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-medium text-content-muted mb-1">
{(SUBTYPE_PRESETS[typeCode] ?? []).length > 0 ? 'Or enter custom sub-type (2 digits):' : 'Enter sub-type (2 digits):'}
</label>
<input
maxLength={2}
placeholder="e.g. 07"
value={customSubType}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '')
setCustomSubType(v)
if (v.length > 0) setSubTypeCode('')
}}
className="w-24 px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
/>
</div>
{effectiveSubType.length === 2 && (
<div className="bg-surface-alt rounded-lg px-4 py-3">
<p className="text-xs text-content-muted mb-1">Next consecutive number:</p>
<p className="text-lg font-mono font-bold text-content">
{loadingCode ? '...' : consecutive !== null ? String(consecutive).padStart(2, '0') : '--'}
</p>
</div>
)}
</div>
)}
{step === 3 && (
<div>
<p className="text-sm font-medium text-content-secondary mb-3">Material name and description:</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Name (dash-separated) *</label>
<input
autoFocus
placeholder="e.g. Chrome-Steel-Hardened"
value={nameParts}
onChange={(e) => setNameParts(e.target.value)}
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
/>
{nameParts && !nameValid && (
<p className="text-xs text-red-500 mt-1">Name must be at least 2 characters (a-z, 0-9, dashes)</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-content-secondary mb-1">Description (optional)</label>
<input
placeholder="e.g. Gehärteter Chromstahl"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
/>
</div>
</div>
</div>
)}
</div>
{/* Live preview bar */}
<div className="px-6 py-3 bg-surface-alt border-t border-border-light">
<p className="text-xs text-content-muted mb-0.5">Preview:</p>
<p className="font-mono text-sm font-semibold text-content truncate">
{fullMaterialName || (
<span className="text-content-muted">
SCHAEFFLER_{typeCode || 'XX'}{effectiveSubType || 'YY'}{consecutive !== null ? String(consecutive).padStart(2, '0') : 'ZZ'}_{sanitizedName || 'Name'}
</span>
)}
</p>
</div>
{/* Footer buttons */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border-default">
<button
onClick={() => setStep(Math.max(1, step - 1))}
disabled={step === 1}
className="flex items-center gap-1 text-sm text-content-secondary hover:text-content disabled:opacity-30"
>
<ChevronLeft size={16} /> Back
</button>
{step < 3 ? (
<button
onClick={() => setStep(step + 1)}
disabled={step === 2 && effectiveSubType.length !== 2}
className="flex items-center gap-1 btn-primary text-sm"
>
Next <ChevronRight size={16} />
</button>
) : (
<button
onClick={() => createMut.mutate()}
disabled={!fullMaterialName || !nameValid || createMut.isPending}
className="btn-primary text-sm"
>
{createMut.isPending ? 'Creating...' : 'Create Material'}
</button>
)}
</div>
</div>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,255 @@
import {
Suspense,
useRef,
useCallback,
useState,
useEffect,
Component,
type ErrorInfo,
type ReactNode,
} from 'react'
import { Canvas, useThree, useFrame } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import { toast } from 'sonner'
import { X, Camera, Loader2, AlertTriangle } from 'lucide-react'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ThreeDViewerProps {
cadFileId: string
onClose: () => void
}
// ---------------------------------------------------------------------------
// Inner model loader separated so Suspense can catch it
// ---------------------------------------------------------------------------
function GltfModel({ url }: { url: string }) {
const { scene } = useGLTF(url)
return <primitive object={scene} />
}
// ---------------------------------------------------------------------------
// Screenshot helper lives inside Canvas so it can access gl / useThree
// ---------------------------------------------------------------------------
interface ScreenshotCaptureProps {
enabled: boolean
cadFileId: string
onDone: () => void
}
function ScreenshotCapture({ enabled, cadFileId, onDone }: ScreenshotCaptureProps) {
const { gl } = useThree()
const didCapture = useRef(false)
useFrame(() => {
if (!enabled || didCapture.current) return
didCapture.current = true
// Grab the canvas as a data-URL after the current frame has been rendered
const dataUrl = gl.domElement.toDataURL('image/png')
// Convert data-URL → Blob without a network fetch:
// data:[<mediatype>][;base64],<data>
const [header, base64Data] = dataUrl.split(',')
const mimeMatch = header.match(/:(.*?);/)
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'
const byteCharacters = atob(base64Data)
const byteArray = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteArray[i] = byteCharacters.charCodeAt(i)
}
const blob = new Blob([byteArray], { type: mimeType })
const formData = new FormData()
formData.append('thumbnail', blob, 'thumbnail.png')
api
.post(`/cad/${cadFileId}/regenerate-thumbnail`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
.then(() => {
toast.success('Thumbnail captured and saved')
})
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Unknown error'
console.error('Thumbnail upload failed', msg)
toast.error('Failed to save thumbnail')
})
.finally(() => {
didCapture.current = false
onDone()
})
})
return null
}
// ---------------------------------------------------------------------------
// Error boundary for the GLTF loader inside Suspense
// ---------------------------------------------------------------------------
class GltfErrorBoundary extends Component<
{ children: ReactNode; onError: (msg: string) => void },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; onError: (msg: string) => void }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true }
}
componentDidCatch(error: Error, _info: ErrorInfo): void {
this.props.onError(error.message || 'Failed to parse GLTF')
}
render(): ReactNode {
if (this.state.hasError) return null
return this.props.children
}
}
// ---------------------------------------------------------------------------
// Loading overlay (shown while model resolves inside Canvas)
// ---------------------------------------------------------------------------
function LoadingOverlay() {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-3 pointer-events-none z-10">
<Loader2 size={40} className="animate-spin text-accent" />
<p className="text-sm text-gray-300">Loading 3D model</p>
</div>
)
}
// ---------------------------------------------------------------------------
// Model loader with resolved tracking
// ---------------------------------------------------------------------------
interface ModelWithReadyProps {
url: string
onReady: () => void
}
function ModelWithReady({ url, onReady }: ModelWithReadyProps) {
const { scene } = useGLTF(url)
useEffect(() => {
onReady()
}, [onReady])
return <primitive object={scene} />
}
// ---------------------------------------------------------------------------
// Main exported component
// ---------------------------------------------------------------------------
export default function ThreeDViewer({ cadFileId, onClose }: ThreeDViewerProps) {
const modelUrl = `/api/cad/${cadFileId}/model`
const [capturing, setCapturing] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [modelReady, setModelReady] = useState(false)
const handleModelReady = useCallback(() => setModelReady(true), [])
const handleError = useCallback((msg: string) => setLoadError(msg), [])
const handleCaptureDone = useCallback(() => setCapturing(false), [])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950">
{/* ------------------------------------------------------------------ */}
{/* Toolbar */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-between px-5 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
<span className="text-white font-semibold tracking-wide">3D Viewer</span>
<div className="flex items-center gap-3">
<button
onClick={() => setCapturing(true)}
disabled={capturing || !modelReady || loadError !== null}
className="flex items-center gap-2 px-4 py-1.5 rounded-md bg-accent hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
>
{capturing ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Camera size={15} />
)}
{capturing ? 'Capturing…' : 'Capture Angle'}
</button>
<button
onClick={onClose}
className="p-1.5 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
aria-label="Close viewer"
>
<X size={20} />
</button>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Viewport */}
{/* ------------------------------------------------------------------ */}
<div className="relative flex-1">
{/* Error state */}
{loadError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-4 z-20">
<AlertTriangle size={48} className="text-red-400" />
<p className="text-lg font-semibold">Failed to load 3D model</p>
<p className="text-sm text-gray-400 max-w-sm text-center">{loadError}</p>
<button
onClick={onClose}
className="mt-2 px-4 py-2 rounded-md bg-gray-700 hover:bg-gray-600 text-sm transition-colors"
>
Close
</button>
</div>
)}
{/* Loading overlay visible until model signals ready */}
{!modelReady && !loadError && <LoadingOverlay />}
{/* Three.js Canvas */}
<Canvas
camera={{ position: [0, 2, 5], fov: 45 }}
gl={{ preserveDrawingBuffer: true }}
style={{ width: '100%', height: '100%', background: '#111827' }}
>
{/* Lights */}
<ambientLight intensity={0.5} />
<directionalLight position={[5, 10, 7]} intensity={1.0} castShadow />
<directionalLight position={[-5, -5, -5]} intensity={0.25} />
{/* GLTF model */}
<GltfErrorBoundary onError={handleError}>
<Suspense fallback={null}>
<ModelWithReady url={modelUrl} onReady={handleModelReady} />
</Suspense>
</GltfErrorBoundary>
{/* Camera controls */}
<OrbitControls enablePan enableZoom enableRotate minDistance={0.3} maxDistance={100} />
{/* Environment map for PBR materials */}
<Environment preset="city" />
{/* Screenshot capture only active when triggered */}
{capturing && (
<ScreenshotCapture
enabled={capturing}
cadFileId={cadFileId}
onDone={handleCaptureDone}
/>
)}
</Canvas>
</div>
</div>
)
}
@@ -0,0 +1,593 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, Legend,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { getDashboardKPIs } from '../../api/analytics'
const SCHAEFFLER_GREEN = '#00893d'
const INDIGO = '#6366f1'
const AMBER = '#f59e0b'
const GREEN = '#22c55e'
const RED = '#ef4444'
const BLUE = '#3b82f6'
const PURPLE = '#8b5cf6'
const TEAL = '#14b8a6'
const ROSE = '#f43f5e'
const CYAN = '#06b6d4'
const CATEGORY_COLORS = [SCHAEFFLER_GREEN, INDIGO, AMBER, BLUE, PURPLE, TEAL, ROSE, CYAN]
const CHART_TOOLTIP_STYLE = {
backgroundColor: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: '8px',
color: 'var(--color-text)',
}
type Preset = '4w' | '3m' | '6m' | '1y' | 'all' | 'custom'
const PRESETS: { key: Preset; label: string }[] = [
{ key: '4w', label: '4 W' },
{ key: '3m', label: '3 M' },
{ key: '6m', label: '6 M' },
{ key: '1y', label: '1 Y' },
{ key: 'all', label: 'All' },
{ key: 'custom', label: 'Custom' },
]
function toISO(d: Date): string {
return d.toISOString().slice(0, 10)
}
function presetRange(key: Preset): { from: string; to: string } | null {
const now = new Date()
const to = toISO(now)
switch (key) {
case '4w': { const d = new Date(now); d.setDate(d.getDate() - 28); return { from: toISO(d), to } }
case '3m': { const d = new Date(now); d.setMonth(d.getMonth() - 3); return { from: toISO(d), to } }
case '6m': { const d = new Date(now); d.setMonth(d.getMonth() - 6); return { from: toISO(d), to } }
case '1y': { const d = new Date(now); d.setFullYear(d.getFullYear() - 1); return { from: toISO(d), to } }
case 'all': return null // no params → backend defaults omitted, we send nothing
case 'custom': return null
}
}
function presetSubtitle(key: Preset, customFrom: string, customTo: string): string {
switch (key) {
case '4w': return 'Last 4 weeks'
case '3m': return 'Last 3 months'
case '6m': return 'Last 6 months'
case '1y': return 'Last year'
case 'all': return 'All time'
case 'custom': {
if (customFrom && customTo) {
const f = new Date(customFrom + 'T00:00:00')
const t = new Date(customTo + 'T00:00:00')
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
return `${fmt(f)} ${fmt(t)}`
}
return 'Select a date range'
}
}
}
function fmtSeconds(s: number | null | undefined): string {
if (s == null) return '—'
if (s >= 60) return `${(s / 60).toFixed(1)} min`
return `${s.toFixed(1)} s`
}
export default function AdminDashboard() {
const [preset, setPreset] = useState<Preset>('6m')
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const { dateFrom, dateTo } = useMemo(() => {
if (preset === 'custom') {
return { dateFrom: customFrom || undefined, dateTo: customTo || undefined }
}
if (preset === 'all') {
return { dateFrom: '2000-01-01', dateTo: toISO(new Date()) }
}
const range = presetRange(preset)
return range ? { dateFrom: range.from, dateTo: range.to } : { dateFrom: undefined, dateTo: undefined }
}, [preset, customFrom, customTo])
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard-kpis', dateFrom, dateTo],
queryFn: () => getDashboardKPIs(dateFrom, dateTo),
staleTime: 60_000,
})
function selectPreset(key: Preset) {
setPreset(key)
if (key !== 'custom') {
setCustomFrom('')
setCustomTo('')
}
}
const subtitle = presetSubtitle(preset, customFrom, customTo)
if (isLoading) return <div className="p-8 text-center text-content-muted">Loading analytics</div>
if (error) return <div className="p-8 text-center text-red-500">Failed to load analytics</div>
if (!data) return null
const {
summary, throughput, revenue, processing_times, item_status, render_times,
product_stats, output_type_usage, render_status, renderer_usage,
top_products, orders_by_user, category_revenue, render_backend_stats,
render_time_by_output_type,
} = data
const pieData = [
{ name: 'Pending', value: item_status.pending, color: AMBER },
{ name: 'Approved', value: item_status.approved, color: GREEN },
{ name: 'Rejected', value: item_status.rejected, color: RED },
]
const renderStatusPieData = [
{ name: 'Pending', value: render_status.pending, color: AMBER },
{ name: 'Processing', value: render_status.processing, color: BLUE },
{ name: 'Completed', value: render_status.completed, color: GREEN },
{ name: 'Failed', value: render_status.failed, color: RED },
]
const rendererPieData = renderer_usage.map((r, i) => ({
name: r.renderer || 'unknown',
value: r.count,
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
}))
return (
<div className="p-8 space-y-6">
<div className="mb-2">
<h1 className="text-2xl font-bold text-content">Analytics Dashboard</h1>
<p className="text-content-muted mt-1 text-sm">{subtitle} · refreshes every 60 s</p>
</div>
{/* Timeframe selector bar */}
<div className="flex flex-wrap items-center gap-2">
{PRESETS.map(({ key, label }) => (
<button
key={key}
onClick={() => selectPreset(key)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-colors ${
preset === key
? 'text-white'
: 'bg-surface-muted text-content-secondary hover:bg-surface-hover'
}`}
style={preset === key ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
{label}
</button>
))}
{preset === 'custom' && (
<div className="flex items-center gap-2 ml-2">
<input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="border border-border-default rounded px-2 py-1 text-xs"
/>
<span className="text-content-muted text-xs"></span>
<input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="border border-border-default rounded px-2 py-1 text-xs"
/>
</div>
)}
</div>
{/* Row 1 — Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<SummaryCard label="Total Orders" value={summary.total_orders} />
<SummaryCard label="Completed" value={summary.completed_orders} />
<SummaryCard label="Rendering Items" value={summary.total_rendering_items} />
<SummaryCard label="Total Revenue (€)" value={`${summary.total_revenue.toFixed(2)}`} />
<SummaryCard label="Products Rendered" value={product_stats.unique_products_rendered} />
<SummaryCard label="CAD Coverage" value={`${product_stats.products_with_cad} / ${product_stats.total_products}`} />
</div>
{/* Row 2 — Throughput + Item status */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-4 lg:col-span-2">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Order Throughput (weekly)</h2>
{throughput.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={throughput} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="week" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="count" name="Created" stroke={SCHAEFFLER_GREEN} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="completed" name="Completed" stroke={INDIGO} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Item Status</h2>
{pieData.every((d) => d.value === 0) ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{pieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 3 — Revenue + Processing times */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue per Month ()</h2>
{revenue.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="month" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
<Bar dataKey="revenue" fill={SCHAEFFLER_GREEN} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-4">Processing Times</h2>
<table className="w-full text-sm">
<tbody className="divide-y divide-border-light">
<MetricRow label="Avg submit → complete" value={fmtSeconds(processing_times.avg_submit_to_complete_s)} />
<MetricRow label="Avg submit → processing" value={fmtSeconds(processing_times.avg_submit_to_processing_s)} />
<MetricRow label="P50 (median)" value={fmtSeconds(processing_times.p50_s)} />
<MetricRow label="P95" value={fmtSeconds(processing_times.p95_s)} />
</tbody>
</table>
<h2 className="text-sm font-semibold text-content-secondary mt-5 mb-3">Render Time Breakdown</h2>
<table className="w-full text-sm">
<tbody className="divide-y divide-border-light">
<MetricRow label="Avg render time" value={fmtSeconds(render_times.avg_render_s)} />
<MetricRow label="Completed renders" value={String(render_times.sample_count)} />
</tbody>
</table>
</div>
</div>
{/* Row 3b — Render Time by Output Type */}
{render_time_by_output_type && render_time_by_output_type.length > 0 && (
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-4">Renderzeit pro Output-Typ</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Horizontal bar chart: Avg + P50 per output type */}
<ResponsiveContainer width="100%" height={Math.max(180, render_time_by_output_type.length * 44)}>
<BarChart
data={render_time_by_output_type}
layout="vertical"
margin={{ top: 4, right: 40, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" horizontal={false} />
<XAxis
type="number"
tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }}
tickFormatter={(v: number) => v >= 60 ? `${(v / 60).toFixed(0)}m` : `${v.toFixed(0)}s`}
/>
<YAxis type="category" dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} width={130} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(v: number | null | undefined, name: string) => [
v != null ? (v >= 60 ? `${(v / 60).toFixed(1)} min` : `${v.toFixed(0)} s`) : '—',
name,
]}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Ø Renderzeit" fill={INDIGO} radius={[0, 3, 3, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
{/* Detail table */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border-default text-left text-content-muted">
<th className="pb-2 pr-3 font-medium">Output-Typ</th>
<th className="pb-2 px-2 font-medium text-right">Jobs</th>
<th className="pb-2 px-2 font-medium text-right">Ø</th>
<th className="pb-2 px-2 font-medium text-right">P50</th>
<th className="pb-2 px-2 font-medium text-right">Min</th>
<th className="pb-2 pl-2 font-medium text-right">Max</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{render_time_by_output_type.map((r) => (
<tr key={r.output_type} className="hover:bg-surface-hover">
<td className="py-1.5 pr-3 font-medium text-content-secondary max-w-[160px] truncate" title={r.output_type}>
{r.output_type}
</td>
<td className="py-1.5 px-2 text-right text-content-muted">{r.job_count}</td>
<td className="py-1.5 px-2 text-right tabular-nums">{fmtSeconds(r.avg_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.p50_render_s)}</td>
<td className="py-1.5 px-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.min_render_s)}</td>
<td className="py-1.5 pl-2 text-right tabular-nums text-content-muted">{fmtSeconds(r.max_render_s)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Row 4 — Output Type Usage + Render Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Output Type Usage</h2>
{output_type_usage.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={output_type_usage} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="output_type" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" fill={INDIGO} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Status</h2>
{renderStatusPieData.every((d) => d.value === 0) ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={renderStatusPieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{renderStatusPieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 5 — Products by Category + Renderer Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Products by Category</h2>
{product_stats.products_by_category.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={product_stats.products_by_category} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{product_stats.products_by_category.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Renderer Usage</h2>
{rendererPieData.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={rendererPieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="45%"
outerRadius={70}
label={({ name, value }) => `${name}: ${value}`}
labelLine={false}
>
{rendererPieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Legend wrapperStyle={{ fontSize: 11 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 5b — Render Backend Comparison */}
{render_backend_stats.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend Job Count</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="completed" name="Completed" fill={GREEN} radius={[3, 3, 0, 0]} />
<Bar dataKey="failed" name="Failed" fill={RED} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Render Backend Avg Time</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={render_backend_stats} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="backend" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} label={{ value: 'seconds', angle: -90, position: 'insideLeft', style: { fontSize: 10 } }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(1)}s`, ''] : ['—', '']} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="avg_render_s" name="Avg" fill={INDIGO} radius={[3, 3, 0, 0]} />
<Bar dataKey="p50_render_s" name="Median (P50)" fill={TEAL} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Row 6 — Top 10 Products + Category Revenue */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Top 10 Products</h2>
{top_products.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium">PIM-ID</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Product</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Category</th>
<th className="py-2 text-content-secondary font-medium text-right">Orders</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{top_products.map((p) => (
<tr key={p.pim_id}>
<td className="py-1.5 pr-3 font-mono text-xs text-content-muted">{p.pim_id}</td>
<td className="py-1.5 pr-3 text-content truncate max-w-[160px]">{p.product_name || '—'}</td>
<td className="py-1.5 pr-3 text-content-muted">{p.category}</td>
<td className="py-1.5 font-medium text-content text-right">{p.order_count}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Revenue by Category ()</h2>
{category_revenue.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={category_revenue} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="category" tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<YAxis tick={{ fill: 'var(--color-text-muted)', fontSize: 10 }} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} formatter={(v: number | undefined) => v != null ? [`${v.toFixed(2)}`, 'Revenue'] : ['—', 'Revenue']} />
<Bar dataKey="revenue" radius={[3, 3, 0, 0]}>
{category_revenue.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Row 7 — Orders by User */}
<div className="card p-4">
<h2 className="text-sm font-semibold text-content-secondary mb-3">Orders by User</h2>
{orders_by_user.length === 0 ? (
<p className="text-xs text-content-muted py-8 text-center">No data yet</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="py-2 pr-3 text-content-secondary font-medium">Name</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Email</th>
<th className="py-2 pr-3 text-content-secondary font-medium">Role</th>
<th className="py-2 pr-3 text-content-secondary font-medium text-right">Orders</th>
<th className="py-2 text-content-secondary font-medium text-right">Revenue ()</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
{orders_by_user.map((u) => (
<tr key={u.email}>
<td className="py-1.5 pr-3 text-content">{u.full_name}</td>
<td className="py-1.5 pr-3 text-content-muted text-xs">{u.email}</td>
<td className="py-1.5 pr-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-secondary">
{u.role === 'project_manager' ? 'PM' : u.role}
</span>
</td>
<td className="py-1.5 pr-3 font-medium text-content text-right">{u.order_count}</td>
<td className="py-1.5 font-medium text-content text-right"> {u.revenue.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function SummaryCard({ label, value }: { label: string; value: number | string }) {
return (
<div className="card p-5">
<p className="text-2xl font-bold text-content">{value}</p>
<p className="text-sm text-content-muted mt-1">{label}</p>
</div>
)
}
function MetricRow({ label, value }: { label: string; value: string }) {
return (
<tr>
<td className="py-1.5 pr-3 text-content-secondary">{label}</td>
<td className="py-1.5 font-medium text-content text-right">{value}</td>
</tr>
)
}
@@ -0,0 +1,105 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Package, Upload, CheckCircle, Clock, AlertCircle } from 'lucide-react'
import { listOrders } from '../../api/orders'
import { useAuthStore } from '../../store/auth'
export default function ClientDashboard() {
const user = useAuthStore((s) => s.user)
const { data: orders } = useQuery({ queryKey: ['orders'], queryFn: () => listOrders({ limit: 100 }) })
const stats = {
total: orders?.length ?? 0,
draft: orders?.filter((o) => o.status === 'draft').length ?? 0,
submitted: orders?.filter((o) => o.status === 'submitted').length ?? 0,
completed: orders?.filter((o) => o.status === 'completed').length ?? 0,
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-content">Welcome, {user?.full_name}</h1>
<p className="text-content-muted mt-1">Schaeffler Media Creation Pipeline</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Orders" value={stats.total} icon={Package} color="blue" />
<StatCard label="Drafts" value={stats.draft} icon={Clock} color="yellow" />
<StatCard label="Submitted" value={stats.submitted} icon={AlertCircle} color="orange" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle} color="green" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="card p-6">
<h2 className="font-semibold text-content mb-4">Quick Actions</h2>
<div className="space-y-3">
<Link to="/upload" className="btn-primary w-full justify-center">
<Upload size={16} />
Upload Excel Order List
</Link>
<Link to="/orders" className="btn-secondary w-full justify-center">
<Package size={16} />
View All Orders
</Link>
</div>
</div>
<div className="card p-6">
<h2 className="font-semibold text-content mb-4">Recent Orders</h2>
{orders && orders.length > 0 ? (
<div className="space-y-2">
{orders.slice(0, 5).map((order) => (
<Link
key={order.id}
to={`/orders/${order.id}`}
className="flex items-center justify-between p-2 rounded hover:bg-surface-hover transition-colors"
>
<span className="text-sm font-medium text-content">{order.order_number}</span>
<div className="flex items-center gap-3">
{order.estimated_price != null && (
<span className="text-xs text-content-muted">
{Number(order.estimated_price).toFixed(2)}
</span>
)}
<StatusBadge status={order.status} />
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-content-muted">No orders yet. Upload an Excel file to get started.</p>
)}
</div>
</div>
</div>
)
}
function StatCard({ label, value, icon: Icon, color }: { label: string; value: number; icon: any; color: string }) {
const colors: Record<string, string> = {
blue: 'text-status-info-text bg-status-info-bg',
yellow: 'text-yellow-600 bg-yellow-50',
orange: 'text-status-warning-text bg-status-warning-bg',
green: 'text-status-success-text bg-status-success-bg',
}
return (
<div className="card p-5">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${colors[color]}`}>
<Icon size={20} />
</div>
<p className="text-2xl font-bold text-content">{value}</p>
<p className="text-sm text-content-muted mt-1">{label}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, string> = {
draft: 'badge-gray',
submitted: 'badge-blue',
processing: 'badge-yellow',
completed: 'badge-green',
rejected: 'badge-red',
}
return <span className={map[status] ?? 'badge-gray'}>{status}</span>
}
+153
View File
@@ -0,0 +1,153 @@
import { Outlet, NavLink, useNavigate, Link } from 'react-router-dom'
import { LayoutDashboard, Package, Settings, LogOut, FlaskConical, Activity, Library, Plus, SlidersHorizontal } from 'lucide-react'
import { useAuthStore } from '../../store/auth'
import { clsx } from 'clsx'
import { useQuery } from '@tanstack/react-query'
import { getWorkerActivity } from '../../api/worker'
import { listOrders } from '../../api/orders'
import NotificationCenter from './NotificationCenter'
const nav = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
{ to: '/orders', icon: Package, label: 'Orders' },
{ to: '/products', icon: Library, label: 'Products' },
{ to: '/materials', icon: FlaskConical, label: 'Materials' },
{ to: '/activity', icon: Activity, label: 'Activity' },
{ to: '/preferences', icon: SlidersHorizontal, label: 'Preferences' },
]
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const { data: activity } = useQuery({
queryKey: ['worker-activity'],
queryFn: getWorkerActivity,
refetchInterval: 8000,
staleTime: 4000,
})
const { data: draftOrders } = useQuery({
queryKey: ['orders', 'draft-count'],
queryFn: () => listOrders({ status: 'draft' }),
staleTime: 10_000,
refetchInterval: 30_000,
})
const draftCount = draftOrders?.length ?? 0
function handleLogout() {
logout()
navigate('/login')
}
return (
<div className="flex h-screen overflow-hidden bg-surface-alt">
{/* Sidebar */}
<aside className="w-60 flex-shrink-0 bg-surface border-r border-border-default flex flex-col">
<div className="p-5 border-b border-border-default">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-accent rounded flex items-center justify-center">
<span className="text-accent-text text-sm font-bold">S</span>
</div>
<div className="flex-1">
<p className="font-semibold text-content text-sm">Schaeffler</p>
<p className="text-xs text-content-muted">Automat</p>
</div>
<NotificationCenter />
</div>
</div>
<nav className="flex-1 p-3 space-y-1">
{/* New Order — primary CTA at the top */}
<Link
to="/orders/new"
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-md text-sm font-semibold bg-accent text-accent-text hover:bg-accent-hover transition-colors shadow-sm"
>
<Plus size={18} />
New Order
</Link>
{nav.map(({ to, icon: Icon, label, end }) => {
const isActivity = to === '/activity'
const isOrders = to === '/orders'
const showSpinner = isActivity && ((activity?.active_count ?? 0) + (activity?.render_active_count ?? 0)) > 0
const showFailed = isActivity && !showSpinner && ((activity?.failed_count ?? 0) + (activity?.render_failed_count ?? 0)) > 0
const showDraftBadge = isOrders && draftCount > 0
return (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Icon size={18} />
{label}
{showDraftBadge && (
<span className="ml-auto text-xs px-1.5 py-0.5 rounded-full bg-surface-muted text-content-muted font-semibold leading-none">
{draftCount}
</span>
)}
{showSpinner && (
<span className="ml-auto w-2 h-2 rounded-full bg-blue-500 animate-pulse" title="Processing…" />
)}
{showFailed && (
<span className="ml-auto w-2 h-2 rounded-full bg-red-500" title="Failed tasks" />
)}
</NavLink>
)
})}
{(user?.role === 'admin' || user?.role === 'project_manager') && (
<NavLink
to="/admin"
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-accent-light text-accent'
: 'text-content-secondary hover:bg-surface-hover',
)
}
>
<Settings size={18} />
Admin
</NavLink>
)}
</nav>
<div className="p-3 border-t border-border-default space-y-1">
<div className="flex items-center gap-3 px-3 py-2">
<div className="w-7 h-7 bg-accent rounded-full flex items-center justify-center shrink-0">
<span className="text-accent-text text-xs font-bold">{user?.full_name?.[0] ?? 'U'}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">{user?.full_name}</p>
<p className="text-xs text-content-muted truncate">
{user?.role === 'project_manager' ? 'Project Manager' : user?.role}
</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content-secondary hover:bg-surface-hover rounded-md transition-colors"
>
<LogOut size={16} />
Sign out
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}
@@ -0,0 +1,235 @@
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, X,
} from 'lucide-react'
import { clsx } from 'clsx'
import {
getNotifications, getUnreadCount, markAsRead, markOneAsRead,
type Notification,
} from '../../api/notifications'
const ACTION_CONFIG: Record<string, { icon: typeof Bell; label: (d: Record<string, unknown> | null) => string; color: string }> = {
'order.submitted': {
icon: Send,
label: (d) => `Order ${d?.order_number ?? '?'} submitted`,
color: 'text-blue-500',
},
'order.processing': {
icon: PlayCircle,
label: (d) => `Order ${d?.order_number ?? '?'} is processing`,
color: 'text-yellow-500',
},
'order.completed': {
icon: CheckCircle,
label: (d) => `Order ${d?.order_number ?? '?'} completed`,
color: 'text-status-success-text',
},
'order.rejected': {
icon: XCircle,
label: (d) => `Order ${d?.order_number ?? '?'} was rejected`,
color: 'text-red-500',
},
'render.completed': {
icon: Image,
label: (d) => `Render done: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`,
color: 'text-status-success-text',
},
'render.failed': {
icon: AlertTriangle,
label: (d) => `Render failed: ${d?.product_name ?? 'unknown'}${d?.output_type ?? ''}`,
color: 'text-red-500',
},
'excel.import_warnings': {
icon: AlertTriangle,
label: (d) => `Excel '${d?.filename ?? '?'}' had ${d?.warning_count ?? '?'} warning(s)`,
color: 'text-amber-500',
},
'excel.import_error': {
icon: XCircle,
label: (d) => `Excel parse failed: ${d?.filename ?? '?'}`,
color: 'text-red-500',
},
'excel.finalize_error': {
icon: XCircle,
label: (d) => `Order creation failed: ${d?.filename ?? '?'}`,
color: 'text-red-500',
},
}
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export default function NotificationCenter() {
const [open, setOpen] = useState(false)
const bellRef = useRef<HTMLButtonElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const qc = useQueryClient()
const { data: unreadCount = 0 } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: getUnreadCount,
refetchInterval: 15_000,
staleTime: 5_000,
})
const { data } = useQuery({
queryKey: ['notifications', 'list'],
queryFn: () => getNotifications({ limit: 20 }),
enabled: open,
staleTime: 5_000,
})
const markAllMutation = useMutation({
mutationFn: () => markAsRead(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] })
},
})
const markOneMutation = useMutation({
mutationFn: (id: string) => markOneAsRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications'] })
},
})
// Click-outside to close
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (
dropdownRef.current && !dropdownRef.current.contains(e.target as Node) &&
bellRef.current && !bellRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
function handleNotificationClick(n: Notification) {
if (!n.read_at) markOneMutation.mutate(n.id)
if (n.entity_type === 'order' && n.entity_id) {
navigate(`/orders/${n.entity_id}`)
}
setOpen(false)
}
// Position dropdown relative to bell button
const bellRect = bellRef.current?.getBoundingClientRect()
return (
<>
<button
ref={bellRef}
onClick={() => setOpen((v) => !v)}
className="relative p-1.5 rounded-md hover:bg-surface-hover transition-colors"
title="Notifications"
>
<Bell size={18} className="text-content-secondary" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{open && bellRect && createPortal(
<div
ref={dropdownRef}
className="fixed z-[9999] w-80 max-h-[28rem] rounded-lg shadow-xl border flex flex-col"
style={{
top: bellRect.bottom + 6,
left: Math.max(8, bellRect.left - 240),
backgroundColor: 'var(--color-bg-surface)',
borderColor: 'var(--color-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border-light">
<span className="text-sm font-semibold text-content">Notifications</span>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllMutation.mutate()}
className="text-xs text-accent hover:underline"
>
Mark all as read
</button>
)}
<button onClick={() => setOpen(false)} className="p-0.5 hover:bg-surface-hover rounded" title="Close notifications">
<X size={14} className="text-content-muted" />
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{!data?.items.length && (
<div className="py-8 text-center text-sm text-content-muted">No notifications</div>
)}
{data?.items.map((n) => {
const cfg = ACTION_CONFIG[n.action] ?? {
icon: Bell,
label: () => n.action,
color: 'text-content-secondary',
}
const Icon = cfg.icon
return (
<button
key={n.id}
onClick={() => handleNotificationClick(n)}
className={clsx(
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-surface-hover transition-colors border-b border-border-light',
!n.read_at && 'bg-status-info-bg',
)}
>
<Icon size={16} className={clsx('mt-0.5 shrink-0', cfg.color)} />
<div className="flex-1 min-w-0">
<p className={clsx('text-sm', !n.read_at ? 'font-medium text-content' : 'text-content-secondary')}>
{cfg.label(n.details)}
</p>
{n.details?.error && (
<p className="mt-1 text-xs text-red-600 font-mono bg-red-50 rounded px-1.5 py-0.5 truncate">
{String(n.details.error)}
</p>
)}
<p className="text-xs text-content-muted mt-0.5">{relativeTime(n.timestamp)}</p>
</div>
{!n.read_at && (
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
</button>
)
})}
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border-light text-center">
<button
onClick={() => { navigate('/notifications'); setOpen(false) }}
className="text-xs text-accent hover:underline"
>
View all notifications
</button>
</div>
</div>,
document.body,
)}
</>
)
}
@@ -0,0 +1,63 @@
import { Sun, Monitor, Moon } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, ACCENT_PRESETS, type ThemeMode } from '../../store/theme'
const MODES: { key: ThemeMode; icon: typeof Sun; label: string }[] = [
{ key: 'light', icon: Sun, label: 'Light' },
{ key: 'system', icon: Monitor, label: 'System' },
{ key: 'dark', icon: Moon, label: 'Dark' },
]
export default function ThemePreferences() {
const { mode, accent, setMode, setAccent } = useThemeStore()
return (
<div className="px-3 py-2 space-y-2">
{/* Mode row */}
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-12 shrink-0">Theme</span>
<div className="flex gap-0.5 bg-surface-alt rounded-md p-0.5 border border-border-light">
{MODES.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setMode(key)}
title={label}
className={clsx(
'flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors',
mode === key
? 'bg-surface text-content shadow-sm'
: 'text-content-muted hover:text-content-secondary',
)}
>
<Icon size={12} />
{label}
</button>
))}
</div>
</div>
{/* Accent row */}
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-12 shrink-0">Accent</span>
<div className="flex gap-2">
{ACCENT_PRESETS.map(({ key, label, hex }) => (
<button
key={key}
onClick={() => setAccent(key)}
title={label}
className={clsx(
'w-5 h-5 rounded-full transition-all',
accent === key ? 'scale-125' : 'hover:scale-110',
)}
style={{
backgroundColor: hex,
outline: accent === key ? `2px solid ${hex}` : undefined,
outlineOffset: accent === key ? '2px' : undefined,
}}
/>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Save, FlaskConical, AlertCircle } from 'lucide-react'
import { listMaterials, saveCadPartMaterials } from '../../api/materials'
import MaterialInput from '../shared/MaterialInput'
import MaterialWizard from '../MaterialWizard'
interface CadPartRow {
part_name: string
material: string
}
interface ExcelComponent {
part_name: string | null
material: string | null
component_type: string | null
column_index: number
}
interface Props {
orderId: string
itemId: string
partNames: string[] // from cad_parsed_objects
savedMaterials: CadPartRow[] // from cad_part_materials
excelComponents?: ExcelComponent[] // from item.components (Excel data)
}
function normName(s: string) {
return s.trim().toLowerCase()
}
export default function CadPartMaterials({ orderId, itemId, partNames, savedMaterials, excelComponents = [] }: Props) {
const qc = useQueryClient()
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const initRows = (): CadPartRow[] =>
partNames.map((name) => {
// 1. Use saved value if present
const saved = savedMaterials.find((s) => s.part_name === name)
if (saved) return { part_name: name, material: saved.material }
// 2. Fall back to Excel component data (case-insensitive match)
const excelMatch = excelComponents.find(
(c) => c.part_name && normName(c.part_name) === normName(name),
)
return { part_name: name, material: excelMatch?.material ?? '' }
})
const [rows, setRows] = useState<CadPartRow[]>(initRows)
// Re-sync when props change (e.g. after save or STEP file change)
useEffect(() => {
setRows(initRows())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partNames.join(','), savedMaterials.length, excelComponents.length])
const { data: library = [] } = useQuery({
queryKey: ['materials'],
queryFn: listMaterials,
})
const saveMut = useMutation({
mutationFn: () => saveCadPartMaterials(orderId, itemId, rows.filter((r) => r.material.trim())),
onSuccess: () => {
toast.success('Materials saved')
qc.invalidateQueries({ queryKey: ['order', orderId] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Save failed'),
})
const isDirty = rows.some((r) => {
const saved = savedMaterials.find((s) => s.part_name === r.part_name)?.material ?? ''
return r.material !== saved
})
const missingCount = rows.filter((r) => !r.material.trim()).length
const setMaterial = (idx: number, value: string) =>
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, material: value } : r)))
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<FlaskConical size={14} className="text-content-muted" />
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
CAD Part Materials ({partNames.length})
</p>
{missingCount > 0 && (
<span className="ml-auto flex items-center gap-1 text-xs font-medium text-red-600">
<AlertCircle size={12} />
{missingCount} missing
</span>
)}
</div>
<div className="border border-border-default rounded-lg overflow-hidden">
{/* Header */}
<div className="grid grid-cols-2 bg-surface-alt border-b border-border-default px-3 py-1.5">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Part Name</p>
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">Material</p>
</div>
{/* Rows */}
{rows.map((row, idx) => {
const missing = !row.material.trim()
return (
<div
key={row.part_name}
className={`grid grid-cols-2 border-b border-border-light last:border-b-0 ${
missing
? 'bg-red-50'
: idx % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'
}`}
>
<div className="px-3 py-2 flex items-center gap-2">
{missing && <AlertCircle size={12} className="text-red-400 shrink-0" />}
<span
className={`text-sm font-mono truncate ${missing ? 'text-red-700' : 'text-content'}`}
title={row.part_name}
>
{row.part_name}
</span>
</div>
<div className="px-2 py-1.5">
<MaterialInput
value={row.material}
onChange={(v) => setMaterial(idx, v)}
library={library}
missing={missing}
onOpenWizard={() => {
setWizardTargetIdx(idx)
setWizardOpen(true)
}}
/>
</div>
</div>
)
})}
</div>
{(isDirty || missingCount > 0) && (
<div className="mt-3 flex items-center gap-3">
{isDirty && (
<button
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending}
className="btn-primary text-sm"
>
<Save size={14} />
{saveMut.isPending ? 'Saving...' : 'Save Materials'}
</button>
)}
{missingCount > 0 && !isDirty && (
<p className="text-xs text-red-600 flex items-center gap-1">
<AlertCircle size={12} />
{missingCount} part{missingCount !== 1 ? 's' : ''} have no material assigned
</p>
)}
</div>
)}
{/* Material Wizard (opened from MaterialInput) */}
<MaterialWizard
open={wizardOpen}
onClose={() => { setWizardOpen(false); setWizardTargetIdx(null) }}
onCreated={(name) => {
if (wizardTargetIdx !== null) {
setMaterial(wizardTargetIdx, name)
}
setWizardTargetIdx(null)
}}
/>
</div>
)
}
@@ -0,0 +1,146 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { Wand2 } from 'lucide-react'
import type { Material } from '../../api/materials'
const TYPE_GROUPS: Record<string, { label: string; color: string }> = {
'01': { label: 'Metals', color: 'text-slate-500' },
'02': { label: 'Coatings', color: 'text-blue-500' },
'03': { label: 'Non-metals', color: 'text-amber-600' },
'04': { label: 'Compounds', color: 'text-purple-500' },
'05': { label: 'Misc', color: 'text-content-muted' },
}
function getTypeCode(mat: Material): string | null {
if (mat.schaeffler_code == null) return null
const s = String(mat.schaeffler_code).padStart(6, '0')
return s.slice(0, 2)
}
/** Extract the human-readable short name after the last underscore: SCHAEFFLER_010101_Steel-Bare -> Steel-Bare */
function shortName(name: string): string {
const match = name.match(/^SCHAEFFLER_\d{6}_(.+)$/)
return match ? match[1].replace(/-/g, ' ') : name
}
export interface MaterialInputProps {
value: string
onChange: (v: string) => void
library: Material[]
missing: boolean
onOpenWizard: () => void
}
export default function MaterialInput({ value, onChange, library, missing, onOpenWizard }: MaterialInputProps) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const trimmed = value.trim()
const suggestions = trimmed
? library.filter((m) => m.name.toLowerCase().includes(trimmed.toLowerCase())
|| shortName(m.name).toLowerCase().includes(trimmed.toLowerCase())
|| (m.description ?? '').toLowerCase().includes(trimmed.toLowerCase()))
: library
// Group suggestions by type code
const grouped = useMemo(() => {
const groups: Array<{ code: string | null; label: string; color: string; items: Material[] }> = []
const buckets = new Map<string | null, Material[]>()
for (const m of suggestions) {
const tc = getTypeCode(m)
if (!buckets.has(tc)) buckets.set(tc, [])
buckets.get(tc)!.push(m)
}
// Sorted type codes first, then non-schaeffler
const sortedKeys = [...buckets.keys()].sort((a, b) => {
if (a === null) return 1
if (b === null) return -1
return a.localeCompare(b)
})
for (const key of sortedKeys) {
const info = key ? TYPE_GROUPS[key] : null
groups.push({
code: key,
label: info?.label ?? 'Custom',
color: info?.color ?? 'text-content-muted',
items: buckets.get(key)!,
})
}
return groups
}, [suggestions])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const select = (name: string) => {
onChange(name)
setOpen(false)
}
return (
<div ref={wrapRef} className="relative">
<input
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
placeholder={missing ? 'Required — assign a material' : 'Search materials...'}
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none bg-surface ${
missing
? 'border-red-300 focus:border-red-500 placeholder-red-300'
: 'border-border-default focus:border-accent'
}`}
/>
{open && (suggestions.length > 0 || true) && (
<div className="absolute left-0 top-full mt-0.5 w-80 border border-border-default rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-surface)' }}>
{grouped.map((group) => (
<div key={group.code ?? 'custom'}>
{/* Group header */}
<div className="sticky top-0 px-3 py-1 border-b border-border-light" style={{ backgroundColor: 'var(--color-bg-app)' }}>
<span className={`text-[10px] font-bold uppercase tracking-wider ${group.color}`}>
{group.code ? `${group.code} ` : ''}{group.label}
</span>
</div>
{group.items.map((m) => (
<button
key={m.id}
onMouseDown={(e) => { e.preventDefault(); select(m.name) }}
className="w-full text-left px-3 py-1.5 hover:bg-accent-light flex items-baseline gap-2"
>
<span className="text-sm font-medium text-content truncate">{shortName(m.name)}</span>
{m.description && (
<span className="text-xs text-content-muted truncate">{m.description}</span>
)}
</button>
))}
</div>
))}
{suggestions.length === 0 && (
<div className="px-3 py-3 text-center text-xs text-content-muted">No materials match "{trimmed}"</div>
)}
{/* Create new material via wizard */}
<button
onMouseDown={(e) => { e.preventDefault(); setOpen(false); onOpenWizard() }}
className="w-full text-left px-3 py-2 border-t border-border-default flex items-center gap-2 hover:bg-surface-hover text-accent sticky bottom-0"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<Wand2 size={13} />
<span className="text-sm font-medium">Create new material...</span>
</button>
</div>
)}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react'
import { X } from 'lucide-react'
import { cn } from '../../utils/format'
interface ModalProps {
title: string
onClose: () => void
children: React.ReactNode
/** Extra classes applied to the inner panel */
className?: string
/** Width preset defaults to 'md' */
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
const sizeMap: Record<NonNullable<ModalProps['size']>, string> = {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-full mx-4',
}
export default function Modal({ title, onClose, children, className, size = 'md' }: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null)
/* Close on Escape */
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose])
/* Prevent scroll on body while modal is open */
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
function handleBackdropClick(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === backdropRef.current) onClose()
}
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
>
<div
className={cn(
'relative w-full rounded-xl shadow-2xl flex flex-col max-h-[90vh]',
sizeMap[size],
className,
)}
style={{ backgroundColor: 'var(--color-bg-surface)' }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-default shrink-0">
<h2 id="modal-title" className="text-lg font-semibold text-content">
{title}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md text-content-muted hover:text-content-secondary hover:bg-surface-muted transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1">{children}</div>
</div>
</div>
)
}
@@ -0,0 +1,168 @@
import React from 'react'
import { ParsedRow, ParsedComponent, ParsedExcelResponse } from '../../api/uploads'
interface Props {
parsed: ParsedExcelResponse
rows: ParsedRow[]
onChange: (rows: ParsedRow[]) => void
}
const STANDARD_FIELDS: { key: keyof ParsedRow; label: string; width: number; mono?: boolean }[] = [
{ key: 'ebene1', label: 'Ebene 1', width: 140 },
{ key: 'ebene2', label: 'Ebene 2', width: 120 },
{ key: 'baureihe', label: 'Baureihe', width: 160 },
{ key: 'pim_id', label: 'PIM-ID', width: 110 },
{ key: 'produkt_baureihe', label: 'Produkt-Baureihe', width: 150 },
{ key: 'gewaehltes_produkt', label: 'Gewähltes Produkt', width: 150 },
{ key: 'name_cad_modell', label: 'CAD-Modell', width: 190, mono: true },
{ key: 'gewuenschte_bildnummer', label: 'Bildnummer', width: 170, mono: true },
{ key: 'lagertyp', label: 'Lagertyp', width: 100 },
]
export default function ExcelSpreadsheet({ parsed, rows, onChange }: Props) {
const maxComps = Math.max(0, ...rows.map((r) => r.components.length))
function updateField(ri: number, field: keyof ParsedRow, value: string | boolean | null) {
const next = rows.map((r, i) => (i === ri ? { ...r, [field]: value } : r))
onChange(next)
}
function updateComp(ri: number, ci: number, field: keyof ParsedComponent, value: string) {
const next = rows.map((r, i) => {
if (i !== ri) return r
const comps = r.components.map((c, j) =>
j === ci ? { ...c, [field]: value || null } : c,
)
// If the row doesn't have this component slot yet, pad it
while (comps.length <= ci) {
comps.push({ part_name: null, material: null, component_type: null, column_index: 11 + comps.length * 2 })
}
comps[ci] = { ...comps[ci], [field]: value || null }
return { ...r, components: comps }
})
onChange(next)
}
const cell =
'w-full px-2 py-1 text-xs bg-transparent border-0 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-accent rounded focus:bg-surface'
const th =
'px-2 py-2 text-left text-xs font-semibold text-content-secondary whitespace-nowrap bg-surface-alt border-b border-r border-border-default sticky top-0 z-10'
const td = 'border-b border-r border-border-light p-0'
return (
<div className="card overflow-hidden">
<div className="p-4 border-b border-border-default flex items-center justify-between">
<div>
<h2 className="font-semibold text-content">
{parsed.template_name || parsed.category_key} {rows.length} rows
</h2>
<p className="text-xs text-content-muted mt-0.5">Click any cell to edit before creating the order</p>
</div>
<span className="badge badge-blue">{maxComps} component columns</span>
</div>
<div className="overflow-auto" style={{ maxHeight: '65vh' }}>
<table className="text-sm border-collapse" style={{ minWidth: 'max-content' }}>
<thead>
{/* Group header row */}
<tr>
<th className={`${th} text-center`} colSpan={1}>#</th>
<th className={`${th} text-center bg-status-info-bg`} colSpan={STANDARD_FIELDS.length}>
Standard Fields
</th>
<th className={`${th} text-center`}>Rendering</th>
{Array.from({ length: maxComps }, (_, i) => (
<th key={i} className={`${th} text-center bg-status-warning-bg`} colSpan={2}>
Component {i + 1}
</th>
))}
</tr>
{/* Field name row */}
<tr>
<th className={`${th} text-content-muted`}>#</th>
{STANDARD_FIELDS.map((f) => (
<th key={f.key} className={`${th} bg-status-info-bg`} style={{ minWidth: f.width }}>
{f.label}
</th>
))}
<th className={`${th} text-center`} style={{ minWidth: 72 }}>
Rendering
</th>
{Array.from({ length: maxComps }, (_, i) => (
<React.Fragment key={i}>
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 180 }}>
Part Name
</th>
<th className={`${th} bg-status-warning-bg`} style={{ minWidth: 110 }}>
Material
</th>
</React.Fragment>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={row.row_index} className={ri % 2 === 0 ? 'bg-surface' : 'bg-surface-alt/50'}>
{/* Row number */}
<td className={`${td} px-2 py-1.5 text-xs text-content-muted font-mono text-right`}>
{row.row_index}
</td>
{/* Standard text fields */}
{STANDARD_FIELDS.map((f) => (
<td key={f.key} className={td}>
<input
type="text"
value={(row[f.key] as string | null) ?? ''}
onChange={(e) => updateField(ri, f.key, e.target.value || null)}
className={`${cell} ${f.mono ? 'font-mono' : ''}`}
/>
</td>
))}
{/* Rendering checkbox */}
<td className={`${td} text-center`}>
<input
type="checkbox"
checked={row.medias_rendering ?? false}
onChange={(e) => updateField(ri, 'medias_rendering', e.target.checked)}
className="w-3.5 h-3.5"
/>
</td>
{/* Component pairs */}
{Array.from({ length: maxComps }, (_, ci) => {
const comp = row.components[ci]
return (
<React.Fragment key={ci}>
<td className={td}>
<input
type="text"
value={comp?.part_name ?? ''}
onChange={(e) => updateComp(ri, ci, 'part_name', e.target.value)}
className={`${cell} font-mono`}
placeholder="—"
/>
</td>
<td className={td}>
<input
type="text"
value={comp?.material ?? ''}
onChange={(e) => updateComp(ri, ci, 'material', e.target.value)}
className={cell}
placeholder="—"
/>
</td>
</React.Fragment>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,272 @@
/**
* StepDropzone — Phase 3
*
* Accepts one or more .stp/.step files via react-dropzone, uploads each to
* POST /api/uploads/step, then calls POST /api/cad/match-to-order to link
* matched files to order items by filename.
*/
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, CheckCircle, XCircle, Loader2, Link2 } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface StepUploadResponse {
cad_file_id: string
original_name: string
file_hash: string
status: string
}
interface MatchedItem {
item_id: string
cad_file_id: string
item_name: string
cad_name: string
}
interface MatchToOrderResponse {
matched: MatchedItem[]
unmatched_cad: string[]
unmatched_items: string[]
}
type FileStatus = 'idle' | 'uploading' | 'done' | 'error'
interface FileEntry {
file: File
status: FileStatus
errorMsg?: string
cadFileId?: string
}
interface StepDropzoneProps {
orderId: string
/** Called after matching completes so the parent can refresh the order */
onMatchComplete?: (result: MatchToOrderResponse) => void
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function StepDropzone({ orderId, onMatchComplete }: StepDropzoneProps) {
const [entries, setEntries] = useState<FileEntry[]>([])
const [matching, setMatching] = useState(false)
const [matchResult, setMatchResult] = useState<MatchToOrderResponse | null>(null)
// Update a single entry by index
const updateEntry = useCallback(
(idx: number, patch: Partial<FileEntry>) =>
setEntries((prev) => prev.map((e, i) => (i === idx ? { ...e, ...patch } : e))),
[],
)
const onDrop = useCallback(
async (accepted: File[]) => {
if (accepted.length === 0) return
// Append new file entries
const startIdx = entries.length
const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' }))
setEntries((prev) => [...prev, ...newEntries])
setMatchResult(null)
// Upload each file sequentially to avoid overwhelming the server
const uploadedIds: string[] = []
for (let i = 0; i < accepted.length; i++) {
const globalIdx = startIdx + i
const file = accepted[i]
const form = new FormData()
form.append('file', file)
try {
const res = await api.post<StepUploadResponse>('/uploads/step', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const { cad_file_id } = res.data
uploadedIds.push(cad_file_id)
updateEntry(globalIdx, { status: 'done', cadFileId: cad_file_id })
} catch (err: any) {
const msg: string =
err?.response?.data?.detail ?? err?.message ?? 'Upload failed'
updateEntry(globalIdx, { status: 'error', errorMsg: msg })
toast.error(`${file.name}: ${msg}`)
}
}
// Collect all successful cad_file_ids from this session (including previous uploads)
const allSuccessfulIds: string[] = [
...entries
.filter((e) => e.status === 'done' && e.cadFileId)
.map((e) => e.cadFileId as string),
...uploadedIds,
]
if (allSuccessfulIds.length === 0) return
// Match to order
setMatching(true)
try {
const res = await api.post<MatchToOrderResponse>('/cad/match-to-order', {
order_id: orderId,
cad_file_ids: allSuccessfulIds,
})
setMatchResult(res.data)
const { matched, unmatched_cad } = res.data
if (matched.length > 0) {
toast.success(`Matched ${matched.length} file(s) to order items`)
}
if (unmatched_cad.length > 0) {
toast.warning(`${unmatched_cad.length} file(s) could not be matched to any item`)
}
onMatchComplete?.(res.data)
} catch (err: any) {
const msg: string =
err?.response?.data?.detail ?? err?.message ?? 'Matching failed'
toast.error(`CAD matching error: ${msg}`)
} finally {
setMatching(false)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[entries, orderId, onMatchComplete, updateEntry],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'application/octet-stream': ['.stp', '.step'] },
multiple: true,
})
const hasEntries = entries.length > 0
return (
<div className="space-y-4">
{/* Drop target */}
<div
{...getRootProps()}
className={[
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors',
isDragActive
? 'border-green-500 bg-status-success-bg'
: 'border-border-default hover:border-border-default bg-surface-alt',
].join(' ')}
>
<input {...getInputProps()} />
<Upload size={32} className="mx-auto mb-3 text-content-muted" />
{isDragActive ? (
<p className="text-green-600 font-medium">Drop STEP files here</p>
) : (
<>
<p className="text-content-secondary font-medium">
Drag and drop .stp / .step files here
</p>
<p className="text-sm text-content-muted mt-1">or click to browse</p>
</>
)}
</div>
{/* Per-file status list */}
{hasEntries && (
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
{entries.map((entry, idx) => (
<li key={idx} className="flex items-center gap-3 px-4 py-3">
<FileStatusIcon status={entry.status} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">
{entry.file.name}
</p>
{entry.status === 'error' && (
<p className="text-xs text-red-500 mt-0.5">{entry.errorMsg}</p>
)}
{entry.status === 'done' && (
<p className="text-xs text-content-muted mt-0.5">
ID: {entry.cadFileId}
</p>
)}
</div>
<StatusLabel status={entry.status} />
</li>
))}
</ul>
)}
{/* Matching spinner */}
{matching && (
<div className="flex items-center gap-2 text-sm text-content-secondary">
<Loader2 size={15} className="animate-spin" />
Matching files to order items...
</div>
)}
{/* Match result summary */}
{matchResult && !matching && (
<div className="rounded-lg border border-border-default bg-surface p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
<Link2 size={15} />
Matching Results
</div>
{matchResult.matched.length > 0 && (
<div>
<p className="text-xs font-medium text-status-success-text mb-1">
Matched ({matchResult.matched.length})
</p>
<ul className="space-y-1">
{matchResult.matched.map((m) => (
<li key={m.item_id} className="flex items-center gap-2 text-xs">
<CheckCircle size={13} className="text-green-500 shrink-0" />
<span className="font-mono text-content-secondary truncate">{m.cad_name}</span>
<span className="text-content-muted"></span>
<span className="text-content-secondary truncate">{m.item_name}</span>
</li>
))}
</ul>
</div>
)}
{matchResult.unmatched_cad.length > 0 && (
<div>
<p className="text-xs font-medium text-status-warning-text mb-1">
Unmatched CAD files ({matchResult.unmatched_cad.length})
</p>
<ul className="space-y-0.5">
{matchResult.unmatched_cad.map((id) => (
<li key={id} className="text-xs text-content-secondary font-mono truncate">
{entries.find((e) => e.cadFileId === id)?.file.name ?? id}
</li>
))}
</ul>
</div>
)}
{matchResult.matched.length === 0 && matchResult.unmatched_cad.length === 0 && (
<p className="text-xs text-content-muted">No files were processed.</p>
)}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function FileStatusIcon({ status }: { status: FileStatus }) {
if (status === 'uploading') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
if (status === 'done') return <CheckCircle size={16} className="text-green-500 shrink-0" />
if (status === 'error') return <XCircle size={16} className="text-red-500 shrink-0" />
return <div className="w-4 h-4 rounded-full bg-surface-muted shrink-0" />
}
function StatusLabel({ status }: { status: FileStatus }) {
if (status === 'uploading') return <span className="text-xs text-blue-500">Uploading...</span>
if (status === 'done') return <span className="text-xs text-green-600">Uploaded</span>
if (status === 'error') return <span className="text-xs text-red-500">Failed</span>
return null
}
@@ -0,0 +1,223 @@
/**
* StepPreUpload — STEP file uploader used during order creation (before an
* order ID exists). Files are uploaded immediately to /api/uploads/step so
* we have cad_file_ids ready. Client-side filename matching gives the user
* live feedback on which Excel rows already have a STEP file.
*/
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, CheckCircle, XCircle, Loader2, FileBox } from 'lucide-react'
import { toast } from 'sonner'
import api from '../../api/client'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface StepUploadResponse {
cad_file_id: string
original_name: string
file_hash: string
status: string
}
type FileStatus = 'uploading' | 'done' | 'error'
interface FileEntry {
file: File
status: FileStatus
errorMsg?: string
cadFileId?: string
}
export interface StepUploadState {
ids: string[] // cad_file_ids of successfully uploaded files
names: string[] // original_names of successfully uploaded files
}
interface Props {
/** name_cad_modell values from parsed rows — used for match preview */
itemNames: string[]
/** Called whenever the set of successfully uploaded files changes */
onUpdate: (state: StepUploadState) => void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function normStem(name: string): string {
return name.trim().toLowerCase().replace(/\.(step|stp)$/i, '')
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function StepPreUpload({ itemNames, onUpdate }: Props) {
const [entries, setEntries] = useState<FileEntry[]>([])
const getSuccessState = (updated: FileEntry[]): StepUploadState => ({
ids: updated.filter((e) => e.status === 'done' && e.cadFileId).map((e) => e.cadFileId!),
names: updated.filter((e) => e.status === 'done').map((e) => e.file.name),
})
const onDrop = useCallback(
async (accepted: File[]) => {
if (accepted.length === 0) return
const startIdx = entries.length
const newEntries: FileEntry[] = accepted.map((f) => ({ file: f, status: 'uploading' }))
const merged = [...entries, ...newEntries]
setEntries(merged)
let working = [...merged]
for (let i = 0; i < accepted.length; i++) {
const idx = startIdx + i
const file = accepted[i]
const form = new FormData()
form.append('file', file)
try {
const res = await api.post<StepUploadResponse>('/uploads/step', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
working = working.map((e, j) =>
j === idx ? { ...e, status: 'done', cadFileId: res.data.cad_file_id } : e,
)
} catch (err: any) {
const msg: string = err?.response?.data?.detail ?? err?.message ?? 'Upload failed'
working = working.map((e, j) =>
j === idx ? { ...e, status: 'error', errorMsg: msg } : e,
)
toast.error(`${file.name}: ${msg}`)
}
setEntries([...working])
}
onUpdate(getSuccessState(working))
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[entries, onUpdate],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'application/octet-stream': ['.stp', '.step'] },
multiple: true,
})
// Client-side match preview
const uploadedStems = new Set(
entries.filter((e) => e.status === 'done').map((e) => normStem(e.file.name)),
)
const matched = itemNames.filter((n) => uploadedStems.has(normStem(n)))
const missing = itemNames.filter((n) => !uploadedStems.has(normStem(n)))
return (
<div className="space-y-4">
{/* Match status bar */}
{itemNames.length > 0 && (
<div className="flex items-center gap-3 text-sm">
<div className="flex items-center gap-1.5 text-status-success-text">
<CheckCircle size={15} className="shrink-0" />
<span><strong>{matched.length}</strong> matched</span>
</div>
{missing.length > 0 && (
<div className="flex items-center gap-1.5 text-amber-600">
<FileBox size={15} className="shrink-0" />
<span><strong>{missing.length}</strong> still need a STEP file</span>
</div>
)}
{missing.length === 0 && (
<span className="text-status-success-text font-medium">All items covered </span>
)}
</div>
)}
{/* Drop zone */}
<div
{...getRootProps()}
className={[
'border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors',
isDragActive
? 'border-accent bg-status-success-bg'
: 'border-border-default hover:border-border-default bg-surface-alt',
].join(' ')}
>
<input {...getInputProps()} />
<Upload size={28} className="mx-auto mb-2 text-content-muted" />
{isDragActive ? (
<p className="text-accent font-medium">Drop STEP files here</p>
) : (
<>
<p className="text-content-secondary font-medium">Drag & drop .stp / .step files</p>
<p className="text-sm text-content-muted mt-1">or click to browse multiple files at once</p>
</>
)}
</div>
{/* Uploaded file list */}
{entries.length > 0 && (
<ul className="divide-y divide-border-light rounded-lg border border-border-default bg-surface overflow-hidden">
{entries.map((entry, idx) => {
const stem = normStem(entry.file.name)
const isMatched = itemNames.some((n) => normStem(n) === stem)
return (
<li key={idx} className="flex items-center gap-3 px-4 py-2.5">
{entry.status === 'uploading' && (
<Loader2 size={15} className="animate-spin text-blue-500 shrink-0" />
)}
{entry.status === 'done' && (
<CheckCircle size={15} className={isMatched ? 'text-green-500 shrink-0' : 'text-amber-400 shrink-0'} />
)}
{entry.status === 'error' && (
<XCircle size={15} className="text-red-500 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">{entry.file.name}</p>
{entry.status === 'error' && (
<p className="text-xs text-red-500">{entry.errorMsg}</p>
)}
{entry.status === 'done' && !isMatched && (
<p className="text-xs text-amber-600">No matching row in Excel</p>
)}
</div>
{entry.status === 'uploading' && (
<span className="text-xs text-blue-500 shrink-0">Uploading</span>
)}
{entry.status === 'done' && (
<span className={`text-xs shrink-0 ${isMatched ? 'text-green-600' : 'text-amber-500'}`}>
{isMatched ? 'Matched' : 'Unmatched'}
</span>
)}
</li>
)
})}
</ul>
)}
{/* Missing items list */}
{missing.length > 0 && entries.some((e) => e.status === 'done') && (
<div className="rounded-lg border border-border-default bg-status-warning-bg p-3">
<p className="text-xs font-semibold text-status-warning-text mb-1.5">
Still missing ({missing.length}):
</p>
<div className="flex flex-wrap gap-1">
{missing.slice(0, 12).map((name) => (
<span
key={name}
className="text-xs font-mono bg-status-warning-bg text-status-warning-text px-1.5 py-0.5 rounded border border-border-default"
>
{name}
</span>
))}
{missing.length > 12 && (
<span className="text-xs text-status-warning-text">+{missing.length - 12} more</span>
)}
</div>
</div>
)}
</div>
)
}