diff --git a/backend/src/db.js b/backend/src/db.js
index df28e80..5c649db 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -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
`)
}
diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js
index 5dd65e1..b730301 100644
--- a/backend/src/routes/settings.js
+++ b/backend/src/routes/settings.js
@@ -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) {
diff --git a/frontend/src/pages/FloatManagement.tsx b/frontend/src/pages/FloatManagement.tsx
index bafd465..62a7028 100644
--- a/frontend/src/pages/FloatManagement.tsx
+++ b/frontend/src/pages/FloatManagement.tsx
@@ -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 }) {
<>
- Bags
+ Bags / Qty
Loose
Total
{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 (
{d.label}
-
-
setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
- placeholder="0" style={inpSt} />
-
={fmtGBP(bagVal)} ea
-
-
setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
- placeholder="0" style={inpSt} />
+ {isNote ? (
+ <>
+
setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
+ placeholder="0 notes" style={inpSt} />
+
+ >
+ ) : (
+ <>
+
+
setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
+ placeholder="0" style={inpSt} />
+
={fmtGBP(bagVal)} ea
+
+
setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
+ placeholder="0" style={inpSt} />
+ >
+ )}
{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}
)
@@ -222,25 +237,31 @@ export function FloatCountForm({ type }: { type: CountType }) {
{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 (
{d.label}
- {targetBags} bag{targetBags !== 1 ? 's' : ''}
-
- {bags > 0 && `${bags}bg`}{bags > 0 && loose > 0 ? ' + ' : ''}{loose > 0 && `${loose}×`}{(bags === 0 && loose === 0) && '—'}
+
+ {targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''}
- 0 ? 'var(--danger)' : '#16a34a' }}>
- {orderBags > 0 ? `+${orderBags} bag${orderBags !== 1 ? 's' : ''}` : 'OK'}
+
+ {isNote
+ ? (looseOrNotes > 0 ? `${looseOrNotes}` : '—')
+ : (bags > 0 ? `${bags}bg` : '') + (bags > 0 && looseOrNotes > 0 ? ' + ' : '') + (looseOrNotes > 0 ? `${looseOrNotes}×` : '') + (bags === 0 && looseOrNotes === 0 ? '—' : '')}
+
+ 0 ? 'var(--danger)' : '#16a34a' }}>
+ {orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'}
)
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index f2ac2c5..9d7cf76 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -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>({})
const [columns, setColumns] = useState([])
- const [tinTargetBags, setTinTargetBags] = useState>({})
+ const [tinTargetUnits, setTinTargetUnits] = useState>({})
+ const [tinDenomNotes, setTinDenomNotes] = useState>({})
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 = {}
+ const units: Record = {}
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 = {}
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 Loading…
return (
-
+
{msg && (
@@ -131,8 +155,7 @@ export function SettingsPage({ user }: { user: User }) {
Newbook PMS
Credentials are managed in the{' '}
- Settings service .
+ Settings service .
{can(user, 'settings') && (
@@ -165,22 +188,50 @@ export function SettingsPage({ user }: { user: User }) {
{/* Change tin target breakdown */}
- Change Tin Target
- Set how many sealed bags of each denomination to keep in the change tin.
-
- {CHANGE_TIN_DENOMS.map(d => {
- const key = d.value.toFixed(2)
- const bags = tinTargetBags[key] ?? 0
- return (
-
- {d.label} bags (= £{(bags * d.bagValue).toFixed(2)} each)
- setTinTargetBags(prev => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))}
- style={inpSt} />
-
- )
- })}
-
+ Change Tin Target
+
+ Set par stock targets. Coins use sealed bags; notes use individual note count.
+
+
{/* Sales breakdown GL columns — requires settings capability */}
@@ -188,19 +239,22 @@ export function SettingsPage({ user }: { user: User }) {
Sales Breakdown Columns
-
- {refreshing ? 'Refreshing…' : 'Sync from Newbook'}
-
+
+ + Placeholder
+
+ {refreshing ? 'Refreshing…' : 'Sync from Newbook'}
+
+
{columns.length === 0 ? (
- 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.
) : (
{columns.map((col, i) => (
-
@@ -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))} />
{col.display_name}
- {col.gl_code}
+ {col.gl_code ? (
+ {col.gl_code}
+ ) : (
+ PLACEHOLDER
+ )}
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 }) {
moveColumn(i, -1)} disabled={i === 0} style={arrowBtn}>↑
moveColumn(i, 1)} disabled={i === columns.length - 1} style={arrowBtn}>↓
+
deleteColumn(i)}
+ style={{ background: 'none', border: 'none', color: '#9ca3af', fontSize: '1rem', cursor: 'pointer', padding: '0', lineHeight: 1 }}
+ title="Remove column">×
))}
@@ -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' }