From 12aef0adb1f508801db01f7a9e15d5c92e7faefd Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 12 Jul 2026 21:14:12 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Purchases=20Chart=20page=20=E2=80=94=20we?= =?UTF-8?q?ekly=20table=20view=20of=20invoices=20by=20supplier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/PurchasesCalendar.tsx | 336 +++++++++++++++++++++++ 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/PurchasesCalendar.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42e0a23..bb4b47d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index faf3ca8..b166db0 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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() { + {can(user, 'disputes') && } diff --git a/frontend/src/pages/PurchasesCalendar.tsx b/frontend/src/pages/PurchasesCalendar.tsx new file mode 100644 index 0000000..0ac998f --- /dev/null +++ b/frontend/src/pages/PurchasesCalendar.tsx @@ -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 + total: string + percentage: string +} + +interface WeeklyPurchasesResponse { + week_start: string + week_end: string + dates: string[] + suppliers: SupplierRow[] + daily_totals: Record + 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({ + 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 ( +
+
+

Purchases Chart

+
+ + + {data ? fmtWeekRange(data.week_start, data.week_end) : 'Loading…'} + + +
+
+ + {isLoading &&
Loading…
} + {error &&
Failed to load weekly purchases.
} + + {data && ( +
+ + + + + {data.dates.map((d, i) => ( + + ))} + + + + + + {data.suppliers.length === 0 && ( + + + + )} + {data.suppliers.map((row) => ( + + + {data.dates.map((d) => { + const invs = row.invoices_by_date[d] ?? [] + return ( + + ) + })} + + + + ))} + + + + + {data.dates.map((d) => ( + + ))} + + + +
Supplier +
{DAY_LABELS[i]}
+
{fmtDate(d)}
+
Total%
+ No invoices this week +
+ + {row.supplier_name} + + + {invs.map((inv) => ( + + + {inv.invoice_number ?? `#${inv.id}`} + + {fmt(inv.total)} + + ))} + {fmt(row.total)} + {parseFloat(row.percentage).toFixed(1)}% +
Daily Total + {fmt(data.daily_totals[d] ?? '0')} + + {fmt(data.week_total)} + +
+
+ )} +
+ ) +} + +const styles: Record = { + 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)', + }, +}