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
|
|
@ -211,7 +211,7 @@ 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 [denomResult, byDateResult] = await Promise.all([
|
const [denomResult, byDateResult, byDateDenomResult] = await Promise.all([
|
||||||
pool.query(
|
pool.query(
|
||||||
`SELECT d.denomination_value,
|
`SELECT d.denomination_value,
|
||||||
ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity,
|
ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity,
|
||||||
|
|
@ -230,11 +230,21 @@ export async function reportRoutes(app) {
|
||||||
ORDER BY session_date ASC`,
|
ORDER BY session_date ASC`,
|
||||||
[from, to]
|
[from, to]
|
||||||
),
|
),
|
||||||
|
pool.query(
|
||||||
|
`SELECT d.denomination_value, c.session_date, SUM(d.total_amount) AS total_amount
|
||||||
|
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, c.session_date
|
||||||
|
ORDER BY d.denomination_value DESC, c.session_date ASC`,
|
||||||
|
[from, to]
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
denominations: denomResult.rows,
|
denominations: denomResult.rows,
|
||||||
by_date: byDateResult.rows,
|
by_date: byDateResult.rows,
|
||||||
|
by_date_denom: byDateDenomResult.rows,
|
||||||
period: { from, to },
|
period: { from, to },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,39 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { PageHeader, Card, Btn, StatusBadge } 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 DayRow {
|
||||||
interface DayRow { session_date: string; status: 'draft' | 'final'; total_cash_counted: string; submitted_by: string | null }
|
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 {
|
interface SummaryResult {
|
||||||
denominations: DenomRow[]
|
|
||||||
by_date: DayRow[]
|
by_date: DayRow[]
|
||||||
|
by_date_denom: DenomDateRow[]
|
||||||
period: { from: string; to: string }
|
period: { from: string; to: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(d: 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', year: 'numeric' })
|
return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short', day: '2-digit', month: 'short',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 [to, setTo] = useState(today)
|
||||||
const [result, setResult] = useState<SummaryResult | null>(null)
|
const [result, setResult] = useState<SummaryResult | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: '760px' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<PageHeader title="Cash Denomination Summary" subtitle="Aggregate cash count across a date range" />
|
<PageHeader title="Cash Summary" subtitle="Daily cash denomination breakdown" />
|
||||||
|
|
||||||
<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>
|
||||||
|
|
@ -50,6 +88,11 @@ export function CashSummary() {
|
||||||
<input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} />
|
<input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} />
|
||||||
</div>
|
</div>
|
||||||
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
|
<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>
|
</Card>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -58,91 +101,86 @@ export function CashSummary() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (<>
|
{result && (
|
||||||
|
result.by_date.length === 0 ? (
|
||||||
{/* 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 && (
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 style={secSt}>Denomination Breakdown</h2>
|
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
No cash ups recorded in this period.
|
||||||
Takings only (excludes float counts)
|
|
||||||
</p>
|
</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>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<tr style={{ background: 'var(--body-bg)', borderBottom: '1px solid var(--card-border)' }}>
|
||||||
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th>
|
<th style={{ ...th, textAlign: 'left', paddingLeft: '0.75rem', minWidth: '90px', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
|
||||||
<th style={thSt}>Total Qty</th>
|
Status
|
||||||
<th style={thSt}>Total Value</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{GBP_DENOMINATIONS.map(d => {
|
{GBP_DENOMINATIONS.map(d => {
|
||||||
const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001)
|
if (!rowTotals[d.value]) return null
|
||||||
if (!row) return null
|
|
||||||
return (
|
return (
|
||||||
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<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={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'white' }}>
|
||||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td>
|
{d.label}
|
||||||
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td>
|
</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>
|
</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)', fontWeight: 700 }}>
|
||||||
<td style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Grand Total</td>
|
<td style={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
|
||||||
<td></td>
|
Cash Total
|
||||||
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td>
|
</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>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)
|
||||||
|
)}
|
||||||
</>)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lbl: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }
|
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 th: React.CSSProperties = { padding: '0.45rem 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)' }
|
const td: React.CSSProperties = { padding: '0.4rem 0.5rem', textAlign: 'right' }
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ const BAG_VALUES: Record<number, number> = {
|
||||||
|
|
||||||
// ── Shared record detail + print view ─────────────────────────────────────────
|
// ── Shared record detail + print view ─────────────────────────────────────────
|
||||||
|
|
||||||
type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }
|
export type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }
|
||||||
|
|
||||||
function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: {
|
export function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: {
|
||||||
record: DetailRecord
|
record: DetailRecord
|
||||||
changeTinTargets: Record<string, number>
|
changeTinTargets: Record<string, number>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -324,30 +324,48 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem', padding: 0, overflow: 'hidden' }}>
|
||||||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
|
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, color: 'var(--text-mid)', padding: '0.75rem 0.75rem 0.5rem' }}>DENOMINATIONS</h2>
|
||||||
{denoms.map(d => {
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
const qty = denomQtys[d.value] ?? 0
|
<thead>
|
||||||
const uv = unitVal(d)
|
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||||
const rowTotal = uv * qty
|
<th style={fthL}>Denomination</th>
|
||||||
const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined
|
<th style={fthR}>{type === 'change_tin' ? 'Bags' : 'Qty'}</th>
|
||||||
return (
|
<th style={fthR}>Amount</th>
|
||||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '80px 1fr 90px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
</tr>
|
||||||
<div>
|
</thead>
|
||||||
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
|
<tbody>
|
||||||
{isBag && <div style={{ fontSize: '0.65rem', color: 'var(--text-mid)' }}>{fmtGBP(uv)}/bag</div>}
|
{denoms.map(d => {
|
||||||
</div>
|
const qty = denomQtys[d.value] ?? 0
|
||||||
<input type="number" min="0" step="1" value={qty || ''}
|
const uv = unitVal(d)
|
||||||
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
const rowTotal = uv * qty
|
||||||
placeholder="0" style={inpSt} />
|
const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined
|
||||||
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
|
return (
|
||||||
</div>
|
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
)
|
<td style={{ padding: '0.3rem 0.75rem' }}>
|
||||||
})}
|
<span style={{ fontWeight: 600 }}>{d.label}</span>
|
||||||
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
|
{isBag && <div style={{ fontSize: '0.65rem', color: 'var(--text-mid)' }}>{fmtGBP(uv)}/bag</div>}
|
||||||
<span>Total Counted</span>
|
</td>
|
||||||
<span>{fmtGBP(totalCounted)}</span>
|
<td style={{ padding: '0.2rem 0.5rem', textAlign: 'right' }}>
|
||||||
</div>
|
<input type="number" min="0" step="1" value={qty || ''}
|
||||||
|
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||||
|
placeholder="0"
|
||||||
|
style={{ border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '80px', textAlign: 'right' }} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.3rem 0.75rem', textAlign: 'right', fontWeight: 600 }}>
|
||||||
|
{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
|
||||||
|
<td colSpan={2} style={{ padding: '0.5rem 0.75rem', fontWeight: 700 }}>Total Counted</td>
|
||||||
|
<td style={{ padding: '0.5rem 0.75rem', textAlign: 'right', fontWeight: 700 }}>{fmtGBP(totalCounted)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{type === 'petty_cash' && (
|
{type === 'petty_cash' && (
|
||||||
|
|
@ -601,5 +619,7 @@ const inpSt: React.CSSProperties = {
|
||||||
border: '1px solid var(--card-border)', borderRadius: '4px',
|
border: '1px solid var(--card-border)', borderRadius: '4px',
|
||||||
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
|
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
|
||||||
}
|
}
|
||||||
|
const fthL: React.CSSProperties = { padding: '0.4rem 0.75rem', textAlign: 'left', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
|
||||||
|
const fthR: React.CSSProperties = { padding: '0.4rem 0.75rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
|
||||||
const thS: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem', textAlign: 'right' }
|
const thS: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem', textAlign: 'right' }
|
||||||
const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' }
|
const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' }
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,235 @@
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { useState, useEffect } from 'react'
|
||||||
import { FloatCountForm, FloatHistory } from './FloatManagement'
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { api } from '../api'
|
||||||
|
import { PageHeader, Card, Btn } from '../components/Layout'
|
||||||
|
import { GBP_DENOMINATIONS, fmtGBP } from '../types'
|
||||||
|
import type { FloatCount, FloatDenomination } from '../types'
|
||||||
|
import { FloatHistory, FloatRecordPrint } from './FloatManagement'
|
||||||
|
import type { DetailRecord } from './FloatManagement'
|
||||||
|
|
||||||
|
function SafeCountAdjust() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const prefill = (location.state as { adjustDenoms?: Record<string, number> } | null)?.adjustDenoms
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState<Record<number, number>>({})
|
||||||
|
const [lastCount, setLastCount] = useState<FloatCount | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [adjust, setAdjust] = useState<Record<number, number>>(() => {
|
||||||
|
if (!prefill) return {}
|
||||||
|
const pf: Record<number, number> = {}
|
||||||
|
Object.entries(prefill).forEach(([k, v]) => { pf[parseFloat(k)] = v })
|
||||||
|
return pf
|
||||||
|
})
|
||||||
|
const [notes, setNotes] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [savedRecord, setSavedRecord] = useState<DetailRecord | null>(null)
|
||||||
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setCurrent({})
|
||||||
|
setLastCount(null)
|
||||||
|
api.get<{ rows: FloatCount[]; total: number }>('/floats?type=safe_cash&offset=0&limit=1')
|
||||||
|
.then(async d => {
|
||||||
|
if (d.rows.length > 0) {
|
||||||
|
const last = d.rows[0]
|
||||||
|
setLastCount(last)
|
||||||
|
const detail = await api.get<DetailRecord>(`/floats/${last.id}`)
|
||||||
|
const curr: Record<number, number> = {}
|
||||||
|
detail.denominations.forEach((dn: FloatDenomination) => {
|
||||||
|
curr[parseFloat(dn.denomination_value)] = parseFloat(String(dn.total_amount))
|
||||||
|
})
|
||||||
|
setCurrent(curr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [reloadKey])
|
||||||
|
|
||||||
|
// Derived
|
||||||
|
const newTotals: Record<number, number> = {}
|
||||||
|
GBP_DENOMINATIONS.forEach(d => {
|
||||||
|
newTotals[d.value] = Math.max(0, (current[d.value] || 0) + (adjust[d.value] || 0))
|
||||||
|
})
|
||||||
|
const prevTotal = GBP_DENOMINATIONS.reduce((s, d) => s + (current[d.value] || 0), 0)
|
||||||
|
const newTotal = GBP_DENOMINATIONS.reduce((s, d) => s + newTotals[d.value], 0)
|
||||||
|
const adjustTotal = GBP_DENOMINATIONS.reduce((s, d) => s + (adjust[d.value] || 0), 0)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true); setError(null)
|
||||||
|
try {
|
||||||
|
const denominations = GBP_DENOMINATIONS
|
||||||
|
.filter(d => newTotals[d.value] > 0)
|
||||||
|
.map(d => ({
|
||||||
|
denomination: d.value,
|
||||||
|
quantity: Math.round(newTotals[d.value] / d.value),
|
||||||
|
bag_quantity: 0,
|
||||||
|
total: newTotals[d.value],
|
||||||
|
}))
|
||||||
|
const res = await api.post<{ count_id: number }>('/floats/save', {
|
||||||
|
count_type: 'safe_cash',
|
||||||
|
count_date: new Date().toISOString(),
|
||||||
|
denominations,
|
||||||
|
total_counted: newTotal,
|
||||||
|
total_receipts: 0,
|
||||||
|
target_amount: 0,
|
||||||
|
variance: 0,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
const detail = await api.get<DetailRecord>(`/floats/${res.count_id}`)
|
||||||
|
setSavedRecord(detail)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedRecord) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '680px' }}>
|
||||||
|
<PageHeader title="Safe Count — Saved" />
|
||||||
|
<FloatRecordPrint
|
||||||
|
record={savedRecord}
|
||||||
|
changeTinTargets={{}}
|
||||||
|
onClose={() => {
|
||||||
|
setSavedRecord(null)
|
||||||
|
setAdjust({})
|
||||||
|
setNotes('')
|
||||||
|
setReloadKey(k => k + 1)
|
||||||
|
}}
|
||||||
|
closeLabel="New Adjustment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastCountDate = lastCount
|
||||||
|
? new Date(lastCount.count_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '700px' }}>
|
||||||
|
<PageHeader
|
||||||
|
title="Safe Count"
|
||||||
|
subtitle={
|
||||||
|
loading ? 'Loading last count…'
|
||||||
|
: lastCount ? `Last count: ${lastCountDate} — ${fmtGBP(lastCount.total_counted)}`
|
||||||
|
: 'No previous count on record'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefill && (
|
||||||
|
<div style={{ background: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem', color: '#1e40af' }}>
|
||||||
|
Adjustment pre-filled from cash summary takings. Review and adjust as needed before saving.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '1rem', padding: 0, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)' }}>
|
||||||
|
<th style={thL}>Denomination</th>
|
||||||
|
<th style={thR}>Current in Safe</th>
|
||||||
|
<th style={thR}>Adjustment</th>
|
||||||
|
<th style={thR}>New Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{GBP_DENOMINATIONS.map(d => {
|
||||||
|
const curr = current[d.value] || 0
|
||||||
|
const adj = adjust[d.value] || 0
|
||||||
|
const newT = newTotals[d.value]
|
||||||
|
return (
|
||||||
|
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
|
<td style={{ padding: '0.3rem 0.75rem', fontWeight: 600 }}>{d.label}</td>
|
||||||
|
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>
|
||||||
|
{curr > 0 ? fmtGBP(curr) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.2rem 0.5rem', textAlign: 'right' }}>
|
||||||
|
<input
|
||||||
|
type="number" step="0.01"
|
||||||
|
value={adjust[d.value] !== undefined && adjust[d.value] !== 0 ? adjust[d.value] : ''}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value === '' ? 0 : parseFloat(e.target.value) || 0
|
||||||
|
setAdjust(prev => ({ ...prev, [d.value]: v }))
|
||||||
|
}}
|
||||||
|
placeholder="0.00"
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--card-border)', borderRadius: '4px',
|
||||||
|
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '110px',
|
||||||
|
textAlign: 'right',
|
||||||
|
color: adj < 0 ? 'var(--danger)' : adj > 0 ? 'var(--success)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{
|
||||||
|
padding: '0.3rem 0.75rem', textAlign: 'right', fontWeight: 600,
|
||||||
|
color: newT === 0 && (curr > 0 || adj < 0) ? 'var(--danger)' : undefined,
|
||||||
|
}}>
|
||||||
|
{newT > 0 ? fmtGBP(newT) : (curr > 0 || adj !== 0) ? fmtGBP(0) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)', fontWeight: 700 }}>
|
||||||
|
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
|
||||||
|
<td style={{ padding: '0.5rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>
|
||||||
|
{fmtGBP(prevTotal)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.5rem 0.5rem', textAlign: 'right', color: adjustTotal < 0 ? 'var(--danger)' : adjustTotal > 0 ? 'var(--success)' : 'var(--text-mid)' }}>
|
||||||
|
{adjustTotal !== 0
|
||||||
|
? (adjustTotal > 0 ? '+' : '-') + fmtGBP(Math.abs(adjustTotal))
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.5rem 0.75rem', textAlign: 'right', fontSize: '1rem' }}>
|
||||||
|
{fmtGBP(newTotal)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes} onChange={e => setNotes(e.target.value)} rows={2}
|
||||||
|
placeholder="Reason for adjustment (e.g. weekly banking, coin exchange, uplift…)"
|
||||||
|
style={{ width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.5rem', fontSize: '0.875rem', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
<Btn onClick={save} disabled={saving || loading}>
|
||||||
|
{saving ? 'Saving…' : 'Save Count'}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="ghost" onClick={() => navigate('/safe/history')}>View History</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SafeCount() {
|
export function SafeCount() {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<FloatCountForm type="safe_cash" />} />
|
<Route index element={<SafeCountAdjust />} />
|
||||||
<Route path="history" element={<FloatHistory type="safe_cash" />} />
|
<Route path="history" element={<FloatHistory type="safe_cash" />} />
|
||||||
<Route path="*" element={<Navigate to="" replace />} />
|
<Route path="*" element={<Navigate to="" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thL: React.CSSProperties = { padding: '0.45rem 0.75rem', textAlign: 'left', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
|
||||||
|
const thR: React.CSSProperties = { padding: '0.45rem 0.75rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue