import { useState, useRef, useEffect, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useNavigate, useSearchParams } from 'react-router-dom' import { useAuth } from '../App' import { Line } from 'react-chartjs-2' import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, } from 'chart.js' ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend ) interface MonthlyPurchaseInvoice { id: number invoice_number: string | null invoice_date: string | null total: number | null net_total: number | null net_stock: number | null gross_stock: number | null supplier_match_type: string | null } interface MonthlySupplierRow { supplier_id: number | null supplier_name: string is_unmatched: boolean invoices_by_date: Record total_net_stock: number percentage: number } interface WeekData { week_start: string week_end: string dates: string[] suppliers: MonthlySupplierRow[] daily_totals: Record week_total: number daily_invoice_totals?: Record week_invoice_total?: number } interface DateRangePurchasesResponse { from_date: string to_date: string period_label: string weeks: WeekData[] all_suppliers: string[] daily_totals: Record period_total: number daily_invoice_totals?: Record period_invoice_total?: number } interface DailyDataPoint { date: string net_sales: number net_purchases: number } interface DailyGPChartResponse { from_date: string to_date: string data: DailyDataPoint[] } interface WeeklyAggregate { weekLabel: string weekStart: string weekEnd: string totalPurchases: number totalSales: number } interface DailyDisputeStats { daily_stats: Record } interface DailyLogbookStats { daily_stats: Record> } // Helper to format date as YYYY-MM-DD for input fields and API const formatDateForApi = (d: Date): string => { return d.toISOString().split('T')[0] } // Session storage keys for persisting dates while tab is open const STORAGE_KEY_FROM = 'purchases-report-from-date' const STORAGE_KEY_TO = 'purchases-report-to-date' // Get initial dates - from sessionStorage if available, otherwise default to last 4 weeks const getInitialDates = () => { const storedFrom = sessionStorage.getItem(STORAGE_KEY_FROM) const storedTo = sessionStorage.getItem(STORAGE_KEY_TO) if (storedFrom && storedTo) { return { from: storedFrom, to: storedTo } } // Default: End of this week (Sunday) backward 28 days (4 full weeks) const today = new Date() const daysUntilSunday = (7 - today.getDay()) % 7 const endOfWeek = new Date(today) endOfWeek.setDate(today.getDate() + daysUntilSunday) const twentyEightDaysAgo = new Date(endOfWeek) twentyEightDaysAgo.setDate(endOfWeek.getDate() - 27) return { from: formatDateForApi(twentyEightDaysAgo), to: formatDateForApi(endOfWeek) } } // Get the start and end of a given month const getMonthBounds = (year: number, month: number): { start: string; end: string } => { const start = new Date(year, month, 1) const end = new Date(year, month + 1, 0) // Last day of month return { start: formatDateForApi(start), end: formatDateForApi(end) } } // Generate list of months for the picker (last 12 months + next 2 months) const getMonthOptions = (): { label: string; year: number; month: number }[] => { const options: { label: string; year: number; month: number }[] = [] const today = new Date() for (let i = -12; i <= 2; i++) { const d = new Date(today.getFullYear(), today.getMonth() + i, 1) const label = d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) options.push({ label, year: d.getFullYear(), month: d.getMonth() }) } return options.reverse() // Most recent first } export default function Purchases() { const { token } = useAuth() const navigate = useNavigate() const [searchParams] = useSearchParams() const printRef = useRef(null) // Check URL params first, then session storage, then defaults const getInitialDatesWithUrlParams = () => { const urlFrom = searchParams.get('from') const urlTo = searchParams.get('to') if (urlFrom && urlTo) { return { from: urlFrom, to: urlTo, fromUrl: true } } const sessionDates = getInitialDates() return { ...sessionDates, fromUrl: false } } const initialDates = getInitialDatesWithUrlParams() // Input state (for typing without triggering queries) const [fromDate, setFromDate] = useState(initialDates.from) const [toDate, setToDate] = useState(initialDates.to) const [selectedMonth, setSelectedMonth] = useState('') // Empty means custom range const [selectionMode, setSelectionMode] = useState<'last28' | 'month' | 'custom'>(initialDates.fromUrl ? 'custom' : 'custom') // Submitted state (actually used for queries - only changes on Generate click or preset buttons) const [submittedFromDate, setSubmittedFromDate] = useState(initialDates.from) const [submittedToDate, setSubmittedToDate] = useState(initialDates.to) // Persist submitted dates to sessionStorage useEffect(() => { sessionStorage.setItem(STORAGE_KEY_FROM, submittedFromDate) sessionStorage.setItem(STORAGE_KEY_TO, submittedToDate) }, [submittedFromDate, submittedToDate]) const monthOptions = getMonthOptions() // Track if dates have changed since last generation const hasUnsavedChanges = fromDate !== submittedFromDate || toDate !== submittedToDate // Generate report with current date selection const handleGenerate = () => { setSubmittedFromDate(fromDate) setSubmittedToDate(toDate) } // When month selection changes, update the date fields and submit useEffect(() => { if (selectedMonth) { const [year, month] = selectedMonth.split('-').map(Number) const bounds = getMonthBounds(year, month) setFromDate(bounds.start) setToDate(bounds.end) setSubmittedFromDate(bounds.start) setSubmittedToDate(bounds.end) } }, [selectedMonth]) // When date fields are manually changed, clear the month selection const handleFromDateChange = (value: string) => { setFromDate(value) setSelectedMonth('') setSelectionMode('custom') } const handleToDateChange = (value: string) => { setToDate(value) setSelectedMonth('') setSelectionMode('custom') } const handleMonthChange = (value: string) => { setSelectedMonth(value) if (value) { setSelectionMode('month') } else { setSelectionMode('custom') } } // Quick preset buttons - these immediately submit the dates const setLast4Weeks = () => { const now = new Date() const daysUntilSun = (7 - now.getDay()) % 7 const end = new Date(now) end.setDate(now.getDate() + daysUntilSun) // End of this week (Sunday) const start = new Date(end) start.setDate(end.getDate() - 27) // 28 days total const startStr = formatDateForApi(start) const endStr = formatDateForApi(end) setFromDate(startStr) setToDate(endStr) setSubmittedFromDate(startStr) setSubmittedToDate(endStr) setSelectedMonth('') setSelectionMode('last28') } const setThisMonth = () => { const now = new Date() setSelectedMonth(`${now.getFullYear()}-${now.getMonth()}`) setSelectionMode('month') } const setLastMonth = () => { const now = new Date() const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) setSelectedMonth(`${lastMonth.getFullYear()}-${lastMonth.getMonth()}`) setSelectionMode('month') } // Get the period prefix based on selection mode const getPeriodPrefix = (): string => { if (selectionMode === 'last28') { return 'Last 4 Weeks: ' } else if (selectionMode === 'month' && selectedMonth) { const [year, month] = selectedMonth.split('-').map(Number) const monthName = new Date(year, month, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) return `Month of ${monthName}: ` } else { return 'Custom Dates: ' } } const [showPrintView, setShowPrintView] = useState(false) const { data, isLoading, error } = useQuery({ queryKey: ['purchases-range', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/api/reports/purchases/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch purchases') return res.json() }, }) // Fetch daily dispute stats for the selected date range const { data: disputeStats } = useQuery({ queryKey: ['daily-dispute-stats', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/api/disputes/stats/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch dispute stats') return res.json() }, }) // Fetch daily allowance stats for the selected date range (all logbook entry types) const { data: allowanceStats } = useQuery({ queryKey: ['daily-allowance-stats', submittedFromDate, submittedToDate], queryFn: async () => { const res = await fetch(`/api/logbook/daily-stats?date_from=${submittedFromDate}&date_to=${submittedToDate}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch allowance stats') return res.json() }, }) // Helper to get allowance breakdown for a date, split into wastage vs other const getAllowanceBreakdown = (dateStr: string): { wastageTotal: number; otherTotal: number; total: number; wastageBreakdown: string; otherBreakdown: string; breakdown: string } => { const dayStats = allowanceStats?.daily_stats?.[dateStr] if (!dayStats) return { wastageTotal: 0, otherTotal: 0, total: 0, wastageBreakdown: '', otherBreakdown: '', breakdown: '' } const typeLabels: Record = { wastage: 'Wastage', transfer: 'Transfer', staff_food: 'Staff Food', manual_adjustment: 'Manual Adjustment' } let wastageTotal = 0 let otherTotal = 0 const allParts: string[] = [] const wastageParts: string[] = [] const otherParts: string[] = [] for (const [type, stats] of Object.entries(dayStats)) { if (stats && stats.total_cost > 0) { const label = typeLabels[type] || type const formatted = `${label}: £${stats.total_cost.toFixed(2)}` allParts.push(formatted) if (type === 'wastage') { wastageTotal += stats.total_cost wastageParts.push(formatted) } else { otherTotal += stats.total_cost otherParts.push(formatted) } } } return { wastageTotal, otherTotal, total: wastageTotal + otherTotal, wastageBreakdown: wastageParts.join('\n'), otherBreakdown: otherParts.join('\n'), breakdown: allParts.join('\n') } } // Calculate date range for last 24 weeks (for weekly comparison chart) const weeklyChartDateRange = useMemo(() => { const now = new Date() // Find the most recent Sunday const daysUntilSun = (7 - now.getDay()) % 7 const endDate = new Date(now) endDate.setDate(now.getDate() + daysUntilSun) // Go back 24 weeks (168 days) from the end date const startDate = new Date(endDate) startDate.setDate(endDate.getDate() - (24 * 7 - 1)) return { from: formatDateForApi(startDate), to: formatDateForApi(endDate) } }, []) // Fetch daily GP data for the last 24 weeks const { data: weeklyChartData, isLoading: weeklyChartLoading, error: weeklyChartError } = useQuery({ queryKey: ['weekly-chart-data', weeklyChartDateRange.from, weeklyChartDateRange.to], queryFn: async () => { const res = await fetch(`/api/reports/gp/daily?from_date=${weeklyChartDateRange.from}&to_date=${weeklyChartDateRange.to}`, { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to fetch weekly chart data') return res.json() }, }) // Aggregate daily data into weekly totals const weeklyAggregates = useMemo(() => { if (!weeklyChartData?.data) return [] const weeks: WeeklyAggregate[] = [] const startDate = new Date(weeklyChartDateRange.from) // Process each week for (let weekIdx = 0; weekIdx < 24; weekIdx++) { const weekStart = new Date(startDate) weekStart.setDate(startDate.getDate() + (weekIdx * 7)) const weekEnd = new Date(weekStart) weekEnd.setDate(weekStart.getDate() + 6) const weekStartStr = formatDateForApi(weekStart) const weekEndStr = formatDateForApi(weekEnd) // Sum up daily values for this week let totalPurchases = 0 let totalSales = 0 let matchedDays = 0 for (const point of weeklyChartData.data) { if (point.date >= weekStartStr && point.date <= weekEndStr) { totalPurchases += Number(point.net_purchases) || 0 totalSales += Number(point.net_sales) || 0 matchedDays++ } } // Format week label const weekStartDate = new Date(weekStartStr) const weekEndDate = new Date(weekEndStr) const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' } const weekLabel = `${weekStartDate.toLocaleDateString('en-GB', opts)} - ${weekEndDate.toLocaleDateString('en-GB', opts)}` weeks.push({ weekLabel, weekStart: weekStartStr, weekEnd: weekEndStr, totalPurchases, totalSales }) } return weeks }, [weeklyChartData, weeklyChartDateRange]) // Split into last 12 weeks and previous 12 weeks const last12Weeks = weeklyAggregates.slice(12, 24) const previous12Weeks = weeklyAggregates.slice(0, 12) // Prepare chart data const weeklyComparisonChartData = useMemo(() => { return { labels: last12Weeks.map(w => w.weekLabel), datasets: [ { label: 'Purchases (Last 12 Weeks)', data: last12Weeks.map(w => w.totalPurchases), borderColor: '#e94560', backgroundColor: 'rgba(233, 69, 96, 0.1)', borderWidth: 2, tension: 0.1, pointRadius: 4, pointBackgroundColor: '#e94560', yAxisID: 'y', }, { label: 'Net Food Sales (Last 12 Weeks)', data: last12Weeks.map(w => w.totalSales), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', borderWidth: 2, tension: 0.1, pointRadius: 4, pointBackgroundColor: '#3b82f6', yAxisID: 'y', }, { label: 'Purchases (Previous 12 Weeks)', data: previous12Weeks.map(w => w.totalPurchases), borderColor: 'rgba(233, 69, 96, 0.4)', backgroundColor: 'transparent', borderWidth: 2, borderDash: [5, 5], tension: 0.1, pointRadius: 3, pointBackgroundColor: 'rgba(233, 69, 96, 0.4)', yAxisID: 'y', }, { label: 'Net Food Sales (Previous 12 Weeks)', data: previous12Weeks.map(w => w.totalSales), borderColor: 'rgba(59, 130, 246, 0.4)', backgroundColor: 'transparent', borderWidth: 2, borderDash: [5, 5], tension: 0.1, pointRadius: 3, pointBackgroundColor: 'rgba(59, 130, 246, 0.4)', yAxisID: 'y', }, { label: 'GP % (Last 12 Weeks)', data: last12Weeks.map(w => { const gp = w.totalSales > 0 ? ((w.totalSales - w.totalPurchases) / w.totalSales) * 100 : 0 return gp }), borderColor: '#10b981', backgroundColor: 'transparent', borderWidth: 2, borderDash: [8, 4], tension: 0.1, pointRadius: 4, pointBackgroundColor: '#10b981', yAxisID: 'y1', }, { label: 'GP % (Previous 12 Weeks)', data: previous12Weeks.map(w => { const gp = w.totalSales > 0 ? ((w.totalSales - w.totalPurchases) / w.totalSales) * 100 : 0 return gp }), borderColor: 'rgba(16, 185, 129, 0.4)', backgroundColor: 'transparent', borderWidth: 2, borderDash: [8, 4], tension: 0.1, pointRadius: 3, pointBackgroundColor: 'rgba(16, 185, 129, 0.4)', yAxisID: 'y1', }, ], } }, [last12Weeks, previous12Weeks]) const weeklyComparisonChartOptions = { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top' as const, labels: { usePointStyle: true, padding: 15, }, }, title: { display: true, text: 'Weekly Purchases vs Net Food Sales (Last 12 Weeks with Previous 12 Weeks Comparison)', font: { size: 16, weight: 'bold' as const, }, padding: { bottom: 20, }, }, tooltip: { callbacks: { label: function(context: any) { if (context.dataset.yAxisID === 'y1') { return `${context.dataset.label}: ${context.parsed.y.toFixed(1)}%` } return `${context.dataset.label}: £${context.parsed.y.toFixed(2)}` } } } }, scales: { y: { type: 'linear' as const, position: 'left' as const, // Don't force beginAtZero - allow negative values (credit notes) to display ticks: { callback: function(value: any) { return '£' + value.toLocaleString() } }, title: { display: true, text: 'Amount (£)', }, }, y1: { type: 'linear' as const, position: 'right' as const, beginAtZero: true, grid: { drawOnChartArea: false, }, ticks: { callback: function(value: any) { return value.toFixed(0) + '%' } }, title: { display: true, text: 'GP %', }, }, x: { title: { display: true, text: 'Week', }, }, }, } const formatDate = (dateStr: string) => { const date = new Date(dateStr) return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' }) } const formatWeekRange = (start: string, end: string) => { const startDate = new Date(start) const endDate = new Date(end) const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' } return `${startDate.toLocaleDateString('en-GB', opts)} - ${endDate.toLocaleDateString('en-GB', opts)}` } // Check if a date is in the selected range const isInRange = (dateStr: string) => { return dateStr >= fromDate && dateStr <= toDate } // Get all invoices flat for printable view const getAllInvoices = (): { invoice: MonthlyPurchaseInvoice; supplierName: string }[] => { if (!data) return [] const all: { invoice: MonthlyPurchaseInvoice; supplierName: string }[] = [] for (const week of data.weeks) { for (const supplier of week.suppliers) { for (const dateStr of Object.keys(supplier.invoices_by_date)) { for (const inv of supplier.invoices_by_date[dateStr]) { // Avoid duplicates (invoice might appear in multiple week iterations if on boundary) if (!all.find(x => x.invoice.id === inv.id)) { all.push({ invoice: inv, supplierName: supplier.supplier_name }) } } } } } // Sort by date all.sort((a, b) => { const dateA = a.invoice.invoice_date || '' const dateB = b.invoice.invoice_date || '' return dateA.localeCompare(dateB) }) return all } const handlePrint = () => { setShowPrintView(true) setTimeout(() => { window.print() setShowPrintView(false) }, 100) } if (isLoading) { return
Loading purchases...
} if (error) { return
Error loading purchases: {(error as Error).message}
} const { weeks = [], period_total = 0, period_label = '' } = data || {} // Printable view if (showPrintView) { const allInvoices = getAllInvoices() return ( <> {/* Hide header/nav when printing */}

Purchases Report - {getPeriodPrefix()}{period_label}

{allInvoices.map(({ invoice, supplierName }) => { // Get values with fallbacks - net and gross fall back to each other const rawGrossTotal = Number(invoice.total ?? 0) const rawNetTotal = Number(invoice.net_total ?? 0) // Net falls back to gross, gross falls back to net const netTotal = rawNetTotal || rawGrossTotal const grossTotal = rawGrossTotal || rawNetTotal const netStock = Number(invoice.net_stock ?? 0) // If gross_stock is null/0 but net_stock exists, estimate from net_stock using VAT ratio const rawGrossStock = Number(invoice.gross_stock ?? 0) const grossStock = rawGrossStock || (Math.abs(netStock) > 0 && Math.abs(grossTotal) > 0 && Math.abs(netTotal) > 0 ? netStock * (grossTotal / netTotal) : netStock) // Calculate non-stock values const netNonStock = netTotal - netStock const grossNonStock = grossTotal - grossStock // Credit notes have negative values const isCreditNote = netTotal < 0 || netStock < 0 // Helper to format value with CR suffix for negatives const formatValue = (val: number, showZero = false): string => { if (Math.abs(val) < 0.01 && !showZero) return '-' if (val < 0) return `-£${Math.abs(val).toFixed(2)} CR` return `£${val.toFixed(2)}` } return ( ) })} {(() => { // Calculate all totals using same fallback logic as rows const totals = allInvoices.reduce((acc, { invoice }) => { const rawGrossTotal = Number(invoice.total ?? 0) const rawNetTotal = Number(invoice.net_total ?? 0) const netTotal = rawNetTotal || rawGrossTotal const grossTotal = rawGrossTotal || rawNetTotal const netStock = Number(invoice.net_stock ?? 0) const rawGrossStock = Number(invoice.gross_stock ?? 0) const grossStock = rawGrossStock || (Math.abs(netStock) > 0 && Math.abs(grossTotal) > 0 && Math.abs(netTotal) > 0 ? netStock * (grossTotal / netTotal) : netStock) return { netStock: acc.netStock + netStock, grossStock: acc.grossStock + grossStock, netTotal: acc.netTotal + netTotal, grossTotal: acc.grossTotal + grossTotal, } }, { netStock: 0, grossStock: 0, netTotal: 0, grossTotal: 0 }) const netNonStock = totals.netTotal - totals.netStock const grossNonStock = totals.grossTotal - totals.grossStock // Helper to format value with CR suffix for negatives const formatTotal = (val: number, showZero = false): string => { if (Math.abs(val) < 0.01 && !showZero) return '-' if (val < 0) return `-£${Math.abs(val).toFixed(2)} CR` return `£${val.toFixed(2)}` } return ( ) })()}
Date Supplier Invoice # Net Stock Gross Stock Net Non-Stock Gross Non-Stock Net Total Gross Total
{invoice.invoice_date || '-'} {supplierName}{isCreditNote && ' (CR)'} {invoice.invoice_number || '-'} {formatValue(netStock, true)} {formatValue(grossStock, true)} {formatValue(netNonStock)} {formatValue(grossNonStock)} {formatValue(netTotal, true)} {formatValue(grossTotal, true)}
Period Total {formatTotal(totals.netStock, true)} {formatTotal(totals.grossStock, true)} {formatTotal(netNonStock)} {formatTotal(grossNonStock)} {formatTotal(totals.netTotal, true)} {formatTotal(totals.grossTotal, true)}
) } return (
{/* Header */}

Purchases Report

{/* Date Selection Controls */}
handleFromDateChange(e.target.value)} style={styles.dateInput} />
handleToDateChange(e.target.value)} style={styles.dateInput} />
{/* Period Label */}
{getPeriodPrefix()}{period_label}
{weeks.length === 0 ? (

No purchases in this period.

) : ( <> {/* Daily Spending by Supplier section */}

Daily Spending by Supplier

{[...weeks].reverse().map((week, reversedIdx) => { const weekIdx = weeks.length - 1 - reversedIdx // Original index for week number // Check if week has any data in the selected range const hasDataInRange = week.dates.some(d => { const dateStr = d if (!isInRange(dateStr)) return false return week.suppliers.some(s => s.invoices_by_date[dateStr]?.length > 0) }) if (!hasDataInRange && week.week_total === 0) return null return (
Week {weekIdx + 1} {formatWeekRange(week.week_start, week.week_end)} Week Total: {Number(week.week_total) < 0 ? '-' : ''}£{Math.abs(Number(week.week_total)).toFixed(2)}{Number(week.week_total) < 0 && ' CR'}
{week.dates.map((d) => { const dateStr = d const inRange = isInRange(dateStr) return ( ) })} {week.suppliers.map((supplier) => { // Only show suppliers with invoices in this week const hasWeekData = Object.values(supplier.invoices_by_date).some(invs => invs.length > 0) if (!hasWeekData) return null return ( {week.dates.map((d) => { const dateStr = d const invoices = supplier.invoices_by_date[dateStr] || [] const inRange = isInRange(dateStr) return ( ) })} ) })} {week.dates.map((d) => { const dateStr = d const inRange = isInRange(dateStr) const stockTotal = Number(week.daily_totals[dateStr] ?? 0) const invoiceTotal = Number(week.daily_invoice_totals?.[dateStr] ?? 0) const hasDifference = Math.abs(invoiceTotal) > 0.01 && Math.abs(stockTotal - invoiceTotal) > 0.01 const isNegative = stockTotal < 0 return ( ) })} {/* Disputes row */} {week.dates.map((d) => { const dateStr = d const inRange = isInRange(dateStr) const dayStats = disputeStats?.daily_stats?.[dateStr] const hasDisputes = dayStats && dayStats.total_disputed > 0 return ( ) })} {/* Allowances row (wastage in purple, other allowances in amber) */} {week.dates.map((d) => { const dateStr = d const inRange = isInRange(dateStr) const { wastageTotal, otherTotal, total, wastageBreakdown, otherBreakdown } = getAllowanceBreakdown(dateStr) const hasAllowances = total > 0 return ( ) })}
Supplier {formatDate(dateStr)} Total %
{supplier.supplier_name} {supplier.is_unmatched && !} {invoices.length > 0 ? (
{invoices.map((inv) => { const netStock = Number(inv.net_stock ?? 0) // Use net_total if available, otherwise fall back to total (for invoices without VAT) const netTotal = Number(inv.net_total ?? inv.total ?? 0) const isNonStockOnly = netStock === 0 && netTotal !== 0 // Compare absolute values to handle credit notes (net_stock is negative, net_total may be positive) const hasMixedItems = netStock !== 0 && Math.abs(Math.abs(netStock) - Math.abs(netTotal)) > 0.01 // Credit notes have negative values const isCreditNote = netStock < 0 || netTotal < 0 return ( ) })}
) : ( - )}
{(() => { const stockTotal = Number(supplier.total_net_stock) // Calculate invoice total from all invoices for this supplier const invoiceTotal = Object.values(supplier.invoices_by_date) .flat() .reduce((sum, inv) => sum + Number(inv.net_total ?? inv.total ?? 0), 0) const hasDifference = Math.abs(invoiceTotal) > 0.01 && Math.abs(stockTotal - invoiceTotal) > 0.01 const isNegative = stockTotal < 0 return ( <> {isNegative ? '-' : ''}£{Math.abs(stockTotal).toFixed(2)}{isNegative && ' CR'} {hasDifference && (
({invoiceTotal < 0 ? '-' : ''}£{Math.abs(invoiceTotal).toFixed(2)})
)} ) })()}
{Number(supplier.percentage).toFixed(1)}%
Daily Total {isNegative ? '-' : ''}£{Math.abs(stockTotal).toFixed(2)}{isNegative && ' CR'} {hasDifference && (
({invoiceTotal < 0 ? '-' : ''}£{Math.abs(invoiceTotal).toFixed(2)})
)}
{Number(week.week_total) < 0 ? '-' : ''}£{Math.abs(Number(week.week_total)).toFixed(2)}{Number(week.week_total) < 0 && ' CR'} {week.week_invoice_total != null && Math.abs(week.week_invoice_total - week.week_total) > 0.01 && (
({Number(week.week_invoice_total) < 0 ? '-' : ''}£{Math.abs(Number(week.week_invoice_total)).toFixed(2)})
)}
100%
Disputes {hasDisputes ? (
{dayStats.unresolved_total > 0 && ( )} {dayStats.resolved_total > 0 && ( )}
) : ( - )}
{(() => { const weekEntries = Object.entries(disputeStats?.daily_stats || {}) .filter(([d]) => week.dates.includes(d)) const weekUnresolved = weekEntries.reduce((sum, [, s]) => sum + (s?.unresolved_total || 0), 0) const weekResolved = weekEntries.reduce((sum, [, s]) => sum + (s?.resolved_total || 0), 0) return (
{weekUnresolved > 0 && £{weekUnresolved.toFixed(2)}} {weekResolved > 0 && £{weekResolved.toFixed(2)}} {weekUnresolved === 0 && weekResolved === 0 && £0.00}
) })()}
Allowances {hasAllowances ? (
{wastageTotal > 0 && ( )} {otherTotal > 0 && ( )}
) : ( - )}
{(() => { const weekWastage = week.dates.reduce((sum, d) => sum + getAllowanceBreakdown(d).wastageTotal, 0) const weekOther = week.dates.reduce((sum, d) => sum + getAllowanceBreakdown(d).otherTotal, 0) return (
{weekWastage > 0 && £{weekWastage.toFixed(2)}} {weekOther > 0 && £{weekOther.toFixed(2)}} {weekWastage === 0 && weekOther === 0 && £0.00}
) })()}
) })}
{/* Period Total */}
Period Total: {Number(period_total) < 0 ? '-' : ''}£{Math.abs(Number(period_total)).toFixed(2)}{Number(period_total) < 0 && ' CR'}
)} {/* Weekly Comparison Chart - Always show at bottom */}
{weeklyChartLoading ? (
Loading weekly comparison chart...
) : weeklyChartError ? (
Error loading chart: {(weeklyChartError as Error).message}
) : weeklyAggregates.length > 0 ? (
) : (
No data available for weekly comparison chart
{weeklyChartData && (
Debug: Date range {weeklyChartDateRange.from} to {weeklyChartDateRange.to}
Data points received: {weeklyChartData.data?.length || 0}
)}
)}
) } const styles: Record = { loading: { padding: '2rem', textAlign: 'center', color: '#666', }, error: { padding: '2rem', textAlign: 'center', color: '#c00', background: '#fee', borderRadius: '8px', }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem', }, title: { color: '#1a1a2e', margin: 0, }, printBtn: { padding: '0.5rem 1rem', background: '#6c757d', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem', }, dateControls: { background: 'white', borderRadius: '12px', padding: '1.5rem', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', marginBottom: '1rem', }, dateRow: { display: 'flex', gap: '1.5rem', flexWrap: 'wrap', marginBottom: '1rem', }, dateField: { display: 'flex', flexDirection: 'column', gap: '0.25rem', }, dateLabel: { fontSize: '0.85rem', color: '#666', fontWeight: 500, }, dateInput: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem', minWidth: '150px', }, monthSelect: { padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem', minWidth: '180px', background: 'white', }, presetRow: { display: 'flex', gap: '0.5rem', flexWrap: 'wrap', }, presetBtn: { padding: '0.4rem 0.75rem', background: '#f0f0f0', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem', }, generateBtn: { padding: '0.4rem 1rem', background: '#e94560', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 'bold', marginLeft: 'auto', }, generateBtnDisabled: { background: '#ccc', cursor: 'default', }, periodLabel: { fontSize: '1.1rem', fontWeight: 'bold', color: '#1a1a2e', marginBottom: '1rem', textAlign: 'center', }, periodTotalContainer: { background: 'white', borderRadius: '12px', padding: '1rem 1.5rem', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', textAlign: 'right', fontSize: '1.1rem', color: '#e94560', }, empty: { background: 'white', padding: '3rem', borderRadius: '12px', textAlign: 'center', color: '#666', }, sectionContainer: { background: 'white', borderRadius: '12px', padding: '1.5rem', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', marginBottom: '2rem', }, chartContainer: { background: 'white', borderRadius: '12px', padding: '1.5rem', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', marginTop: '2rem', }, sectionTitle: { margin: '0 0 1rem 0', color: '#1a1a2e', }, weekContainer: { marginBottom: '2rem', }, weekHeader: { display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.5rem', flexWrap: 'wrap', }, weekTitle: { fontWeight: 'bold', fontSize: '1rem', color: '#1a1a2e', }, weekRange: { color: '#666', fontSize: '0.9rem', }, weekTotal: { marginLeft: 'auto', fontWeight: 'bold', color: '#e94560', }, tableContainer: { background: 'white', borderRadius: '12px', overflow: 'auto', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', }, table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem', }, th: { padding: '0.75rem 0.5rem', background: '#1a1a2e', color: 'white', fontWeight: '600', textAlign: 'center', whiteSpace: 'nowrap', }, supplierHeader: { textAlign: 'left', paddingLeft: '1rem', minWidth: '150px', }, totalHeader: { background: '#2d2d44', }, percentHeader: { background: '#2d2d44', minWidth: '60px', }, outOfMonth: { background: '#3d3d54', opacity: 0.7, }, outOfMonthCell: { background: '#f8f9fa', opacity: 0.6, }, tr: { borderBottom: '1px solid #eee', }, td: { padding: '0.5rem', textAlign: 'center', verticalAlign: 'top', }, supplierCell: { textAlign: 'left', paddingLeft: '1rem', fontWeight: '500', whiteSpace: 'nowrap', }, unmatchedSupplier: { color: '#721c24', background: '#f8d7da', }, unmatchedBadge: { display: 'inline-block', marginLeft: '0.3rem', width: '14px', height: '14px', lineHeight: '14px', textAlign: 'center', background: '#dc3545', color: 'white', borderRadius: '50%', fontSize: '0.7rem', fontWeight: 'bold', }, invoicesCell: { display: 'flex', flexDirection: 'column', gap: '0.25rem', alignItems: 'center', }, invoiceBtn: { display: 'inline-block', padding: '0.2rem 0.5rem', background: '#e8f4e8', color: '#155724', borderRadius: '4px', textDecoration: 'none', fontSize: '0.85rem', fontWeight: '500', border: '1px solid #c3e6cb', cursor: 'pointer', }, fuzzyInvoice: { background: '#fff3cd', color: '#856404', border: '1px solid #ffc107', }, nonStockInvoice: { background: '#e9ecef', color: '#6c757d', border: '1px solid #ced4da', }, unmatchedInvoice: { background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb', }, creditNoteInvoice: { background: '#d4edda', color: '#155724', border: '2px solid #28a745', fontWeight: 'bold', }, emptyCell: { color: '#ccc', }, totalCell: { fontWeight: 'bold', background: '#f8f9fa', }, negativeTotalCell: { fontWeight: 'bold', background: '#d4edda', color: '#155724', }, percentCell: { fontWeight: '500', background: '#f8f9fa', color: '#666', }, footerRow: { background: '#f8f9fa', borderTop: '2px solid #dee2e6', }, footerLabel: { textAlign: 'left', paddingLeft: '1rem', fontWeight: 'bold', }, footerCell: { fontWeight: 'bold', }, grandTotal: { fontWeight: 'bold', background: '#1a1a2e', color: 'white', }, // Stats rows (disputes, wastage) statsRow: { background: '#fafbfc', borderTop: '1px solid #e9ecef', }, statsLabel: { textAlign: 'left', paddingLeft: '1rem', fontWeight: '500', color: '#6c757d', fontSize: '0.85rem', }, statsBtn: { display: 'inline-block', padding: '0.2rem 0.5rem', background: '#dc3545', color: 'white', borderRadius: '4px', fontSize: '0.8rem', fontWeight: '600', border: 'none', cursor: 'pointer', minWidth: '28px', }, statsBtnResolved: { display: 'inline-block', padding: '0.2rem 0.5rem', background: '#6c757d', color: 'white', borderRadius: '4px', fontSize: '0.8rem', fontWeight: '600', border: 'none', cursor: 'pointer', minWidth: '28px', }, allowancePurpleBtn: { display: 'inline-block', padding: '0.2rem 0.5rem', background: '#6f42c1', color: 'white', borderRadius: '4px', fontSize: '0.8rem', fontWeight: '600', border: 'none', cursor: 'pointer', }, wastageBtn: { display: 'inline-block', padding: '0.2rem 0.5rem', background: '#fd7e14', color: 'white', borderRadius: '4px', fontSize: '0.8rem', fontWeight: '600', border: 'none', cursor: 'pointer', }, statsTotal: { fontWeight: '600', color: '#6c757d', fontSize: '0.85rem', }, // Monthly spending calendar calendarContainer: { background: 'white', borderRadius: '12px', padding: '1.5rem', boxShadow: '0 2px 8px rgba(0,0,0,0.06)', marginTop: '2rem', }, calendarTitle: { margin: '0 0 1rem 0', color: '#1a1a2e', }, calendarGrid: { display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px', }, calendarDayHeader: { textAlign: 'center', fontWeight: 'bold', padding: '0.5rem', background: '#1a1a2e', color: 'white', fontSize: '0.8rem', }, calendarCellEmpty: { background: '#f8f9fa', minHeight: '60px', }, calendarCell: { background: '#f8f9fa', minHeight: '60px', padding: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', }, calendarCellWithData: { background: '#e8f4e8', border: '1px solid #c3e6cb', }, calendarDay: { fontSize: '0.85rem', fontWeight: '500', color: '#333', }, calendarAmount: { fontSize: '0.75rem', fontWeight: 'bold', color: '#155724', }, calendarFooter: { textAlign: 'right', marginTop: '1rem', fontSize: '1.1rem', color: '#e94560', }, // Print styles printContainer: { padding: '1rem', background: 'white', }, printTitle: { marginBottom: '1rem', color: '#1a1a2e', fontSize: '1.2rem', }, printTable: { width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem', }, printTableSmall: { width: '100%', borderCollapse: 'collapse', fontSize: '0.65rem', }, printTh: { padding: '0.3rem 0.4rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'left', fontWeight: 'bold', }, printThSmall: { padding: '0.2rem 0.3rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'left', fontWeight: 'bold', }, printThDate: { padding: '0.3rem 0.4rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'left', fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '80px', }, printThDateSmall: { padding: '0.2rem 0.3rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'left', fontWeight: 'bold', whiteSpace: 'nowrap', minWidth: '60px', }, printThRight: { padding: '0.3rem 0.4rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'right', fontWeight: 'bold', }, printThRightSmall: { padding: '0.2rem 0.3rem', background: '#f8f9fa', border: '1px solid #dee2e6', textAlign: 'right', fontWeight: 'bold', }, printTd: { padding: '0.2rem 0.4rem', border: '1px solid #dee2e6', }, printTdSmall: { padding: '0.15rem 0.2rem', border: '1px solid #dee2e6', }, printTdDate: { padding: '0.2rem 0.4rem', border: '1px solid #dee2e6', whiteSpace: 'nowrap', }, printTdDateSmall: { padding: '0.15rem 0.2rem', border: '1px solid #dee2e6', whiteSpace: 'nowrap', }, printTdRight: { padding: '0.2rem 0.4rem', border: '1px solid #dee2e6', textAlign: 'right', }, printTdRightSmall: { padding: '0.15rem 0.2rem', border: '1px solid #dee2e6', textAlign: 'right', }, printFooter: { background: '#f8f9fa', }, }