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 = { 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>(new Set()) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [showFilters, setShowFilters] = useState(false) const [selected, setSelected] = useState>(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 = { 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 (
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
{/* Row 1: title + view toggle + new order */}

Orders

New Order
{/* Row 2: Search bar */}
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 && ( )}
{/* Row 3: Status chips + date filter toggle */}
{isSearchMode ? 'Filter results:' : 'Show:'} {STATUSES.map((s) => { const meta = STATUS_META[s] const active = selectedStatuses.has(s) const count = byStatus[s]?.length ?? 0 return ( ) })}
{(selectedStatuses.size > 0 || hasDateFilter || searchInput) && ( )}
{/* Date filter panel */} {showFilters && (
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" />
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" />
{hasDateFilter && ( )}
)}
{/* ── Content ──────────────────────────────────────────────────────── */} {isLoading && !isSearchMode && orders.length === 0 ? ( view === 'kanban' ? : ) : isLoading && isSearchMode ? (
Searching…
) : isSearchMode ? ( navigate(`/orders/${id}`)} /> ) : orders.length === 0 ? (

No orders yet

Upload an Excel file to create your first order.

Upload Excel
) : view === 'kanban' ? ( navigate(`/orders/${id}`)} /> ) : ( navigate(`/orders/${id}`)} /> )} setConfirmState((s) => ({ ...s, open: false }))} /> {/* ── Bulk delete bar ───────────────────────────────────────────────── */} {selected.size > 0 && (
{selected.size} order{selected.size > 1 ? 's' : ''} selected
)}
) } // ── Skeleton loaders ────────────────────────────────────────────────────────── function ListSkeleton() { return (
{Array.from({ length: 5 }, (_, i) => (
))}
) } function KanbanSkeleton() { return (
{['bg-gray-400', 'bg-blue-400', 'bg-amber-400'].map((color, ci) => (
{Array.from({ length: 2 }, (_, i) => (
))}
))}
) } // ── 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 (

No results for “{query}”

Try searching by product name, bearing type, PIM ID, Baureihe, or order number.

) } return (

{results.length} order{results.length !== 1 ? 's' : ''},  {totalItems} matching item{totalItems !== 1 ? 's' : ''}

{results.map((order) => ( onNavigate(order.id)} /> ))}
) } 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 (
{/* Order header */}
{order.order_number} {order.status} {date} {order.items.length} matching item{order.items.length !== 1 ? 's' : ''}
{/* Matching items */} {order.items.length > 0 && ( <> {/* Column headers */}
PIM ID / Level
Baureihe
Product
CAD Model
Bearing type
{order.items.map((item) => ( ))}
)}
) } function Highlight({ text, query }: { text: string | null; query: string }) { if (!text) return null const idx = text.toLowerCase().indexOf(query.toLowerCase()) if (idx === -1) return {text} return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ) } function SearchItemRow({ item, query, onNavigate, }: { item: OrderItem query: string onNavigate: () => void }) { const [thumbUrl, setThumbUrl] = useState(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 (
{/* Thumbnail */}
{thumbUrl ? ( ) : ( )}
{item.pim_id && (

)} {item.ebene2 && (

)}
) } // ── Kanban board ───────────────────────────────────────────────────────────── function KanbanBoard({ byStatus, visibleStatuses, onNavigate, }: { byStatus: Record visibleStatuses: Status[] onNavigate: (id: string) => void }) { return (
{visibleStatuses.map((status) => { const meta = STATUS_META[status] const cards = byStatus[status] return (
{meta.label} {cards.length}
{cards.length === 0 ? (
No orders
) : ( cards.map((order) => ( onNavigate(order.id)} /> )) )}
) })}
) } 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 (
{completed > 0 &&
} {processing > 0 &&
} {failed > 0 &&
} {cancelled > 0 &&
} {pending > 0 &&
}

{allDone ? `${total}/${total}` : `Rendered ${completed}/${total}`} {failed > 0 && ({failed} failed)} {cancelled > 0 && ({cancelled} cancelled)}

) } 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 ( ) } // ── List view ───────────────────────────────────────────────────────────────── function ListView({ orders, selected, allSelected, onToggleOne, onToggleAll, onNavigate, }: { orders: Order[] selected: Set allSelected: boolean onToggleOne: (id: string) => void onToggleAll: () => void onNavigate: (id: string) => void }) { const deletable = orders.filter((o) => isDeletable(o.status)) return (
{deletable.length > 0 && ( )}
Order
Items
Status
Created
{orders.length === 0 ? (
No orders match the current filters.
) : (
{orders.map((order) => { const canSelect = isDeletable(order.status) const isSelected = selected.has(order.id) const meta = STATUS_META[order.status as Status] return (
onNavigate(order.id)} >
e.stopPropagation()}> {canSelect ? ( onToggleOne(order.id)} className="w-3.5 h-3.5 rounded accent-red-500 cursor-pointer" /> ) : }

{order.order_number}

{order.notes && (

{order.notes}

)}
{order.item_count} item{order.item_count !== 1 ? 's' : ''}
{order.status}
{new Date(order.created_at).toLocaleDateString('de-DE')}
) })}
)}
) }