diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 3319c0d..21b9172 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -148,7 +148,9 @@ export async function reportRoutes(app) { 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) + // Only fuzzy-match when the column has a non-empty gl_code — prevents + // placeholder columns (empty code) from matching any GL group via gName.includes('') + return code.length > 0 && (gName.includes(code) || code.includes(gName)) }) const net = parseFloat(item?.earned_revenue_ex || 0) const vat = parseFloat(item?.earned_revenue_tax || 0) @@ -209,20 +211,30 @@ export async function reportRoutes(app) { const { from, to } = req.query if (!from || !to) return reply.status(400).send({ error: 'from and to required' }) - const { rows } = await pool.query( - `SELECT d.denomination_value, - ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity, - SUM(d.total_amount) AS total_value - FROM denominations d - JOIN cash_ups c ON d.cash_up_id = c.id - WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings' - GROUP BY d.denomination_value - ORDER BY d.denomination_value DESC`, - [from, to] - ) + const [denomResult, byDateResult] = await Promise.all([ + pool.query( + `SELECT d.denomination_value, + ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity, + SUM(d.total_amount) AS total_value + FROM denominations d + JOIN cash_ups c ON d.cash_up_id = c.id + WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings' + GROUP BY d.denomination_value + ORDER BY d.denomination_value DESC`, + [from, to] + ), + pool.query( + `SELECT session_date, status, total_cash_counted, submitted_by + FROM cash_ups + WHERE session_date >= $1 AND session_date <= $2 + ORDER BY session_date ASC`, + [from, to] + ), + ]) return { - denominations: rows, + denominations: denomResult.rows, + by_date: byDateResult.rows, period: { from, to }, } }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7c97be3..08e0537 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -15,8 +15,8 @@ const navItems: { to: string; label: string; icon: typeof Banknote; cap?: Cashup { to: '/history', label: 'History', icon: ClipboardList, cap: 'history' }, { to: '/report', label: 'Weekly Report', icon: BarChart2, cap: 'reports' }, { to: '/floats', label: 'Float Management', icon: Wallet, cap: 'floats' }, - { to: '/safe', label: 'Safe Count', icon: Vault, cap: 'safe_count' }, { to: '/summary', label: 'Cash Summary', icon: FileText, cap: 'cash_summary' }, + { to: '/safe', label: 'Safe Count', icon: Vault, cap: 'safe_count' }, { to: '/settings',label: 'Settings', icon: Settings, cap: 'settings' }, ] diff --git a/frontend/src/index.css b/frontend/src/index.css index a11911d..02145b4 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -34,3 +34,26 @@ body { button { cursor: pointer; font-family: inherit; } input, textarea, select { font-family: inherit; } + +@media print { + nav { display: none !important; } + main { overflow: visible !important; } + body { background: white !important; } + /* force colours/borders to render in print */ + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } + /* card borders visible on paper */ + [style*="border:"], [style*="border "] { border-color: #ccc !important; } + /* hide interactive controls */ + button, input[type="file"], label[for] { display: none !important; } + /* show disabled inputs as plain readable values */ + input:disabled, textarea:disabled { + border: none !important; + background: transparent !important; + box-shadow: none !important; + padding-left: 0 !important; + } + /* keep tables together */ + table { break-inside: avoid; } + /* denomination grid — keep each row visible */ + div[style*="grid"] { break-inside: avoid; } +} diff --git a/frontend/src/pages/CashSummary.tsx b/frontend/src/pages/CashSummary.tsx index 8b69dfe..3986641 100644 --- a/frontend/src/pages/CashSummary.tsx +++ b/frontend/src/pages/CashSummary.tsx @@ -1,10 +1,19 @@ import { useState } from 'react' import { api } from '../api' -import { PageHeader, Card, Btn } from '../components/Layout' +import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { GBP_DENOMINATIONS, fmtGBP, today } from '../types' interface DenomRow { denomination_value: string; total_quantity: string; total_value: string } -interface SummaryResult { denominations: DenomRow[]; period: { from: string; to: string } } +interface DayRow { session_date: string; status: 'draft' | 'final'; total_cash_counted: string; submitted_by: string | null } +interface SummaryResult { + denominations: DenomRow[] + by_date: DayRow[] + period: { from: string; to: string } +} + +function fmtDate(d: string) { + return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }) +} export function CashSummary() { const [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) }) @@ -28,19 +37,17 @@ export function CashSummary() { const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0 return ( -
+
- - setFrom(e.target.value)} - style={inpSt} /> + + setFrom(e.target.value)} style={inpSt} />
- - setTo(e.target.value)} - style={inpSt} /> + + setTo(e.target.value)} style={inpSt} />
{loading ? 'Loading…' : 'Generate'}
@@ -51,49 +58,91 @@ export function CashSummary() {
)} - {result && ( - -

- {result.period.from} → {result.period.to} -

-

- Takings only (excludes float counts) -

+ {result && (<> - - - - - - - - - - {GBP_DENOMINATIONS.map(d => { - const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001) - if (!row) return null - return ( - - - - + {/* Daily breakdown */} + +

{result.period.from} → {result.period.to}

+ {result.by_date.length === 0 ? ( +

No cash ups recorded in this period.

+ ) : ( +
DenominationTotal QtyTotal Value
{d.label}{row.total_quantity}{fmtGBP(row.total_value)}
+ + + + + + + + + + {result.by_date.map(row => ( + + + + + - ) - })} - - - - - - - - -
DateStatusCash CountedSubmitted By
{fmtDate(row.session_date)}{fmtGBP(row.total_cash_counted)}{row.submitted_by ?? '—'}
Grand Total{fmtGBP(grandTotal)}
+ ))} + + + + Period Total + + {fmtGBP(result.by_date.reduce((s, r) => s + parseFloat(r.total_cash_counted), 0))} + + + + + + )}
- )} + + {/* Denomination breakdown */} + {result.denominations.length > 0 && ( + +

Denomination Breakdown

+

+ Takings only (excludes float counts) +

+ + + + + + + + + + {GBP_DENOMINATIONS.map(d => { + const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001) + if (!row) return null + return ( + + + + + + ) + })} + + + + + + + + +
DenominationTotal QtyTotal Value
{d.label}{row.total_quantity}{fmtGBP(row.total_value)}
Grand Total{fmtGBP(grandTotal)}
+
+ )} + + )}
) } +const lbl: 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 secSt: React.CSSProperties = { fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem' } const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' } diff --git a/frontend/src/pages/DailyCashUp.tsx b/frontend/src/pages/DailyCashUp.tsx index 8375b57..c5e730e 100644 --- a/frontend/src/pages/DailyCashUp.tsx +++ b/frontend/src/pages/DailyCashUp.tsx @@ -93,7 +93,13 @@ export function DailyCashUp({ user }: Props) { if (data.card_machines.length) { setMachines(MACHINES.map(name => { const m = data.card_machines.find(c => c.machine_name === name) - return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 } + if (!m) return { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 } + return { + ...m, + total_amount: parseFloat(String(m.total_amount)) || 0, + amex_amount: parseFloat(String(m.amex_amount)) || 0, + visa_mc_amount: parseFloat(String(m.visa_mc_amount)) || 0, + } })) } @@ -238,6 +244,11 @@ export function DailyCashUp({ user }: Props) { if (status === 'final') { setCashUp(prev => prev ? { ...prev, status: 'final' } : null) setPageState('locked') + } else { + // Reload the cashUp record to get the persisted ID (needed for attachment uploads) + api.get<{ cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; reconciliation: ReconciliationRow[]; attachments: Attachment[] }>(`/cashup?date=${date}`) + .then(data => setCashUp(data.cash_up)) + .catch(() => {}) } } catch (e: unknown) { flash(e instanceof Error ? e.message : 'Save failed.', false)