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:
parent
c2ab162870
commit
2f9b876d0d
5 changed files with 156 additions and 61 deletions
|
|
@ -148,7 +148,9 @@ export async function reportRoutes(app) {
|
||||||
const gid = String(r.gl_group_id ?? '')
|
const gid = String(r.gl_group_id ?? '')
|
||||||
if (gid === col.gl_code || gid.toUpperCase() === col.gl_code.toUpperCase()) return true
|
if (gid === col.gl_code || gid.toUpperCase() === col.gl_code.toUpperCase()) return true
|
||||||
const gName = glGroupById[gid] ?? ''
|
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 net = parseFloat(item?.earned_revenue_ex || 0)
|
||||||
const vat = parseFloat(item?.earned_revenue_tax || 0)
|
const vat = parseFloat(item?.earned_revenue_tax || 0)
|
||||||
|
|
@ -209,20 +211,30 @@ export async function reportRoutes(app) {
|
||||||
const { from, to } = req.query
|
const { from, to } = req.query
|
||||||
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
if (!from || !to) return reply.status(400).send({ error: 'from and to required' })
|
||||||
|
|
||||||
const { rows } = await pool.query(
|
const [denomResult, byDateResult] = await Promise.all([
|
||||||
`SELECT d.denomination_value,
|
pool.query(
|
||||||
ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity,
|
`SELECT d.denomination_value,
|
||||||
SUM(d.total_amount) AS total_value
|
ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity,
|
||||||
FROM denominations d
|
SUM(d.total_amount) AS total_value
|
||||||
JOIN cash_ups c ON d.cash_up_id = c.id
|
FROM denominations d
|
||||||
WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings'
|
JOIN cash_ups c ON d.cash_up_id = c.id
|
||||||
GROUP BY d.denomination_value
|
WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings'
|
||||||
ORDER BY d.denomination_value DESC`,
|
GROUP BY d.denomination_value
|
||||||
[from, to]
|
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 {
|
return {
|
||||||
denominations: rows,
|
denominations: denomResult.rows,
|
||||||
|
by_date: byDateResult.rows,
|
||||||
period: { from, to },
|
period: { from, to },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ const navItems: { to: string; label: string; icon: typeof Banknote; cap?: Cashup
|
||||||
{ to: '/history', label: 'History', icon: ClipboardList, cap: 'history' },
|
{ to: '/history', label: 'History', icon: ClipboardList, cap: 'history' },
|
||||||
{ to: '/report', label: 'Weekly Report', icon: BarChart2, cap: 'reports' },
|
{ to: '/report', label: 'Weekly Report', icon: BarChart2, cap: 'reports' },
|
||||||
{ to: '/floats', label: 'Float Management', icon: Wallet, cap: 'floats' },
|
{ 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: '/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' },
|
{ to: '/settings',label: 'Settings', icon: Settings, cap: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,26 @@ body {
|
||||||
|
|
||||||
button { cursor: pointer; font-family: inherit; }
|
button { cursor: pointer; font-family: inherit; }
|
||||||
input, textarea, select { 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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { api } from '../api'
|
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'
|
import { GBP_DENOMINATIONS, fmtGBP, today } from '../types'
|
||||||
|
|
||||||
interface DenomRow { denomination_value: string; total_quantity: string; total_value: string }
|
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() {
|
export function CashSummary() {
|
||||||
const [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) })
|
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
|
const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0
|
||||||
|
|
||||||
return (
|
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" />
|
<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' }}>
|
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>From</label>
|
<label style={lbl}>From</label>
|
||||||
<input type="date" value={from} onChange={e => setFrom(e.target.value)}
|
<input type="date" value={from} onChange={e => setFrom(e.target.value)} style={inpSt} />
|
||||||
style={inpSt} />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>To</label>
|
<label style={lbl}>To</label>
|
||||||
<input type="date" value={to} onChange={e => setTo(e.target.value)}
|
<input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} />
|
||||||
style={inpSt} />
|
|
||||||
</div>
|
</div>
|
||||||
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
|
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -51,49 +58,91 @@ export function CashSummary() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{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>
|
|
||||||
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
{/* Daily breakdown */}
|
||||||
<thead>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<h2 style={secSt}>{result.period.from} → {result.period.to}</h2>
|
||||||
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th>
|
{result.by_date.length === 0 ? (
|
||||||
<th style={thSt}>Total Qty</th>
|
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>No cash ups recorded in this period.</p>
|
||||||
<th style={thSt}>Total Value</th>
|
) : (
|
||||||
</tr>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
</thead>
|
<thead>
|
||||||
<tbody>
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||||
{GBP_DENOMINATIONS.map(d => {
|
<th style={{ ...thSt, textAlign: 'left' }}>Date</th>
|
||||||
const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001)
|
<th style={{ ...thSt, textAlign: 'left' }}>Status</th>
|
||||||
if (!row) return null
|
<th style={thSt}>Cash Counted</th>
|
||||||
return (
|
<th style={{ ...thSt, textAlign: 'left' }}>Submitted By</th>
|
||||||
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
</tr>
|
||||||
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{d.label}</td>
|
</thead>
|
||||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td>
|
<tbody>
|
||||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td>
|
{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>
|
</tr>
|
||||||
)
|
))}
|
||||||
})}
|
</tbody>
|
||||||
</tbody>
|
<tfoot>
|
||||||
<tfoot>
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||||
<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', fontWeight: 700 }}>Grand Total</td>
|
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700 }}>
|
||||||
<td></td>
|
{fmtGBP(result.by_date.reduce((s, r) => s + parseFloat(r.total_cash_counted), 0))}
|
||||||
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td>
|
</td>
|
||||||
</tr>
|
<td />
|
||||||
</tfoot>
|
</tr>
|
||||||
</table>
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</Card>
|
</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>
|
</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 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)' }
|
const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' }
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,13 @@ export function DailyCashUp({ user }: Props) {
|
||||||
if (data.card_machines.length) {
|
if (data.card_machines.length) {
|
||||||
setMachines(MACHINES.map(name => {
|
setMachines(MACHINES.map(name => {
|
||||||
const m = data.card_machines.find(c => c.machine_name === 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') {
|
if (status === 'final') {
|
||||||
setCashUp(prev => prev ? { ...prev, status: 'final' } : null)
|
setCashUp(prev => prev ? { ...prev, status: 'final' } : null)
|
||||||
setPageState('locked')
|
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) {
|
} catch (e: unknown) {
|
||||||
flash(e instanceof Error ? e.message : 'Save failed.', false)
|
flash(e instanceof Error ? e.message : 'Save failed.', false)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue