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:
parent
12aef0adb1
commit
1502ab8ed2
3 changed files with 1 additions and 339 deletions
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue