kitchen/frontend/src/components/GPReport.tsx
jtricerolph ba075276b1 Audit pass: cookie auth migration, route guards, GP% clamp, CSV export, OCR transaction safety, N+1 fix, and cleanup
- Migrated all 435 frontend fetch calls from Authorization Bearer header to credentials: 'include' (cookie auth)
- Removed ?token= from all file/image URLs (browser history exposure)
- Added ProtectedRoute wrapper to all capability-gated routes in App.tsx
- OCR background task: added transaction boundary, improved error handling and status rollback
- DuplicateDetector: wrapped in non-fatal try/except so crashes don't abort invoice processing
- File upload: commit DB row before writing to disk to prevent orphaned files
- GP% clamped to 100% in GPReport (credit notes can inflate above 100%)
- Added CSV export to GPReport (suppliers, daily data, allowances breakdown)
- Backend file-serving endpoints: cookie auth with ?token= fallback for backward compatibility
- DATA_DIR: moved from hardcoded /app/data to os.getenv in invoices.py and recipes.py
- N+1 fix in list_recipes: batch-loads latest cost snapshot in 1 query (was N)
- Zero-yield sub-recipe: logs warning instead of silently zeroing cost contribution
- Budget spend rate input: rejects negative values
- GPReport allowances toggle: persisted to localStorage across page loads
- DB pool_size/max_overflow: configurable via DB_POOL_SIZE/DB_MAX_OVERFLOW env vars
- Fixed SyntaxWarning from \\d in invoices.py docstring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 10:04:20 +00:00

1520 lines
53 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../App'
interface SupplierBreakdown {
supplier_id: number | null
supplier_name: string
net_purchases: number
percentage: number
}
interface GLAccountBreakdown {
gl_account_id: number
gl_account_name: string
net_revenue: number
percentage: number
}
interface DateRangeGPResponse {
from_date: string
to_date: string
period_label: string
net_food_sales: number
net_food_purchases: number
gross_profit: number
gross_profit_percent: number
supplier_breakdown: SupplierBreakdown[]
gl_account_breakdown: GLAccountBreakdown[]
// Cost distribution breakdown
cd_deductions_total: number | null
cd_reallocations_total: number | null
// Allowances breakdown by type
wastage_total: number | null
transfer_total: number | null
staff_food_total: number | null
manual_adjustment_total: number | null
disputes_total: number | null
}
interface DailyDataPoint {
date: string
net_sales: number
net_purchases: number
occupancy: number | null
lunch_covers: number | null
dinner_covers: number | null
total_covers: number | null
}
interface DailyChartData {
from_date: string
to_date: string
data: DailyDataPoint[]
}
interface TopSellerItem {
item_name: string
qty: number
revenue: number
}
interface PackageFavoriteItem {
item_name: string
qty: number
}
interface CategoryTopSellers {
category: string
top_by_qty: TopSellerItem[]
top_by_revenue: TopSellerItem[]
}
interface TopSellersResponse {
from_date: string
to_date: string
source: 'sambapos' | 'newbook'
// SambaPOS category-based format
categories: CategoryTopSellers[]
// Legacy Newbook format
top_by_qty: TopSellerItem[]
top_by_revenue: TopSellerItem[]
package_favorites: PackageFavoriteItem[]
total_charges_processed: number
total_items_aggregated: number
}
// Helper to format date as YYYY-MM-DD for input fields and API
const formatDate = (d: Date): string => {
return d.toISOString().split('T')[0]
}
// 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: formatDate(start), end: formatDate(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()
// Start from 12 months ago
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
}
// Session storage keys for persisting dates while tab is open
const STORAGE_KEY_FROM = 'gp-report-from-date'
const STORAGE_KEY_TO = 'gp-report-to-date'
// Get initial dates - from sessionStorage if available, otherwise default to last 30 days
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: rolling past 30 days from yesterday
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const thirtyDaysAgo = new Date(yesterday)
thirtyDaysAgo.setDate(yesterday.getDate() - 30)
return { from: formatDate(thirtyDaysAgo), to: formatDate(yesterday) }
}
export default function GPReport() {
const { token } = useAuth()
// Get initial dates (from session or defaults)
const initialDates = getInitialDates()
// Input state (for typing without triggering queries)
const [fromDate, setFromDate] = useState(initialDates.from)
const [toDate, setToDate] = useState(initialDates.to)
const [selectedMonth, setSelectedMonth] = useState<string>('') // Empty means custom range
const [selectionMode, setSelectionMode] = useState<'last30' | 'week' | 'month' | 'custom'>('custom')
// Submitted state (actually used for queries - only changes on Generate click)
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()
// Allowances checkbox state - default: all checked EXCEPT wastage
const _defaultAllowances = {
wastage: false,
transfer: true,
staffFood: true,
manualAdjustment: true,
disputes: true,
cdDeductions: true,
cdReallocations: true,
}
const [allowancesSelection, setAllowancesSelection] = useState(() => {
try {
const stored = localStorage.getItem('gpreport_allowances_v1')
return stored ? { ..._defaultAllowances, ...JSON.parse(stored) } : _defaultAllowances
} catch {
return _defaultAllowances
}
})
// 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
// Auto-adjust the other date if the range becomes invalid
const handleFromDateChange = (value: string) => {
setFromDate(value)
// If from date is after to date, set to date to match from date
if (value > toDate) {
setToDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleToDateChange = (value: string) => {
setToDate(value)
// If to date is before from date, set from date to match to date
if (value < fromDate) {
setFromDate(value)
}
setSelectedMonth('')
setSelectionMode('custom')
}
const handleMonthChange = (value: string) => {
setSelectedMonth(value)
if (value) {
setSelectionMode('month')
} else {
setSelectionMode('custom')
}
}
// Quick preset buttons - these also immediately submit the dates
const setLast30Days = () => {
const end = new Date()
end.setDate(end.getDate() - 1) // Yesterday (today won't have confirmed sales)
const start = new Date(end)
start.setDate(end.getDate() - 30)
const startStr = formatDate(start)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('last30')
}
const setThisMonth = () => {
const now = new Date()
const monthKey = `${now.getFullYear()}-${now.getMonth()}`
const bounds = getMonthBounds(now.getFullYear(), now.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setLastMonth = () => {
const now = new Date()
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const monthKey = `${lastMonth.getFullYear()}-${lastMonth.getMonth()}`
const bounds = getMonthBounds(lastMonth.getFullYear(), lastMonth.getMonth())
setFromDate(bounds.start)
setToDate(bounds.end)
setSubmittedFromDate(bounds.start)
setSubmittedToDate(bounds.end)
setSelectedMonth(monthKey)
setSelectionMode('month')
}
const setThisWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
// Calculate Monday of current week (0 = Sunday, 1 = Monday, etc.)
const monday = new Date(today)
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
// End date is yesterday (today won't have confirmed sales)
const end = new Date()
end.setDate(end.getDate() - 1)
const startStr = formatDate(monday)
const endStr = formatDate(end)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
const setLastWeek = () => {
const today = new Date()
const dayOfWeek = today.getDay()
// Calculate Monday of last week
const lastMonday = new Date(today)
lastMonday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1) - 7)
// Sunday of last week
const lastSunday = new Date(lastMonday)
lastSunday.setDate(lastMonday.getDate() + 6)
const startStr = formatDate(lastMonday)
const endStr = formatDate(lastSunday)
setFromDate(startStr)
setToDate(endStr)
setSubmittedFromDate(startStr)
setSubmittedToDate(endStr)
setSelectedMonth('')
setSelectionMode('week')
}
// Get the period prefix based on selection mode
const getPeriodPrefix = (): string => {
if (selectionMode === 'last30') {
return 'Last 30 Days: '
} else if (selectionMode === 'week') {
return 'Week: '
} 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 { data, isLoading, error } = useQuery<DateRangeGPResponse>({
queryKey: ['gp-range', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/range?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch GP data')
return res.json()
},
staleTime: 5 * 60 * 1000, // Keep data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
})
// Fetch daily chart data
const { data: chartData } = useQuery<DailyChartData>({
queryKey: ['gp-daily', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/daily?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
credentials: 'include',
})
if (!res.ok) throw new Error('Failed to fetch chart data')
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
// Fetch top sellers data
const { data: topSellers, isLoading: topSellersLoading } = useQuery<TopSellersResponse>({
queryKey: ['gp-top-sellers', submittedFromDate, submittedToDate],
queryFn: async () => {
const res = await fetch(`/kitchen/api/reports/gp/top-sellers?from_date=${submittedFromDate}&to_date=${submittedToDate}`, {
credentials: 'include',
})
if (!res.ok) {
// Don't throw for top sellers - just return empty data
return { from_date: submittedFromDate, to_date: submittedToDate, source: 'newbook' as const, categories: [], top_by_qty: [], top_by_revenue: [], package_favorites: [], total_charges_processed: 0, total_items_aggregated: 0 }
}
return res.json()
},
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
})
const formatCurrency = (value: number, showCR = false) => {
const num = Number(value)
if (num < 0 && showCR) {
return `${Math.abs(num).toFixed(2)} CR`
}
return `£${num.toFixed(2)}`
}
// Simple SVG line chart component
const renderChart = () => {
if (!chartData?.data?.length) return null
const width = 500
const height = 200
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const dataPoints = chartData.data
const maxValue = Math.max(
...dataPoints.map(d => Math.max(d.net_sales, d.net_purchases)),
1 // Avoid division by zero
)
// Scale functions
const xScale = (index: number) => padding.left + (index / (dataPoints.length - 1 || 1)) * chartWidth
const yScale = (value: number) => padding.top + chartHeight - (value / maxValue) * chartHeight
// Create path for sales line
const salesPath = dataPoints.map((d, i) =>
`${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.net_sales)}`
).join(' ')
// Create path for purchases line
const purchasesPath = dataPoints.map((d, i) =>
`${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.net_purchases)}`
).join(' ')
// Y-axis labels
const yLabels = [0, maxValue / 2, maxValue].map(v => ({
value: v,
y: yScale(v),
label: `£${(v / 1000).toFixed(v >= 1000 ? 0 : 1)}k`
}))
// X-axis labels (show first, middle, last dates)
const xLabels = [
{ index: 0, label: new Date(dataPoints[0].date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) },
{ index: Math.floor(dataPoints.length / 2), label: new Date(dataPoints[Math.floor(dataPoints.length / 2)]?.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) },
{ index: dataPoints.length - 1, label: new Date(dataPoints[dataPoints.length - 1].date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) },
]
// Find week boundaries (Mondays) for vertical lines
const weekLines: number[] = []
dataPoints.forEach((d, i) => {
const date = new Date(d.date)
if (date.getDay() === 1 && i > 0) { // Monday and not first point
weekLines.push(i)
}
})
return (
<div style={styles.chartContainer}>
<h4 style={styles.chartTitle}>Daily Sales & Purchases</h4>
<svg width={width} height={height} style={styles.chartSvg}>
{/* Horizontal grid lines */}
{yLabels.map((l, i) => (
<g key={i}>
<line
x1={padding.left}
y1={l.y}
x2={width - padding.right}
y2={l.y}
stroke="#eee"
strokeDasharray="4,4"
/>
<text x={padding.left - 5} y={l.y + 4} textAnchor="end" fontSize="10" fill="#666">
{l.label}
</text>
</g>
))}
{/* Vertical week boundary lines */}
{weekLines.map((index, i) => (
<line
key={`week-${i}`}
x1={xScale(index)}
y1={padding.top}
x2={xScale(index)}
y2={height - padding.bottom}
stroke="#ccc"
strokeDasharray="4,4"
/>
))}
{/* X-axis labels */}
{xLabels.map((l, i) => (
<text
key={i}
x={xScale(l.index)}
y={height - 8}
textAnchor="middle"
fontSize="10"
fill="#666"
>
{l.label}
</text>
))}
{/* Sales line (green) */}
<path d={salesPath} fill="none" stroke="#28a745" strokeWidth="2" />
{/* Purchases line (red) */}
<path d={purchasesPath} fill="none" stroke="#dc3545" strokeWidth="2" />
</svg>
<div style={styles.chartLegend}>
<span style={styles.legendItem}>
<span style={{ ...styles.legendColor, background: '#28a745' }} /> Net Sales
</span>
<span style={styles.legendItem}>
<span style={{ ...styles.legendColor, background: '#dc3545' }} /> Net Purchases
</span>
</div>
</div>
)
}
if (isLoading) {
return <div style={styles.loading}>Loading GP data...</div>
}
if (error) {
return <div style={styles.error}>Error loading GP data: {(error as Error).message}</div>
}
const {
period_label = '',
net_food_sales = 0,
net_food_purchases = 0,
gross_profit = 0,
gross_profit_percent = 0,
wastage_total = null,
transfer_total = null,
staff_food_total = null,
manual_adjustment_total = null,
disputes_total = null,
cd_deductions_total = null,
cd_reallocations_total = null,
} = data || {}
const isNegativeGP = gross_profit < 0
// Check which allowance types have data
const hasWastage = wastage_total !== null && wastage_total > 0
const hasTransfer = transfer_total !== null && transfer_total > 0
const hasStaffFood = staff_food_total !== null && staff_food_total > 0
const hasManualAdjustment = manual_adjustment_total !== null && manual_adjustment_total > 0
const hasDisputes = disputes_total !== null && disputes_total > 0
const hasCdDeductions = cd_deductions_total !== null && cd_deductions_total !== 0
const hasCdReallocations = cd_reallocations_total !== null && cd_reallocations_total !== 0
// Check if any allowances/adjustments data exists
const hasAnyAllowancesData = hasWastage || hasTransfer || hasStaffFood || hasManualAdjustment || hasDisputes || hasCdDeductions || hasCdReallocations
// Calculate selected allowances total based on checkbox selection
// Note: API returns decimals as strings, so we need Number() conversion
const salesNum = Number(net_food_sales) || 0
const purchasesNum = Number(net_food_purchases) || 0
const cdDeductions = Number(cd_deductions_total) || 0
const cdReallocations = Number(cd_reallocations_total) || 0
const calculateSelectedAdjustments = () => {
let total = 0
// CD adjustments reduce/increase purchases (deductions are negative = reduce cost)
if (allowancesSelection.cdDeductions && hasCdDeductions) total += cdDeductions // negative value
if (allowancesSelection.cdReallocations && hasCdReallocations) total += cdReallocations // positive value
return total
}
const calculateSelectedAllowances = () => {
let total = 0
if (allowancesSelection.wastage && hasWastage) total += Number(wastage_total) || 0
if (allowancesSelection.transfer && hasTransfer) total += Number(transfer_total) || 0
if (allowancesSelection.staffFood && hasStaffFood) total += Number(staff_food_total) || 0
if (allowancesSelection.manualAdjustment && hasManualAdjustment) total += Number(manual_adjustment_total) || 0
if (allowancesSelection.disputes && hasDisputes) total += Number(disputes_total) || 0
return total
}
const selectedCdTotal = calculateSelectedAdjustments()
const selectedAllowancesTotal = calculateSelectedAllowances()
const adjustedPurchases = purchasesNum + selectedCdTotal
const hasSelectedAllowances = selectedAllowancesTotal > 0 || selectedCdTotal !== 0
// Calculate GP with selected allowances + CD adjustments
const gpWithSelectedAllowances = salesNum > 0
? Math.min((salesNum - adjustedPurchases + selectedAllowancesTotal) / salesNum * 100, 100)
: 0
const downloadCSV = () => {
if (!data) return
const rows: string[][] = [
['Kitchen Flash GP Report'],
['Period', period_label],
[],
['Metric', 'Value'],
['Net Food Sales', net_food_sales.toString()],
['Net Food Purchases', net_food_purchases.toString()],
['Gross Profit', gross_profit.toString()],
['Gross Profit %', Number(gross_profit_percent).toFixed(2)],
]
if (hasAnyAllowancesData) {
rows.push([])
rows.push(['Adjustments', ''])
if (hasWastage) rows.push(['Wastage', (wastage_total ?? 0).toString()])
if (hasTransfer) rows.push(['Transfers', (transfer_total ?? 0).toString()])
if (hasStaffFood) rows.push(['Staff Food', (staff_food_total ?? 0).toString()])
if (hasManualAdjustment) rows.push(['Manual Adjustments', (manual_adjustment_total ?? 0).toString()])
if (hasDisputes) rows.push(['Disputes', (disputes_total ?? 0).toString()])
rows.push(['GP with Adjustments %', gpWithSelectedAllowances.toFixed(2)])
}
if (data.supplier_breakdown?.length) {
rows.push([])
rows.push(['Supplier', 'Net Purchases', '% of Total'])
data.supplier_breakdown.forEach(s => {
rows.push([s.supplier_name, s.net_purchases.toString(), s.percentage.toFixed(1)])
})
}
if (chartData?.data?.length) {
rows.push([])
rows.push(['Date', 'Net Sales', 'Net Purchases', 'Covers'])
chartData.data.forEach(d => {
rows.push([d.date, d.net_sales.toString(), d.net_purchases.toString(), (d.total_covers ?? '').toString()])
})
}
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `kitchen-flash-${submittedFromDate}-to-${submittedToDate}.csv`
a.click()
URL.revokeObjectURL(url)
}
// Toggle checkbox handler
const toggleAllowance = (key: keyof typeof allowancesSelection) => {
setAllowancesSelection(prev => {
const next = { ...prev, [key]: !prev[key] }
try { localStorage.setItem('gpreport_allowances_v1', JSON.stringify(next)) } catch {}
return next
})
}
return (
<div>
{/* Header */}
<div style={styles.header}>
<h2 style={styles.title}>Kitchen Flash Report</h2>
</div>
{/* Date Selection Controls */}
<div style={styles.dateControls}>
<div style={styles.dateRow}>
<div style={styles.dateField}>
<label style={styles.dateLabel}>From</label>
<input
type="date"
value={fromDate}
onChange={(e) => handleFromDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>To</label>
<input
type="date"
value={toDate}
onChange={(e) => handleToDateChange(e.target.value)}
style={styles.dateInput}
/>
</div>
<div style={styles.dateField}>
<label style={styles.dateLabel}>Month</label>
<select
value={selectedMonth}
onChange={(e) => handleMonthChange(e.target.value)}
style={styles.monthSelect}
>
<option value="">Custom Range</option>
{monthOptions.map((opt) => (
<option key={`${opt.year}-${opt.month}`} value={`${opt.year}-${opt.month}`}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div style={styles.presetRow}>
<button onClick={setLast30Days} style={styles.presetBtn}>Last 30 Days</button>
<button onClick={setThisWeek} style={styles.presetBtn}>This Week</button>
<button onClick={setLastWeek} style={styles.presetBtn}>Last Week</button>
<button onClick={setThisMonth} style={styles.presetBtn}>This Month</button>
<button onClick={setLastMonth} style={styles.presetBtn}>Last Month</button>
<button
onClick={handleGenerate}
disabled={!hasUnsavedChanges}
style={{
...styles.generateBtn,
...(hasUnsavedChanges ? {} : styles.generateBtnDisabled),
}}
>
{hasUnsavedChanges ? 'Generate Report' : 'Report Generated'}
</button>
</div>
</div>
{/* Period Label */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div style={styles.periodLabel}>{getPeriodPrefix()}{period_label}</div>
{data && (
<button onClick={downloadCSV} style={styles.csvBtn}>
Download CSV
</button>
)}
</div>
{/* Main Content - GP Estimate and Chart side by side */}
<div style={styles.mainContent}>
{/* Gross Profit Estimate Section */}
<div style={styles.sectionContainer}>
<h3 style={styles.sectionTitle}>Gross Profit Estimate</h3>
<div style={styles.calculationContainer}>
<div style={styles.calcRow}>
<span style={styles.calcLabel}>Net Food Sales</span>
<span style={styles.calcValue}>{formatCurrency(net_food_sales)}</span>
</div>
<div style={styles.calcRow}>
<span style={styles.calcLabel}>Net Food Purchases</span>
<span style={styles.calcValue}>{formatCurrency(net_food_purchases)}</span>
</div>
<div style={styles.divider} />
<div style={styles.calcRow}>
<span style={styles.calcLabelBold}>Gross Profit</span>
<span style={{ ...styles.calcValueBold, ...(isNegativeGP ? styles.negativeValue : styles.positiveValue) }}>
{formatCurrency(gross_profit)}
</span>
</div>
<div style={styles.calcRow}>
<span style={styles.calcLabelBold}>Gross Profit %</span>
<span style={{ ...styles.calcValueBold, ...(isNegativeGP ? styles.negativeValue : styles.positiveValue) }}>
{Number(gross_profit_percent).toFixed(1)}%
</span>
</div>
{hasAnyAllowancesData && (
<>
<div style={styles.wastageSection} />
<div style={styles.allowancesHeader}>Adjustments</div>
{hasCdDeductions && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.cdDeductions}
onChange={() => toggleAllowance('cdDeductions')}
/>
<span>Distributed Deductions</span>
</label>
<span style={{ ...styles.calcValue, color: '#2e7d32' }}>
{formatCurrency(cd_deductions_total || 0)}
</span>
</div>
)}
{hasCdReallocations && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.cdReallocations}
onChange={() => toggleAllowance('cdReallocations')}
/>
<span>Distributed Reallocations</span>
</label>
<span style={{ ...styles.calcValue, color: '#e65100' }}>
+{formatCurrency(cd_reallocations_total || 0)}
</span>
</div>
)}
{hasWastage && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.wastage}
onChange={() => toggleAllowance('wastage')}
/>
<span>Wastage</span>
</label>
<span style={{ ...styles.calcValue, color: '#e74c3c' }}>
{formatCurrency(wastage_total || 0)}
</span>
</div>
)}
{hasTransfer && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.transfer}
onChange={() => toggleAllowance('transfer')}
/>
<span>Transfers</span>
</label>
<span style={{ ...styles.calcValue, color: '#3498db' }}>
{formatCurrency(transfer_total || 0)}
</span>
</div>
)}
{hasStaffFood && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.staffFood}
onChange={() => toggleAllowance('staffFood')}
/>
<span>Staff Food</span>
</label>
<span style={{ ...styles.calcValue, color: '#9b59b6' }}>
{formatCurrency(staff_food_total || 0)}
</span>
</div>
)}
{hasManualAdjustment && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.manualAdjustment}
onChange={() => toggleAllowance('manualAdjustment')}
/>
<span>Manual Adjustments</span>
</label>
<span style={{ ...styles.calcValue, color: '#34495e' }}>
{formatCurrency(manual_adjustment_total || 0)}
</span>
</div>
)}
{hasDisputes && (
<div style={styles.allowanceRow}>
<label style={styles.allowanceCheckbox}>
<input
type="checkbox"
checked={allowancesSelection.disputes}
onChange={() => toggleAllowance('disputes')}
/>
<span>Open Disputes</span>
</label>
<span style={{ ...styles.calcValue, color: '#e67e22' }}>
{formatCurrency(disputes_total || 0)}
</span>
</div>
)}
<div style={styles.divider} />
{selectedCdTotal !== 0 && (
<div style={styles.calcRow}>
<span style={styles.calcLabel}>Adjusted Purchases</span>
<span style={styles.calcValue}>
{formatCurrency(adjustedPurchases)}
</span>
</div>
)}
{selectedAllowancesTotal > 0 && (
<div style={styles.calcRow}>
<span style={styles.calcLabel}>Selected Allowances</span>
<span style={{ ...styles.calcValue, color: '#27ae60' }}>
{formatCurrency(selectedAllowancesTotal)}
</span>
</div>
)}
<div style={styles.calcRow}>
<span style={styles.calcLabelBold}>Adjusted GP %</span>
<span style={{ ...styles.calcValueBold, color: hasSelectedAllowances ? '#27ae60' : '#999' }}>
{gpWithSelectedAllowances.toFixed(1)}%
</span>
</div>
</>
)}
</div>
</div>
{/* Chart Section */}
<div style={styles.sectionContainer}>
{renderChart()}
</div>
</div>
{/* Breakdown Tables Row */}
<div style={styles.breakdownRow}>
{/* Supplier Breakdown Table */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Supplier Breakdown</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Supplier</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Net Purchases</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>%</th>
</tr>
</thead>
<tbody>
{data?.supplier_breakdown?.length ? (
data.supplier_breakdown.map((supplier, index) => {
const isCredit = supplier.net_purchases < 0
return (
<tr key={supplier.supplier_id || `unknown-${index}`} style={isCredit ? { background: '#d4edda' } : {}}>
<td style={styles.tableCell}>
{supplier.supplier_name}
{isCredit && <span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '0.5rem' }}>(CR)</span>}
</td>
<td style={{
...styles.tableCell,
textAlign: 'right',
fontFamily: 'monospace',
...(isCredit ? { color: '#28a745', fontWeight: 'bold' } : {})
}}>
{formatCurrency(supplier.net_purchases, true)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{Number(supplier.percentage).toFixed(1)}%
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={3} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No supplier data
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* GL Account Revenue Breakdown Table */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Revenue Breakdown</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>GL Account</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Net Revenue</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>%</th>
</tr>
</thead>
<tbody>
{data?.gl_account_breakdown?.length ? (
data.gl_account_breakdown.map((account) => (
<tr key={account.gl_account_id}>
<td style={styles.tableCell}>{account.gl_account_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{formatCurrency(account.net_revenue)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{Number(account.percentage).toFixed(1)}%
</td>
</tr>
))
) : (
<tr>
<td colSpan={3} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No revenue data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Top Sellers Section */}
{topSellersLoading ? (
<div style={{ ...styles.breakdownContainer, textAlign: 'center', marginBottom: '1.5rem' }}>
<div style={{ color: '#666', padding: '2rem' }}>Loading top sellers...</div>
</div>
) : topSellers?.source === 'sambapos' && topSellers?.categories?.length > 0 ? (
/* SambaPOS Category-based Top Sellers */
<>
{/* Top Sellers by Quantity - One column per category */}
<div style={styles.topSellersSection}>
<h3 style={styles.sectionTitle}>Top Sellers by Quantity</h3>
<div style={styles.categoryGrid}>
{topSellers.categories.map((cat) => (
<div key={cat.category} style={styles.categoryColumn}>
<h4 style={styles.categoryHeader}>{cat.category}</h4>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
</tr>
</thead>
<tbody>
{cat.top_by_qty.length > 0 ? (
cat.top_by_qty.map((item) => (
<tr key={item.item_name}>
<td style={styles.tableCell}>{item.item_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{item.qty}
</td>
</tr>
))
) : (
<tr>
<td colSpan={2} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
</div>
</div>
{/* Top Sellers by Revenue - One column per category */}
<div style={styles.topSellersSection}>
<h3 style={styles.sectionTitle}>Top Sellers by Gross Revenue</h3>
<div style={styles.categoryGrid}>
{topSellers.categories.map((cat) => (
<div key={cat.category} style={styles.categoryColumn}>
<h4 style={styles.categoryHeader}>{cat.category}</h4>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Gross</th>
</tr>
</thead>
<tbody>
{cat.top_by_revenue.length > 0 ? (
cat.top_by_revenue.map((item) => (
<tr key={item.item_name}>
<td style={styles.tableCell}>{item.item_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{formatCurrency(item.revenue)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={2} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
))}
</div>
</div>
</>
) : topSellers?.source === 'sambapos' && (!topSellers?.categories || topSellers.categories.length === 0) ? (
/* SambaPOS with no categories configured */
<div style={{ ...styles.breakdownContainer, textAlign: 'center', marginBottom: '1.5rem' }}>
<h3 style={styles.sectionTitle}>Top Sellers</h3>
<p style={{ color: '#666', margin: '2rem 0' }}>
No categories configured. <a href="/kitchen/sambapos" style={{ color: '#e94560' }}>Configure SambaPOS categories</a> to see top sellers by category.
</p>
</div>
) : (
/* Legacy Newbook format - flat lists */
<div style={styles.breakdownRow}>
{/* Top Sellers by Quantity */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Top Sellers by Quantity</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>#</th>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Revenue</th>
</tr>
</thead>
<tbody>
{topSellers?.top_by_qty?.length ? (
topSellers.top_by_qty.map((item, index) => (
<tr key={item.item_name}>
<td style={{ ...styles.tableCell, color: '#999', width: '30px' }}>{index + 1}</td>
<td style={styles.tableCell}>{item.item_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{item.qty}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#666' }}>
{formatCurrency(item.revenue)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No top sellers data
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Top Sellers by Revenue */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Top Sellers by Gross Revenue</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>#</th>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Gross</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
</tr>
</thead>
<tbody>
{topSellers?.top_by_revenue?.length ? (
topSellers.top_by_revenue.map((item, index) => (
<tr key={item.item_name}>
<td style={{ ...styles.tableCell, color: '#999', width: '30px' }}>{index + 1}</td>
<td style={styles.tableCell}>{item.item_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{formatCurrency(item.revenue)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#666' }}>
{item.qty}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No top sellers data
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Package Guest Favorites */}
<div style={styles.breakdownContainer}>
<h3 style={styles.sectionTitle}>Package Guest Favorites</h3>
<table style={styles.breakdownTable}>
<thead>
<tr>
<th style={styles.tableHeader}>#</th>
<th style={styles.tableHeader}>Item</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Qty</th>
</tr>
</thead>
<tbody>
{topSellers?.package_favorites?.length ? (
topSellers.package_favorites.map((item, index) => (
<tr key={item.item_name}>
<td style={{ ...styles.tableCell, color: '#999', width: '30px' }}>{index + 1}</td>
<td style={styles.tableCell}>{item.item_name}</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', fontWeight: 'bold' }}>
{item.qty}
</td>
</tr>
))
) : (
<tr>
<td colSpan={3} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No package data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Daily Data Table */}
<div style={styles.dailyTableContainer}>
<h3 style={styles.sectionTitle}>Daily Breakdown</h3>
<div style={styles.tableWrapper}>
<table style={styles.dailyTable}>
<thead>
<tr>
<th style={styles.tableHeader}>Date</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Net Sales</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Net Purchases</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Occupancy</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Lunch Covers</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Dinner Covers</th>
<th style={{ ...styles.tableHeader, textAlign: 'right' }}>Total Covers</th>
</tr>
</thead>
<tbody>
{chartData?.data?.length ? (
[...chartData.data].reverse().map((day) => (
<tr key={day.date}>
<td style={styles.tableCell}>
{new Date(day.date).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{formatCurrency(day.net_sales)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace' }}>
{formatCurrency(day.net_purchases)}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#999' }}>
{day.occupancy ?? '—'}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#999' }}>
{day.lunch_covers ?? '—'}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#999' }}>
{day.dinner_covers ?? '—'}
</td>
<td style={{ ...styles.tableCell, textAlign: 'right', fontFamily: 'monospace', color: '#999' }}>
{day.total_covers ?? '—'}
</td>
</tr>
))
) : (
<tr>
<td colSpan={7} style={{ ...styles.tableCell, textAlign: 'center', color: '#999' }}>
No daily data
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
loading: {
padding: '2rem',
textAlign: 'center',
color: '#666',
},
error: {
padding: '2rem',
textAlign: 'center',
color: '#c00',
background: '#fee',
borderRadius: '8px',
},
header: {
marginBottom: '1.5rem',
},
title: {
color: '#1a1a2e',
margin: 0,
},
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',
textAlign: 'center',
},
csvBtn: {
padding: '0.4rem 0.9rem',
fontSize: '0.8rem',
background: 'transparent',
border: '1px solid #1a1a2e',
color: '#1a1a2e',
borderRadius: '6px',
cursor: 'pointer',
whiteSpace: 'nowrap' as const,
},
mainContent: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
alignItems: 'stretch',
},
sectionContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '2rem',
flex: '1 1 auto',
minWidth: '300px',
},
sectionTitle: {
margin: '0 0 1.5rem 0',
color: '#1a1a2e',
},
calculationContainer: {
maxWidth: '400px',
},
calcRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem 0',
},
calcLabel: {
color: '#555',
fontSize: '1rem',
},
calcValue: {
fontSize: '1rem',
fontFamily: 'monospace',
},
calcLabelBold: {
color: '#1a1a2e',
fontSize: '1.1rem',
fontWeight: 'bold',
},
calcValueBold: {
fontSize: '1.2rem',
fontWeight: 'bold',
fontFamily: 'monospace',
},
divider: {
borderTop: '2px solid #dee2e6',
margin: '0.5rem 0',
},
wastageSection: {
borderTop: '1px dashed #e67e22',
margin: '0.75rem 0 0.5rem 0',
},
allowancesHeader: {
fontSize: '0.9rem',
fontWeight: 600,
color: '#666',
marginBottom: '0.5rem',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
},
allowanceRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.4rem 0',
},
allowanceCheckbox: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
fontSize: '0.95rem',
color: '#444',
},
positiveValue: {
color: '#155724',
},
negativeValue: {
color: '#dc3545',
},
chartContainer: {
width: '100%',
},
chartTitle: {
margin: '0 0 1rem 0',
color: '#1a1a2e',
fontSize: '1rem',
fontWeight: 600,
},
chartSvg: {
display: 'block',
maxWidth: '100%',
},
chartLegend: {
display: 'flex',
gap: '1.5rem',
marginTop: '0.75rem',
justifyContent: 'center',
},
legendItem: {
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.85rem',
color: '#555',
},
legendColor: {
width: '12px',
height: '12px',
borderRadius: '2px',
display: 'inline-block',
},
breakdownRow: {
display: 'flex',
gap: '1.5rem',
flexWrap: 'wrap',
marginBottom: '1.5rem',
},
breakdownContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
flex: '1 1 300px',
minWidth: '280px',
},
breakdownTable: {
width: '100%',
borderCollapse: 'collapse',
},
tableHeader: {
textAlign: 'left',
padding: '0.75rem 0.5rem',
borderBottom: '2px solid #dee2e6',
color: '#1a1a2e',
fontWeight: 600,
fontSize: '0.85rem',
},
tableCell: {
padding: '0.5rem',
borderBottom: '1px solid #f0f0f0',
fontSize: '0.9rem',
},
dailyTableContainer: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '2rem',
},
tableWrapper: {
overflowX: 'auto',
},
dailyTable: {
width: '100%',
borderCollapse: 'collapse',
minWidth: '700px',
},
// SambaPOS category-based top sellers styles
topSellersSection: {
background: 'white',
borderRadius: '12px',
padding: '1.5rem',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: '1.5rem',
},
categoryGrid: {
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
},
categoryColumn: {
flex: '1 1 180px',
minWidth: '160px',
maxWidth: '250px',
},
categoryHeader: {
margin: '0 0 0.75rem 0',
padding: '0.5rem 0.75rem',
background: '#f8f9fa',
borderRadius: '6px',
fontSize: '0.95rem',
fontWeight: 600,
color: '#1a1a2e',
borderLeft: '3px solid #e94560',
},
}