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>
This commit is contained in:
parent
8b2dc4ad78
commit
ed9cce1d76
4 changed files with 392 additions and 103 deletions
|
|
@ -1,22 +1,39 @@
|
|||
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 DenomRow { denomination_value: string; total_quantity: string; total_value: string }
|
||||
interface DayRow { session_date: string; status: 'draft' | 'final'; total_cash_counted: string; submitted_by: string | null }
|
||||
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 {
|
||||
denominations: DenomRow[]
|
||||
by_date: DayRow[]
|
||||
by_date_denom: DenomDateRow[]
|
||||
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' })
|
||||
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 [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) })
|
||||
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)
|
||||
|
|
@ -34,11 +51,32 @@ export function CashSummary() {
|
|||
}
|
||||
}
|
||||
|
||||
const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0
|
||||
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', maxWidth: '760px' }}>
|
||||
<PageHeader title="Cash Denomination Summary" subtitle="Aggregate cash count across a date range" />
|
||||
<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>
|
||||
|
|
@ -50,6 +88,11 @@ export function CashSummary() {
|
|||
<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 && (
|
||||
|
|
@ -58,91 +101,86 @@ export function CashSummary() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{result && (<>
|
||||
|
||||
{/* 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 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 && (
|
||||
{result && (
|
||||
result.by_date.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 style={{ fontSize: '0.875rem', color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>
|
||||
No cash ups recorded in this period.
|
||||
</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ padding: 0, overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
||||
<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 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 => {
|
||||
const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001)
|
||||
if (!row) return null
|
||||
if (!rowTotals[d.value]) 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>
|
||||
<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)' }}>
|
||||
<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 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 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 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' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue