Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
509
frontend/src/components/SearchInvoices.tsx
Normal file
509
frontend/src/components/SearchInvoices.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* 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<string>(keys.supplier, '')
|
||||
const [status, setStatus] = usePersistedState<string>(keys.status, '')
|
||||
const [dateFrom, setDateFrom] = usePersistedState(keys.dateFrom, defaultDates.from)
|
||||
const [dateTo, setDateTo] = usePersistedState(keys.dateTo, defaultDates.to)
|
||||
const [groupBy, setGroupBy] = usePersistedState<string>(keys.groupBy, '')
|
||||
const [sortColumn, setSortColumn] = usePersistedState<string>(keys.sortColumn, '')
|
||||
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>(keys.sortDirection, 'asc')
|
||||
|
||||
// Collapsed groups state
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
// Debounce search input for live search
|
||||
const debouncedSearch = useDebounce(searchInput, 300)
|
||||
|
||||
// Fetch suppliers for dropdown
|
||||
const { data: suppliers } = useQuery<Supplier[]>({
|
||||
queryKey: ['suppliers'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/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<SearchResponse>({
|
||||
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(`/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<string, string> = {
|
||||
pending: '#f59e0b',
|
||||
processed: '#3b82f6',
|
||||
reviewed: '#8b5cf6',
|
||||
confirmed: '#22c55e',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: colors[status] || '#9ca3af',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string, InvoiceSearchItem[]> = {}
|
||||
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 (
|
||||
<div style={{ padding: '20px', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '20px' }}>Search Invoices</h1>
|
||||
|
||||
{/* Search Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search invoice number, vendor..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 250px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeLineItems}
|
||||
onChange={(e) => setIncludeLineItems(e.target.checked)}
|
||||
/>
|
||||
Include line items
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={supplierId}
|
||||
onChange={(e) => setSupplierId(e.target.value)}
|
||||
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">All Suppliers</option>
|
||||
{suppliers?.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processed">Processed</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
style={{ padding: '8px', border: '1px solid #d1d5db', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">No Grouping</option>
|
||||
<option value="supplier">Group by Supplier</option>
|
||||
<option value="month">Group by Month</option>
|
||||
</select>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<label style={{ fontSize: '14px' }}>From:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
|
||||
/>
|
||||
<label style={{ fontSize: '14px' }}>To:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
style={{ padding: '6px', border: '1px solid #d1d5db', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
|
||||
|
||||
{data && (
|
||||
<div>
|
||||
<p style={{ marginBottom: '12px', color: '#6b7280' }}>
|
||||
Found {data.total_count} invoice{data.total_count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{groupBy && data.groups ? (
|
||||
// Grouped view
|
||||
<div>
|
||||
{data.groups.map((group) => (
|
||||
<div
|
||||
key={group.name}
|
||||
style={{
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{collapsedGroups.has(group.name) ? '▸' : '▾'} {group.name}
|
||||
</span>
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{group.count} invoice{group.count !== 1 ? 's' : ''}
|
||||
{group.total !== null && ` • ${formatCurrency(group.total)}`}
|
||||
</span>
|
||||
</div>
|
||||
{!collapsedGroups.has(group.name) && groupedItems?.[group.name] && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f8fafc' }}>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('invoice_number')}>
|
||||
Invoice #{getSortIndicator('invoice_number')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
|
||||
Supplier{getSortIndicator('supplier')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('date')}>
|
||||
Date{getSortIndicator('date')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('net_total')}>
|
||||
Net Total{getSortIndicator('net_total')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('gross_total')}>
|
||||
Gross Total{getSortIndicator('gross_total')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('status')}>
|
||||
Status{getSortIndicator('status')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedItems[group.name].map((inv) => (
|
||||
<tr key={inv.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>
|
||||
<a
|
||||
href={`/invoice/${inv.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2563eb', textDecoration: 'none' }}
|
||||
>
|
||||
{inv.invoice_number || '-'}
|
||||
</a>
|
||||
</td>
|
||||
<td style={tdStyle}>{inv.supplier_name || inv.vendor_name || '-'}</td>
|
||||
<td style={tdStyle}>{formatDateForDisplay(inv.invoice_date)}</td>
|
||||
<td style={tdStyle}>{formatCurrency(getNetTotal(inv))}</td>
|
||||
<td style={tdStyle}>{formatCurrency(getGrossTotal(inv))}</td>
|
||||
<td style={tdStyle}>{getStatusBadge(inv.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Flat table view
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f1f5f9' }}>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('invoice_number')}>
|
||||
Invoice #{getSortIndicator('invoice_number')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('supplier')}>
|
||||
Supplier{getSortIndicator('supplier')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('date')}>
|
||||
Date{getSortIndicator('date')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('net_total')}>
|
||||
Net Total{getSortIndicator('net_total')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('gross_total')}>
|
||||
Gross Total{getSortIndicator('gross_total')}
|
||||
</th>
|
||||
<th style={thStyleClickable} onClick={() => handleSort('status')}>
|
||||
Status{getSortIndicator('status')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((inv) => (
|
||||
<tr key={inv.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<td style={tdStyle}>
|
||||
<a
|
||||
href={`/invoice/${inv.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2563eb', textDecoration: 'none' }}
|
||||
>
|
||||
{inv.invoice_number || '-'}
|
||||
</a>
|
||||
</td>
|
||||
<td style={tdStyle}>{inv.supplier_name || inv.vendor_name || '-'}</td>
|
||||
<td style={tdStyle}>{formatDateForDisplay(inv.invoice_date)}</td>
|
||||
<td style={tdStyle}>{formatCurrency(getNetTotal(inv))}</td>
|
||||
<td style={tdStyle}>{formatCurrency(getGrossTotal(inv))}</td>
|
||||
<td style={tdStyle}>{getStatusBadge(inv.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue