From f62aa6f3470e3fb1eeb3d605c183849a1659b9fe Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 00:14:40 +0000 Subject: [PATCH] Fix gross sales zero bug; add Excel stats bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: sales_breakdown column matching was always failing because Newbook returns numeric gl_group_id but column settings use string codes like 'ACCOMMODATION'. Added normalised name-fuzzy fallback matching and a daily_gross_sales field that sums ALL earned revenue for the date, bypassing column config entirely. - Frontend: dayGrossSales() now uses daily_gross_sales from backend first - Add floating Excel-style status bar (bottom-right): when cells are selected it shows Count, Sum, and Average of the selected values; disappears when ✕ clicked or selection cleared Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/reports.js | 37 +++++++++++++------ frontend/src/pages/MultiDayReport.tsx | 51 ++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index d556927..760c5d3 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -131,26 +131,43 @@ export async function reportRoutes(app) { { category: 'bacs', banked_amount: pt.bacs, reported_amount: pt.bacs }, ] - // Sales breakdown from earned revenue - let displayedGross = 0 + // Build a lookup from gl_group_id → name using the accounts list + // gl_group_id from Newbook is often numeric; normalise to string for comparison + const glGroupById = {} + for (const a of (glAccountList || [])) { + const gid = String(a.gl_group_id ?? '') + if (gid && !glGroupById[gid]) glGroupById[gid] = (a.gl_group_name ?? '').toLowerCase().replace(/[^a-z0-9]/g, '') + } + + // Sales breakdown — match earned revenue to configured columns + // Try: (1) exact code match, (2) normalised name contains code / vice-versa const salesBreakdown = enabledColumns.map(col => { - const item = earnedRevenue.find(r => - r.period === date && r.gl_group_id.toUpperCase() === col.gl_code.toUpperCase() - ) - const net = parseFloat(item?.earned_revenue_ex || 0) - const vat = parseFloat(item?.earned_revenue_tax || 0) - const gross = parseFloat(item?.earned_revenue || 0) - displayedGross += gross + const code = col.gl_code.toLowerCase().replace(/[^a-z0-9]/g, '') + const item = earnedRevenue.find(r => { + if (r.period !== date) return false + const gid = String(r.gl_group_id ?? '') + if (gid === col.gl_code || gid.toUpperCase() === col.gl_code.toUpperCase()) return true + const gName = glGroupById[gid] ?? '' + return gName.includes(code) || code.includes(gName) + }) + const net = parseFloat(item?.earned_revenue_ex || 0) + const vat = parseFloat(item?.earned_revenue_tax || 0) + const gross = parseFloat(item?.earned_revenue || 0) return { gl_code: col.gl_code, category: col.display_name, net_amount: net, vat_amount: vat, gross_amount: gross } }) + // Daily gross sales = ALL earned revenue for this date (bypasses column config) + const daily_gross_sales = earnedRevenue + .filter(r => r.period === date) + .reduce((s, r) => s + (r.earned_revenue || 0), 0) + // Daily stats from payments const grossSales = freshPayments.reduce((s, p) => s + parseFloat(p.amount), 0) const dailyStats = grossSales > 0 ? { business_date: date, gross_sales: grossSales, transaction_count: freshPayments.length } : null - return { date, cash_up: cashUp, reconciliation, daily_stats: dailyStats, sales_breakdown: salesBreakdown } + return { date, cash_up: cashUp, reconciliation, daily_stats: dailyStats, sales_breakdown: salesBreakdown, daily_gross_sales } })) return { diff --git a/frontend/src/pages/MultiDayReport.tsx b/frontend/src/pages/MultiDayReport.tsx index fb349bd..708c178 100644 --- a/frontend/src/pages/MultiDayReport.tsx +++ b/frontend/src/pages/MultiDayReport.tsx @@ -13,6 +13,7 @@ interface DayData { reconciliation: ReconRow[] daily_stats: { gross_sales: number; transaction_count: number } | null sales_breakdown: SalesCol[] + daily_gross_sales?: number } interface OccupancyCategoryRaw { category_id?: string | number; category_name?: string @@ -54,6 +55,7 @@ function recon(day: DayData, cat: string): ReconRow { } function dayGrossSales(day: DayData) { + if (day.daily_gross_sales != null) return day.daily_gross_sales return day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) } @@ -84,13 +86,36 @@ function varLabel(banked: number, reported: number) { // ── Excel-like cell selection ───────────────────────────────────────────────── +interface SelStats { count: number; numCount: number; sum: number; avg: number } + function useExcelSelection() { type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number } const [sel, setSel] = useState(null) const [anchor, setAnchor] = useState<{ tableId: string; row: number; col: number } | null>(null) + const [stats, setStats] = useState(null) const selRef = useRef(sel) selRef.current = sel + function computeStats(s: Sel | null) { + if (!s) { setStats(null); return } + const tbl = document.getElementById(s.tableId) as HTMLTableElement | null + if (!tbl) { setStats(null); return } + const rows = Array.from(tbl.querySelectorAll('tr')) + let count = 0; const nums: number[] = [] + for (let r = s.r1; r <= s.r2; r++) { + const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) + for (let c = s.c1; c <= s.c2; c++) { + const text = (cells[c]?.textContent ?? '').trim() + if (!text) continue + count++ + const n = parseFloat(text.replace(/[£,%\s]/g, '').replace(/,/g, '')) + if (!isNaN(n)) nums.push(n) + } + } + const sum = nums.reduce((a, b) => a + b, 0) + setStats({ count, numCount: nums.length, sum, avg: nums.length ? sum / nums.length : 0 }) + } + const onClick = useCallback((tableId: string, e: React.MouseEvent) => { const cell = (e.target as Element).closest('td,th') as HTMLElement | null if (!cell) return @@ -100,12 +125,16 @@ function useExcelSelection() { const ri = allRows.indexOf(row) const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement) if (ri < 0 || ci < 0) return + let newSel: Sel if (e.shiftKey && anchor?.tableId === tableId) { - setSel({ tableId, r1: Math.min(anchor.row, ri), c1: Math.min(anchor.col, ci), r2: Math.max(anchor.row, ri), c2: Math.max(anchor.col, ci) }) + newSel = { tableId, r1: Math.min(anchor.row, ri), c1: Math.min(anchor.col, ci), r2: Math.max(anchor.row, ri), c2: Math.max(anchor.col, ci) } } else { - setSel({ tableId, r1: ri, c1: ci, r2: ri, c2: ci }) + newSel = { tableId, r1: ri, c1: ci, r2: ri, c2: ci } setAnchor({ tableId, row: ri, col: ci }) } + setSel(newSel) + // compute stats after next paint so DOM is settled + requestAnimationFrame(() => computeStats(newSel)) }, [anchor]) useEffect(() => { @@ -132,7 +161,9 @@ function useExcelSelection() { return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' } } - return { onClick, cs } + function clearSel() { setSel(null); setStats(null) } + + return { onClick, cs, stats, clearSel } } // ── Occupancy stats ─────────────────────────────────────────────────────────── @@ -269,7 +300,7 @@ export function MultiDayReport() { const [error, setError] = useState('') const [debtors, setDebtors] = useState(null) const [debtorsLoading, setDebtorsLoading] = useState(false) - const { onClick, cs } = useExcelSelection() + const { onClick, cs, stats, clearSel } = useExcelSelection() async function generate() { setLoading(true); setError(''); setResult(null); setDebtors(null) @@ -727,6 +758,18 @@ export function MultiDayReport() { )} + + {/* Excel-style status bar — shown when cells are selected */} + {stats && ( +
+ Count: {stats.count} + {stats.numCount > 0 && <> + Sum: {fmtGBP(stats.sum)} + Average: {fmtGBP(stats.avg)} + } + +
+ )} ) }