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 ( -
- - 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. +

+ + + + + + + + + + + {CHANGE_TIN_DENOMS.map(d => { + const key = d.value.toFixed(2) + const qty = tinTargetUnits[key] ?? 0 + const total = qty * d.unitValue + return ( + + + + + + + ) + })} + +
DenomQty= Total @ eachNotes
+ {d.label} + + ({d.unit}) + + + setTinTargetUnits(prev => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))} + style={inpSt} /> + 0 ? 'inherit' : 'var(--text-mid)', whiteSpace: 'nowrap' }}> + {total > 0 ? `${fmtGBP(total)} @ ${fmtGBP(d.unitValue)} each` : '—'} + + setTinDenomNotes(prev => ({ ...prev, [key]: e.target.value }))} + style={{ ...inpSt, fontSize: '0.78rem', padding: '0.3rem 0.5rem' }} /> +
{/* 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 }) {
+
))}
@@ -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' }