From e4ab1370256b0fce959dea7e89d52fca7d6b0ae2 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 22:29:50 +0000 Subject: [PATCH] Fix invalid date in history; expand weekly report - History: slice session_date to YYYY-MM-DD before Date parse (postgres returns date columns as ISO timestamps in JSON) - History: fix Edit/View navigate link passing full ISO string as date param - Weekly report: add daily status row, reconciliation variance (banked vs Newbook reported), sales with gross+net, debtors/creditors lazy-loaded Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/History.tsx | 4 +- frontend/src/pages/MultiDayReport.tsx | 263 +++++++++++++++++++++----- 2 files changed, 219 insertions(+), 48 deletions(-) diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index b0e4467..e06a2e0 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -136,7 +136,7 @@ export function History() { - {new Date(row.session_date + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })} + {new Date(row.session_date.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })} @@ -148,7 +148,7 @@ export function History() { navigate(`/daily?date=${row.session_date}`)}> + onClick={() => navigate(`/daily?date=${row.session_date.slice(0, 10)}`)}> {row.status === 'draft' ? 'Edit' : 'View'} {row.status === 'draft' && ( diff --git a/frontend/src/pages/MultiDayReport.tsx b/frontend/src/pages/MultiDayReport.tsx index 1acac4e..5cfc068 100644 --- a/frontend/src/pages/MultiDayReport.tsx +++ b/frontend/src/pages/MultiDayReport.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { api } from '../api' -import { PageHeader, Card, Btn } from '../components/Layout' +import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { fmtGBP } from '../types' interface SalesCol { gl_code: string; category: string; net_amount: number; vat_amount: number; gross_amount: number } interface ReconRow { category: string; banked_amount: number; reported_amount: number } interface DayData { date: string - cash_up: { total_cash_counted: string; status: string } | null + cash_up: { id: number; status: 'draft' | 'final'; total_cash_counted: string } | null reconciliation: ReconRow[] daily_stats: { gross_sales: number; transaction_count: number } | null sales_breakdown: SalesCol[] @@ -17,15 +17,38 @@ interface ReportResult { report_data: DayData[] sales_columns: Array<{ gl_code: string; display_name: string }> occupancy_data: OccupancyItem[] + bookings_data: Array<{ date?: string; period?: string; arrivals?: number; departures?: number; [k: string]: unknown }> +} +interface DebtorBalance { creditors: number; debtors: number; overall: number } +interface DebtorsResult { + period_open_balance: DebtorBalance + balances_by_date: Record } const RECON_LABELS: Record = { - cash: 'Cash', gateway_visa_mc: 'Gateway V/MC', gateway_amex: 'Gateway Amex', - pdq_visa_mc: 'PDQ V/MC', pdq_amex: 'PDQ Amex', bacs: 'BACS', + cash: 'Cash', + gateway_visa_mc: 'Gateway V/MC', + gateway_amex: 'Gateway Amex', + pdq_visa_mc: 'PDQ V/MC', + pdq_amex: 'PDQ Amex', + bacs: 'BACS', } +const VARIANCE_CATEGORIES = new Set(['cash', 'pdq_visa_mc', 'pdq_amex']) + function fmtDate(d: string) { - return new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }) + const s = (d ?? '').slice(0, 10) + if (!s) return '—' + return new Date(s + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }) +} + +function variance(banked: number, reported: number) { + return banked - reported +} + +function varStyle(v: number): React.CSSProperties { + if (Math.abs(v) < 0.005) return { textAlign: 'right', color: 'var(--text-mid)' } + return { textAlign: 'right', color: v < 0 ? 'var(--danger)' : '#16a34a', fontWeight: 600 } } export function MultiDayReport() { @@ -36,9 +59,11 @@ export function MultiDayReport() { const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [debtors, setDebtors] = useState(null) + const [debtorsLoading, setDebtorsLoading] = useState(false) async function generate() { - setLoading(true); setError(''); setResult(null) + setLoading(true); setError(''); setResult(null); setDebtors(null) try { const data = await api.post('/reports/multiday', { start_date: startDate, num_days: numDays }) setResult(data) @@ -49,6 +74,15 @@ export function MultiDayReport() { } } + useEffect(() => { + if (!result) return + setDebtorsLoading(true) + api.post('/reports/debtors-creditors', { start_date: startDate, num_days: numDays }) + .then(d => setDebtors(d)) + .catch(() => {}) + .finally(() => setDebtorsLoading(false)) + }, [result, startDate, numDays]) + const dates = result?.report_data.map(d => d.date) ?? [] const salesCols = result?.sales_columns ?? [] @@ -56,17 +90,17 @@ export function MultiDayReport() {
+ {/* Controls */}
- - setStartDate(e.target.value)} - style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }} /> + + setStartDate(e.target.value)} style={inpSt} />
- + setNumDays(Math.max(1, Math.min(365, parseInt(e.target.value) || 7)))} - style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem', width: '80px' }} /> + style={{ ...inpSt, width: '80px' }} />
{loading ? 'Generating…' : 'Generate Report'} @@ -81,39 +115,110 @@ export function MultiDayReport() { {result && ( <> - {/* Table 1: Reconciliation Summary */} + {/* Daily Status Row */} + +

Daily Cash Up Status

+
+ {result.report_data.map(day => ( +
+
{fmtDate(day.date)}
+ {day.cash_up + ? + : None + } + {day.cash_up && ( +
+ {fmtGBP(day.cash_up.total_cash_counted)} +
+ )} +
+ ))} +
+
+ + {/* Table 1: Reconciliation */} -

Reconciliation Summary

- +

Payment Reconciliation

+
- {dates.map(d => )} + {dates.map(d => ( + + ))} {Object.entries(RECON_LABELS).map(([key, label]) => { - const values = result.report_data.map(day => { - const row = day.reconciliation.find(r => r.category === key) - return row ? row.banked_amount : 0 - }) - const rowTotal = values.reduce((s, v) => s + v, 0) - if (values.every(v => v === 0) && rowTotal === 0) return null - return ( - - - {values.map((v, i) => )} - + const rows = result.report_data.map(day => day.reconciliation.find(r => r.category === key)) + const bankedVals = rows.map(r => r?.banked_amount ?? 0) + const reportedVals = rows.map(r => r?.reported_amount ?? 0) + const hasVariance = VARIANCE_CATEGORIES.has(key) + const totalBanked = bankedVals.reduce((s, v) => s + v, 0) + const totalReported = reportedVals.reduce((s, v) => s + v, 0) + + if (bankedVals.every(v => v === 0) && reportedVals.every(v => v === 0)) return null + + return [ + /* Reported row */ + hasVariance && ( + + + {reportedVals.map((v, i) => ( + + ))} + + + ), + /* Banked / main row */ + + + {bankedVals.map((v, i) => { + const rep = reportedVals[i] + const v2 = hasVariance ? variance(v, rep) : null + return ( + + ) + })} + - ) + ].filter(Boolean) })} - {/* Cash total row */} + {/* Total row */} - + {result.report_data.map((day, i) => { - const dayTotal = day.reconciliation.reduce((s, r) => s + r.banked_amount, 0) - return + const t = day.reconciliation.reduce((s, r) => s + r.banked_amount, 0) + return })}
Category{fmtDate(d)} + {fmtDate(d)} + Total
{label}{v ? fmtGBP(v) : '—'}{fmtGBP(rowTotal)}
+ {label} — Newbook + + {v ? fmtGBP(v) : '—'} + + {fmtGBP(totalReported)} +
+ {hasVariance ? `${label} — Banked` : label} + + {v ? fmtGBP(v) : '—'} + {v2 !== null && Math.abs(v2) > 0.005 && ( +
+ {v2 > 0 ? '+' : ''}{fmtGBP(v2)} +
+ )} +
+ {fmtGBP(totalBanked)} + {hasVariance && Math.abs(totalBanked - totalReported) > 0.005 && ( +
+ {totalBanked - totalReported > 0 ? '+' : ''}{fmtGBP(totalBanked - totalReported)} +
+ )} +
Total BankedTotal{fmtGBP(dayTotal)}{t ? fmtGBP(t) : '—'} {fmtGBP(result.report_data.reduce((s, d) => s + d.reconciliation.reduce((ss, r) => ss + r.banked_amount, 0), 0))} @@ -126,8 +231,8 @@ export function MultiDayReport() { {/* Table 2: Sales Breakdown */} {salesCols.length > 0 && ( -

Sales Breakdown (Net)

- +

Sales Breakdown

+
@@ -137,26 +242,46 @@ export function MultiDayReport() { {salesCols.map(col => { - const values = result.report_data.map(day => { + const vals = result.report_data.map(day => { const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code) - return sb?.net_amount ?? 0 + return { net: sb?.net_amount ?? 0, gross: sb?.gross_amount ?? 0, vat: sb?.vat_amount ?? 0 } }) - const rowTotal = values.reduce((s, v) => s + v, 0) + const totalGross = vals.reduce((s, v) => s + v.gross, 0) + if (vals.every(v => v.gross === 0)) return null return ( - {values.map((v, i) => )} - + {vals.map((v, i) => ( + + ))} + ) })} - + {result.report_data.map((day, i) => { - const dayTotal = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) - return + const t = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) + return })} + + + + {result.report_data.map((day, i) => { + const t = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) + return + })} + @@ -168,8 +293,8 @@ export function MultiDayReport() { {/* Table 3: Occupancy */} {result.occupancy_data.length > 0 && ( -

Occupancy

-
Category
{col.display_name}{v ? fmtGBP(v) : '—'}{fmtGBP(rowTotal)} + {v.gross ? fmtGBP(v.gross) : '—'} + {v.vat > 0 && ( +
+ net {fmtGBP(v.net)} +
+ )} +
{fmtGBP(totalGross)}
TotalTotal Gross{fmtGBP(dayTotal)}{t ? fmtGBP(t) : '—'} + {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))} +
Total Net{t ? fmtGBP(t) : '—'} {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
+

Occupancy

+
@@ -182,10 +307,12 @@ export function MultiDayReport() { {result.occupancy_data.map((row, i) => { const date = row.period ?? row.date ?? '' - const occ = row.total_rooms && row.rooms_sold ? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1) : '—' + const occ = row.total_rooms && row.rooms_sold + ? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1) + : '—' return ( - + @@ -197,11 +324,55 @@ export function MultiDayReport() {
Date
{date ? fmtDate(date) : '—'}{fmtDate(date)} {row.rooms_sold ?? '—'} {row.total_rooms ?? '—'} {row.total_people ?? '—'}
)} + + {/* Table 4: Debtors / Creditors */} + +

Debtors / Creditors

+ {debtorsLoading ? ( +

Loading balances…

+ ) : debtors ? ( + + + + + + + + + + + + + + + + + {dates.map(d => { + const b = debtors.balances_by_date[d] + if (!b) return null + return ( + + + + + + + ) + })} + +
DateDebtorsCreditorsNet
Opening Balance{fmtGBP(debtors.period_open_balance.debtors)}{fmtGBP(debtors.period_open_balance.creditors)}{fmtGBP(debtors.period_open_balance.overall)}
{fmtDate(d)}{fmtGBP(b.debtors)}{fmtGBP(b.creditors)}{fmtGBP(b.overall)}
+ ) : ( +

Balances unavailable.

+ )} +
)} ) } +const labelSt: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' } +const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' } +const sectionTitle: React.CSSProperties = { fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' } const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600, whiteSpace: 'nowrap' } const td: React.CSSProperties = { padding: '0.45rem 0.625rem' }