Settings: change tin notes/notes, label fix, placeholder GL columns

Change tin targets:
- Add £5/£10/£20/£50 note rows (qty in individual notes, not bags)
- Fix label: (= £40.00 @ £20.00 each) instead of (= £40.00 each)
- Add per-denomination Notes text field (stored as change_tin_notes setting)
- Table layout replaces 2-col grid for all 10 denominations

Float count form:
- Notes denominations show a single Qty input; coins keep Bags + Loose
- Change order correctly shows "notes" vs "bags" needed per denomination

Sales breakdown columns:
- Add placeholder column button (gl_code='', labelled PLACEHOLDER)
- Add delete (×) button on every column row
- Fix key={i} to support multiple placeholders with same empty gl_code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 15:58:46 +00:00
parent 618992206e
commit 6c805ce5a7
4 changed files with 150 additions and 65 deletions

View file

@ -143,7 +143,8 @@ export async function initDb() {
('petty_cash_float', '200.00'),
('till_float_target', '0.00'),
('sales_breakdown_columns', '[]'),
('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}')
('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}'),
('change_tin_notes', '{}')
ON CONFLICT (key) DO NOTHING
`)
}

View file

@ -4,7 +4,7 @@ import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
'default_report_days', 'petty_cash_float', 'till_float_target',
'sales_breakdown_columns', 'change_tin_breakdown',
'sales_breakdown_columns', 'change_tin_breakdown', 'change_tin_notes',
]
export async function settingsRoutes(app) {

View file

@ -53,7 +53,10 @@ export function FloatCountForm({ type }: { type: CountType }) {
}, [type])
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) => {
const bagVal = BAG_VALUES[d.value] ?? 0
return s + d.value * (denomQtys[d.value] ?? 0) + bagVal * (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'
@ -120,27 +123,39 @@ export function FloatCountForm({ type }: { type: CountType }) {
<>
<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' }}>Bags / Qty</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 isNote = !BAG_VALUES[d.value]
const qty = denomQtys[d.value] ?? 0
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
const rowTotal = isNote ? d.value * qty : bagVal * bags + d.value * (denomQtys[d.value] ?? 0)
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>
{isNote ? (
<>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0 notes" style={inpSt} />
<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 || ''}
<input type="number" min="0" step="1" value={(denomQtys[d.value] ?? 0) || ''}
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>
)
@ -222,25 +237,31 @@ export function FloatCountForm({ type }: { type: CountType }) {
</thead>
<tbody>
{denoms.map(d => {
const isNote = !BAG_VALUES[d.value]
const bagVal = BAG_VALUES[d.value] ?? 0
if (!bagVal) return null
const unitVal = isNote ? d.value : bagVal
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
if (target <= 0) return null
const targetBags = Math.round(target / bagVal)
const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0
const bags = bagQtys[d.value] ?? 0
const loose = denomQtys[d.value] ?? 0
const countedVal = bags * bagVal + loose * d.value
const looseOrNotes = denomQtys[d.value] ?? 0
const countedVal = isNote ? d.value * looseOrNotes : bagVal * bags + d.value * looseOrNotes
const needed = target - countedVal
const orderBags = needed > 0.005 ? Math.ceil(needed / bagVal) : 0
const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0
const unitLabel = isNote ? 'note' : 'bag'
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 style={{ padding: '0.3rem 0.4rem', textAlign: 'right', color: 'var(--text-mid)' }}>
{targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''}
</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 style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
{isNote
? (looseOrNotes > 0 ? `${looseOrNotes}` : '—')
: (bags > 0 ? `${bags}bg` : '') + (bags > 0 && looseOrNotes > 0 ? ' + ' : '') + (looseOrNotes > 0 ? `${looseOrNotes}×` : '') + (bags === 0 && looseOrNotes === 0 ? '—' : '')}
</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
{orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'}
</td>
</tr>
)

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { can, type User } from '../types'
import { can, fmtGBP, type User } from '../types'
interface SettingsData {
default_report_days: string
@ -9,15 +9,21 @@ interface SettingsData {
till_float_target: string
sales_breakdown_columns: string
change_tin_breakdown: string
change_tin_notes: string
}
// Banknotes use individual note qty; coins use sealed bag qty
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 },
{ value: 50, label: '£50', unitValue: 50, unit: 'notes' as const },
{ value: 20, label: '£20', unitValue: 20, unit: 'notes' as const },
{ value: 10, label: '£10', unitValue: 10, unit: 'notes' as const },
{ value: 5, label: '£5', unitValue: 5, unit: 'notes' as const },
{ value: 2, label: '£2', unitValue: 20, unit: 'bags' as const },
{ value: 1, label: '£1', unitValue: 20, unit: 'bags' as const },
{ value: 0.50, label: '50p', unitValue: 10, unit: 'bags' as const },
{ value: 0.20, label: '20p', unitValue: 10, unit: 'bags' as const },
{ value: 0.10, label: '10p', unitValue: 5, unit: 'bags' as const },
{ value: 0.05, label: '5p', unitValue: 5, unit: 'bags' as const },
]
interface GlColumn { gl_code: string; display_name: string; enabled: boolean; sort_order: number }
@ -25,7 +31,8 @@ interface GlColumn { gl_code: string; display_name: string; enabled: boolean; so
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 [tinTargetUnits, setTinTargetUnits] = useState<Record<string, number>>({})
const [tinDenomNotes, setTinDenomNotes] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
@ -43,13 +50,14 @@ export function SettingsPage({ user }: { user: User }) {
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
try {
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
const bags: Record<string, number> = {}
const units: 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)
units[key] = d.unitValue > 0 ? Math.round((parseFloat(breakdown[key] ?? 0)) / d.unitValue) : 0
}
setTinTargetBags(bags)
} catch { setTinTargetBags({}) }
setTinTargetUnits(units)
} catch { setTinTargetUnits({}) }
try { setTinDenomNotes(JSON.parse(s.change_tin_notes || '{}')) } catch { setTinDenomNotes({}) }
}).finally(() => setLoading(false))
}, [])
@ -63,9 +71,14 @@ export function SettingsPage({ user }: { user: User }) {
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
tinBreakdown[key] = (tinTargetUnits[key] ?? 0) * d.unitValue
}
await api.put('/settings', { ...settings, sales_breakdown_columns: JSON.stringify(columns), change_tin_breakdown: JSON.stringify(tinBreakdown) })
await api.put('/settings', {
...settings,
sales_breakdown_columns: JSON.stringify(columns),
change_tin_breakdown: JSON.stringify(tinBreakdown),
change_tin_notes: JSON.stringify(tinDenomNotes),
})
flash('Settings saved.')
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
@ -108,10 +121,21 @@ export function SettingsPage({ user }: { user: User }) {
setColumns(next)
}
function addPlaceholder() {
const next = [...columns, { gl_code: '', display_name: 'Placeholder', enabled: true, sort_order: columns.length + 1 }]
setColumns(next)
}
function deleteColumn(idx: number) {
const next = columns.filter((_, i) => i !== idx)
next.forEach((c, i) => (c.sort_order = i + 1))
setColumns(next)
}
if (loading) return <div style={{ padding: '1.5rem', color: 'var(--text-mid)' }}>Loading</div>
return (
<div style={{ padding: '1.5rem', maxWidth: '680px' }}>
<div style={{ padding: '1.5rem', maxWidth: '720px' }}>
<PageHeader title="Settings" />
{msg && (
@ -131,8 +155,7 @@ export function SettingsPage({ user }: { user: User }) {
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook PMS</h2>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.25rem' }}>
Credentials are managed in the{' '}
<a href="/settings" target="_blank" rel="noreferrer"
style={{ color: 'var(--gold)' }}>Settings service</a>.
<a href="/settings" target="_blank" rel="noreferrer" style={{ color: 'var(--gold)' }}>Settings service</a>.
</p>
</div>
{can(user, 'settings') && (
@ -165,22 +188,50 @@ export function SettingsPage({ user }: { user: User }) {
{/* 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' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Change Tin Target</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>
Set par stock targets. Coins use sealed bags; notes use individual note count.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={thS}>Denom</th>
<th style={thS}>Qty</th>
<th style={{ ...thS, color: 'var(--text-mid)' }}>= Total @ each</th>
<th style={thS}>Notes</th>
</tr>
</thead>
<tbody>
{CHANGE_TIN_DENOMS.map(d => {
const key = d.value.toFixed(2)
const bags = tinTargetBags[key] ?? 0
const qty = tinTargetUnits[key] ?? 0
const total = qty * d.unitValue
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 }))}
<tr key={key} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdS}>
<span style={{ fontWeight: 600 }}>{d.label}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', marginLeft: '0.35rem' }}>
({d.unit})
</span>
</td>
<td style={{ ...tdS, width: '80px' }}>
<input type="number" min="0" step="1" value={qty}
onChange={e => setTinTargetUnits(prev => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))}
style={inpSt} />
</div>
</td>
<td style={{ ...tdS, color: total > 0 ? 'inherit' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
{total > 0 ? `${fmtGBP(total)} @ ${fmtGBP(d.unitValue)} each` : '—'}
</td>
<td style={{ ...tdS, minWidth: '160px' }}>
<input type="text" value={tinDenomNotes[key] ?? ''} placeholder="Notes…"
onChange={e => setTinDenomNotes(prev => ({ ...prev, [key]: e.target.value }))}
style={{ ...inpSt, fontSize: '0.78rem', padding: '0.3rem 0.5rem' }} />
</td>
</tr>
)
})}
</div>
</tbody>
</table>
</Card>
{/* Sales breakdown GL columns — requires settings capability */}
@ -188,19 +239,22 @@ export function SettingsPage({ user }: { user: User }) {
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Sales Breakdown Columns</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<Btn onClick={addPlaceholder} small variant="ghost">+ Placeholder</Btn>
<Btn onClick={refreshGl} disabled={refreshing} small variant="secondary">
{refreshing ? 'Refreshing…' : 'Sync from Newbook'}
</Btn>
</div>
</div>
{columns.length === 0 ? (
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>
No GL columns configured. Click "Sync from Newbook" to import GL account groups.
No columns configured. Click "Sync from Newbook" to import GL account groups, or "+ Placeholder" to add a blank column.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{columns.map((col, i) => (
<div key={col.gl_code} style={{
display: 'grid', gridTemplateColumns: '24px 1fr 140px 60px', gap: '0.5rem',
<div key={i} style={{
display: 'grid', gridTemplateColumns: '24px 1fr 140px 60px 28px', gap: '0.5rem',
alignItems: 'center', padding: '0.5rem', border: '1px solid var(--card-border)',
borderRadius: '6px', background: col.enabled ? 'white' : 'var(--body-bg)',
}}>
@ -208,7 +262,11 @@ export function SettingsPage({ user }: { user: User }) {
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, enabled: e.target.checked } : c))} />
<div>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{col.display_name}</span>
{col.gl_code ? (
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginLeft: '0.5rem' }}>{col.gl_code}</span>
) : (
<span style={{ fontSize: '0.65rem', color: '#9ca3af', marginLeft: '0.5rem', background: '#f3f4f6', padding: '0.1rem 0.4rem', borderRadius: '3px', fontWeight: 600 }}>PLACEHOLDER</span>
)}
</div>
<input type="text" value={col.display_name}
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, display_name: e.target.value } : c))}
@ -217,6 +275,9 @@ export function SettingsPage({ user }: { user: User }) {
<button onClick={() => moveColumn(i, -1)} disabled={i === 0} style={arrowBtn}></button>
<button onClick={() => moveColumn(i, 1)} disabled={i === columns.length - 1} style={arrowBtn}></button>
</div>
<button onClick={() => deleteColumn(i)}
style={{ background: 'none', border: 'none', color: '#9ca3af', fontSize: '1rem', cursor: 'pointer', padding: '0', lineHeight: 1 }}
title="Remove column">×</button>
</div>
))}
</div>
@ -245,3 +306,5 @@ function FieldRow({ label, value, onChange, type = 'text' }: {
const labelSt: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem', fontWeight: 600 }
const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.6rem', fontSize: '0.875rem', width: '100%' }
const arrowBtn: React.CSSProperties = { background: 'none', border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.15rem 0.4rem', cursor: 'pointer', fontSize: '0.75rem' }
const thS: React.CSSProperties = { padding: '0.4rem 0.6rem', fontWeight: 600, fontSize: '0.75rem', textAlign: 'left', color: 'var(--text-mid)' }
const tdS: React.CSSProperties = { padding: '0.4rem 0.6rem', verticalAlign: 'middle' }