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:
parent
d03371dca1
commit
5623f64732
6 changed files with 182 additions and 39 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export interface FloatCount {
|
|||
export interface FloatDenomination {
|
||||
denomination_value: string
|
||||
quantity: number
|
||||
bag_quantity?: number
|
||||
total_amount: string
|
||||
target?: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue