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:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View 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',
}