Add Purchases Chart page — weekly table view of invoices by supplier
Backend endpoint /api/reports/purchases/weekly existed but had no frontend. Adds PurchasesCalendar page under Invoices > Purchases Chart: 7-day columns, one row per supplier, each cell lists invoice chips (number + total) linking to the invoice detail. Daily totals row in footer, prev/next week navigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0d6ca9b979
commit
12aef0adb1
3 changed files with 340 additions and 1 deletions
|
|
@ -42,6 +42,7 @@ import MenuList from './components/MenuList'
|
|||
import MenuEditor from './components/MenuEditor'
|
||||
import BulkAllergens from './components/BulkAllergens'
|
||||
import PriceImpact from './components/PriceImpact'
|
||||
import PurchasesCalendar from './pages/PurchasesCalendar'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
|
@ -59,6 +60,7 @@ export default function App() {
|
|||
<Route path="/upload" element={<Upload />} />
|
||||
<Route path="/invoices" element={<InvoiceList />} />
|
||||
<Route path="/invoice/:id" element={<Review />} />
|
||||
<Route path="/purchases-calendar" element={<PurchasesCalendar />} />
|
||||
<Route path="/disputes" element={<Disputes />} />
|
||||
<Route path="/search/invoices" element={<SearchInvoices />} />
|
||||
<Route path="/search/line-items" element={<SearchLineItems />} />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
LayoutDashboard, Upload, FileText, Search, List, AlertCircle,
|
||||
ShoppingCart, TrendingUp, BarChart2, PieChart, Activity, RefreshCw,
|
||||
Calendar, Users, Hotel, BookOpen, Wheat, ChefHat, UtensilsCrossed,
|
||||
Menu, ShieldCheck, Package, Settings, ChevronRight,
|
||||
Menu, ShieldCheck, Package, Settings, ChevronRight, TableProperties,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
|
|
@ -46,6 +46,7 @@ export default function Layout() {
|
|||
<SidebarSection label="Invoices">
|
||||
<NavItem to="/upload" label="Upload" icon={Upload} />
|
||||
<NavItem to="/invoices" label="Invoices" icon={FileText} />
|
||||
<NavItem to="/purchases-calendar" label="Purchases Chart" icon={TableProperties} />
|
||||
<NavItem to="/search/invoices" label="Search" icon={Search} />
|
||||
<NavItem to="/search/line-items" label="Line Items" icon={List} />
|
||||
{can(user, 'disputes') && <NavItem to="/disputes" label="Disputes" icon={AlertCircle} />}
|
||||
|
|
|
|||
336
frontend/src/pages/PurchasesCalendar.tsx
Normal file
336
frontend/src/pages/PurchasesCalendar.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
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)',
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue