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
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import ThreeDViewer from '../components/cad/ThreeDViewer'
/**
* Route: /cad/:id
*
* Renders the full-screen 3D viewer for a specific CAD file.
* When the viewer is closed the user is navigated back.
*/
export default function CadPreviewPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
if (!id) {
return (
<div className="flex flex-col items-center justify-center h-full text-content-muted gap-4 p-8">
<p className="text-lg">No CAD file ID provided.</p>
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-sm text-accent hover:underline"
>
<ArrowLeft size={16} />
Go back
</button>
</div>
)
}
return (
<ThreeDViewer
cadFileId={id}
onClose={() => navigate(-1)}
/>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { useAuthStore } from '../store/auth'
import AdminDashboard from '../components/dashboard/AdminDashboard'
import ClientDashboard from '../components/dashboard/ClientDashboard'
export default function DashboardPage() {
const user = useAuthStore((s) => s.user)
const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager'
return isPrivileged ? <AdminDashboard /> : <ClientDashboard />
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import api from '../api/client'
import { useAuthStore } from '../store/auth'
export default function LoginPage() {
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const res = await api.post('/auth/login', { email, password })
setAuth(res.data.access_token, res.data.user)
navigate('/')
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-surface-alt">
<div className="card p-8 w-full max-w-md">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-accent rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-2xl font-bold">S</span>
</div>
<h1 className="text-2xl font-bold text-content">Schaeffler Automat</h1>
<p className="text-content-muted text-sm mt-1">Media Creation Pipeline</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="input-base w-full"
placeholder="admin@schaeffler.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="input-base w-full"
/>
</div>
<button type="submit" disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
+541
View File
@@ -0,0 +1,541 @@
import { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download,
Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag,
} from 'lucide-react'
import {
listMaterials, createMaterial, updateMaterial, deleteMaterial,
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases,
} from '../api/materials'
import type { Material } from '../api/materials'
import MaterialWizard from '../components/MaterialWizard'
const TYPE_GROUPS = [
{ code: '01', label: 'Metals', icon: Wrench, bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700' },
{ code: '02', label: 'Coatings', icon: Paintbrush, bg: 'bg-status-info-bg', border: 'border-border-default', text: 'text-status-info-text' },
{ code: '03', label: 'Non-metals', icon: Shapes, bg: 'bg-status-warning-bg', border: 'border-border-default', text: 'text-status-warning-text' },
{ code: '04', label: 'Compounds', icon: FlaskConical, bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700' },
{ code: '05', label: 'Misc', icon: HelpCircle, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary' },
] as const
function getTypeCode(mat: Material): string | null {
if (mat.schaeffler_code == null) return null
return String(mat.schaeffler_code).padStart(6, '0').slice(0, 2)
}
interface MaterialGroup {
code: string | null
label: string
icon: typeof Wrench
bg: string
border: string
text: string
items: Material[]
}
export default function MaterialsPage() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [showWizard, setShowWizard] = 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 [collapsed, setCollapsed] = useState<Set<string | null>>(new Set())
const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set())
const [aliasInput, setAliasInput] = useState<Record<string, string>>({})
const { data: materials = [], isLoading } = 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 seedMut = useMutation({
mutationFn: seedSchaefflerMaterials,
onSuccess: (data) => {
if (data.inserted > 0) {
toast.success(`Imported ${data.inserted} of ${data.total} Schaeffler standard materials`)
} else {
toast.info('All Schaeffler standard materials already exist')
}
qc.invalidateQueries({ queryKey: ['materials'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to import'),
})
const seedAliasMut = useMutation({
mutationFn: seedAliases,
onSuccess: (data) => {
if (data.inserted > 0) {
toast.success(`Seeded ${data.inserted} aliases (${data.total} total checked)`)
} else {
toast.info('All aliases already exist')
}
qc.invalidateQueries({ queryKey: ['materials'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to seed aliases'),
})
const addAliasMut = useMutation({
mutationFn: ({ materialId, alias }: { materialId: string; alias: string }) => addAlias(materialId, alias),
onSuccess: (_data, vars) => {
toast.success('Alias added')
qc.invalidateQueries({ queryKey: ['materials'] })
setAliasInput((prev) => ({ ...prev, [vars.materialId]: '' }))
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add alias'),
})
const deleteAliasMut = useMutation({
mutationFn: deleteAlias,
onSuccess: () => {
toast.success('Alias removed')
qc.invalidateQueries({ queryKey: ['materials'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'),
})
const startEdit = (mat: Material) => {
setEditingId(mat.id)
setEditName(mat.name)
setEditDesc(mat.description ?? '')
}
const toggleAliases = (id: string) => {
setExpandedAliases((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleAddAlias = (materialId: string) => {
const val = (aliasInput[materialId] || '').trim()
if (val) addAliasMut.mutate({ materialId, alias: val })
}
// Search filters include aliases
const filtered = search.trim()
? materials.filter((m) => {
const q = search.toLowerCase()
return (
m.name.toLowerCase().includes(q) ||
m.description?.toLowerCase().includes(q) ||
m.aliases.some((a) => a.toLowerCase().includes(q))
)
})
: materials
// Group filtered materials by type code
const groups = useMemo((): MaterialGroup[] => {
const buckets = new Map<string | null, Material[]>()
for (const m of filtered) {
const tc = getTypeCode(m)
if (!buckets.has(tc)) buckets.set(tc, [])
buckets.get(tc)!.push(m)
}
const result: MaterialGroup[] = []
// Known type groups first
for (const tg of TYPE_GROUPS) {
const items = buckets.get(tg.code)
if (items && items.length > 0) {
result.push({ code: tg.code, label: tg.label, icon: tg.icon, bg: tg.bg, border: tg.border, text: tg.text, items })
buckets.delete(tg.code)
}
}
// Custom / non-schaeffler materials
const custom = buckets.get(null)
if (custom && custom.length > 0) {
result.push({ code: null, label: 'Custom', icon: Plus, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary', items: custom })
}
return result
}, [filtered])
const toggleGroup = (code: string | null) => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
}
const totalAliases = materials.reduce((sum, m) => sum + m.aliases.length, 0)
return (
<div className="p-8 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<FlaskConical size={22} className="text-accent" />
<div className="flex-1">
<h1 className="text-2xl font-bold text-content">Material Library</h1>
<p className="text-sm text-content-secondary mt-0.5">
Shared materials used when assigning CAD part materials to order items.
{totalAliases > 0 && <span className="ml-2 text-content-muted">({totalAliases} aliases configured)</span>}
</p>
</div>
<button
onClick={() => {
if (confirm('Import 35 Schaeffler standard materials? Existing entries will be skipped.'))
seedMut.mutate()
}}
disabled={seedMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5"
title="Import the 35 standard Schaeffler SCHAEFFLER_... materials used in Blender material libraries. Existing entries are skipped."
>
<Download size={14} /> {seedMut.isPending ? 'Importing...' : 'Import Standards'}
</button>
<button
onClick={() => {
if (confirm('Seed material aliases from naming scheme mappings? Existing aliases will be skipped.'))
seedAliasMut.mutate()
}}
disabled={seedAliasMut.isPending}
className="btn-secondary text-sm flex items-center gap-1.5"
title="Seed ~100 material aliases from the Schaeffler naming scheme (German descriptions, intermediate codes → SCHAEFFLER_... library names). Existing aliases are skipped."
>
<Tag size={14} /> {seedAliasMut.isPending ? 'Seeding...' : 'Seed Aliases'}
</button>
<button
onClick={() => setShowWizard(true)}
className="btn-secondary text-sm flex items-center gap-1.5"
title="Open the Schaeffler Wizard — guided tool to set up SCHAEFFLER_... materials and aliases from the standard naming scheme"
>
<Wand2 size={14} /> Schaeffler Wizard
</button>
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
<Plus size={16} /> Add Material
</button>
</div>
{/* Add form */}
{showAdd && (
<div className="card p-4 mb-6 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="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
/>
</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="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent"
/>
</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>
)}
{/* Search */}
<div className="relative mb-4">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search materials or aliases..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent bg-surface"
/>
</div>
{/* Grouped table */}
{isLoading ? (
<div className="card p-8 text-center text-content-muted text-sm">Loading...</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center text-content-muted text-sm">
{search ? 'No materials match your search.' : 'No materials yet. Add the first one above.'}
</div>
) : (
<div className="space-y-3">
{groups.map((group) => {
const Icon = group.icon
const isCollapsed = collapsed.has(group.code)
return (
<div key={group.code ?? 'custom'} className={`card overflow-hidden border ${group.border}`}>
{/* Group header */}
<button
onClick={() => toggleGroup(group.code)}
className={`w-full flex items-center gap-3 px-5 py-3 ${group.bg} hover:brightness-95 transition-all`}
>
{isCollapsed ? <ChevronRight size={16} className={group.text} /> : <ChevronDown size={16} className={group.text} />}
<Icon size={16} className={group.text} />
<span className={`text-sm font-semibold ${group.text}`}>
{group.code ? `${group.code}` : ''}{group.label}
</span>
<span className="text-xs text-content-muted ml-auto">{group.items.length} material{group.items.length !== 1 ? 's' : ''}</span>
</button>
{!isCollapsed && (
<>
{/* Column header */}
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] bg-surface border-b border-border-light px-6 py-1.5">
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Name</p>
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Description</p>
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Source</p>
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Aliases</p>
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Actions</p>
</div>
{/* Rows */}
<div className="divide-y divide-border-light">
{group.items.map((mat) => {
const aliasesExpanded = expandedAliases.has(mat.id)
return (
<div key={mat.id}>
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] items-center px-6 py-2.5 gap-3 hover:bg-surface-hover">
{editingId === mat.id ? (
<div className="col-span-5 flex items-center gap-3 flex-wrap">
<input
autoFocus
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Name"
className="flex-1 min-w-[140px] 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 min-w-[200px] 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" title="Cancel">
<X size={16} />
</button>
</div>
) : (
<>
<div className="min-w-0">
<p className="text-sm font-medium text-content truncate">{mat.name}</p>
{mat.schaeffler_code != null && (
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
)}
</div>
<div className="min-w-0">
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
</div>
<div>
<SourceBadge source={mat.source} />
</div>
<div>
<button
onClick={() => toggleAliases(mat.id)}
className="inline-flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary"
title={`${mat.aliases.length} alias${mat.aliases.length !== 1 ? 'es' : ''} — click to ${aliasesExpanded ? 'collapse' : 'expand'}`}
>
<Tag size={12} />
<span>{mat.aliases.length}</span>
{mat.aliases.length > 0 && (
aliasesExpanded
? <ChevronDown size={12} />
: <ChevronRight size={12} />
)}
</button>
</div>
<div className="flex items-center gap-1">
<button onClick={() => startEdit(mat)} className="text-content-muted hover:text-content" title="Edit">
<Pencil size={14} />
</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={14} />
</button>
</div>
</>
)}
</div>
{/* Expandable alias section */}
{aliasesExpanded && editingId !== mat.id && (
<div className="px-8 pb-3 pt-1 bg-surface-alt/50 border-t border-border-light">
<div className="flex flex-wrap gap-1.5 mb-2">
{mat.aliases.length === 0 && (
<span className="text-xs text-content-muted italic">No aliases configured</span>
)}
{mat.aliases.map((alias) => (
<AliasPill
key={alias}
alias={alias}
materialId={mat.id}
onDelete={deleteAliasMut}
materials={materials}
/>
))}
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Add alias (e.g. Stahl, brüniert)"
value={aliasInput[mat.id] || ''}
onChange={(e) => setAliasInput((prev) => ({ ...prev, [mat.id]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddAlias(mat.id)
}}
className="flex-1 max-w-xs px-2 py-1 border border-border-default rounded text-xs focus:outline-none focus:border-accent"
/>
<button
onClick={() => handleAddAlias(mat.id)}
disabled={!(aliasInput[mat.id] || '').trim() || addAliasMut.isPending}
className="text-xs text-accent hover:text-accent-hover font-medium disabled:opacity-40"
>
+ Add
</button>
</div>
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
})}
{/* Footer count */}
<div className="text-center py-2">
<p className="text-xs text-content-muted">
{filtered.length} of {materials.length} material{materials.length !== 1 ? 's' : ''}
{totalAliases > 0 && ` · ${totalAliases} aliases`}
</p>
</div>
</div>
)}
{/* Wizard modal */}
<MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} />
</div>
)
}
function AliasPill({
alias,
materialId,
onDelete,
materials,
}: {
alias: string
materialId: string
onDelete: { mutate: (id: string) => void; isPending: boolean }
materials: Material[]
}) {
// We need the alias ID for deletion - find it from the material's aliases list
// Since we only have alias strings from MaterialOut, we need to query the ID
// We'll use a lazy approach: delete by fetching aliases for this material
const handleDelete = async () => {
try {
const { listAliases: fetchAliases } = await import('../api/materials')
const aliases = await fetchAliases(materialId)
const found = aliases.find((a) => a.alias === alias)
if (found) {
onDelete.mutate(found.id)
}
} catch {
// Fallback: ignore
}
}
return (
<span className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded-full">
{alias}
<button
onClick={handleDelete}
className="text-indigo-400 hover:text-red-500 ml-0.5"
title={`Remove alias "${alias}"`}
>
<X size={10} />
</button>
</span>
)
}
function SourceBadge({ source }: { source: string }) {
if (source === 'schaeffler_standard') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-status-success-bg text-status-success-text px-2 py-0.5 rounded-full">
Standard
</span>
)
}
if (source === 'cad_import') {
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
CAD import
</span>
)
}
return (
<span className="inline-flex items-center gap-1 text-xs font-medium bg-surface-muted text-content-secondary px-2 py-0.5 rounded-full">
Manual
</span>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { Link } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Package } from 'lucide-react'
export default function NewOrderPage() {
return (
<div className="p-8 max-w-3xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link to="/orders" className="btn-secondary"><ArrowLeft size={16} />Back</Link>
<h1 className="text-2xl font-bold text-content">New Order</h1>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Excel Upload */}
<Link
to="/upload"
className="card p-6 hover:shadow-md transition-shadow group"
>
<div className="w-12 h-12 rounded-lg bg-emerald-50 flex items-center justify-center mb-4 group-hover:bg-emerald-100 transition-colors">
<FileSpreadsheet size={24} className="text-emerald-600" />
</div>
<h2 className="text-lg font-semibold text-content mb-1">Upload Excel</h2>
<p className="text-sm text-content-muted">
Import order items from an Excel template file with product data and components.
</p>
</Link>
{/* Product Library */}
<Link
to="/orders/new/product"
className="card p-6 hover:shadow-md transition-shadow group"
>
<div className="w-12 h-12 rounded-lg bg-status-info-bg flex items-center justify-center mb-4 group-hover:bg-surface-hover transition-colors">
<Package size={24} className="text-status-info-text" />
</div>
<h2 className="text-lg font-semibold text-content mb-1">Product Library</h2>
<p className="text-sm text-content-muted">
Select products from the library and configure output types for rendering.
</p>
</Link>
</div>
</div>
)
}
+899
View File
@@ -0,0 +1,899 @@
import { useState, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
ArrowLeft, ArrowRight, Search, Box, Check, ShoppingCart, Trash2,
ChevronDown, ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts } from '../api/products'
import { listOutputTypes } from '../api/outputTypes'
import { createOrder } from '../api/orders'
import { estimatePrice } from '../api/pricing'
import type { Product, RenderPosition } from '../api/products'
import type { OutputType } from '../api/outputTypes'
const 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' },
]
type WizardStep = 1 | 2 | 3
// Maps product_id → Set of output_type_id
type OutputSelections = Record<string, Set<string>>
// Maps product_id → Set of position_id
type PositionSelections = Record<string, Set<string>>
export default function NewProductOrderPage() {
const navigate = useNavigate()
const [step, setStep] = useState<WizardStep>(1)
const [searchQ, setSearchQ] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [selectedProducts, setSelectedProducts] = useState<Map<string, Product>>(new Map())
const [outputSelections, setOutputSelections] = useState<OutputSelections>({})
const [positionSelections, setPositionSelections] = useState<PositionSelections>({})
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
// ---- Step 1: load products with STEP files ----
const { data: products, isLoading: productsLoading } = useQuery({
queryKey: ['wizard-products', searchQ, categoryFilter],
queryFn: () => listProducts({
q: searchQ,
category_key: categoryFilter,
ready_only: true,
limit: 200,
}),
})
// ---- Step 2: load all output types (we'll filter client-side per product category) ----
const { data: allOutputTypes } = useQuery({
queryKey: ['wizard-output-types'],
queryFn: () => listOutputTypes(false),
enabled: step >= 2,
})
function initPositionsForProduct(product: Product) {
if ((product.render_positions?.length ?? 0) > 0) {
// Default: all positions selected
setPositionSelections((ps) => ({
...ps,
[product.id]: new Set(product.render_positions!.map((p) => p.id)),
}))
}
}
function toggleProduct(product: Product) {
const willSelect = !selectedProducts.has(product.id)
setSelectedProducts((prev) => {
const next = new Map(prev)
if (next.has(product.id)) {
next.delete(product.id)
} else {
next.set(product.id, product)
}
return next
})
if (willSelect) {
initPositionsForProduct(product)
}
}
const allFilteredSelected =
(products?.length ?? 0) > 0 && (products ?? []).every((p) => selectedProducts.has(p.id))
function selectAllFiltered() {
const toInit = (products ?? []).filter((p) => !selectedProducts.has(p.id))
setSelectedProducts((prev) => {
const next = new Map(prev)
;(products ?? []).forEach((p) => next.set(p.id, p))
return next
})
toInit.forEach(initPositionsForProduct)
}
function deselectAllFiltered() {
setSelectedProducts((prev) => {
const next = new Map(prev)
;(products ?? []).forEach((p) => next.delete(p.id))
return next
})
}
function getCompatibleOutputTypes(categoryKey: string | null): OutputType[] {
if (!allOutputTypes) return []
return allOutputTypes.filter((ot) =>
ot.compatible_categories.length === 0 ||
(categoryKey && ot.compatible_categories.includes(categoryKey))
)
}
function toggleOutputType(productId: string, outputTypeId: string) {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
if (set.has(outputTypeId)) {
set.delete(outputTypeId)
} else {
set.add(outputTypeId)
}
return { ...prev, [productId]: set }
})
}
// Union of all output types compatible with at least one selected product
const globalOutputTypes = useMemo(() => {
if (!allOutputTypes || selectedProducts.size === 0) return []
const seenIds = new Set<string>()
const result: OutputType[] = []
for (const product of selectedProducts.values()) {
for (const ot of getCompatibleOutputTypes(product.category_key)) {
if (!seenIds.has(ot.id)) {
seenIds.add(ot.id)
result.push(ot)
}
}
}
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProducts, allOutputTypes])
function toggleOutputTypeGlobal(otId: string) {
let compatibleCount = 0
let selectedCount = 0
for (const [productId, product] of selectedProducts) {
const compatible = getCompatibleOutputTypes(product.category_key)
if (!compatible.some((ot) => ot.id === otId)) continue
compatibleCount++
if (outputSelections[productId]?.has(otId)) selectedCount++
}
if (compatibleCount === 0) return
const shouldSelect = selectedCount < compatibleCount
setOutputSelections((prev) => {
const next = { ...prev }
for (const [productId, product] of selectedProducts) {
const compatible = getCompatibleOutputTypes(product.category_key)
if (!compatible.some((ot) => ot.id === otId)) continue
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(otId)
else set.delete(otId)
next[productId] = set
}
return next
})
}
function togglePosition(productId: string, positionId: string) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
if (set.has(positionId)) set.delete(positionId)
else set.add(positionId)
return { ...prev, [productId]: set }
})
}
// Union of all unique position names across selected products that have positions
const globalPositionNames = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
for (const product of selectedProducts.values()) {
for (const pos of product.render_positions ?? []) {
if (!seen.has(pos.name)) {
seen.add(pos.name)
result.push(pos.name)
}
}
}
return result
}, [selectedProducts])
function togglePositionGlobal(positionName: string) {
// Count how many products have this position name and how many have it selected
let compatibleCount = 0
let selectedCount = 0
for (const [productId, product] of selectedProducts) {
const pos = (product.render_positions ?? []).find((p) => p.name === positionName)
if (!pos) continue
compatibleCount++
if (positionSelections[productId]?.has(pos.id)) selectedCount++
}
if (compatibleCount === 0) return
const shouldSelect = selectedCount < compatibleCount
setPositionSelections((prev) => {
const next = { ...prev }
for (const [productId, product] of selectedProducts) {
const pos = (product.render_positions ?? []).find((p) => p.name === positionName)
if (!pos) continue
const set = new Set(prev[productId] || [])
if (shouldSelect) set.add(pos.id)
else set.delete(pos.id)
next[productId] = set
}
return next
})
}
// Build flat list of order lines for review (Step 3)
// Each (product, outputType, position?) triple becomes one line.
const orderLines = useMemo(() => {
const lines: Array<{
key: string
product: Product
outputType: OutputType
position: RenderPosition | null
}> = []
for (const [productId, product] of selectedProducts) {
const selectedOts = outputSelections[productId]
if (!selectedOts) continue
const hasPositions = (product.render_positions?.length ?? 0) > 0
for (const otId of selectedOts) {
const ot = allOutputTypes?.find((o) => o.id === otId)
if (!ot) continue
if (hasPositions) {
const selectedPosIds = positionSelections[productId] || new Set()
if (selectedPosIds.size === 0) {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
} else {
for (const posId of selectedPosIds) {
const pos = product.render_positions!.find((p) => p.id === posId)
if (pos) lines.push({ key: `${productId}-${otId}-${posId}`, product, outputType: ot, position: pos })
}
}
} else {
lines.push({ key: `${productId}-${otId}`, product, outputType: ot, position: null })
}
}
}
return lines
}, [selectedProducts, outputSelections, positionSelections, allOutputTypes])
function removeLine(productId: string, outputTypeId: string, positionId: string | null) {
if (positionId) {
setPositionSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(positionId)
return { ...prev, [productId]: set }
})
} else {
setOutputSelections((prev) => {
const set = new Set(prev[productId] || [])
set.delete(outputTypeId)
return { ...prev, [productId]: set }
})
}
}
// Check that every selected product has at least one output type
const allProductsHaveOutputTypes = useMemo(() => {
for (const productId of selectedProducts.keys()) {
const set = outputSelections[productId]
if (!set || set.size === 0) return false
}
return true
}, [selectedProducts, outputSelections])
const totalRenderJobs = useMemo(() => {
let count = 0
for (const set of Object.values(outputSelections)) {
count += set.size
}
return count
}, [outputSelections])
// Build estimate lines for pricing query
const estimateLines = useMemo(() => {
return orderLines.map((l) => ({
product_id: l.product.id,
output_type_id: l.outputType.id,
}))
}, [orderLines])
const { data: priceEstimate } = useQuery({
queryKey: ['price-estimate', estimateLines],
queryFn: () => estimatePrice(estimateLines),
enabled: estimateLines.length > 0 && step >= 2,
placeholderData: keepPreviousData,
})
// Helper to find per-line price from estimate breakdown
function getLinePrice(productId: string, outputTypeId: string): number | null {
if (!priceEstimate) return null
const match = priceEstimate.breakdown.find(
(b) => b.product_id === productId && b.output_type_id === outputTypeId
)
return match?.unit_price ?? null
}
async function handleSubmit() {
if (orderLines.length === 0) return
setSubmitting(true)
try {
const result = await createOrder({
notes: notes || undefined,
lines: orderLines.map((l) => ({
product_id: l.product.id,
output_type_id: l.outputType.id,
render_position_id: l.position?.id ?? null,
})),
})
toast.success(`Draft order ${result.order_number} created — review and submit`)
navigate(`/orders/${result.id}`)
} catch (e: any) {
toast.error(e.response?.data?.detail || 'Failed to create order')
} finally {
setSubmitting(false)
}
}
return (
<div className="p-8 max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Link to="/orders/new" className="btn-secondary"><ArrowLeft size={16} />Back</Link>
<h1 className="text-2xl font-bold text-content">New Product Order</h1>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2 mb-8">
{[
{ n: 1, label: 'Select Products' },
{ n: 2, label: 'Configure Outputs' },
{ n: 3, label: 'Review & Submit' },
].map(({ n, label }, i) => (
<div key={n} className="flex items-center gap-2">
{i > 0 && (
<div
className="w-8 h-px"
style={{ backgroundColor: step >= n ? 'var(--color-accent)' : 'var(--color-border)' }}
/>
)}
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${
step === n
? 'text-white'
: step > n
? 'bg-status-success-bg text-status-success-text'
: 'bg-surface-muted text-content-muted'
}`}
style={step === n ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
<span className="w-5 h-5 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
{step > n ? <Check size={12} /> : n}
</span>
{label}
</div>
</div>
))}
</div>
{/* ================================================================ */}
{/* STEP 1: Select Products */}
{/* ================================================================ */}
{step === 1 && (
<div className="pb-24">
{/* Search + filter bar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by name or PIM-ID..."
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent"
>
<option value="">All categories</option>
{CATEGORIES.map((c) => (
<option key={c.key} value={c.key}>{c.label}</option>
))}
</select>
{(products?.length ?? 0) > 0 && (
<button
onClick={allFilteredSelected ? deselectAllFiltered : selectAllFiltered}
className="px-3 py-2 rounded-lg border border-border-default text-sm text-content-secondary hover:border-accent hover:text-accent transition-colors whitespace-nowrap"
title={allFilteredSelected ? 'Deselect all currently visible products' : 'Select all currently visible products'}
>
{allFilteredSelected
? `Deselect all (${products!.length})`
: `Select all (${products!.length})`}
</button>
)}
</div>
{/* Product grid */}
{productsLoading ? (
<div className="text-center py-12 text-content-muted">Loading products...</div>
) : !products?.length ? (
<div className="text-center py-12 text-content-muted">No products with STEP files found.</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.map((p) => {
const isSelected = selectedProducts.has(p.id)
return (
<div
key={p.id}
onClick={() => toggleProduct(p)}
className={`card cursor-pointer transition-all overflow-hidden relative ${
isSelected
? 'ring-2 ring-accent shadow-md'
: 'hover:shadow-md'
}`}
>
{/* Selection checkbox overlay */}
<div
className={`absolute top-2 right-2 w-6 h-6 rounded-full border-2 flex items-center justify-center z-10 transition-colors ${
isSelected ? 'text-white' : 'bg-surface border-border-default'
}`}
style={isSelected ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{isSelected && <Check size={14} />}
</div>
{/* Thumbnail */}
<div className="h-32 bg-surface-muted flex items-center justify-center overflow-hidden">
{(p.render_image_url || p.thumbnail_url) ? (
<img
src={p.render_image_url || p.thumbnail_url!}
alt={p.name || p.pim_id}
className="h-full w-full object-contain"
/>
) : (
<Box size={36} className="text-content-muted" />
)}
</div>
{/* Info */}
<div className="p-3">
<p className="text-xs text-content-muted font-mono">{p.pim_id}</p>
<p className="text-sm font-medium text-content truncate">
{p.name || p.pim_id}
</p>
{p.category_key && (
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text">
{CATEGORIES.find((c) => c.key === p.category_key)?.label || p.category_key}
</span>
)}
</div>
</div>
)
})}
</div>
)}
{/* Sticky bottom bar */}
{selectedProducts.size > 0 && (
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<span className="text-sm font-medium text-content-secondary">
<ShoppingCart size={16} className="inline mr-2" />
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setStep(2)}
className="btn-primary"
>
Next <ArrowRight size={16} />
</button>
</div>
)}
</div>
)}
{/* ================================================================ */}
{/* STEP 2: Configure Output Types */}
{/* ================================================================ */}
{step === 2 && (
<div className="pb-24">
<p className="text-sm text-content-muted mb-4">
Select which output types to generate for each product. Only compatible types are shown.
</p>
{/* Global toggles — apply to all products at once */}
{(globalOutputTypes.length > 0 || globalPositionNames.length > 0) && (
<div className="card p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wide">
Apply to all products
</p>
{/* Output types row */}
{globalOutputTypes.length > 0 && (
<div>
<p className="text-xs text-content-muted mb-1.5">Output Types</p>
<div className="flex flex-wrap gap-2">
{globalOutputTypes.map((ot) => {
let compatibleCount = 0
let selectedCount = 0
for (const [productId, product] of selectedProducts) {
const compatible = getCompatibleOutputTypes(product.category_key)
if (!compatible.some((o) => o.id === ot.id)) continue
compatibleCount++
if (outputSelections[productId]?.has(ot.id)) selectedCount++
}
const allSel = selectedCount === compatibleCount && compatibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={ot.id}
onClick={() => toggleOutputTypeGlobal(ot.id)}
title={`${selectedCount} / ${compatibleCount} product${compatibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'text-white'
: someSel
? 'bg-status-success-bg text-status-success-text border-green-400'
: 'bg-surface text-content-secondary border-border-default hover:border-accent hover:text-accent'
}`}
style={allSel ? { backgroundColor: 'var(--color-accent)', borderColor: 'var(--color-accent)' } : undefined}
>
{allSel && <Check size={12} />}
{ot.name}
{selectedProducts.size > 1 && (
<span
className={`text-xs ${someSel ? 'text-status-success-text' : allSel ? '' : 'text-content-muted'}`}
style={allSel ? { color: 'rgba(255,255,255,0.7)' } : undefined}
>
{selectedCount}/{compatibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
{/* Perspectives row */}
{globalPositionNames.length > 0 && (
<div className="pt-2 border-t border-border-light">
<p className="text-xs text-content-muted mb-1.5">Perspectives</p>
<div className="flex flex-wrap gap-2">
{globalPositionNames.map((posName) => {
let compatibleCount = 0
let selectedCount = 0
for (const [productId, product] of selectedProducts) {
const pos = (product.render_positions ?? []).find((p) => p.name === posName)
if (!pos) continue
compatibleCount++
if (positionSelections[productId]?.has(pos.id)) selectedCount++
}
const allSel = selectedCount === compatibleCount && compatibleCount > 0
const someSel = selectedCount > 0 && !allSel
return (
<button
key={posName}
onClick={() => togglePositionGlobal(posName)}
title={`${selectedCount} / ${compatibleCount} product${compatibleCount !== 1 ? 's' : ''} selected`}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
allSel
? 'bg-purple-600 text-white border-purple-600'
: someSel
? 'bg-purple-100 text-purple-700 border-purple-400'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{allSel && <Check size={12} />}
{posName}
{selectedProducts.size > 1 && (
<span className={`text-xs ${allSel ? 'text-white/70' : someSel ? 'text-purple-500' : 'text-content-muted'}`}>
{selectedCount}/{compatibleCount}
</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
<div className="space-y-3">
{Array.from(selectedProducts.values()).map((product) => (
<ProductOutputRow
key={product.id}
product={product}
compatibleTypes={getCompatibleOutputTypes(product.category_key)}
selected={outputSelections[product.id] || new Set()}
onToggle={(otId) => toggleOutputType(product.id, otId)}
selectedPositions={positionSelections[product.id] || new Set()}
onTogglePosition={(posId) => togglePosition(product.id, posId)}
/>
))}
</div>
{/* Bottom bar */}
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<div className="flex items-center gap-4">
<button onClick={() => setStep(1)} className="btn-secondary">
<ArrowLeft size={16} /> Back
</button>
<span className="text-sm text-content-muted">
{selectedProducts.size} product{selectedProducts.size !== 1 ? 's' : ''} &middot; {orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></>
)}
</span>
</div>
<button
onClick={() => setStep(3)}
disabled={!allProductsHaveOutputTypes}
className="btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
Next <ArrowRight size={16} />
</button>
</div>
</div>
)}
{/* ================================================================ */}
{/* STEP 3: Review & Submit */}
{/* ================================================================ */}
{step === 3 && (
<div className="pb-24">
{orderLines.length === 0 ? (
<div className="card p-8 text-center text-content-muted">
No render jobs configured. Go back and select output types.
</div>
) : (
<>
<div className="card overflow-hidden mb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-default bg-surface-alt text-left">
<th className="px-4 py-3 font-medium text-content-secondary">Product</th>
<th className="px-4 py-3 font-medium text-content-secondary">Output Type</th>
<th className="px-4 py-3 font-medium text-content-secondary">Position</th>
<th className="px-4 py-3 font-medium text-content-secondary">Renderer</th>
<th className="px-4 py-3 font-medium text-content-secondary">Format</th>
<th className="px-4 py-3 font-medium text-content-secondary text-right">Price</th>
<th className="px-4 py-3 font-medium text-content-secondary w-12"></th>
</tr>
</thead>
<tbody>
{orderLines.map((line) => (
<tr key={line.key} className="border-b border-border-light hover:bg-surface-hover">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded bg-surface-muted flex items-center justify-center overflow-hidden shrink-0">
{(line.product.render_image_url || line.product.thumbnail_url) ? (
<img src={line.product.render_image_url || line.product.thumbnail_url!} className="w-full h-full object-contain" />
) : (
<Box size={18} className="text-content-muted" />
)}
</div>
<div className="min-w-0">
<p className="font-medium text-content truncate">
{line.product.name || line.product.pim_id}
</p>
<span className="text-xs text-content-muted font-mono">{line.product.pim_id}</span>
</div>
</div>
</td>
<td className="px-4 py-3 text-content-secondary">{line.outputType.name}</td>
<td className="px-4 py-3">
{line.position ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 font-medium">
{line.position.name}
</span>
) : (
<span className="text-content-muted text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-content-muted">{line.outputType.renderer}</td>
<td className="px-4 py-3 text-content-muted uppercase">{line.outputType.output_format}</td>
<td className="px-4 py-3 text-right">
{(() => {
const price = getLinePrice(line.product.id, line.outputType.id)
return price != null ? (
<span className="font-medium text-content-secondary">{price.toFixed(2)}</span>
) : (
<span className="text-content-muted"></span>
)
})()}
</td>
<td className="px-4 py-3">
<button
onClick={() => removeLine(line.product.id, line.outputType.id, line.position?.id ?? null)}
className="text-content-muted hover:text-red-500 transition-colors"
title="Remove this render job from the order"
>
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Notes */}
<div className="card p-4 mb-4">
<label className="block text-sm font-medium text-content-secondary mb-1">
Order Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any special instructions..."
rows={3}
className="w-full px-3 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent resize-none"
/>
</div>
</>
)}
{/* Bottom bar */}
<div className="fixed bottom-0 left-60 right-0 bg-surface border-t border-border-default px-8 py-4 flex items-center justify-between z-50 shadow-lg">
<button onClick={() => setStep(2)} className="btn-secondary">
<ArrowLeft size={16} /> Back
</button>
<div className="flex items-center gap-3">
<span className="text-sm text-content-muted">
{orderLines.length} render job{orderLines.length !== 1 ? 's' : ''}
{priceEstimate && priceEstimate.total > 0 && (
<> &middot; Estimated: <span className="font-semibold text-content-secondary">{priceEstimate.total.toFixed(2)}</span></>
)}
</span>
<button
onClick={handleSubmit}
disabled={submitting || orderLines.length === 0}
className="btn-primary disabled:opacity-40 disabled:cursor-not-allowed"
>
{submitting ? 'Creating...' : 'Create Order'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ---- Sub-component: expandable product row for step 2 ----
function ProductOutputRow({
product,
compatibleTypes,
selected,
onToggle,
selectedPositions,
onTogglePosition,
}: {
product: Product
compatibleTypes: OutputType[]
selected: Set<string>
onToggle: (otId: string) => void
selectedPositions: Set<string>
onTogglePosition: (posId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
return (
<div className="card overflow-hidden">
{/* Header row */}
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-surface-hover"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown size={16} className="text-content-muted" /> : <ChevronRight size={16} className="text-content-muted" />}
<div className="w-10 h-10 rounded bg-surface-muted flex items-center justify-center overflow-hidden shrink-0">
{(product.render_image_url || product.thumbnail_url) ? (
<img src={product.render_image_url || product.thumbnail_url!} className="w-full h-full object-contain" />
) : (
<Box size={18} className="text-content-muted" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-content truncate">
{product.name || product.pim_id}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted font-mono">{product.pim_id}</span>
{product.category_key && (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text">
{product.category_key}
</span>
)}
</div>
</div>
<span className="text-xs text-content-muted">
{selected.size} selected
</span>
</div>
{/* Output type checkboxes */}
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-border-light">
{compatibleTypes.length === 0 ? (
<p className="text-sm text-content-muted py-2">No compatible output types found.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-2">
{compatibleTypes.map((ot) => {
const checked = selected.has(ot.id)
return (
<label
key={ot.id}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border cursor-pointer transition-colors ${
checked ? '' : 'border-border-default'
}`}
style={checked ? { borderColor: 'var(--color-accent)', backgroundColor: 'var(--color-accent-light)' } : undefined}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(ot.id)}
className=""
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-content">{ot.name}</p>
<p className="text-xs text-content-muted">
{ot.renderer} &middot; {ot.output_format.toUpperCase()}
{ot.price_per_item != null && (
<> &middot; <span className="text-emerald-600 font-medium">{ot.price_per_item.toFixed(2)}</span></>
)}
</p>
</div>
</label>
)
})}
</div>
)}
{/* Render position toggles — only shown if product has positions */}
{(product.render_positions?.length ?? 0) > 0 && (
<div className="mt-3 pt-3 border-t border-border-light">
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-medium text-content-muted">Render Positions</p>
<button
className="text-xs text-accent hover:underline"
onClick={() => product.render_positions!.forEach((p) => !selectedPositions.has(p.id) && onTogglePosition(p.id))}
>
All
</button>
<span className="text-content-muted text-xs">·</span>
<button
className="text-xs text-content-muted hover:underline"
onClick={() => product.render_positions!.forEach((p) => selectedPositions.has(p.id) && onTogglePosition(p.id))}
>
None
</button>
</div>
<div className="flex flex-wrap gap-2">
{product.render_positions!.map((pos) => {
const active = selectedPositions.has(pos.id)
return (
<button
key={pos.id}
onClick={() => onTogglePosition(pos.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors ${
active
? 'bg-purple-600 text-white border-purple-600'
: 'bg-surface text-content-secondary border-border-default hover:border-purple-400 hover:text-purple-600'
}`}
>
{active && <Check size={12} />}
{pos.name}
{pos.is_default && !active && (
<span className="text-xs text-content-muted">(default)</span>
)}
</button>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)
}
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Bell, Send, PlayCircle, CheckCircle, XCircle, Image, AlertTriangle, CheckCheck,
} from 'lucide-react'
import { clsx } from 'clsx'
import {
getNotifications, 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 formatTime(ts: string): string {
const d = new Date(ts)
const diff = Date.now() - d.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`
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const PAGE_SIZE = 30
export default function NotificationsPage() {
const [unreadOnly, setUnreadOnly] = useState(false)
const [offset, setOffset] = useState(0)
const navigate = useNavigate()
const qc = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'page', offset, unreadOnly],
queryFn: () => getNotifications({ limit: PAGE_SIZE, offset, unread_only: unreadOnly }),
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'] }),
})
function handleClick(n: Notification) {
if (!n.read_at) markOneMutation.mutate(n.id)
if (n.entity_type === 'order' && n.entity_id) {
navigate(`/orders/${n.entity_id}`)
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
const unreadCount = data?.unread_count ?? 0
const hasMore = offset + PAGE_SIZE < total
return (
<div className="p-6 max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold text-content">Notifications</h1>
<p className="text-sm text-content-muted mt-0.5">{unreadCount} unread</p>
</div>
<div className="flex items-center gap-3">
<div className="flex bg-surface-muted rounded-md p-0.5">
<button
onClick={() => { setUnreadOnly(false); setOffset(0) }}
className={clsx(
'px-3 py-1.5 text-xs font-medium rounded',
!unreadOnly ? 'bg-surface shadow text-content' : 'text-content-muted',
)}
>
All
</button>
<button
onClick={() => { setUnreadOnly(true); setOffset(0) }}
className={clsx(
'px-3 py-1.5 text-xs font-medium rounded',
unreadOnly ? 'bg-surface shadow text-content' : 'text-content-muted',
)}
>
Unread
</button>
</div>
{unreadCount > 0 && (
<button
onClick={() => markAllMutation.mutate()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-accent border border-accent rounded-md hover:bg-accent-light transition-colors"
>
<CheckCheck size={14} />
Mark all as read
</button>
)}
</div>
</div>
<div className="bg-surface rounded-lg border border-border-default divide-y divide-border-light">
{isLoading && (
<div className="py-12 text-center text-sm text-content-muted">Loading...</div>
)}
{!isLoading && !items.length && (
<div className="py-12 text-center text-sm text-content-muted">
{unreadOnly ? 'No unread notifications' : 'No notifications yet'}
</div>
)}
{items.map((n) => {
const cfg = ACTION_CONFIG[n.action] ?? {
icon: Bell,
label: () => n.action,
color: 'text-content-muted',
}
const Icon = cfg.icon
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className={clsx(
'w-full flex items-start gap-3 px-5 py-4 text-left hover:bg-surface-hover transition-colors',
!n.read_at && 'bg-status-info-bg',
)}
>
<div className={clsx('mt-0.5 p-1.5 rounded-full bg-surface-muted', cfg.color)}>
<Icon size={16} />
</div>
<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>
<p className="text-xs text-content-muted mt-1">{formatTime(n.timestamp)}</p>
{n.action === 'excel.import_warnings' && n.details?.warnings && (
<ul className="mt-1.5 text-xs text-content-secondary list-disc list-inside space-y-0.5">
{(n.details.warnings as string[]).slice(0, 3).map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
)}
{n.details?.error && (
<p className="mt-1.5 text-xs text-red-600 font-mono bg-red-50 rounded px-2 py-1 whitespace-pre-wrap break-all">
{String(n.details.error)}
</p>
)}
</div>
{!n.read_at && (
<span className="mt-2 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
)}
</button>
)
})}
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center justify-between mt-4">
<button
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
disabled={offset === 0}
className="px-3 py-1.5 text-sm border rounded-md disabled:opacity-40"
>
Previous
</button>
<span className="text-sm text-content-muted">
{offset + 1}&ndash;{Math.min(offset + PAGE_SIZE, total)} of {total}
</span>
<button
onClick={() => setOffset(offset + PAGE_SIZE)}
disabled={!hasMore}
className="px-3 py-1.5 text-sm border rounded-md disabled:opacity-40"
>
Next
</button>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+797
View File
@@ -0,0 +1,797 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom'
import { useState, useMemo, useEffect } from 'react'
import {
Plus, Package, Trash2, X, Search, SlidersHorizontal,
LayoutGrid, LayoutList, Calendar, FileSpreadsheet,
Clock, CheckCircle2, XCircle, Loader2, Send, FilePen,
ChevronRight, Filter,
} from 'lucide-react'
import { toast } from 'sonner'
import { listOrders, searchOrders, deleteOrder } from '../api/orders'
import { fetchThumbnailBlob } from '../api/cad'
import type { Order, OrderDetail, OrderItem } from '../api/orders'
// ── Constants ────────────────────────────────────────────────────────────────
const STATUSES = ['draft', 'submitted', 'processing', 'completed', 'rejected'] as const
type Status = typeof STATUSES[number]
const STATUS_META: Record<Status, {
label: string
icon: React.ElementType
header: string
card: string
badge: string
chip: string // active chip style
chipInactive: string
}> = {
draft: { label: 'Draft', icon: FilePen, header: 'bg-gray-500', card: 'border-l-gray-400', badge: 'badge-gray', chip: 'bg-gray-500 text-white border-gray-500', chipInactive: 'border-border-default text-content-secondary hover:border-gray-400 hover:bg-surface-hover' },
submitted: { label: 'Submitted', icon: Send, header: 'bg-blue-500', card: 'border-l-blue-400', badge: 'badge-blue', chip: 'bg-blue-500 text-white border-blue-500', chipInactive: 'border-border-default text-content-secondary hover:border-blue-300 hover:bg-status-info-bg' },
processing: { label: 'Processing', icon: Loader2, header: 'bg-amber-500', card: 'border-l-amber-400', badge: 'badge-yellow', chip: 'bg-amber-500 text-white border-amber-500', chipInactive: 'border-border-default text-content-secondary hover:border-amber-300 hover:bg-status-warning-bg' },
completed: { label: 'Completed', icon: CheckCircle2, header: 'bg-green-600', card: 'border-l-green-500', badge: 'badge-green', chip: 'bg-green-600 text-white border-green-600', chipInactive: 'border-border-default text-content-secondary hover:border-green-300 hover:bg-status-success-bg' },
rejected: { label: 'Rejected', icon: XCircle, header: 'bg-red-500', card: 'border-l-red-400', badge: 'badge-red', chip: 'bg-red-500 text-white border-red-500', chipInactive: 'border-border-default text-content-secondary hover:border-red-300 hover:bg-status-error-bg' },
}
const isDeletable = (s: string) =>
s === 'draft' || s === 'submitted' || s === 'rejected'
// ── Page ─────────────────────────────────────────────────────────────────────
export default function OrdersPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [view, setView] = useState<'kanban' | 'list'>('kanban')
const [searchInput, setSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [selectedStatuses, setSelectedStatuses] = useState<Set<Status>>(new Set())
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [showFilters, setShowFilters] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
// Debounce the search input (400 ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
}, [searchInput])
const isSearchMode = debouncedSearch.length > 0
// Normal orders list
const { data: orders = [], isLoading: ordersLoading } = useQuery({
queryKey: ['orders'],
queryFn: () => listOrders(),
refetchInterval: 15000,
})
// Search results (backend full-text search)
const { data: searchResults = [], isLoading: searchLoading } = useQuery({
queryKey: [
'orders', 'search', debouncedSearch,
[...selectedStatuses].sort().join(','), dateFrom, dateTo,
],
queryFn: () => searchOrders({
q: debouncedSearch,
statuses: selectedStatuses.size > 0 ? [...selectedStatuses] : undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}),
enabled: isSearchMode,
staleTime: 10_000,
})
const isLoading = isSearchMode ? searchLoading : ordersLoading
// Status chip toggle
const toggleStatus = (s: Status) =>
setSelectedStatuses((prev) => {
const n = new Set(prev)
n.has(s) ? n.delete(s) : n.add(s)
return n
})
// Client-side filtering for non-search mode
const filtered = useMemo(() => {
let result = orders
if (dateFrom) result = result.filter((o) => o.created_at >= dateFrom)
if (dateTo) result = result.filter((o) => o.created_at.slice(0, 10) <= dateTo)
return result
}, [orders, dateFrom, dateTo])
// Kanban grouping
const byStatus = useMemo(() => {
const map: Record<Status, Order[]> = {
draft: [], submitted: [], processing: [], completed: [], rejected: [],
}
for (const o of filtered) {
if (o.status in map) map[o.status as Status].push(o)
}
return map
}, [filtered])
// Which columns to show in kanban (all if none selected, or only selected)
const visibleStatuses: Status[] =
selectedStatuses.size > 0
? STATUSES.filter((s) => selectedStatuses.has(s))
: [...STATUSES]
const hasDateFilter = !!(dateFrom || dateTo)
const clearFilters = () => {
setSelectedStatuses(new Set())
setDateFrom('')
setDateTo('')
setSearchInput('')
}
// ── Bulk-delete helpers ──────────────────────────────────────────────────
const listFiltered = filtered.filter(
(o) => selectedStatuses.size === 0 || selectedStatuses.has(o.status as Status)
)
const deletableFiltered = listFiltered.filter((o) => isDeletable(o.status))
const allSelected =
deletableFiltered.length > 0 &&
deletableFiltered.every((o) => selected.has(o.id))
const toggleOne = (id: string) =>
setSelected((prev) => {
const n = new Set(prev)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
const toggleAll = () =>
setSelected(
allSelected ? new Set() : new Set(deletableFiltered.map((o) => o.id))
)
const deleteMut = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => deleteOrder(id)))
},
onSuccess: (_, ids) => {
toast.success(`${ids.length} order${ids.length > 1 ? 's' : ''} deleted`)
setSelected(new Set())
qc.invalidateQueries({ queryKey: ['orders'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
if (!confirm(`Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
deleteMut.mutate(ids)
}
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div className="px-6 pt-5 pb-4 bg-surface border-b border-border-default shrink-0">
{/* Row 1: title + view toggle + new order */}
<div className="flex items-center gap-3 mb-4">
<h1 className="text-2xl font-bold text-content">Orders</h1>
<div className="ml-auto flex items-center gap-2">
<div className="flex border border-border-default rounded-md overflow-hidden">
<button
onClick={() => setView('kanban')}
title="Kanban view"
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
view === 'kanban' ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'
}`}
>
<LayoutGrid size={15} />
</button>
<button
onClick={() => setView('list')}
title="List view"
className={`px-2.5 py-1.5 text-sm flex items-center border-l border-border-default transition-colors ${
view === 'list' ? 'bg-surface-muted text-content' : 'text-content-muted hover:text-content-secondary'
}`}
>
<LayoutList size={15} />
</button>
</div>
<Link to="/orders/new" className="btn-primary">
<Plus size={16} /> New Order
</Link>
</div>
</div>
{/* Row 2: Search bar */}
<div className="relative mb-3">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
<input
type="text"
placeholder="Search orders, products, bearings, PIM IDs, CAD model names…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full pl-9 pr-9 py-2.5 text-sm border border-border-default rounded-lg
bg-surface-alt focus:bg-surface focus:outline-none focus:ring-2
focus:ring-accent focus:border-transparent transition-colors"
/>
{searchInput && (
<button
onClick={() => setSearchInput('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted hover:text-content transition-colors"
title="Clear search"
>
<X size={14} />
</button>
)}
</div>
{/* Row 3: Status chips + date filter toggle */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-content-muted mr-1 shrink-0">
{isSearchMode ? 'Filter results:' : 'Show:'}
</span>
{STATUSES.map((s) => {
const meta = STATUS_META[s]
const active = selectedStatuses.has(s)
const count = byStatus[s]?.length ?? 0
return (
<button
key={s}
onClick={() => toggleStatus(s)}
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
active ? meta.chip : meta.chipInactive
}`}
>
<meta.icon size={11} className={active ? '' : 'opacity-60'} />
{meta.label}
{!isSearchMode && count > 0 && (
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none ${
active ? 'bg-white/25 text-white' : 'bg-surface-muted text-content-secondary'
}`}>
{count}
</span>
)}
</button>
)
})}
<div className="ml-auto flex items-center gap-2 shrink-0">
<button
onClick={() => setShowFilters((v) => !v)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border transition-colors ${
showFilters || hasDateFilter
? 'border-accent bg-accent-light text-accent'
: 'border-border-default text-content-secondary hover:border-border-default'
}`}
>
<Filter size={12} />
Date
{hasDateFilter && <span className="w-1.5 h-1.5 bg-accent rounded-full ml-0.5" />}
</button>
{(selectedStatuses.size > 0 || hasDateFilter || searchInput) && (
<button
onClick={clearFilters}
className="text-xs text-content-muted hover:text-content flex items-center gap-1 transition-colors"
>
<X size={11} /> Clear all
</button>
)}
</div>
</div>
{/* Date filter panel */}
{showFilters && (
<div className="mt-3 flex items-center gap-4 flex-wrap px-3 py-2.5 bg-surface-alt rounded-lg border border-border-default">
<Calendar size={14} className="text-content-muted shrink-0" />
<div className="flex items-center gap-2 text-sm">
<label className="text-content-secondary whitespace-nowrap">From</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex items-center gap-2 text-sm">
<label className="text-content-secondary whitespace-nowrap">To</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{hasDateFilter && (
<button
onClick={() => { setDateFrom(''); setDateTo('') }}
className="text-xs text-content-muted hover:text-content-secondary flex items-center gap-1"
>
<X size={11} /> Clear dates
</button>
)}
</div>
)}
</div>
{/* ── Content ──────────────────────────────────────────────────────── */}
{isLoading ? (
<div className="flex-1 flex items-center justify-center text-content-muted">
<Loader2 size={24} className="animate-spin mr-2" />
{isSearchMode ? 'Searching…' : 'Loading orders…'}
</div>
) : isSearchMode ? (
<SearchResultsView
results={searchResults}
query={debouncedSearch}
onNavigate={(id) => navigate(`/orders/${id}`)}
/>
) : orders.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center p-12">
<Package size={48} className="text-content-muted mb-4" />
<p className="text-content-secondary font-medium mb-1">No orders yet</p>
<p className="text-content-muted text-sm mb-4">Upload an Excel file to create your first order.</p>
<Link to="/upload" className="btn-primary">
<FileSpreadsheet size={16} /> Upload Excel
</Link>
</div>
) : view === 'kanban' ? (
<KanbanBoard
byStatus={byStatus}
visibleStatuses={visibleStatuses}
onNavigate={(id) => navigate(`/orders/${id}`)}
/>
) : (
<ListView
orders={listFiltered}
selected={selected}
allSelected={allSelected}
onToggleOne={toggleOne}
onToggleAll={toggleAll}
onNavigate={(id) => navigate(`/orders/${id}`)}
/>
)}
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] z-50
flex items-center gap-3 px-5 py-3
bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10">
<span className="text-sm font-medium">
{selected.size} order{selected.size > 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-white/20" />
<button
onClick={handleDeleteSelected}
disabled={deleteMut.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500
hover:bg-red-600 text-sm font-medium transition-colors disabled:opacity-50"
>
<Trash2 size={14} />
{deleteMut.isPending ? 'Deleting…' : 'Delete'}
</button>
<button
onClick={() => setSelected(new Set())}
className="p-1.5 rounded-lg text-gray-400 hover:text-white transition-colors"
title="Clear selection"
>
<X size={14} />
</button>
</div>
)}
</div>
)
}
// ── Search results view ───────────────────────────────────────────────────────
function SearchResultsView({
results,
query,
onNavigate,
}: {
results: OrderDetail[]
query: string
onNavigate: (id: string) => void
}) {
const totalItems = results.reduce((acc, o) => acc + o.items.length, 0)
if (results.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-12">
<Search size={40} className="text-content-muted mb-4" />
<p className="text-content-secondary font-medium mb-1">No results for &ldquo;{query}&rdquo;</p>
<p className="text-content-muted text-sm">
Try searching by product name, bearing type, PIM ID, Baureihe, or order number.
</p>
</div>
)
}
return (
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4">
<p className="text-sm text-content-secondary mb-4">
<span className="font-semibold text-content-secondary">{results.length}</span> order{results.length !== 1 ? 's' : ''},&nbsp;
<span className="font-semibold text-content-secondary">{totalItems}</span> matching item{totalItems !== 1 ? 's' : ''}
</p>
<div className="space-y-4">
{results.map((order) => (
<SearchOrderCard
key={order.id}
order={order}
query={query}
onNavigate={() => onNavigate(order.id)}
/>
))}
</div>
</div>
</div>
)
}
function SearchOrderCard({
order,
query,
onNavigate,
}: {
order: OrderDetail
query: string
onNavigate: () => void
}) {
const meta = STATUS_META[order.status as Status] ?? STATUS_META.draft
const date = new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: 'short', year: 'numeric',
})
return (
<div className="card overflow-hidden">
{/* Order header */}
<div className={`flex items-center gap-3 px-4 py-3 border-l-4 ${meta.card} bg-surface-alt border-b border-border-light`}>
<span className="font-bold font-mono text-content">{order.order_number}</span>
<span className={`badge ${meta.badge}`}>{order.status}</span>
<span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} /> {date}
</span>
<span className="text-xs text-content-secondary">
{order.items.length} matching item{order.items.length !== 1 ? 's' : ''}
</span>
<button
onClick={onNavigate}
className="ml-auto text-xs text-accent font-medium hover:underline flex items-center gap-1"
>
Open order <ChevronRight size={12} />
</button>
</div>
{/* Matching items */}
{order.items.length > 0 && (
<>
{/* Column headers */}
<div className="grid grid-cols-[3rem_1fr_1fr_1fr_1fr_1fr] gap-x-4 px-4 py-1.5
text-[10px] font-semibold text-content-muted uppercase tracking-wider
bg-surface-alt border-b border-border-light">
<div />
<div>PIM ID / Level</div>
<div>Baureihe</div>
<div>Product</div>
<div>CAD Model</div>
<div>Bearing type</div>
</div>
<div className="divide-y divide-border-light">
{order.items.map((item) => (
<SearchItemRow
key={item.id}
item={item}
query={query}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div>
)
}
function Highlight({ text, query }: { text: string | null; query: string }) {
if (!text) return null
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return <span>{text}</span>
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-100 text-yellow-900 rounded px-0.5 not-italic">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
)
}
function SearchItemRow({
item,
query,
onNavigate,
}: {
item: OrderItem
query: string
onNavigate: () => void
}) {
const [thumbUrl, setThumbUrl] = useState<string | null>(null)
useEffect(() => {
if (!item.cad_file_id) return
let revoked = false
fetchThumbnailBlob(item.cad_file_id).then((url) => {
if (!revoked) setThumbUrl(url)
}).catch(() => {})
return () => {
revoked = true
if (thumbUrl) URL.revokeObjectURL(thumbUrl)
}
}, [item.cad_file_id])
return (
<div
className="grid grid-cols-[3rem_1fr_1fr_1fr_1fr_1fr] gap-x-4 px-4 py-2.5 items-center
hover:bg-surface-hover cursor-pointer transition-colors"
onClick={onNavigate}
>
{/* Thumbnail */}
<div className="w-10 h-10 rounded bg-surface-muted overflow-hidden flex items-center justify-center shrink-0">
{thumbUrl ? (
<img src={thumbUrl} alt="" className="w-full h-full object-cover" />
) : (
<Package size={16} className="text-content-muted" />
)}
</div>
<div className="text-xs min-w-0">
{item.pim_id && (
<p className="font-semibold text-content truncate">
<Highlight text={item.pim_id} query={query} />
</p>
)}
{item.ebene2 && (
<p className="text-content-muted truncate">
<Highlight text={item.ebene2} query={query} />
</p>
)}
</div>
<div className="text-xs text-content-secondary truncate">
<Highlight text={item.baureihe} query={query} />
</div>
<div className="text-xs text-content-secondary truncate">
<Highlight text={item.gewaehltes_produkt} query={query} />
</div>
<div className="text-xs font-mono text-content-secondary truncate">
<Highlight text={item.name_cad_modell} query={query} />
</div>
<div className="text-xs text-content-muted truncate">
<Highlight text={item.lagertyp} query={query} />
</div>
</div>
)
}
// ── Kanban board ─────────────────────────────────────────────────────────────
function KanbanBoard({
byStatus,
visibleStatuses,
onNavigate,
}: {
byStatus: Record<Status, Order[]>
visibleStatuses: Status[]
onNavigate: (id: string) => void
}) {
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full min-w-max">
{visibleStatuses.map((status) => {
const meta = STATUS_META[status]
const cards = byStatus[status]
return (
<div key={status} className="flex flex-col w-72 min-w-[272px]">
<div className={`${meta.header} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<meta.icon size={16} className="text-white/80 shrink-0" />
<span className="text-white font-semibold text-sm">{meta.label}</span>
<span className="ml-auto bg-white/20 text-white text-xs font-bold px-2 py-0.5 rounded-full">
{cards.length}
</span>
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 overflow-y-auto space-y-2 min-h-[120px]">
{cards.length === 0 ? (
<div className="h-20 flex items-center justify-center text-content-muted text-xs">
No orders
</div>
) : (
cards.map((order) => (
<KanbanCard
key={order.id}
order={order}
meta={meta}
onNavigate={() => onNavigate(order.id)}
/>
))
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function RenderProgressBar({ progress }: {
progress: { total: number; completed: number; processing: number; failed: number; pending: number; cancelled?: number }
}) {
const { total, completed, processing, failed, pending, cancelled = 0 } = progress
if (total === 0) return null
const pct = (n: number) => `${(n / total) * 100}%`
const allDone = completed === total
return (
<div className="mt-2">
<div className="flex h-1.5 rounded-full overflow-hidden bg-surface-muted">
{completed > 0 && <div className="bg-green-500 transition-all duration-300" style={{ width: pct(completed) }} />}
{processing > 0 && <div className="bg-blue-500 transition-all duration-300" style={{ width: pct(processing) }} />}
{failed > 0 && <div className="bg-red-400 transition-all duration-300" style={{ width: pct(failed) }} />}
{cancelled > 0 && <div className="bg-orange-300 transition-all duration-300" style={{ width: pct(cancelled) }} />}
{pending > 0 && <div className="bg-gray-200 transition-all duration-300" style={{ width: pct(pending) }} />}
</div>
<p className={`text-[10px] mt-0.5 font-medium ${allDone ? 'text-green-600' : 'text-content-muted'}`}>
{allDone ? `${total}/${total}` : `Rendered ${completed}/${total}`}
{failed > 0 && <span className="text-red-400 ml-1">({failed} failed)</span>}
{cancelled > 0 && <span className="text-orange-400 ml-1">({cancelled} cancelled)</span>}
</p>
</div>
)
}
function KanbanCard({
order,
meta,
onNavigate,
}: {
order: Order
meta: typeof STATUS_META[Status]
onNavigate: () => void
}) {
const date = new Date(order.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: 'short', year: 'numeric',
})
const rp = order.render_progress
return (
<button
onClick={onNavigate}
className={`w-full text-left bg-surface rounded-lg shadow-sm border border-border-default
border-l-4 ${meta.card} hover:shadow-md hover:-translate-y-0.5
transition-all duration-150 p-3 group`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="text-sm font-bold text-content font-mono leading-tight">
{order.order_number}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-content-secondary">
<span className="flex items-center gap-1">
<Package size={11} />
{order.item_count} item{order.item_count !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<Clock size={11} />
{date}
</span>
</div>
{rp && rp.total > 0 && (
<RenderProgressBar progress={rp} />
)}
{order.notes && !rp && (
<p className="mt-2 text-xs text-content-muted truncate">{order.notes}</p>
)}
<div className="mt-2 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-accent font-medium">Open </span>
</div>
</button>
)
}
// ── List view ─────────────────────────────────────────────────────────────────
function ListView({
orders,
selected,
allSelected,
onToggleOne,
onToggleAll,
onNavigate,
}: {
orders: Order[]
selected: Set<string>
allSelected: boolean
onToggleOne: (id: string) => void
onToggleAll: () => void
onNavigate: (id: string) => void
}) {
const deletable = orders.filter((o) => isDeletable(o.status))
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-6 my-4 card overflow-hidden">
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5 text-xs font-semibold text-content-secondary uppercase tracking-wide">
<div>
{deletable.length > 0 && (
<input
type="checkbox"
checked={allSelected}
onChange={onToggleAll}
className="w-3.5 h-3.5 rounded accent-red-500 cursor-pointer"
/>
)}
</div>
<div>Order</div>
<div>Items</div>
<div>Status</div>
<div>Created</div>
</div>
{orders.length === 0 ? (
<div className="p-10 text-center text-content-muted text-sm">
No orders match the current filters.
</div>
) : (
<div className="divide-y divide-border-light">
{orders.map((order) => {
const canSelect = isDeletable(order.status)
const isSelected = selected.has(order.id)
const meta = STATUS_META[order.status as Status]
return (
<div
key={order.id}
className={`grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3
hover:bg-surface-hover transition-colors cursor-pointer
${isSelected ? 'bg-red-50 hover:bg-red-50' : ''}`}
onClick={() => onNavigate(order.id)}
>
<div onClick={(e) => e.stopPropagation()}>
{canSelect ? (
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleOne(order.id)}
className="w-3.5 h-3.5 rounded accent-red-500 cursor-pointer"
/>
) : <span className="w-3.5 h-3.5 inline-block" />}
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-content font-mono">{order.order_number}</p>
{order.notes && (
<p className="text-xs text-content-muted truncate mt-0.5">{order.notes}</p>
)}
</div>
<div className="text-sm text-content-secondary">
{order.item_count} item{order.item_count !== 1 ? 's' : ''}
</div>
<div>
<span className={`badge ${meta?.badge ?? 'badge-gray'}`}>{order.status}</span>
</div>
<div className="text-xs text-content-muted">
{new Date(order.created_at).toLocaleDateString('de-DE')}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
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 PreferencesPage() {
const { mode, accent, setMode, setAccent } = useThemeStore()
return (
<div className="p-8 max-w-2xl">
<h1 className="text-2xl font-bold text-content mb-1">Preferences</h1>
<p className="text-sm text-content-muted mb-8">Customize your Schaeffler Automat experience.</p>
{/* Appearance */}
<section className="card p-6 space-y-6">
<h2 className="text-base font-semibold text-content">Appearance</h2>
{/* Theme mode */}
<div className="flex items-start gap-6">
<div className="w-36 shrink-0">
<p className="text-sm font-medium text-content">Theme</p>
<p className="text-xs text-content-muted mt-0.5">Light, dark, or follow system</p>
</div>
<div className="flex gap-2">
{MODES.map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setMode(key)}
className={clsx(
'flex flex-col items-center gap-2 w-20 py-3 rounded-lg border text-xs font-medium transition-colors',
mode === key
? 'text-accent'
: 'border-border-default bg-surface text-content-secondary hover:bg-surface-hover',
)}
style={mode === key ? { borderColor: 'var(--color-accent)', backgroundColor: 'var(--color-accent-light)' } : undefined}
>
<Icon size={18} />
{label}
</button>
))}
</div>
</div>
<div className="border-t border-border-light" />
{/* Accent color */}
<div className="flex items-start gap-6">
<div className="w-36 shrink-0">
<p className="text-sm font-medium text-content">Accent color</p>
<p className="text-xs text-content-muted mt-0.5">Used for buttons, links, and highlights</p>
</div>
<div className="flex gap-3 pt-1">
{ACCENT_PRESETS.map(({ key, label, hex }) => (
<button
key={key}
onClick={() => setAccent(key)}
title={label}
className={clsx(
'w-8 h-8 rounded-full transition-all',
accent === key ? 'scale-125' : 'hover:scale-110',
)}
style={{
backgroundColor: hex,
outline: accent === key ? `2px solid ${hex}` : undefined,
outlineOffset: accent === key ? '3px' : undefined,
}}
/>
))}
</div>
</div>
</section>
</div>
)
}
File diff suppressed because it is too large Load Diff
+388
View File
@@ -0,0 +1,388 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
LayoutGrid, List, Trash2, X,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products'
import type { Product } from '../api/products'
const CATEGORY_LABELS: Record<string, string> = {
TRB: 'TRB',
Kugellager: 'Kugellager',
CRB: 'CRB',
Gleitlager: 'Gleitlager',
SRB_TORB: 'SRB/TORB',
Linear_schiene: 'Linear',
Anschlagplatten: 'Anschlag',
}
function CadStatusChip({ status }: { status: string | null }) {
if (!status) return (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">no STEP</span>
)
if (status === 'completed') return (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text flex items-center gap-1">
<CheckCircle2 size={11} /> ready
</span>
)
if (status === 'processing') return (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text flex items-center gap-1">
<Clock size={11} /> processing
</span>
)
if (status === 'failed') return (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-error-bg text-status-error-text flex items-center gap-1">
<AlertTriangle size={11} /> failed
</span>
)
return (
<span className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text">{status}</span>
)
}
function ProductCard({ product, onClick, selected, onSelect }: {
product: Product
onClick: () => void
selected: boolean
onSelect: (checked: boolean) => void
}) {
return (
<div
className={`card cursor-pointer hover:shadow-md transition-shadow overflow-hidden relative ${
selected ? 'ring-2 ring-accent' : ''
}`}
onClick={onClick}
>
{/* Checkbox overlay */}
<div
className="absolute top-2 left-2 z-10"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected}
onChange={(e) => onSelect(e.target.checked)}
className="w-4 h-4 cursor-pointer"
/>
</div>
{/* Thumbnail */}
<div className="h-40 bg-surface-muted flex items-center justify-center overflow-hidden">
{(product.render_image_url || product.thumbnail_url) ? (
<img
src={product.render_image_url || product.thumbnail_url!}
alt={product.name || product.pim_id}
className="h-full w-full object-contain"
/>
) : (
<Box size={48} className="text-content-muted" />
)}
</div>
<div className="p-3 space-y-1">
<span className="inline-block text-xs font-mono bg-surface-muted text-content-secondary px-2 py-0.5 rounded">
{product.pim_id}
</span>
<p className="font-semibold text-content text-sm leading-tight truncate" title={product.name || ''}>
{product.name || <span className="text-content-muted italic">no name</span>}
</p>
<div className="flex items-center gap-2 flex-wrap">
{product.category_key && (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
{CATEGORY_LABELS[product.category_key] || product.category_key}
</span>
)}
{product.baureihe && (
<span className="text-xs text-content-muted truncate">{product.baureihe}</span>
)}
</div>
<div className="pt-1 flex items-center gap-2">
<CadStatusChip status={product.processing_status} />
</div>
</div>
</div>
)
}
export default function ProductLibraryPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [hasCadFilter, setHasCadFilter] = useState<string>('')
const [materialsFilter, setMaterialsFilter] = useState('')
const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set())
const { data: products, isLoading } = useQuery({
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
queryFn: () => listProducts({
q: search || undefined,
category_key: categoryFilter || undefined,
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
materials_filter: materialsFilter || undefined,
limit: 200,
}),
})
// ── Selection helpers ──────────────────────────────────────────────────
const toggleOne = (id: string) => {
setSelected((prev) => {
const n = new Set(prev)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
}
const allSelected =
!!products?.length && products.every((p) => selected.has(p.id))
const toggleAll = () => {
if (!products) return
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
}
// ── Bulk delete ────────────────────────────────────────────────────────
const deleteMut = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => deleteProduct(id, true)))
},
onSuccess: (_, ids) => {
toast.success(`${ids.length} product${ids.length > 1 ? 's' : ''} deleted`)
setSelected(new Set())
qc.invalidateQueries({ queryKey: ['products'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const handleDeleteSelected = () => {
const ids = [...selected]
if (!ids.length) return
if (!confirm(`Delete ${ids.length} product${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
deleteMut.mutate(ids)
}
return (
<div className="p-8 pb-24">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Library size={22} className="text-accent" />
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : 'Loading\u2026'}
</p>
</div>
</div>
{/* View toggle */}
<div className="flex border border-border-default rounded-md overflow-hidden">
<button
onClick={() => setView('grid')}
title="Grid view"
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
view === 'grid'
? 'text-white'
: 'bg-surface text-content-muted hover:bg-surface-hover'
}`}
style={view === 'grid' ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
<LayoutGrid size={16} />
</button>
<button
onClick={() => setView('table')}
title="Table view"
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
view === 'table'
? 'text-white'
: 'bg-surface text-content-muted hover:bg-surface-hover'
}`}
style={view === 'table' ? { backgroundColor: 'var(--color-accent)' } : undefined}
>
<List size={16} />
</button>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3 mb-6 flex-wrap">
<div className="relative flex-1 min-w-48 max-w-sm">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
type="text"
placeholder="Search by PIM-ID or name\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
title="Filter by product category (TRB, Kugellager, CRB, etc.)"
>
<option value="">All categories</option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={hasCadFilter}
onChange={(e) => setHasCadFilter(e.target.value)}
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
title="Filter by STEP file status — only products with an uploaded STEP file can be rendered"
>
<option value="">All CAD status</option>
<option value="yes">Has STEP</option>
<option value="no">No STEP</option>
</select>
<select
value={materialsFilter}
onChange={(e) => setMaterialsFilter(e.target.value)}
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
title="Filter by material assignment status — complete = all CAD parts have a material assigned"
>
<option value="">All materials</option>
<option value="complete"> All materials assigned</option>
<option value="incomplete"> Incomplete materials</option>
</select>
</div>
{/* Content */}
{isLoading ? (
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
) : !products?.length ? (
<div className="text-center py-16 text-content-muted">
<Library size={48} className="mx-auto mb-3 opacity-30" />
<p>No products found</p>
<p className="text-sm mt-1">Upload an Excel file to populate the library</p>
</div>
) : view === 'grid' ? (
/* ── Grid view ─────────────────────────────────────────────────── */
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => navigate(`/products/${product.id}`)}
selected={selected.has(product.id)}
onSelect={() => toggleOne(product.id)}
/>
))}
</div>
) : (
/* ── Table view ────────────────────────────────────────────────── */
<div className="card overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-default text-left bg-surface-alt">
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="w-4 h-4 cursor-pointer"
title="Select / deselect all visible products"
/>
</th>
<th className="px-4 py-3 w-16"></th>
<th className="px-4 py-3 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-3 font-medium text-content-secondary">Name</th>
<th className="px-4 py-3 font-medium text-content-secondary">Category</th>
<th className="px-4 py-3 font-medium text-content-secondary">Baureihe</th>
<th className="px-4 py-3 font-medium text-content-secondary">CAD Status</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr
key={product.id}
className={`border-b border-border-light hover:bg-surface-hover cursor-pointer transition-colors ${
selected.has(product.id) ? 'bg-status-success-bg' : ''
}`}
onClick={() => navigate(`/products/${product.id}`)}
>
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selected.has(product.id)}
onChange={() => toggleOne(product.id)}
className="w-4 h-4 cursor-pointer"
/>
</td>
<td className="px-4 py-2.5">
<div className="w-10 h-10 bg-surface-muted rounded flex items-center justify-center overflow-hidden">
{(product.render_image_url || product.thumbnail_url) ? (
<img
src={product.render_image_url || product.thumbnail_url!}
alt=""
className="w-full h-full object-contain"
/>
) : (
<Box size={18} className="text-content-muted" />
)}
</div>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-content-secondary">
{product.pim_id}
</td>
<td className="px-4 py-2.5 font-medium text-content max-w-48 truncate" title={product.name || ''}>
{product.name || <span className="text-content-muted italic">no name</span>}
</td>
<td className="px-4 py-2.5">
{product.category_key && (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
{CATEGORY_LABELS[product.category_key] || product.category_key}
</span>
)}
</td>
<td className="px-4 py-2.5 text-content-secondary text-xs max-w-40 truncate" title={product.baureihe || ''}>
{product.baureihe || '\u2014'}
</td>
<td className="px-4 py-2.5">
<CadStatusChip status={product.processing_status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Floating action bar ───────────────────────────────────────── */}
{selected.size > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
<span className="text-sm font-medium">
{selected.size} selected
</span>
<button
onClick={handleDeleteSelected}
disabled={deleteMut.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors disabled:opacity-50"
>
<Trash2 size={14} />
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
</button>
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
title="Clear selection"
>
<X size={14} /> Clear
</button>
</div>
)}
</div>
)
}
+655
View File
@@ -0,0 +1,655 @@
import { useCallback, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
FileSpreadsheet, CheckCircle, X, Plus,
Info, PackagePlus, PackageCheck, Ban, FileBox, ArrowRight, Copy,
} from 'lucide-react'
import { toast } from 'sonner'
import { uploadExcel, finalizeExcelImport } from '../api/uploads'
import type { ExcelPreviewResult, OutputTypeSelection } from '../api/uploads'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
import api from '../api/client'
import StepDropzone from '../components/upload/StepDropzone'
function StatCard({ icon, value, label, description, color }: {
icon: React.ReactNode
value: number
label: string
description: string
color: string
}) {
if (value === 0) return null
return (
<div className={`flex gap-3 p-3 rounded-lg border ${color}`}>
<div className="shrink-0 mt-0.5">{icon}</div>
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-lg font-bold">{value}</span>
<span className="text-sm font-medium">{label}</span>
</div>
<p className="text-xs opacity-75 mt-0.5">{description}</p>
</div>
</div>
)
}
export default function UploadPage() {
const navigate = useNavigate()
const qc = useQueryClient()
// Step 1: Excel parsed (preview only — no products created yet)
const [previewResult, setPreviewResult] = useState<ExcelPreviewResult | null>(null)
// Step 2: per-row include toggles
const [includedRows, setIncludedRows] = useState<Record<number, boolean>>({})
// Step 3: per-row selected output_type_ids
const [rowOutputTypes, setRowOutputTypes] = useState<Record<number, Record<string, boolean>>>({})
const [step, setStep] = useState<1 | 2 | 3 | 4>(1)
const [notes, setNotes] = useState('')
const [createdOrder, setCreatedOrder] = useState<{ id: string; order_number: string } | null>(null)
const [createDraftForSkipped, setCreateDraftForSkipped] = useState(false)
const { data: outputTypes = [] } = useQuery<OutputType[]>({
queryKey: ['output-types'],
queryFn: () => listOutputTypes(false),
})
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: async () => {
const res = await api.get('/templates')
return res.data as Array<{ id: string; category_key: string; name: string }>
},
})
const uploadMut = useMutation({
mutationFn: uploadExcel,
onSuccess: (data) => {
setPreviewResult(data)
// Default: include all rows that have an identifier (pim_id or produkt_baureihe)
// Pre-uncheck duplicate rows so only the first occurrence is included
const inc: Record<number, boolean> = {}
const rot: Record<number, Record<string, boolean>> = {}
data.rows.forEach((row) => {
const hasId = !!(row.pim_id || row.produkt_baureihe)
inc[row.row_index] = hasId && !row.is_duplicate
rot[row.row_index] = {}
})
setIncludedRows(inc)
setRowOutputTypes(rot)
data.warnings.forEach((w) => toast.warning(w))
setStep(2)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Upload failed'),
})
const finalizeMut = useMutation({
mutationFn: async () => {
if (!previewResult) throw new Error('No preview result')
const templateId = templates?.find(
(t) => t.category_key === previewResult.category_key,
)?.id
const included_row_indices: number[] = []
const output_type_selections: OutputTypeSelection[] = []
previewResult.rows.forEach((row) => {
if (!includedRows[row.row_index]) return
if (!row.pim_id && !row.produkt_baureihe) return
included_row_indices.push(row.row_index)
const selectedTypes = rowOutputTypes[row.row_index] || {}
const typeIds = Object.entries(selectedTypes)
.filter(([, checked]) => checked)
.map(([id]) => id)
if (typeIds.length > 0) {
output_type_selections.push({
row_index: row.row_index,
output_type_ids: typeIds,
})
}
})
const primaryOrder = await finalizeExcelImport({
excel_path: previewResult.excel_path,
included_row_indices,
output_type_selections,
notes: notes || undefined,
template_id: templateId,
})
// Optionally create a tracking-only draft for the unchecked rows
let draftOrder = null
if (createDraftForSkipped && excludedWithId.length > 0) {
const skippedIndices = excludedWithId.map((r) => r.row_index)
draftOrder = await finalizeExcelImport({
excel_path: previewResult.excel_path,
included_row_indices: skippedIndices,
output_type_selections: [],
notes: 'Draft — awaiting STEP files',
template_id: templateId,
})
}
return { primaryOrder, draftOrder }
},
onSuccess: ({ primaryOrder, draftOrder }) => {
qc.invalidateQueries({ queryKey: ['orders'] })
setCreatedOrder({ id: primaryOrder.id, order_number: primaryOrder.order_number })
if (draftOrder) {
const n = draftOrder.items?.length ?? 0
toast.success(
`Draft ${draftOrder.order_number} created with ${n} product${n !== 1 ? 's' : ''} awaiting STEP files`,
)
}
setStep(4)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create order'),
})
const onDrop = useCallback(
(files: File[]) => { if (files[0]) uploadMut.mutate(files[0]) },
[uploadMut],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
},
multiple: false,
})
// Rows that are included and have an identifier
const includedWithId = previewResult?.rows.filter(
(r) => includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
) ?? []
// Rows that are excluded (have an identifier but not included in the primary order)
const excludedWithId = previewResult?.rows.filter(
(r) => !includedRows[r.row_index] && (r.pim_id || r.produkt_baureihe),
) ?? []
// Count how many rows actually have an output type selected
const rowsWithOutputType = includedWithId.filter((row) => {
const sel = rowOutputTypes[row.row_index] || {}
return Object.values(sel).some(Boolean)
}).length
function toggleAllOutputType(typeId: string, checked: boolean) {
setRowOutputTypes((prev) => {
const updated = { ...prev }
includedWithId.forEach((row) => {
updated[row.row_index] = { ...(updated[row.row_index] || {}), [typeId]: checked }
})
return updated
})
}
function deselectWithoutStep() {
if (!previewResult) return
setIncludedRows((prev) => {
const updated = { ...prev }
previewResult.rows.forEach((row) => {
if (!row.has_step && (row.pim_id || row.produkt_baureihe)) {
updated[row.row_index] = false
}
})
return updated
})
}
return (
<div className="p-8 max-w-full mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-content">Upload Order List</h1>
<p className="text-sm text-content-muted mt-1">
Import products from Excel and create a new output job order.
</p>
</div>
{/* ── Step 1: Excel drop zone ─────────────────────────────────────── */}
{step === 1 && (
<div
{...getRootProps()}
className={`card p-16 text-center cursor-pointer border-2 border-dashed transition-colors ${
isDragActive
? 'border-accent bg-accent-light'
: 'border-border-default hover:border-accent'
}`}
>
<input {...getInputProps()} />
{uploadMut.isPending ? (
<div className="text-content-secondary">
<div className="animate-spin w-8 h-8 border-2 border-accent border-t-transparent rounded-full mx-auto mb-3" />
Parsing Excel file\u2026
</div>
) : (
<>
<FileSpreadsheet size={44} className="text-content-muted mx-auto mb-3" />
<p className="text-content-secondary font-medium text-lg">
{isDragActive ? 'Drop the Excel file here' : 'Drag & drop an Excel order list'}
</p>
<p className="text-content-muted text-sm mt-1">or click to browse \u2014 .xlsx / .xls</p>
</>
)}
</div>
)}
{/* ── Step 2: Product Matching Report ─────────────────────────────── */}
{step === 2 && previewResult && (
<div className="space-y-4">
{/* File info header */}
<div className="card p-4">
<div className="flex items-center gap-3">
<CheckCircle size={20} className="text-green-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold text-content truncate">{previewResult.filename}</p>
<p className="text-sm text-content-secondary">
{previewResult.row_count} rows parsed
{previewResult.category_key && <> &middot; Primary category: <strong>{previewResult.category_key}</strong></>}
</p>
</div>
<button
className="btn-secondary text-xs"
onClick={() => { setPreviewResult(null); setStep(1) }}
title="Discard this preview and upload a different Excel file"
>
<X size={13} /> Replace
</button>
</div>
</div>
{/* Import summary — explained stats */}
<div className="card p-4">
<div className="flex items-center gap-2 mb-3">
<Info size={15} className="text-content-muted" />
<h3 className="text-sm font-semibold text-content-secondary">Preview Summary</h3>
</div>
<p className="text-xs text-content-secondary mb-3 leading-relaxed">
No products have been created yet. This is a <strong>preview</strong> of what will happen when you finalize the order.
Each unique <strong>Produkt (Baureihe)</strong> in the Excel becomes one product in the library.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<StatCard
icon={<PackagePlus size={18} className="text-blue-600" />}
value={previewResult.new_product_count}
label="new products"
description="Will be created when you finalize the order."
color="bg-status-info-bg border-border-default text-status-info-text"
/>
<StatCard
icon={<PackageCheck size={18} className="text-green-600" />}
value={previewResult.existing_product_count}
label="existing products"
description="Already in the library from a previous import."
color="bg-status-success-bg border-border-default text-status-success-text"
/>
<StatCard
icon={<Ban size={18} className="text-amber-600" />}
value={previewResult.no_pim_id_count}
label="rows skipped"
description="No PIM-ID or Baureihe found. Cannot be matched to a product."
color="bg-status-warning-bg border-border-default text-status-warning-text"
/>
<StatCard
icon={<Copy size={18} className="text-orange-600" />}
value={previewResult.duplicate_count}
label="duplicate Baureihe"
description="Same Produkt-Baureihe appears multiple times. Pre-unchecked — only first occurrence imported."
color="bg-status-warning-bg border-border-default text-status-warning-text"
/>
</div>
</div>
{/* Duplicate warning banner */}
{previewResult.duplicate_count > 0 && (
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3">
<div className="flex items-start gap-2">
<span className="text-status-warning-text font-bold text-sm shrink-0"></span>
<div>
<p className="text-sm font-semibold text-status-warning-text">
{previewResult.duplicate_count} duplicate Produkt-Baureihe row{previewResult.duplicate_count !== 1 ? 's' : ''} detected
</p>
<p className="text-xs text-status-warning-text mt-0.5">
Each product is unique only the <strong>first occurrence</strong> of a Baureihe will be imported.
Duplicate rows are pre-unchecked (shown in amber). You can manually re-check them to overwrite the first.
</p>
</div>
</div>
</div>
)}
{/* Row table */}
<div className="card overflow-auto">
<div className="px-4 py-3 border-b border-border-light bg-surface-alt">
<h3 className="text-sm font-semibold text-content-secondary">Row Details</h3>
<p className="text-xs text-content-secondary mt-0.5">
Uncheck rows you don't want to include in the order.
</p>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary w-10">
<input
type="checkbox"
checked={previewResult.rows.every((r) => (!r.pim_id && !r.produkt_baureihe) || includedRows[r.row_index])}
onChange={(e) => {
const updated: Record<number, boolean> = {}
previewResult.rows.forEach((r) => {
updated[r.row_index] = (r.pim_id || r.produkt_baureihe) ? e.target.checked : false
})
setIncludedRows(updated)
}}
title="Select / deselect all rows"
/>
</th>
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Baureihe</th>
<th className="px-4 py-2 font-medium text-content-secondary"
title="Gew\u00e4hltes Produkt \u2014 the specific material/coating variant from the Excel"
>Gew. Produkt</th>
<th className="px-4 py-2 font-medium text-content-secondary">Category</th>
<th className="px-4 py-2 font-medium text-content-secondary">Status</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="Whether a STEP/CAD file is already linked to this product">STEP</th>
</tr>
</thead>
<tbody>
{previewResult.rows.map((row) => {
const hasId = !!(row.pim_id || row.produkt_baureihe)
return (
<tr key={row.row_index} className={`border-b ${row.is_duplicate ? 'bg-status-warning-bg border-border-default hover:bg-status-warning-bg' : 'border-border-light hover:bg-surface-hover'}`}>
<td className="px-4 py-2">
<input
type="checkbox"
disabled={!hasId}
checked={!!includedRows[row.row_index]}
onChange={(e) =>
setIncludedRows({ ...includedRows, [row.row_index]: e.target.checked })
}
/>
</td>
<td className="px-4 py-2 font-mono text-xs">
{row.pim_id || <span className="text-content-muted">&mdash;</span>}
</td>
<td className="px-4 py-2 text-xs">
{row.produkt_baureihe || <span className="text-content-muted">&mdash;</span>}
</td>
<td className="px-4 py-2 text-sm">
{row.gewaehltes_produkt || '\u2014'}
</td>
<td className="px-4 py-2">
{row.category_key ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
{row.category_key}
</span>
) : (
<span className="text-content-muted">&mdash;</span>
)}
</td>
<td className="px-4 py-2">
{!hasId ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted"
title="No PIM-ID or Baureihe — this row will be skipped"
>
skipped
</span>
) : row.is_duplicate ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium"
title={`Duplicate Produkt-Baureihe — first occurrence is row ${row.duplicate_of_row}. Uncheck to exclude.`}
>
Duplicate of row {row.duplicate_of_row}
</span>
) : row.product_exists ? (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text font-medium"
title="This product already exists in the library"
>
existing
</span>
) : (
<span
className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium"
title="This product will be created when you finalize"
>
new (will be created)
</span>
)}
</td>
<td className="px-4 py-2 text-center">
{!hasId ? null : row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" />
) : (
<X size={14} className="text-red-400 mx-auto" title="No STEP file" />
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
className="btn-primary"
disabled={includedWithId.length === 0}
onClick={() => setStep(3)}
>
Next: Select Output Types &rarr;
</button>
</div>
</div>
)}
{/* ── Step 4: Upload STEP Files ────────────────────────────────────── */}
{step === 4 && createdOrder && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<FileBox size={18} className="text-content-secondary" />
<h2 className="font-semibold text-content">
Upload STEP Files — {createdOrder.order_number}
</h2>
</div>
</div>
<p className="text-sm text-content-secondary">
Drop one or more <strong>.stp / .step</strong> files below.
Each file is matched to an order item by filename stem (case-insensitive).
You can also skip this and upload STEP files later from the order detail page.
</p>
</div>
<div className="card p-6">
<StepDropzone
orderId={createdOrder.id}
onMatchComplete={() => qc.invalidateQueries({ queryKey: ['order', createdOrder.id] })}
/>
</div>
<div className="flex justify-end gap-3">
<button
className="btn-secondary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
Skip &mdash; Go to Order
</button>
<button
className="btn-primary"
onClick={() => navigate(`/orders/${createdOrder.id}`)}
>
<ArrowRight size={16} />
Done &mdash; Go to Order
</button>
</div>
</div>
)}
{/* ── Step 3: Output Type Selection ───────────────────────────────── */}
{step === 3 && previewResult && (
<div className="space-y-4">
<div className="card p-4">
<div className="flex items-center justify-between mb-1">
<h2 className="font-semibold text-content">Select Output Types</h2>
<button className="text-sm text-content-muted hover:text-content-secondary" onClick={() => setStep(2)}>
&larr; Back
</button>
</div>
<p className="text-sm text-content-secondary">
Choose which output types to request for each included product.
Leave unchecked to create a tracking-only line.
</p>
</div>
{outputTypes.length > 0 && (
<div className="card p-4">
<p className="text-xs font-medium text-content-secondary mb-2">Select/deselect all rows:</p>
<div className="flex items-center gap-3 flex-wrap">
{outputTypes.map((ot) => (
<div key={ot.id} className="flex items-center gap-1.5">
<input
type="checkbox"
id={`all-ot-${ot.id}`}
onChange={(e) => toggleAllOutputType(ot.id, e.target.checked)}
/>
<label htmlFor={`all-ot-${ot.id}`} className="text-sm cursor-pointer">
{ot.name}
</label>
</div>
))}
{includedWithId.some((r) => !r.has_step) && (
<button
type="button"
className="ml-auto flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-red-200 text-red-700 bg-red-50 hover:bg-red-100"
onClick={deselectWithoutStep}
title="Uncheck all rows that have no STEP file linked"
>
<X size={12} />
Deselect without STEP
</button>
)}
</div>
</div>
)}
<div className="card overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-light text-left">
<th className="px-4 py-2 font-medium text-content-secondary">PIM-ID</th>
<th className="px-4 py-2 font-medium text-content-secondary">Product Name</th>
<th className="px-4 py-2 font-medium text-content-secondary text-center" title="STEP file linked">STEP</th>
{outputTypes.map((ot) => (
<th key={ot.id} className="px-4 py-2 font-medium text-content-secondary text-center">
{ot.name}
</th>
))}
</tr>
</thead>
<tbody>
{includedWithId.map((row) => (
<tr key={row.row_index} className="border-b border-border-light hover:bg-surface-hover">
<td className="px-4 py-2 font-mono text-xs">{row.pim_id || '\u2014'}</td>
<td className="px-4 py-2">{row.gewaehltes_produkt || row.produkt_baureihe || '\u2014'}</td>
<td className="px-4 py-2 text-center">
{row.has_step ? (
<CheckCircle size={14} className="text-green-500 mx-auto" title="STEP file linked" />
) : (
<X size={14} className="text-red-400 mx-auto" title="No STEP file" />
)}
</td>
{outputTypes.map((ot) => (
<td key={ot.id} className="px-4 py-2 text-center">
<input
type="checkbox"
checked={!!(rowOutputTypes[row.row_index]?.[ot.id])}
onChange={(e) =>
setRowOutputTypes((prev) => ({
...prev,
[row.row_index]: {
...(prev[row.row_index] || {}),
[ot.id]: e.target.checked,
},
}))
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{rowsWithOutputType === 0 && includedWithId.length > 0 && (
<div className="rounded-lg border border-border-default bg-status-warning-bg px-4 py-3 text-sm text-status-warning-text">
<strong>No output types selected.</strong> This order will be created with tracking-only lines —
no rendering will be dispatched until output types are added to the order. You can add
output types later from the order detail page.
</div>
)}
{excludedWithId.length > 0 && (
<div className="rounded-lg border border-border-default bg-status-info-bg px-4 py-3">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={createDraftForSkipped}
onChange={(e) => setCreateDraftForSkipped(e.target.checked)}
/>
<div>
<span className="text-sm font-medium text-status-info-text">
Also create a draft for the {excludedWithId.length} skipped product{excludedWithId.length !== 1 ? 's' : ''}
</span>
<p className="text-xs text-status-info-text mt-0.5">
A separate tracking-only draft order will be created for unchecked rows.
Upload STEP files there once available, then assign output types.
</p>
</div>
</label>
</div>
)}
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-content-secondary mb-1">
Order Notes <span className="text-content-muted font-normal">(optional)</span>
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Add any notes for this order\u2026"
/>
</div>
<button
className="btn-primary shrink-0"
onClick={() => finalizeMut.mutate()}
disabled={finalizeMut.isPending || includedWithId.length === 0}
>
<Plus size={16} />
{finalizeMut.isPending
? 'Creating\u2026'
: createDraftForSkipped && excludedWithId.length > 0
? `Create 2 Orders (${includedWithId.length} + ${excludedWithId.length} products)`
: `Create Order (${includedWithId.length} products)`}
</button>
</div>
</div>
)}
</div>
)
}
+633
View File
@@ -0,0 +1,633 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Activity, CheckCircle2, XCircle, Loader2, Clock, RefreshCw,
ChevronDown, ChevronRight, RotateCcw, Terminal, Cpu, Image,
ExternalLink, Trash2, Ban, ListOrdered,
} from 'lucide-react'
import { Link } from 'react-router-dom'
import {
getWorkerActivity, reprocessCadFile, CadActivityEntry, RenderLog, RenderJobEntry,
getQueueStatus, purgeQueue, cancelTask, QueueTask,
} from '../api/worker'
import LiveRenderLog from '../components/LiveRenderLog'
export default function WorkerActivityPage() {
const qc = useQueryClient()
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const { data, isLoading, dataUpdatedAt } = useQuery({
queryKey: ['worker-activity'],
queryFn: getWorkerActivity,
refetchInterval: 5000,
})
const reprocessMut = useMutation({
mutationFn: reprocessCadFile,
onSuccess: () => {
toast.success('Re-queued for full reprocessing')
qc.invalidateQueries({ queryKey: ['worker-activity'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const lastUpdated = dataUpdatedAt
? new Date(dataUpdatedAt).toLocaleTimeString('de-DE')
: '—'
const toggle = (id: string) =>
setExpanded((s) => {
const n = new Set(s)
n.has(id) ? n.delete(id) : n.add(id)
return n
})
return (
<div className="p-8 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Activity size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-content">Worker Activity</h1>
<span className="ml-auto flex items-center gap-2 text-xs text-content-muted">
Auto-refresh every 5 s · Last: {lastUpdated}
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-activity'] })}
className="p-1 rounded hover:bg-surface-muted"
title="Refresh now"
>
<RefreshCw size={13} />
</button>
</span>
</div>
{/* Queue panel */}
<QueuePanel />
{/* Summary */}
{data && (
<div className="grid grid-cols-3 sm:grid-cols-6 gap-4">
<StatCard label="CAD files" value={data.cad_processing.length} color="text-content-secondary" />
<StatCard label="CAD processing" value={data.active_count}
color={data.active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="CAD failed" value={data.failed_count}
color={data.failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
<StatCard label="Render jobs" value={data.render_jobs.length} color="text-content-secondary" />
<StatCard label="Rendering" value={data.render_active_count}
color={data.render_active_count > 0 ? 'text-status-info-text' : 'text-content-secondary'} />
<StatCard label="Render failed" value={data.render_failed_count}
color={data.render_failed_count > 0 ? 'text-red-600' : 'text-content-secondary'} />
</div>
)}
{isLoading && (
<div className="flex items-center gap-2 text-content-muted py-12 justify-center">
<Loader2 size={18} className="animate-spin" /> Loading activity
</div>
)}
{data && data.cad_processing.length === 0 && !isLoading && (
<div className="card p-12 text-center text-content-muted">
<Activity size={32} className="mx-auto mb-3 text-content-muted" />
<p className="font-medium">No recent activity</p>
<p className="text-sm mt-1">STEP file processing jobs will appear here.</p>
</div>
)}
{/* ── Render Jobs ─────────────────────────────────────────────────── */}
{data && data.render_jobs.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">Render Jobs</h2>
<div className="card overflow-hidden divide-y divide-border-light">
{data.render_jobs.map((job) => (
<RenderJobRow key={job.order_line_id} job={job} />
))}
</div>
</div>
)}
{/* ── CAD File Processing ──────────────────────────────────────────── */}
{data && data.cad_processing.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-content-muted uppercase tracking-wide mb-2">CAD File Processing</h2>
</div>
)}
{data && data.cad_processing.length > 0 && (
<div className="card overflow-hidden divide-y divide-border-light">
{data.cad_processing.map((entry) => (
<div key={entry.cad_file_id}>
{/* ── Main row ── */}
<div
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover cursor-pointer select-none"
onClick={() => toggle(entry.cad_file_id)}
>
{/* Expand toggle */}
<span className="text-content-muted shrink-0">
{expanded.has(entry.cad_file_id)
? <ChevronDown size={15} />
: <ChevronRight size={15} />}
</span>
{/* Status icon */}
<StatusIcon status={entry.processing_status} />
{/* File name */}
<div className="flex-1 min-w-0">
<p className="font-mono text-sm text-content truncate" title={entry.original_name}>
{entry.original_name}
</p>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{entry.order_numbers.length > 0 && (
<div className="flex gap-1 flex-wrap">
{entry.order_numbers.map((n) => (
<span key={n} className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded">
{n}
</span>
))}
</div>
)}
{entry.file_size != null && (
<span className="text-xs text-content-muted">{formatBytes(entry.file_size)}</span>
)}
{entry.render_log?.renderer && (
<RendererBadge log={entry.render_log} />
)}
{entry.render_log?.total_duration_s != null && (
<span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} />{entry.render_log.total_duration_s}s total
</span>
)}
</div>
{entry.error_message && (
<p className="text-xs text-red-500 mt-0.5 truncate" title={entry.error_message}>
{entry.error_message}
</p>
)}
</div>
{/* Timestamp */}
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
<p>{new Date(entry.updated_at).toLocaleDateString('de-DE')}</p>
<p>{new Date(entry.updated_at).toLocaleTimeString('de-DE')}</p>
</div>
{/* Re-process button */}
<button
onClick={(e) => {
e.stopPropagation()
reprocessMut.mutate(entry.cad_file_id)
}}
disabled={reprocessMut.isPending}
title="Re-convert STEP + regenerate thumbnail"
className="shrink-0 p-1.5 rounded text-content-muted hover:text-accent hover:bg-surface-hover transition-colors"
>
<RotateCcw size={14} />
</button>
</div>
{/* ── Expanded detail panel ── */}
{expanded.has(entry.cad_file_id) && (
<div className="bg-surface-alt border-t border-border-light px-6 py-4 space-y-4">
<RenderDetails entry={entry} />
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Queue panel ──────────────────────────────────────────────────────────────
function shortName(taskName: string): string {
// "app.tasks.step_tasks.regenerate_thumbnail" → "regenerate_thumbnail"
const parts = taskName.split('.')
return parts[parts.length - 1] ?? taskName
}
function firstArg(task: QueueTask): string {
const a = task.args?.[0]
if (!a) return '—'
const s = String(a)
return s.length > 28 ? s.slice(0, 12) + '…' + s.slice(-8) : s
}
function QueuePanel() {
const qc = useQueryClient()
const { data: queue, isLoading } = useQuery({
queryKey: ['worker-queue'],
queryFn: getQueueStatus,
refetchInterval: 3000,
})
const purgeMut = useMutation({
mutationFn: purgeQueue,
onSuccess: (res) => {
toast.success(res.message)
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Purge failed'),
})
const cancelMut = useMutation({
mutationFn: cancelTask,
onSuccess: () => {
toast.success('Task revoked')
qc.invalidateQueries({ queryKey: ['worker-queue'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const totalPending = queue?.pending_count ?? 0
const activeCount = queue?.active.length ?? 0
const reservedCount = queue?.reserved.length ?? 0
const isEmpty = totalPending === 0 && activeCount === 0 && reservedCount === 0
// Group pending by task name for summary
const pendingGroups: Record<string, number> = {}
for (const t of queue?.pending ?? []) {
const name = shortName(t.task_name)
pendingGroups[name] = (pendingGroups[name] ?? 0) + 1
}
return (
<div className="card overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border-light">
<ListOrdered size={15} className="text-content-muted" />
<h2 className="text-sm font-semibold text-content-secondary flex-1">Celery Queue</h2>
<button
onClick={() => qc.invalidateQueries({ queryKey: ['worker-queue'] })}
className="p-1 rounded hover:bg-surface-muted text-content-muted"
title="Refresh"
>
<RefreshCw size={13} className={isLoading ? 'animate-spin' : ''} />
</button>
{totalPending > 0 && (
<button
onClick={() => {
if (confirm(`Purge all ${totalPending} pending task(s) from the queue?`)) {
purgeMut.mutate()
}
}}
disabled={purgeMut.isPending}
className="flex items-center gap-1 px-2.5 py-1 rounded border border-red-200 text-red-600 text-xs font-medium hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
Purge all ({totalPending})
</button>
)}
</div>
{/* Body */}
<div className="px-4 py-3 space-y-3">
{/* Summary chips */}
<div className="flex items-center gap-3 text-xs flex-wrap">
<span className={`font-semibold ${totalPending > 0 ? 'text-status-warning-text' : 'text-content-muted'}`}>
{totalPending} pending
</span>
<span className="text-content-muted">·</span>
<span className={`font-semibold ${activeCount > 0 ? 'text-status-info-text' : 'text-content-muted'}`}>
{activeCount} active
</span>
<span className="text-content-muted">·</span>
<span className="text-content-muted">{reservedCount} reserved</span>
{queue?.queue_depths && Object.entries(queue.queue_depths).map(([q, n]) => n > 0 && (
<span key={q} className="ml-1 px-1.5 py-0.5 rounded bg-status-warning-bg border border-border-default text-status-warning-text font-mono">
{q}: {n}
</span>
))}
</div>
{isEmpty && !isLoading && (
<p className="text-xs text-content-muted py-1">Queue is empty no pending or active tasks.</p>
)}
{/* Active tasks */}
{(queue?.active.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Active</p>
<div className="space-y-1">
{queue!.active.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-status-info-bg border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse shrink-0" />
<span className="font-medium text-status-info-text shrink-0">{shortName(t.task_name)}</span>
<span className="text-status-info-text font-mono truncate flex-1">{firstArg(t)}</span>
{t.worker && (
<span className="text-status-info-text truncate max-w-[120px]">{t.worker.split('@')[0]}</span>
)}
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke + terminate)"
className="shrink-0 p-0.5 rounded text-status-info-text hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Reserved tasks */}
{(queue?.reserved.length ?? 0) > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">Reserved (prefetched)</p>
<div className="space-y-1">
{queue!.reserved.map((t) => (
<div key={t.task_id} className="flex items-center gap-2 text-xs rounded-md bg-surface-alt border border-border-default px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-gray-400 shrink-0" />
<span className="font-medium text-content-secondary shrink-0">{shortName(t.task_name)}</span>
<span className="text-content-secondary font-mono truncate flex-1">{firstArg(t)}</span>
<button
onClick={() => cancelMut.mutate(t.task_id)}
disabled={cancelMut.isPending}
title="Cancel (revoke)"
className="shrink-0 p-0.5 rounded text-content-muted hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Ban size={13} />
</button>
</div>
))}
</div>
</div>
)}
{/* Pending breakdown (grouped by task name) */}
{totalPending > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wide text-content-muted font-semibold mb-1">
Pending ({totalPending}{totalPending > 100 ? ', showing first 100' : ''})
</p>
<div className="flex flex-wrap gap-1.5">
{Object.entries(pendingGroups).map(([name, count]) => (
<span
key={name}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border-default bg-status-warning-bg text-status-warning-text text-xs font-medium"
>
{name}
<span className="bg-status-warning-bg text-status-warning-text rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none border border-border-default">
{count}
</span>
</span>
))}
</div>
</div>
)}
</div>
</div>
)
}
// ── Render job row ───────────────────────────────────────────────────────────
function RenderJobRow({ job }: { job: RenderJobEntry }) {
const elapsed = job.render_started_at && job.render_completed_at
? ((new Date(job.render_completed_at).getTime() - new Date(job.render_started_at).getTime()) / 1000).toFixed(1)
: null
return (
<>
<div className="flex items-center gap-3 px-4 py-3 hover:bg-surface-hover">
<StatusIcon status={job.render_status} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-content truncate">
{job.product_name || 'Unknown product'}
</span>
{job.output_type_name && (
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-light text-accent font-medium">
{job.output_type_name}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{job.order_number && (
<Link
to={`/orders`}
className="text-xs font-medium bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded hover:bg-surface-hover"
>
{job.order_number}
</Link>
)}
{job.render_backend_used && (
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
job.render_backend_used === 'flamenco'
? 'bg-status-warning-bg text-status-warning-text'
: 'bg-status-info-bg text-status-info-text'
}`}>
{job.render_backend_used === 'flamenco' ? 'Flamenco' : 'Celery'}
</span>
)}
{job.flamenco_job_id && (
<a
href="http://localhost:8080"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-status-warning-text hover:text-status-warning-text flex items-center gap-0.5"
>
<ExternalLink size={10} /> Flamenco
</a>
)}
{elapsed && (
<span className="text-xs text-content-muted flex items-center gap-1">
<Clock size={11} />{elapsed}s
</span>
)}
</div>
</div>
<div className="text-xs text-content-muted shrink-0 text-right hidden sm:block">
<p>{new Date(job.updated_at).toLocaleDateString('de-DE')}</p>
<p>{new Date(job.updated_at).toLocaleTimeString('de-DE')}</p>
</div>
</div>
<div className="px-4 pb-1">
<LiveRenderLog
orderLineId={job.order_line_id}
isActive={job.render_status === 'processing'}
/>
</div>
</>
)
}
// ── Render detail panel ──────────────────────────────────────────────────────
function RenderDetails({ entry }: { entry: CadActivityEntry }) {
const log = entry.render_log
return (
<div className="space-y-4">
{/* File info */}
<Section icon={<Image size={13} />} title="File">
<KVGrid>
<KV label="Name" value={entry.original_name} mono />
<KV label="Size" value={entry.file_size != null ? formatBytes(entry.file_size) : '—'} />
<KV label="Status" value={entry.processing_status} />
<KV label="Uploaded" value={new Date(entry.created_at).toLocaleString('de-DE')} />
<KV label="Last updated" value={new Date(entry.updated_at).toLocaleString('de-DE')} />
{entry.order_numbers.length > 0 && (
<KV label="Orders" value={entry.order_numbers.join(', ')} />
)}
</KVGrid>
{entry.error_message && (
<div className="mt-2 rounded bg-red-50 border border-red-200 px-3 py-2">
<p className="text-xs font-semibold text-red-600 mb-0.5">Error</p>
<pre className="text-xs text-red-700 whitespace-pre-wrap break-words">{entry.error_message}</pre>
</div>
)}
</Section>
{/* Render settings */}
{log && (
<Section icon={<Cpu size={13} />} title="Render settings">
<KVGrid>
<KV label="Renderer" value={log.renderer ?? '—'} />
{log.renderer === 'blender' && <>
<KV label="Engine" value={log.engine_used ?? log.engine ?? '—'} highlight={log.engine_used !== log.engine} />
<KV label="Samples" value={log.samples?.toString() ?? '—'} />
<KV label="Device" value={log.cycles_device ?? '—'} />
<KV label="STL quality" value={log.stl_quality ?? '—'} />
<KV label="Smooth angle" value={log.smooth_angle != null ? `${log.smooth_angle}°` : '—'} />
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
</>}
{log.renderer === 'threejs' && (
<KV label="Resolution" value={log.width && log.height ? `${log.width}×${log.height}` : '—'} />
)}
<KV label="Output format" value={log.format ?? '—'} />
{log.fallback && <KV label="Fallback" value="Yes (Pillow placeholder)" highlight />}
</KVGrid>
</Section>
)}
{/* Timing */}
{log && (log.total_duration_s != null || log.stl_duration_s != null) && (
<Section icon={<Clock size={13} />} title="Timing">
<KVGrid>
{log.total_duration_s != null && <KV label="Total" value={`${log.total_duration_s}s`} />}
{log.stl_duration_s != null && <KV label="STEP→STL" value={`${log.stl_duration_s}s`} />}
{log.render_duration_s != null && <KV label="Render" value={`${log.render_duration_s}s`} />}
{log.stl_size_bytes != null && <KV label="STL size" value={formatBytes(log.stl_size_bytes)} />}
{log.output_size_bytes != null && <KV label="PNG size" value={formatBytes(log.output_size_bytes)} />}
{log.parts_count != null && <KV label="Mesh parts" value={log.parts_count.toString()} />}
</KVGrid>
</Section>
)}
{/* Blender log */}
{log?.log_lines && log.log_lines.length > 0 && (
<Section icon={<Terminal size={13} />} title={`Blender log (${log.log_lines.length} lines)`}>
<BlenderLog lines={log.log_lines} />
</Section>
)}
</div>
)
}
function Section({
icon, title, children,
}: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
return (
<div>
<p className="flex items-center gap-1.5 text-xs font-semibold text-content-muted uppercase tracking-wide mb-2">
{icon}{title}
</p>
{children}
</div>
)
}
function KVGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-1.5">
{children}
</div>
)
}
function KV({ label, value, mono, highlight }: {
label: string; value: string; mono?: boolean; highlight?: boolean
}) {
return (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wide text-content-muted">{label}</span>
<span className={`text-xs break-all ${mono ? 'font-mono' : ''} ${highlight ? 'text-status-warning-text font-medium' : 'text-content-secondary'}`}>
{value}
</span>
</div>
)
}
function BlenderLog({ lines }: { lines: string[] }) {
return (
<div className="bg-gray-900 rounded-md overflow-auto max-h-64">
<pre className="text-xs text-gray-200 p-3 leading-5 whitespace-pre-wrap">
{lines.map((l, i) => {
const color =
l.includes('ERROR') || l.includes('failed') ? 'text-red-400' :
l.includes('WARNING') || l.includes('warn') ? 'text-yellow-300' :
l.includes('Saved:') || l.includes('render done') ? 'text-green-400' :
l.includes('separated into') || l.includes('parts_count') ? 'text-cyan-400' :
'text-gray-200'
return (
<span key={i} className={`block ${color}`}>{l}</span>
)
})}
</pre>
</div>
)
}
function RendererBadge({ log }: { log: RenderLog }) {
if (log.renderer === 'blender') {
const eng = log.engine_used ?? log.engine ?? ''
const label = eng.includes('fallback')
? `Blender · Cycles (↩ fallback)`
: `Blender · ${eng}`
return (
<span className="text-xs bg-status-info-bg text-status-info-text px-1.5 py-0.5 rounded font-medium">
{label}
</span>
)
}
if (log.renderer === 'threejs') {
return (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded font-medium">
Three.js
</span>
)
}
return (
<span className="text-xs bg-surface-muted text-content-secondary px-1.5 py-0.5 rounded font-medium">
{log.renderer}
</span>
)
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 size={16} className="text-green-500 shrink-0" />
if (status === 'failed') return <XCircle size={16} className="text-red-500 shrink-0" />
if (status === 'processing') return <Loader2 size={16} className="animate-spin text-blue-500 shrink-0" />
return <Clock size={16} className="text-content-muted shrink-0" />
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-content-muted mt-0.5">{label}</p>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}