ca62319688
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>
896 lines
36 KiB
TypeScript
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 “{query}”</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' : ''},
|
|
<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>
|
|
)
|
|
}
|