Fix cashup bugs: PDQ totals, WET placeholder, print styles, cash summary

Issue #1: Parse card machine amounts as floats in applyLoaded — pg returns
DECIMAL as strings which caused JS string concatenation in totalPdq and
reconciliation banked amounts. After draft save, reload cashUp to get the
persisted ID so PDQ Z-report uploads work on first save.

Issue #2: Guard empty gl_code in sales breakdown name-matching — an empty
column code caused gName.includes('') to always be true, making placeholder
columns (like WET 5%) match the first available GL group (dry dept).

Issue #3: Move Cash Summary above Safe Count in sidebar nav. Extend
cash-summary endpoint with per-day breakdown. Rewrite CashSummary.tsx
with a daily cash takings table above the denomination summary.

Issue #4: Add @media print CSS — hide sidebar nav, force colour printing,
style disabled inputs as plain text, and keep tables/cards intact on paper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 11:25:27 +00:00
parent c2ab162870
commit 2f9b876d0d
5 changed files with 156 additions and 61 deletions

View file

@ -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 (
<div style={{ padding: '1.5rem', maxWidth: '640px' }}>
<div style={{ padding: '1.5rem', maxWidth: '760px' }}>
<PageHeader title="Cash Denomination Summary" subtitle="Aggregate cash count across a date range" />
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>From</label>
<input type="date" value={from} onChange={e => setFrom(e.target.value)}
style={inpSt} />
<label style={lbl}>From</label>
<input type="date" value={from} onChange={e => setFrom(e.target.value)} style={inpSt} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>To</label>
<input type="date" value={to} onChange={e => setTo(e.target.value)}
style={inpSt} />
<label style={lbl}>To</label>
<input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} />
</div>
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
</Card>
@ -51,49 +58,91 @@ export function CashSummary() {
</div>
)}
{result && (
<Card>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.25rem' }}>
{result.period.from} {result.period.to}
</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Takings only (excludes float counts)
</p>
{result && (<>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th>
<th style={thSt}>Total Qty</th>
<th style={thSt}>Total Value</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{d.label}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td>
{/* Daily breakdown */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={secSt}>{result.period.from} {result.period.to}</h2>
{result.by_date.length === 0 ? (
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>No cash ups recorded in this period.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Date</th>
<th style={{ ...thSt, textAlign: 'left' }}>Status</th>
<th style={thSt}>Cash Counted</th>
<th style={{ ...thSt, textAlign: 'left' }}>Submitted By</th>
</tr>
</thead>
<tbody>
{result.by_date.map(row => (
<tr key={row.session_date} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{fmtDate(row.session_date)}</td>
<td style={{ padding: '0.45rem 0.5rem' }}><StatusBadge status={row.status} /></td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_cash_counted)}</td>
<td style={{ padding: '0.45rem 0.5rem', color: 'var(--text-mid)', fontSize: '0.8rem' }}>{row.submitted_by ?? '—'}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Grand Total</td>
<td></td>
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td>
</tr>
</tfoot>
</table>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td colSpan={2} style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Period Total</td>
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.by_date.reduce((s, r) => s + parseFloat(r.total_cash_counted), 0))}
</td>
<td />
</tr>
</tfoot>
</table>
)}
</Card>
)}
{/* Denomination breakdown */}
{result.denominations.length > 0 && (
<Card>
<h2 style={secSt}>Denomination Breakdown</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Takings only (excludes float counts)
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th>
<th style={thSt}>Total Qty</th>
<th style={thSt}>Total Value</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{d.label}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Grand Total</td>
<td></td>
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td>
</tr>
</tfoot>
</table>
</Card>
)}
</>)}
</div>
)
}
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)' }