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
|
|
@ -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 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 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 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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ export async function floatRoutes(app) {
|
||||||
|
|
||||||
for (const d of denominations) {
|
for (const d of denominations) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, total_amount)
|
`INSERT INTO float_denominations (float_count_id, denomination_value, quantity, bag_quantity, total_amount)
|
||||||
VALUES ($1,$2,$3,$4)`,
|
VALUES ($1,$2,$3,$4,$5)`,
|
||||||
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseFloat(d.total)]
|
[countId, parseFloat(d.denomination), parseInt(d.quantity), parseInt(d.bag_quantity ?? 0), parseFloat(d.total)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ export async function uploadAttachment(
|
||||||
label?: string,
|
label?: string,
|
||||||
) {
|
) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('file', file)
|
|
||||||
fd.append('attachment_type', attachmentType)
|
fd.append('attachment_type', attachmentType)
|
||||||
if (label) fd.append('label', label)
|
if (label) fd.append('label', label)
|
||||||
|
fd.append('file', file)
|
||||||
const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, {
|
const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,23 @@ const TYPE_LABELS: Record<CountType, string> = {
|
||||||
safe_cash: 'Safe Cash',
|
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)
|
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 }) {
|
export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
|
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
|
||||||
|
const [bagQtys, setBagQtys] = useState<Record<number, number>>({})
|
||||||
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
|
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
|
||||||
const [notes, setNotes] = useState('')
|
const [notes, setNotes] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -41,7 +52,9 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [type])
|
}, [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 totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0)
|
||||||
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
|
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
|
||||||
? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0)
|
? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0)
|
||||||
|
|
@ -53,12 +66,21 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
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', {
|
await api.post('/floats/save', {
|
||||||
count_type: type,
|
count_type: type,
|
||||||
count_date: new Date().toISOString(),
|
count_date: new Date().toISOString(),
|
||||||
denominations: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
|
denominations,
|
||||||
denomination: d.value, quantity: denomQtys[d.value] ?? 0, total: d.value * (denomQtys[d.value] ?? 0),
|
|
||||||
})),
|
|
||||||
receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [],
|
receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [],
|
||||||
total_counted: totalCounted,
|
total_counted: totalCounted,
|
||||||
total_receipts: totalReceipts,
|
total_receipts: totalReceipts,
|
||||||
|
|
@ -68,6 +90,7 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
})
|
})
|
||||||
setMsg({ text: 'Count saved.', ok: true })
|
setMsg({ text: 'Count saved.', ok: true })
|
||||||
setDenomQtys({})
|
setDenomQtys({})
|
||||||
|
setBagQtys({})
|
||||||
setReceipts([])
|
setReceipts([])
|
||||||
setNotes('')
|
setNotes('')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -93,25 +116,51 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
|
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
|
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
|
||||||
|
{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 => {
|
{denoms.map(d => {
|
||||||
const qty = denomQtys[d.value] ?? 0
|
const bags = bagQtys[d.value] ?? 0
|
||||||
const target = type === 'change_tin' ? parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? '0')) : undefined
|
const loose = denomQtys[d.value] ?? 0
|
||||||
const rowTotal = d.value * qty
|
const bagVal = BAG_VALUES[d.value] ?? 0
|
||||||
|
const rowTotal = bagVal * bags + d.value * loose
|
||||||
return (
|
return (
|
||||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
<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>
|
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
|
||||||
<input type="number" min="0" step="1" value={qty || ''}
|
<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 }))}
|
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||||
placeholder="0" style={inpSt} />
|
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>
|
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
|
||||||
</div>
|
</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 }}>
|
<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>Total Counted</span>
|
||||||
<span>{fmtGBP(totalCounted)}</span>
|
<span>{fmtGBP(totalCounted)}</span>
|
||||||
|
|
@ -159,6 +208,48 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
</Card>
|
</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' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
|
<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}
|
<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' }}>
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
<Btn onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Count'}</Btn>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -207,16 +298,21 @@ export function FloatHistory({ type }: { type: CountType }) {
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{detail.denominations.map(d => (
|
{detail.denominations.map(d => {
|
||||||
|
const bags = d.bag_quantity ?? 0
|
||||||
|
return (
|
||||||
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<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' }}>{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' }}>
|
||||||
|
{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>
|
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
|
||||||
{d.target !== undefined && (
|
{d.target !== undefined && d.target > 0 && (
|
||||||
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
|
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{detail.receipts.length > 0 && (
|
{detail.receipts.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,21 @@ interface SettingsData {
|
||||||
change_tin_breakdown: string
|
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 }
|
interface GlColumn { gl_code: string; display_name: string; enabled: boolean; sort_order: number }
|
||||||
|
|
||||||
export function SettingsPage({ user }: { user: User }) {
|
export function SettingsPage({ user }: { user: User }) {
|
||||||
const [settings, setSettings] = useState<Partial<SettingsData>>({})
|
const [settings, setSettings] = useState<Partial<SettingsData>>({})
|
||||||
const [columns, setColumns] = useState<GlColumn[]>([])
|
const [columns, setColumns] = useState<GlColumn[]>([])
|
||||||
|
const [tinTargetBags, setTinTargetBags] = useState<Record<string, number>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
@ -31,6 +41,15 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
api.get<SettingsData>('/settings').then(s => {
|
api.get<SettingsData>('/settings').then(s => {
|
||||||
setSettings(s)
|
setSettings(s)
|
||||||
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
|
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))
|
}).finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -41,7 +60,12 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
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.')
|
flash('Settings saved.')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
flash(e instanceof Error ? e.message : 'Save failed.', false)
|
flash(e instanceof Error ? e.message : 'Save failed.', false)
|
||||||
|
|
@ -139,6 +163,26 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Sales breakdown GL columns — requires settings capability */}
|
||||||
{can(user, 'settings') && (
|
{can(user, 'settings') && (
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export interface FloatCount {
|
||||||
export interface FloatDenomination {
|
export interface FloatDenomination {
|
||||||
denomination_value: string
|
denomination_value: string
|
||||||
quantity: number
|
quantity: number
|
||||||
|
bag_quantity?: number
|
||||||
total_amount: string
|
total_amount: string
|
||||||
target?: number
|
target?: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue