Fix gross sales zero bug; add Excel stats bar
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
e7af072851
commit
f62aa6f347
2 changed files with 74 additions and 14 deletions
|
|
@ -131,26 +131,43 @@ export async function reportRoutes(app) {
|
||||||
{ category: 'bacs', banked_amount: pt.bacs, reported_amount: pt.bacs },
|
{ category: 'bacs', banked_amount: pt.bacs, reported_amount: pt.bacs },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Sales breakdown from earned revenue
|
// Build a lookup from gl_group_id → name using the accounts list
|
||||||
let displayedGross = 0
|
// 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 salesBreakdown = enabledColumns.map(col => {
|
||||||
const item = earnedRevenue.find(r =>
|
const code = col.gl_code.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||||
r.period === date && r.gl_group_id.toUpperCase() === col.gl_code.toUpperCase()
|
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 net = parseFloat(item?.earned_revenue_ex || 0)
|
||||||
const vat = parseFloat(item?.earned_revenue_tax || 0)
|
const vat = parseFloat(item?.earned_revenue_tax || 0)
|
||||||
const gross = parseFloat(item?.earned_revenue || 0)
|
const gross = parseFloat(item?.earned_revenue || 0)
|
||||||
displayedGross += gross
|
|
||||||
return { gl_code: col.gl_code, category: col.display_name, net_amount: net, vat_amount: vat, gross_amount: gross }
|
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
|
// Daily stats from payments
|
||||||
const grossSales = freshPayments.reduce((s, p) => s + parseFloat(p.amount), 0)
|
const grossSales = freshPayments.reduce((s, p) => s + parseFloat(p.amount), 0)
|
||||||
const dailyStats = grossSales > 0
|
const dailyStats = grossSales > 0
|
||||||
? { business_date: date, gross_sales: grossSales, transaction_count: freshPayments.length }
|
? { business_date: date, gross_sales: grossSales, transaction_count: freshPayments.length }
|
||||||
: null
|
: 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 {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface DayData {
|
||||||
reconciliation: ReconRow[]
|
reconciliation: ReconRow[]
|
||||||
daily_stats: { gross_sales: number; transaction_count: number } | null
|
daily_stats: { gross_sales: number; transaction_count: number } | null
|
||||||
sales_breakdown: SalesCol[]
|
sales_breakdown: SalesCol[]
|
||||||
|
daily_gross_sales?: number
|
||||||
}
|
}
|
||||||
interface OccupancyCategoryRaw {
|
interface OccupancyCategoryRaw {
|
||||||
category_id?: string | number; category_name?: string
|
category_id?: string | number; category_name?: string
|
||||||
|
|
@ -54,6 +55,7 @@ function recon(day: DayData, cat: string): ReconRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayGrossSales(day: DayData) {
|
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)
|
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 ─────────────────────────────────────────────────
|
// ── Excel-like cell selection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SelStats { count: number; numCount: number; sum: number; avg: number }
|
||||||
|
|
||||||
function useExcelSelection() {
|
function useExcelSelection() {
|
||||||
type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number }
|
type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number }
|
||||||
const [sel, setSel] = useState<Sel | null>(null)
|
const [sel, setSel] = useState<Sel | null>(null)
|
||||||
const [anchor, setAnchor] = useState<{ tableId: string; row: number; col: number } | null>(null)
|
const [anchor, setAnchor] = useState<{ tableId: string; row: number; col: number } | null>(null)
|
||||||
|
const [stats, setStats] = useState<SelStats | null>(null)
|
||||||
const selRef = useRef(sel)
|
const selRef = useRef(sel)
|
||||||
selRef.current = 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<HTMLTableElement>) => {
|
const onClick = useCallback((tableId: string, e: React.MouseEvent<HTMLTableElement>) => {
|
||||||
const cell = (e.target as Element).closest('td,th') as HTMLElement | null
|
const cell = (e.target as Element).closest('td,th') as HTMLElement | null
|
||||||
if (!cell) return
|
if (!cell) return
|
||||||
|
|
@ -100,12 +125,16 @@ function useExcelSelection() {
|
||||||
const ri = allRows.indexOf(row)
|
const ri = allRows.indexOf(row)
|
||||||
const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement)
|
const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement)
|
||||||
if (ri < 0 || ci < 0) return
|
if (ri < 0 || ci < 0) return
|
||||||
|
let newSel: Sel
|
||||||
if (e.shiftKey && anchor?.tableId === tableId) {
|
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 {
|
} 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 })
|
setAnchor({ tableId, row: ri, col: ci })
|
||||||
}
|
}
|
||||||
|
setSel(newSel)
|
||||||
|
// compute stats after next paint so DOM is settled
|
||||||
|
requestAnimationFrame(() => computeStats(newSel))
|
||||||
}, [anchor])
|
}, [anchor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -132,7 +161,9 @@ function useExcelSelection() {
|
||||||
return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' }
|
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 ───────────────────────────────────────────────────────────
|
// ── Occupancy stats ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -269,7 +300,7 @@ export function MultiDayReport() {
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [debtors, setDebtors] = useState<DebtorsResult | null>(null)
|
const [debtors, setDebtors] = useState<DebtorsResult | null>(null)
|
||||||
const [debtorsLoading, setDebtorsLoading] = useState(false)
|
const [debtorsLoading, setDebtorsLoading] = useState(false)
|
||||||
const { onClick, cs } = useExcelSelection()
|
const { onClick, cs, stats, clearSel } = useExcelSelection()
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true); setError(''); setResult(null); setDebtors(null)
|
setLoading(true); setError(''); setResult(null); setDebtors(null)
|
||||||
|
|
@ -727,6 +758,18 @@ export function MultiDayReport() {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
{/* Excel-style status bar — shown when cells are selected */}
|
||||||
|
{stats && (
|
||||||
|
<div style={{ position: 'fixed', bottom: '20px', right: '20px', background: '#2c3e50', color: '#ecf0f1', padding: '8px 16px', borderRadius: '6px', fontSize: '0.78rem', zIndex: 9999, boxShadow: '0 4px 12px rgba(0,0,0,0.4)', display: 'flex', gap: '1.5rem', alignItems: 'center', userSelect: 'none' }}>
|
||||||
|
<span>Count: <strong>{stats.count}</strong></span>
|
||||||
|
{stats.numCount > 0 && <>
|
||||||
|
<span>Sum: <strong>{fmtGBP(stats.sum)}</strong></span>
|
||||||
|
<span>Average: <strong>{fmtGBP(stats.avg)}</strong></span>
|
||||||
|
</>}
|
||||||
|
<button onClick={clearSel} style={{ background: 'none', border: 'none', color: '#bdc3c7', cursor: 'pointer', fontSize: '0.9rem', lineHeight: 1, padding: '0 2px', marginLeft: '4px' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue