Wire Purchases Chart nav to existing Purchases.tsx page

The full Purchases.tsx page at /purchases (multi-week tables, date range picker,
disputes/allowances rows, 24-week comparison chart) was already ported but had
no sidebar nav entry. Adds it under Invoices → Purchases Chart and removes the
simplified single-week PurchasesCalendar placeholder that replaced it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 21:24:01 +00:00
parent 12aef0adb1
commit 1502ab8ed2
3 changed files with 1 additions and 339 deletions

View file

@ -42,7 +42,6 @@ import MenuList from './components/MenuList'
import MenuEditor from './components/MenuEditor' import MenuEditor from './components/MenuEditor'
import BulkAllergens from './components/BulkAllergens' import BulkAllergens from './components/BulkAllergens'
import PriceImpact from './components/PriceImpact' import PriceImpact from './components/PriceImpact'
import PurchasesCalendar from './pages/PurchasesCalendar'
export default function App() { export default function App() {
return ( return (
@ -60,7 +59,6 @@ export default function App() {
<Route path="/upload" element={<Upload />} /> <Route path="/upload" element={<Upload />} />
<Route path="/invoices" element={<InvoiceList />} /> <Route path="/invoices" element={<InvoiceList />} />
<Route path="/invoice/:id" element={<Review />} /> <Route path="/invoice/:id" element={<Review />} />
<Route path="/purchases-calendar" element={<PurchasesCalendar />} />
<Route path="/disputes" element={<Disputes />} /> <Route path="/disputes" element={<Disputes />} />
<Route path="/search/invoices" element={<SearchInvoices />} /> <Route path="/search/invoices" element={<SearchInvoices />} />
<Route path="/search/line-items" element={<SearchLineItems />} /> <Route path="/search/line-items" element={<SearchLineItems />} />

View file

@ -46,7 +46,7 @@ export default function Layout() {
<SidebarSection label="Invoices"> <SidebarSection label="Invoices">
<NavItem to="/upload" label="Upload" icon={Upload} /> <NavItem to="/upload" label="Upload" icon={Upload} />
<NavItem to="/invoices" label="Invoices" icon={FileText} /> <NavItem to="/invoices" label="Invoices" icon={FileText} />
<NavItem to="/purchases-calendar" label="Purchases Chart" icon={TableProperties} /> <NavItem to="/purchases" label="Purchases Chart" icon={TableProperties} />
<NavItem to="/search/invoices" label="Search" icon={Search} /> <NavItem to="/search/invoices" label="Search" icon={Search} />
<NavItem to="/search/line-items" label="Line Items" icon={List} /> <NavItem to="/search/line-items" label="Line Items" icon={List} />
{can(user, 'disputes') && <NavItem to="/disputes" label="Disputes" icon={AlertCircle} />} {can(user, 'disputes') && <NavItem to="/disputes" label="Disputes" icon={AlertCircle} />}

View file

@ -1,336 +0,0 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useAuth } from '../App'
interface PurchaseInvoice {
id: number
invoice_number: string | null
total: string | null
supplier_match_type: string | null
}
interface SupplierRow {
supplier_id: number | null
supplier_name: string
is_unmatched: boolean
invoices_by_date: Record<string, PurchaseInvoice[]>
total: string
percentage: string
}
interface WeeklyPurchasesResponse {
week_start: string
week_end: string
dates: string[]
suppliers: SupplierRow[]
daily_totals: Record<string, string>
week_total: string
}
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function fmt(val: string | null | undefined): string {
if (!val) return '—'
const n = parseFloat(val)
if (isNaN(n)) return '—'
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n)
}
function fmtDate(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' })
}
function fmtWeekRange(start: string, end: string): string {
return `${fmtDate(start)} ${fmtDate(end)}`
}
export default function PurchasesCalendar() {
const { token } = useAuth()
const [weekOffset, setWeekOffset] = useState(0)
const { data, isLoading, error } = useQuery<WeeklyPurchasesResponse>({
queryKey: ['purchases-weekly', weekOffset],
queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/purchases/weekly?week_offset=${weekOffset}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Failed to fetch weekly purchases')
return res.json()
},
enabled: !!token,
})
return (
<div style={styles.page}>
<div style={styles.header}>
<h1 style={styles.title}>Purchases Chart</h1>
<div style={styles.weekNav}>
<button style={styles.navBtn} onClick={() => setWeekOffset(o => o - 1)}>
<ChevronLeft size={16} strokeWidth={1.75} />
Prev
</button>
<span style={styles.weekLabel}>
{data ? fmtWeekRange(data.week_start, data.week_end) : 'Loading…'}
</span>
<button
style={{ ...styles.navBtn, ...(weekOffset >= 0 ? styles.navBtnDisabled : {}) }}
onClick={() => setWeekOffset(o => o + 1)}
disabled={weekOffset >= 0}
>
Next
<ChevronRight size={16} strokeWidth={1.75} />
</button>
</div>
</div>
{isLoading && <div style={styles.state}>Loading</div>}
{error && <div style={styles.stateError}>Failed to load weekly purchases.</div>}
{data && (
<div style={styles.tableWrap}>
<table style={styles.table}>
<thead>
<tr>
<th style={{ ...styles.th, ...styles.supplierCol }}>Supplier</th>
{data.dates.map((d, i) => (
<th key={d} style={styles.th}>
<div style={styles.dayLabel}>{DAY_LABELS[i]}</div>
<div style={styles.dayDate}>{fmtDate(d)}</div>
</th>
))}
<th style={{ ...styles.th, ...styles.totalCol }}>Total</th>
<th style={{ ...styles.th, ...styles.pctCol }}>%</th>
</tr>
</thead>
<tbody>
{data.suppliers.length === 0 && (
<tr>
<td colSpan={data.dates.length + 3} style={styles.emptyRow}>
No invoices this week
</td>
</tr>
)}
{data.suppliers.map((row) => (
<tr key={`${row.supplier_id ?? row.supplier_name}`} style={styles.tr}>
<td style={{ ...styles.td, ...styles.supplierCell }}>
<span style={row.is_unmatched ? styles.unmatchedLabel : undefined}>
{row.supplier_name}
</span>
</td>
{data.dates.map((d) => {
const invs = row.invoices_by_date[d] ?? []
return (
<td key={d} style={styles.td}>
{invs.map((inv) => (
<a
key={inv.id}
href={`/kitchen/invoice/${inv.id}`}
style={{
...styles.invoiceChip,
...(inv.supplier_match_type === null ? styles.chipUnmatched : {}),
}}
>
<span style={styles.chipNum}>
{inv.invoice_number ?? `#${inv.id}`}
</span>
<span style={styles.chipAmt}>{fmt(inv.total)}</span>
</a>
))}
</td>
)
})}
<td style={{ ...styles.td, ...styles.totalCell }}>{fmt(row.total)}</td>
<td style={{ ...styles.td, ...styles.pctCell }}>
{parseFloat(row.percentage).toFixed(1)}%
</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={styles.totalsRow}>
<td style={{ ...styles.td, ...styles.supplierCell, fontWeight: 600 }}>Daily Total</td>
{data.dates.map((d) => (
<td key={d} style={{ ...styles.td, fontWeight: 600, color: 'var(--text-primary)' }}>
{fmt(data.daily_totals[d] ?? '0')}
</td>
))}
<td style={{ ...styles.td, fontWeight: 700, color: 'var(--text-primary)' }}>
{fmt(data.week_total)}
</td>
<td style={styles.td} />
</tr>
</tfoot>
</table>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
page: {
padding: '24px',
maxWidth: '100%',
overflowX: 'hidden',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '20px',
},
title: {
fontSize: '20px',
fontWeight: 600,
color: 'var(--text-primary)',
margin: 0,
},
weekNav: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
weekLabel: {
fontSize: '14px',
color: 'var(--text-secondary)',
minWidth: '180px',
textAlign: 'center',
},
navBtn: {
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
fontSize: '13px',
border: '1px solid var(--border)',
borderRadius: '6px',
background: 'var(--surface)',
color: 'var(--text-primary)',
cursor: 'pointer',
},
navBtnDisabled: {
opacity: 0.4,
cursor: 'not-allowed',
},
state: {
padding: '40px',
textAlign: 'center',
color: 'var(--text-secondary)',
},
stateError: {
padding: '40px',
textAlign: 'center',
color: '#e53e3e',
},
tableWrap: {
overflowX: 'auto',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface)',
},
table: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '13px',
},
th: {
padding: '10px 12px',
background: 'var(--surface-secondary, #f4f5f7)',
fontWeight: 600,
textAlign: 'left',
borderBottom: '2px solid var(--border)',
whiteSpace: 'nowrap',
color: 'var(--text-secondary)',
fontSize: '12px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
dayLabel: {
fontWeight: 700,
color: 'var(--text-primary)',
fontSize: '12px',
},
dayDate: {
fontWeight: 400,
fontSize: '11px',
marginTop: '2px',
},
supplierCol: {
minWidth: '160px',
},
totalCol: {
minWidth: '100px',
textAlign: 'right' as const,
},
pctCol: {
minWidth: '60px',
textAlign: 'right' as const,
},
tr: {
borderBottom: '1px solid var(--border)',
},
td: {
padding: '8px 12px',
verticalAlign: 'top',
color: 'var(--text-secondary)',
},
supplierCell: {
color: 'var(--text-primary)',
fontWeight: 500,
whiteSpace: 'nowrap',
},
totalCell: {
textAlign: 'right' as const,
whiteSpace: 'nowrap',
color: 'var(--text-primary)',
fontWeight: 500,
},
pctCell: {
textAlign: 'right' as const,
whiteSpace: 'nowrap',
},
unmatchedLabel: {
color: '#e53e3e',
fontStyle: 'italic',
},
invoiceChip: {
display: 'flex',
flexDirection: 'column' as const,
gap: '1px',
padding: '3px 6px',
marginBottom: '4px',
borderRadius: '4px',
background: 'var(--surface-secondary, #f4f5f7)',
border: '1px solid var(--border)',
textDecoration: 'none',
cursor: 'pointer',
transition: 'background 0.15s',
},
chipUnmatched: {
borderColor: '#fc8181',
background: '#fff5f5',
},
chipNum: {
fontSize: '11px',
color: 'var(--text-primary)',
fontWeight: 500,
},
chipAmt: {
fontSize: '12px',
color: 'var(--text-primary)',
fontWeight: 600,
},
emptyRow: {
padding: '40px',
textAlign: 'center' as const,
color: 'var(--text-secondary)',
},
totalsRow: {
borderTop: '2px solid var(--border)',
background: 'var(--surface-secondary, #f4f5f7)',
},
}