cashup/frontend/src/pages/CashSummary.tsx
jtricerolph ed9cce1d76 Cashup: safe count adjust flow, cash summary cross-tab, table denomination view
Safe Count (new flow):
- Replaces simple re-use of FloatCountForm with a dedicated 3-column table:
  Current in Safe | Adjustment | New Total
- Loads the most recent safe_cash float count on mount to pre-populate Current
- Adjustment inputs accept positive (deposit) or negative (uplift/exchange) values
- New Total = Current + Adjustment per denomination; minimum clamped to 0
- Saves a new safe_cash float_count record via existing /floats/save endpoint

Cash Summary (redesign):
- Backend adds a per-date denomination query (by_date_denom) to the
  cash-summary endpoint in addition to the existing aggregate query
- Frontend rewritten as a cross-tab: denominations down rows, dates across
  columns, row totals on the right, per-day cash totals in the footer
- Status badges (draft/final) shown per column in a header row
- "+ Add to Safe" button navigates to /safe passing the period's denomination
  totals as React Router state, pre-filling the adjustment column

Float Management:
- Denomination entry form switched from CSS grid divs to a proper <table>
  matching the style of the print view (header row, qty input right-aligned,
  amount column, total in tfoot)
- DetailRecord type and FloatRecordPrint component exported so SafeCount can
  reuse the existing print/view component for safe cash records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:56:45 +00:00

186 lines
7.6 KiB
TypeScript

import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { GBP_DENOMINATIONS, fmtGBP, today } from '../types'
interface DayRow {
session_date: string
status: 'draft' | 'final'
total_cash_counted: string
submitted_by: string | null
}
interface DenomDateRow {
denomination_value: string
session_date: string
total_amount: string
}
interface SummaryResult {
by_date: DayRow[]
by_date_denom: DenomDateRow[]
period: { from: string; to: string }
}
function fmtDateShort(d: string) {
return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', {
weekday: 'short', day: '2-digit', month: 'short',
})
}
export function CashSummary() {
const navigate = useNavigate()
const [from, setFrom] = useState(() => {
const d = new Date()
d.setDate(d.getDate() - 6)
return d.toISOString().slice(0, 10)
})
const [to, setTo] = useState(today)
const [result, setResult] = useState<SummaryResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generate() {
setLoading(true); setError(''); setResult(null)
try {
const data = await api.get<SummaryResult>(`/reports/cash-summary?from=${from}&to=${to}`)
setResult(data)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to generate summary')
} finally {
setLoading(false)
}
}
function addToSafe() {
if (!result) return
const adjustDenoms: Record<string, number> = {}
result.by_date_denom.forEach(r => {
adjustDenoms[r.denomination_value] =
(adjustDenoms[r.denomination_value] || 0) + parseFloat(r.total_amount)
})
navigate('/safe', { state: { adjustDenoms } })
}
// Cross-tab lookups: denomMap[date_str][denom_float] = amount
const denomMap: Record<string, Record<number, number>> = {}
const rowTotals: Record<number, number> = {}
result?.by_date_denom.forEach(r => {
const date = r.session_date.slice(0, 10)
const dv = parseFloat(r.denomination_value)
const amt = parseFloat(r.total_amount)
if (!denomMap[date]) denomMap[date] = {}
denomMap[date][dv] = amt
rowTotals[dv] = (rowTotals[dv] || 0) + amt
})
const grandTotal = Object.values(rowTotals).reduce((s, v) => s + v, 0)
return (
<div style={{ padding: '1.5rem' }}>
<PageHeader title="Cash Summary" subtitle="Daily cash denomination breakdown" />
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={lbl}>From</label>
<input type="date" value={from} onChange={e => setFrom(e.target.value)} style={inpSt} />
</div>
<div>
<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>
{result && result.by_date.length > 0 && (
<Btn variant="secondary" onClick={addToSafe} style={{ marginLeft: 'auto' }}>
+ Add to Safe
</Btn>
)}
</Card>
{error && (
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.75rem 1rem', marginBottom: '1rem' }}>
{error}
</div>
)}
{result && (
result.by_date.length === 0 ? (
<Card>
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>
No cash ups recorded in this period.
</p>
</Card>
) : (
<Card style={{ padding: 0, overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ background: 'var(--body-bg)', borderBottom: '1px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left', paddingLeft: '0.75rem', minWidth: '90px', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
Status
</th>
{result.by_date.map(d => (
<th key={d.session_date} style={{ ...th, padding: '0.4rem 0.5rem' }}>
<StatusBadge status={d.status} />
</th>
))}
<th style={{ ...th, borderLeft: '2px solid var(--card-border)' }}></th>
</tr>
<tr style={{ background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left', paddingLeft: '0.75rem', fontWeight: 700, position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
Denomination
</th>
{result.by_date.map(d => (
<th key={d.session_date} style={{ ...th, whiteSpace: 'nowrap', fontWeight: 700 }}>
{fmtDateShort(d.session_date)}
</th>
))}
<th style={{ ...th, fontWeight: 700, borderLeft: '2px solid var(--card-border)' }}>Total</th>
</tr>
</thead>
<tbody>
{GBP_DENOMINATIONS.map(d => {
if (!rowTotals[d.value]) return null
return (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'white' }}>
{d.label}
</td>
{result.by_date.map(row => {
const date = row.session_date.slice(0, 10)
const amt = denomMap[date]?.[d.value] || 0
return (
<td key={date} style={td}>{amt > 0 ? fmtGBP(amt) : '—'}</td>
)
})}
<td style={{ ...td, fontWeight: 700, borderLeft: '2px solid var(--card-border)' }}>
{fmtGBP(rowTotals[d.value])}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)', fontWeight: 700 }}>
<td style={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
Cash Total
</td>
{result.by_date.map(row => (
<td key={row.session_date} style={td}>
{fmtGBP(row.total_cash_counted)}
</td>
))}
<td style={{ ...td, fontWeight: 700, fontSize: '0.9rem', borderLeft: '2px solid var(--card-border)' }}>
{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 th: React.CSSProperties = { padding: '0.45rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' }
const td: React.CSSProperties = { padding: '0.4rem 0.5rem', textAlign: 'right' }