Files
HartOMat/frontend/src/pages/Orders.tsx
T
Hartmut ca62319688 feat: sharp edge pipeline V02, tessellation presets, media cache-bust, GMSH plan
Sharp Edge Pipeline V02:
- export_step_to_gltf.py: replace BRep_Tool.Polygon3D_s (returns None in XCAF) with
  GCPnts_UniformAbscissa curve sampling at 0.3mm step — extracts 17,129 segment pairs
- Inject sharp_edge_pairs + sharp_threshold_deg into GLB extras (scenes[0].extras)
  via binary GLB JSON-chunk patching (no extra dependency)
- export_gltf.py: read schaeffler_sharp_edge_pairs from Blender scene custom props,
  apply via KD-tree to mark edges sharp=True + seam=True (OCC mm Z-up → Blender transform)
- tools/restore_sharp_marks.py: dual-pass (dihedral angle + OCC pairs), updated coordinate
  transform (X, -Z, Y) * 0.001

Tessellation:
- Admin UI: Draft / Standard / Fine preset buttons with active-state highlighting
- Default angular deflection: preview 0.5→0.1 rad, production 0.2→0.05 rad
- export_glb.py: read updated defaults from system_settings

Media / Cache:
- media/service.py: get_download_url appends ?v={file_size_bytes} cache-buster
- media/router.py: Cache-Control: no-cache for all download/thumbnail endpoints

Render pipeline:
- still_render.py / turntable_render.py: shared GPU activation + camera improvements
- render_order_line.py: global render position support
- render_thumbnail.py: updated defaults

Frontend:
- InlineCadViewer: file_size_bytes-aware URL update triggers re-fetch on regeneration
- ThreeDViewer: material panel, part selection, PBR mode improvements
- Admin.tsx: tessellation preset cards, GMSH setting dropdown
- MediaBrowser, ProductDetail, OrderDetail, Orders: various UI improvements
- New: MaterialPanel, GlobalRenderPositionsPanel, StepIndicator components
- New: renderPositions.ts API client

Plans / Docs:
- plan.md: GMSH Frontal-Delaunay tessellation plan (6 tasks)
- LEARNINGS.md: OCC Polygon3D_s None issue + GCPnts fix
- .gitignore: add backend/core (core dump from root process)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 14:40:36 +01:00

896 lines
36 KiB
TypeScript

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'
import ConfirmModal from '../components/ConfirmModal'
// ── 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'>(() => window.innerWidth < 768 ? '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())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Auto-switch to list view on narrow screens
useEffect(() => {
const handler = () => { if (window.innerWidth < 768) setView('list') }
window.addEventListener('resize', handler)
return () => window.removeEventListener('resize', handler)
}, [])
// 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
const ordersMap = Object.fromEntries(orders.map((o) => [o.id, o]))
const submittedCount = ids.filter((id) => ordersMap[id]?.status === 'submitted').length
const message = submittedCount > 0
? `⚠️ ${submittedCount} of ${ids.length} selected order${ids.length > 1 ? 's' : ''} ${submittedCount === 1 ? 'has' : 'have'} been submitted and may be processing. Delete anyway?`
: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}? This cannot be undone.`
setConfirmState({
open: true,
title: `Delete ${ids.length} order${ids.length > 1 ? 's' : ''}`,
message,
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
},
})
}
// ── 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 && !isSearchMode && orders.length === 0 ? (
view === 'kanban' ? <KanbanSkeleton /> : <ListSkeleton />
) : isLoading && isSearchMode ? (
<div className="flex-1 flex items-center justify-center text-content-muted">
<Loader2 size={24} className="animate-spin mr-2" />
Searching
</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}`)}
/>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{/* ── Bulk delete bar ───────────────────────────────────────────────── */}
{selected.size > 0 && (
<div
className="fixed bottom-6 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"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
>
<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>
)
}
// ── Skeleton loaders ──────────────────────────────────────────────────────────
function ListSkeleton() {
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-6 my-4 card overflow-hidden animate-pulse">
<div className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] bg-surface-alt border-b border-border-default px-4 py-2.5">
<div className="h-3 w-3 bg-surface-muted rounded" />
<div className="h-3 w-24 bg-surface-muted rounded" />
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-3 w-14 bg-surface-muted rounded" />
<div className="h-3 w-16 bg-surface-muted rounded" />
</div>
<div className="divide-y divide-border-light">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="grid grid-cols-[2rem_1fr_6rem_5rem_6rem] items-center px-4 py-3 gap-x-4">
<div className="h-3.5 w-3.5 bg-surface-muted rounded" />
<div className="space-y-1.5">
<div className="h-3.5 w-32 bg-surface-muted rounded" />
<div className="h-2.5 w-48 bg-surface-muted rounded opacity-60" />
</div>
<div className="h-3 w-12 bg-surface-muted rounded" />
<div className="h-5 w-16 bg-surface-muted rounded-full" />
<div className="h-3 w-14 bg-surface-muted rounded" />
</div>
))}
</div>
</div>
</div>
)
}
function KanbanSkeleton() {
return (
<div className="flex-1 overflow-x-auto min-h-0">
<div className="flex gap-4 p-6 h-full">
{['bg-gray-400', 'bg-blue-400', 'bg-amber-400'].map((color, ci) => (
<div key={ci} className="flex flex-col w-72 min-w-[272px] animate-pulse">
<div className={`${color} rounded-t-xl px-4 py-3 flex items-center gap-2`}>
<div className="h-4 w-4 bg-white/40 rounded" />
<div className="h-3.5 w-20 bg-white/40 rounded" />
<div className="ml-auto h-5 w-6 bg-white/30 rounded-full" />
</div>
<div className="flex-1 bg-surface-muted rounded-b-xl p-2 space-y-2 min-h-[120px]">
{Array.from({ length: 2 }, (_, i) => (
<div key={i} className="bg-surface rounded-lg p-3 border border-border-default border-l-4 border-l-border-default">
<div className="h-3.5 w-24 bg-surface-muted rounded mb-2" />
<div className="flex gap-3">
<div className="h-3 w-16 bg-surface-muted rounded" />
<div className="h-3 w-20 bg-surface-muted rounded" />
</div>
</div>
))}
</div>
</div>
))}
</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.status === 'rejected' && order.rejection_reason && (
<p className="mt-2 text-xs text-red-500 italic truncate" title={order.rejection_reason}>
{order.rejection_reason}
</p>
)}
{order.notes && !rp && order.status !== 'rejected' && (
<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>
)
}