Float management: bagged coins, history nav fix, change order sheet, upload 500 fix

- Fix history nav: type underscore → hyphen for route match
- Change tin: Bags + Loose inputs per denomination with UK bag values
- Change tin target: per-denomination bag count in Settings
- Change order sheet: inline table showing bags to order vs counted
- db: bag_quantity column on float_denominations
- api.ts: put text fields before file in FormData so busboy doesn't drain file stream early
- db.js: additive migration for uploaded_by column on cash_count_attachments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 15:29:38 +00:00
parent d03371dca1
commit 5623f64732
6 changed files with 182 additions and 39 deletions

View file

@ -126,7 +126,9 @@ export async function initDb() {
);
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS attachment_type TEXT NOT NULL DEFAULT 'other';
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS uploaded_by TEXT NOT NULL DEFAULT '';
ALTER TABLE cash_ups ADD COLUMN IF NOT EXISTS checked_transactions JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE float_denominations ADD COLUMN IF NOT EXISTS bag_quantity INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,

View file

@ -29,9 +29,9 @@ export async function floatRoutes(app) {
for (const d of denominations) {
await pool.query(
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, total_amount)
VALUES ($1,$2,$3,$4)`,
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseFloat(d.total)]
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, bag_quantity, total_amount)
VALUES ($1,$2,$3,$4,$5)`,
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseInt(d.bag_quantity ?? 0), parseFloat(d.total)]
)
}

View file

@ -28,9 +28,9 @@ export async function uploadAttachment(
label?: string,
) {
const fd = new FormData()
fd.append('file', file)
fd.append('attachment_type', attachmentType)
if (label) fd.append('label', label)
fd.append('file', file)
const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, {
method: 'POST',
credentials: 'include',

View file

@ -13,12 +13,23 @@ const TYPE_LABELS: Record<CountType, string> = {
safe_cash: 'Safe Cash',
}
// Denominations relevant for each type (change_tin uses bags, no £0.02/£0.01)
// Denominations relevant for each type (change_tin excludes 1p/2p)
const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05)
// UK standard bag values (£ per sealed bag of each denomination)
const BAG_VALUES: Record<number, number> = {
0.05: 5, // 100 × 5p
0.10: 5, // 50 × 10p
0.20: 10, // 50 × 20p
0.50: 10, // 20 × 50p
1.00: 20, // 20 × £1
2.00: 20, // 10 × £2
}
export function FloatCountForm({ type }: { type: CountType }) {
const navigate = useNavigate()
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
const [bagQtys, setBagQtys] = useState<Record<number, number>>({})
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
@ -41,7 +52,9 @@ export function FloatCountForm({ type }: { type: CountType }) {
}).catch(() => {})
}, [type])
const totalCounted = denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
const totalCounted = type === 'change_tin'
? denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0) + (BAG_VALUES[d.value] ?? 0) * (bagQtys[d.value] ?? 0), 0)
: denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
const totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0)
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0)
@ -53,12 +66,21 @@ export function FloatCountForm({ type }: { type: CountType }) {
async function save() {
setSaving(true)
try {
const denominations = type === 'change_tin'
? denoms
.filter(d => (denomQtys[d.value] ?? 0) > 0 || (bagQtys[d.value] ?? 0) > 0)
.map(d => {
const loose = denomQtys[d.value] ?? 0
const bags = bagQtys[d.value] ?? 0
return { denomination: d.value, quantity: loose, bag_quantity: bags, total: d.value * loose + (BAG_VALUES[d.value] ?? 0) * bags }
})
: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
denomination: d.value, quantity: denomQtys[d.value] ?? 0, bag_quantity: 0, total: d.value * (denomQtys[d.value] ?? 0),
}))
await api.post('/floats/save', {
count_type: type,
count_date: new Date().toISOString(),
denominations: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
denomination: d.value, quantity: denomQtys[d.value] ?? 0, total: d.value * (denomQtys[d.value] ?? 0),
})),
denominations,
receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [],
total_counted: totalCounted,
total_receipts: totalReceipts,
@ -68,6 +90,7 @@ export function FloatCountForm({ type }: { type: CountType }) {
})
setMsg({ text: 'Count saved.', ok: true })
setDenomQtys({})
setBagQtys({})
setReceipts([])
setNotes('')
} catch (e: unknown) {
@ -93,25 +116,51 @@ export function FloatCountForm({ type }: { type: CountType }) {
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
{denoms.map(d => {
const qty = denomQtys[d.value] ?? 0
const target = type === 'change_tin' ? parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? '0')) : undefined
const rowTotal = d.value * qty
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
{target !== undefined && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', textAlign: 'right' }}>
tgt {fmtGBP(target)}
</span>
)}
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
{type === 'change_tin' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span />
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Bags</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Loose</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span>
</div>
)
})}
{denoms.map(d => {
const bags = bagQtys[d.value] ?? 0
const loose = denomQtys[d.value] ?? 0
const bagVal = BAG_VALUES[d.value] ?? 0
const rowTotal = bagVal * bags + d.value * loose
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<div>
<input type="number" min="0" step="1" value={bags || ''}
onChange={e => setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<div style={{ fontSize: '0.65rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '0.1rem' }}>={fmtGBP(bagVal)} ea</div>
</div>
<input type="number" min="0" step="1" value={loose || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div>
)
})}
</>
) : (
denoms.map(d => {
const qty = denomQtys[d.value] ?? 0
const rowTotal = d.value * qty
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div>
)
})
)}
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
<span>Total Counted</span>
<span>{fmtGBP(totalCounted)}</span>
@ -159,6 +208,48 @@ export function FloatCountForm({ type }: { type: CountType }) {
</Card>
)}
{type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ textAlign: 'left', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Denom</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Target</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Counted</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Order</th>
</tr>
</thead>
<tbody>
{denoms.map(d => {
const bagVal = BAG_VALUES[d.value] ?? 0
if (!bagVal) return null
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
if (target <= 0) return null
const targetBags = Math.round(target / bagVal)
const bags = bagQtys[d.value] ?? 0
const loose = denomQtys[d.value] ?? 0
const countedVal = bags * bagVal + loose * d.value
const needed = target - countedVal
const orderBags = needed > 0.005 ? Math.ceil(needed / bagVal) : 0
return (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{d.label}</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', color: 'var(--text-mid)' }}>{targetBags} bag{targetBags !== 1 ? 's' : ''}</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
{bags > 0 && `${bags}bg`}{bags > 0 && loose > 0 ? ' + ' : ''}{loose > 0 && `${loose}×`}{(bags === 0 && loose === 0) && '—'}
</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderBags > 0 ? 'var(--danger)' : '#16a34a' }}>
{orderBags > 0 ? `+${orderBags} bag${orderBags !== 1 ? 's' : ''}` : 'OK'}
</td>
</tr>
)
})}
</tbody>
</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}
@ -167,7 +258,7 @@ export function FloatCountForm({ type }: { type: CountType }) {
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Count'}</Btn>
<Btn variant="ghost" onClick={() => navigate(`/floats/${type}/history`)}>View History</Btn>
<Btn variant="ghost" onClick={() => navigate(`/floats/${type.replace(/_/g, '-')}/history`)}>View History</Btn>
</div>
</div>
)
@ -207,16 +298,21 @@ export function FloatHistory({ type }: { type: CountType }) {
</div>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{detail.denominations.map(d => (
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.5rem' }}>{fmtGBP(d.denomination_value)}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>×{d.quantity}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
{d.target !== undefined && (
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
)}
</tr>
))}
{detail.denominations.map(d => {
const bags = d.bag_quantity ?? 0
return (
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.5rem' }}>{fmtGBP(d.denomination_value)}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>
{bags > 0 ? `${bags} bag${bags !== 1 ? 's' : ''}` : ''}{bags > 0 && d.quantity > 0 ? ' + ' : ''}{d.quantity > 0 ? `×${d.quantity}` : ''}{bags === 0 && d.quantity === 0 ? '—' : ''}
</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
{d.target !== undefined && d.target > 0 && (
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
)}
</tr>
)
})}
</tbody>
</table>
{detail.receipts.length > 0 && (

View file

@ -11,11 +11,21 @@ interface SettingsData {
change_tin_breakdown: string
}
const CHANGE_TIN_DENOMS = [
{ value: 2.00, label: '£2', bagValue: 20 },
{ value: 1.00, label: '£1', bagValue: 20 },
{ value: 0.50, label: '50p', bagValue: 10 },
{ value: 0.20, label: '20p', bagValue: 10 },
{ value: 0.10, label: '10p', bagValue: 5 },
{ value: 0.05, label: '5p', bagValue: 5 },
]
interface GlColumn { gl_code: string; display_name: string; enabled: boolean; sort_order: number }
export function SettingsPage({ user }: { user: User }) {
const [settings, setSettings] = useState<Partial<SettingsData>>({})
const [columns, setColumns] = useState<GlColumn[]>([])
const [tinTargetBags, setTinTargetBags] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
@ -31,6 +41,15 @@ export function SettingsPage({ user }: { user: User }) {
api.get<SettingsData>('/settings').then(s => {
setSettings(s)
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
try {
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
const bags: Record<string, number> = {}
for (const d of CHANGE_TIN_DENOMS) {
const key = d.value.toFixed(2)
bags[key] = Math.round((parseFloat(breakdown[key] ?? 0)) / d.bagValue)
}
setTinTargetBags(bags)
} catch { setTinTargetBags({}) }
}).finally(() => setLoading(false))
}, [])
@ -41,7 +60,12 @@ export function SettingsPage({ user }: { user: User }) {
async function save() {
setSaving(true)
try {
await api.put('/settings', { ...settings, sales_breakdown_columns: JSON.stringify(columns) })
const tinBreakdown: Record<string, number> = {}
for (const d of CHANGE_TIN_DENOMS) {
const key = d.value.toFixed(2)
tinBreakdown[key] = (tinTargetBags[key] ?? 0) * d.bagValue
}
await api.put('/settings', { ...settings, sales_breakdown_columns: JSON.stringify(columns), change_tin_breakdown: JSON.stringify(tinBreakdown) })
flash('Settings saved.')
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
@ -139,6 +163,26 @@ export function SettingsPage({ user }: { user: User }) {
</div>
</Card>
{/* Change tin target breakdown */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Change Tin Target</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>Set how many sealed bags of each denomination to keep in the change tin.</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
{CHANGE_TIN_DENOMS.map(d => {
const key = d.value.toFixed(2)
const bags = tinTargetBags[key] ?? 0
return (
<div key={key}>
<label style={labelSt}>{d.label} bags <span style={{ fontWeight: 400 }}>(= £{(bags * d.bagValue).toFixed(2)} each)</span></label>
<input type="number" min="0" step="1" value={bags}
onChange={e => setTinTargetBags(prev => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))}
style={inpSt} />
</div>
)
})}
</div>
</Card>
{/* Sales breakdown GL columns — requires settings capability */}
{can(user, 'settings') && (
<Card style={{ marginBottom: '1rem' }}>

View file

@ -110,6 +110,7 @@ export interface FloatCount {
export interface FloatDenomination {
denomination_value: string
quantity: number
bag_quantity?: number
total_amount: string
target?: number
}