/** * Invoice Search Page * Search and filter invoices with optional line item content search. */ import { useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useAuth } from '../App' import { useDebounce, usePersistedState, getDefaultDateRange, formatDateForDisplay, formatCurrency, SEARCH_STORAGE_KEYS, } from '../utils/searchHelpers' interface InvoiceSearchItem { id: number invoice_number: string | null invoice_date: string | null total: number | null net_total: number | null supplier_id: number | null supplier_name: string | null vendor_name: string | null status: string document_type: string | null } interface GroupSummary { name: string count: number total: number | null } interface SearchResponse { items: InvoiceSearchItem[] total_count: number grouped_by: string | null groups: GroupSummary[] | null } interface Supplier { id: number name: string } const keys = SEARCH_STORAGE_KEYS.invoices export default function SearchInvoices() { const { token } = useAuth() const defaultDates = useMemo(() => getDefaultDateRange(), []) // Persisted search state const [searchInput, setSearchInput] = usePersistedState(keys.query, '') const [includeLineItems, setIncludeLineItems] = usePersistedState(keys.includeLineItems, false) const [supplierId, setSupplierId] = usePersistedState(keys.supplier, '') const [status, setStatus] = usePersistedState(keys.status, '') const [dateFrom, setDateFrom] = usePersistedState(keys.dateFrom, defaultDates.from) const [dateTo, setDateTo] = usePersistedState(keys.dateTo, defaultDates.to) const [groupBy, setGroupBy] = usePersistedState(keys.groupBy, '') const [sortColumn, setSortColumn] = usePersistedState(keys.sortColumn, '') const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>(keys.sortDirection, 'asc') // Collapsed groups state const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) // Debounce search input for live search const debouncedSearch = useDebounce(searchInput, 300) // Fetch suppliers for dropdown const { data: suppliers } = useQuery({ queryKey: ['suppliers'], queryFn: async () => { const res = await fetch('/kitchen/api/suppliers/', { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch suppliers') return res.json() }, }) // Search invoices const { data, isLoading, error } = useQuery({ queryKey: [ 'search-invoices', debouncedSearch, includeLineItems, supplierId, status, dateFrom, dateTo, groupBy, ], queryFn: async () => { const params = new URLSearchParams() if (debouncedSearch) params.set('q', debouncedSearch) if (includeLineItems) params.set('include_line_items', 'true') if (supplierId) params.set('supplier_id', supplierId) if (status) params.set('status', status) if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) if (groupBy) params.set('group_by', groupBy) params.set('limit', '200') const res = await fetch(`/kitchen/api/search/invoices?${params}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Search failed') return res.json() }, enabled: !!token, }) const toggleGroup = (name: string) => { setCollapsedGroups((prev) => { const next = new Set(prev) if (next.has(name)) { next.delete(name) } else { next.add(name) } return next }) } const handleSort = (column: string) => { if (sortColumn === column) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') } else { setSortColumn(column) setSortDirection('asc') } } const getSortIndicator = (column: string) => { if (sortColumn !== column) return null return sortDirection === 'asc' ? ' ▲' : ' ▼' } const getStatusBadge = (status: string) => { const colors: Record = { pending: '#f59e0b', processed: '#3b82f6', reviewed: '#8b5cf6', confirmed: '#22c55e', } return ( {status} ) } // Helper to get net/gross total, filling in missing values const getNetTotal = (inv: InvoiceSearchItem) => { if (inv.net_total !== null) return inv.net_total if (inv.total !== null) return inv.total // Assume no VAT if net is missing return null } const getGrossTotal = (inv: InvoiceSearchItem) => { if (inv.total !== null) return inv.total if (inv.net_total !== null) return inv.net_total // Assume no VAT if gross is missing return null } // Sort items const sortedItems = useMemo(() => { if (!data?.items || !sortColumn) return data?.items || [] const sorted = [...data.items].sort((a, b) => { let aVal: any let bVal: any switch (sortColumn) { case 'invoice_number': aVal = a.invoice_number || '' bVal = b.invoice_number || '' break case 'supplier': aVal = a.supplier_name || a.vendor_name || '' bVal = b.supplier_name || b.vendor_name || '' break case 'date': aVal = a.invoice_date || '' bVal = b.invoice_date || '' break case 'net_total': aVal = getNetTotal(a) ?? -Infinity bVal = getNetTotal(b) ?? -Infinity break case 'gross_total': aVal = getGrossTotal(a) ?? -Infinity bVal = getGrossTotal(b) ?? -Infinity break case 'status': aVal = a.status || '' bVal = b.status || '' break default: return 0 } if (typeof aVal === 'string' && typeof bVal === 'string') { return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal) } else { return sortDirection === 'asc' ? aVal - bVal : bVal - aVal } }) return sorted }, [data?.items, sortColumn, sortDirection]) // Group items by the groupBy field const groupedItems = useMemo(() => { if (!sortedItems || !groupBy || !data?.groups) return null const grouped: Record = {} for (const item of sortedItems) { let key: string if (groupBy === 'supplier') { key = item.supplier_name || 'Unknown' } else if (groupBy === 'month') { key = item.invoice_date?.substring(0, 7) || 'Unknown' } else { key = 'All' } if (!grouped[key]) grouped[key] = [] grouped[key].push(item) } return grouped }, [sortedItems, groupBy, data?.groups]) return (

Search Invoices

{/* Search Controls */}
setSearchInput(e.target.value)} style={{ flex: '1 1 250px', padding: '8px 12px', border: '1px solid #d1d5db', borderRadius: '4px', fontSize: '14px', }} />
setDateFrom(e.target.value)} style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }} /> setDateTo(e.target.value)} style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }} />
{/* Results */} {isLoading &&

Loading...

} {error &&

Error: {(error as Error).message}

} {data && (

Found {data.total_count} invoice{data.total_count !== 1 ? 's' : ''}

{groupBy && data.groups ? ( // Grouped view
{data.groups.map((group) => (
toggleGroup(group.name)} style={{ padding: '12px 16px', backgroundColor: '#f1f5f9', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }} > {collapsedGroups.has(group.name) ? '▸' : '▾'} {group.name} {group.count} invoice{group.count !== 1 ? 's' : ''} {group.total !== null && ` • ${formatCurrency(group.total)}`}
{!collapsedGroups.has(group.name) && groupedItems?.[group.name] && ( {groupedItems[group.name].map((inv) => ( ))}
handleSort('invoice_number')}> Invoice #{getSortIndicator('invoice_number')} handleSort('supplier')}> Supplier{getSortIndicator('supplier')} handleSort('date')}> Date{getSortIndicator('date')} handleSort('net_total')}> Net Total{getSortIndicator('net_total')} handleSort('gross_total')}> Gross Total{getSortIndicator('gross_total')} handleSort('status')}> Status{getSortIndicator('status')}
{inv.invoice_number || '-'} {inv.supplier_name || inv.vendor_name || '-'} {formatDateForDisplay(inv.invoice_date)} {formatCurrency(getNetTotal(inv))} {formatCurrency(getGrossTotal(inv))} {getStatusBadge(inv.status)}
)}
))}
) : ( // Flat table view {sortedItems.map((inv) => ( ))}
handleSort('invoice_number')}> Invoice #{getSortIndicator('invoice_number')} handleSort('supplier')}> Supplier{getSortIndicator('supplier')} handleSort('date')}> Date{getSortIndicator('date')} handleSort('net_total')}> Net Total{getSortIndicator('net_total')} handleSort('gross_total')}> Gross Total{getSortIndicator('gross_total')} handleSort('status')}> Status{getSortIndicator('status')}
{inv.invoice_number || '-'} {inv.supplier_name || inv.vendor_name || '-'} {formatDateForDisplay(inv.invoice_date)} {formatCurrency(getNetTotal(inv))} {formatCurrency(getGrossTotal(inv))} {getStatusBadge(inv.status)}
)}
)}
) } const thStyle: React.CSSProperties = { padding: '12px', textAlign: 'left', fontWeight: 600, fontSize: '14px', } const thStyleClickable: React.CSSProperties = { ...thStyle, cursor: 'pointer', userSelect: 'none', } const tdStyle: React.CSSProperties = { padding: '12px', fontSize: '14px', }