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:
parent
618992206e
commit
6c805ce5a7
4 changed files with 150 additions and 65 deletions
|
|
@ -143,7 +143,8 @@ export async function initDb() {
|
||||||
('petty_cash_float', '200.00'),
|
('petty_cash_float', '200.00'),
|
||||||
('till_float_target', '0.00'),
|
('till_float_target', '0.00'),
|
||||||
('sales_breakdown_columns', '[]'),
|
('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
|
ON CONFLICT (key) DO NOTHING
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
|
||||||
|
|
||||||
const ALL_KEYS = [
|
const ALL_KEYS = [
|
||||||
'default_report_days', 'petty_cash_float', 'till_float_target',
|
'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) {
|
export async function settingsRoutes(app) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
const totalCounted = type === 'change_tin'
|
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)
|
: 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'
|
||||||
|
|
@ -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' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||||
<span />
|
<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: 'center' }}>Loose</span>
|
||||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span>
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span>
|
||||||
</div>
|
</div>
|
||||||
{denoms.map(d => {
|
{denoms.map(d => {
|
||||||
|
const isNote = !BAG_VALUES[d.value]
|
||||||
|
const qty = denomQtys[d.value] ?? 0
|
||||||
const bags = bagQtys[d.value] ?? 0
|
const bags = bagQtys[d.value] ?? 0
|
||||||
const loose = denomQtys[d.value] ?? 0
|
|
||||||
const bagVal = BAG_VALUES[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 (
|
return (
|
||||||
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', 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>
|
||||||
|
{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>
|
<div>
|
||||||
<input type="number" min="0" step="1" value={bags || ''}
|
<input type="number" min="0" step="1" value={bags || ''}
|
||||||
onChange={e => setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
onChange={e => setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||||
placeholder="0" style={inpSt} />
|
placeholder="0" style={inpSt} />
|
||||||
<div style={{ fontSize: '0.65rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '0.1rem' }}>={fmtGBP(bagVal)} ea</div>
|
<div style={{ fontSize: '0.65rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '0.1rem' }}>={fmtGBP(bagVal)} ea</div>
|
||||||
</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 }))}
|
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
|
||||||
placeholder="0" style={inpSt} />
|
placeholder="0" style={inpSt} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|
@ -222,25 +237,31 @@ export function FloatCountForm({ type }: { type: CountType }) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{denoms.map(d => {
|
{denoms.map(d => {
|
||||||
|
const isNote = !BAG_VALUES[d.value]
|
||||||
const bagVal = BAG_VALUES[d.value] ?? 0
|
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))
|
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
|
||||||
if (target <= 0) return null
|
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 bags = bagQtys[d.value] ?? 0
|
||||||
const loose = denomQtys[d.value] ?? 0
|
const looseOrNotes = denomQtys[d.value] ?? 0
|
||||||
const countedVal = bags * bagVal + loose * d.value
|
const countedVal = isNote ? d.value * looseOrNotes : bagVal * bags + d.value * looseOrNotes
|
||||||
const needed = target - countedVal
|
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 (
|
return (
|
||||||
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<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', 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', color: 'var(--text-mid)' }}>
|
||||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
|
{targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''}
|
||||||
{bags > 0 && `${bags}bg`}{bags > 0 && loose > 0 ? ' + ' : ''}{loose > 0 && `${loose}×`}{(bags === 0 && loose === 0) && '—'}
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderBags > 0 ? 'var(--danger)' : '#16a34a' }}>
|
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
|
||||||
{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 ? '—' : '')}
|
||||||
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { PageHeader, Card, Btn } from '../components/Layout'
|
import { PageHeader, Card, Btn } from '../components/Layout'
|
||||||
import { can, type User } from '../types'
|
import { can, fmtGBP, type User } from '../types'
|
||||||
|
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
default_report_days: string
|
default_report_days: string
|
||||||
|
|
@ -9,15 +9,21 @@ interface SettingsData {
|
||||||
till_float_target: string
|
till_float_target: string
|
||||||
sales_breakdown_columns: string
|
sales_breakdown_columns: string
|
||||||
change_tin_breakdown: string
|
change_tin_breakdown: string
|
||||||
|
change_tin_notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Banknotes use individual note qty; coins use sealed bag qty
|
||||||
const CHANGE_TIN_DENOMS = [
|
const CHANGE_TIN_DENOMS = [
|
||||||
{ value: 2.00, label: '£2', bagValue: 20 },
|
{ value: 50, label: '£50', unitValue: 50, unit: 'notes' as const },
|
||||||
{ value: 1.00, label: '£1', bagValue: 20 },
|
{ value: 20, label: '£20', unitValue: 20, unit: 'notes' as const },
|
||||||
{ value: 0.50, label: '50p', bagValue: 10 },
|
{ value: 10, label: '£10', unitValue: 10, unit: 'notes' as const },
|
||||||
{ value: 0.20, label: '20p', bagValue: 10 },
|
{ value: 5, label: '£5', unitValue: 5, unit: 'notes' as const },
|
||||||
{ value: 0.10, label: '10p', bagValue: 5 },
|
{ value: 2, label: '£2', unitValue: 20, unit: 'bags' as const },
|
||||||
{ value: 0.05, label: '5p', bagValue: 5 },
|
{ 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 }
|
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 }) {
|
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 [tinTargetUnits, setTinTargetUnits] = useState<Record<string, number>>({})
|
||||||
|
const [tinDenomNotes, setTinDenomNotes] = useState<Record<string, string>>({})
|
||||||
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)
|
||||||
|
|
@ -43,13 +50,14 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
|
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
|
||||||
try {
|
try {
|
||||||
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
|
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) {
|
for (const d of CHANGE_TIN_DENOMS) {
|
||||||
const key = d.value.toFixed(2)
|
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)
|
setTinTargetUnits(units)
|
||||||
} catch { setTinTargetBags({}) }
|
} catch { setTinTargetUnits({}) }
|
||||||
|
try { setTinDenomNotes(JSON.parse(s.change_tin_notes || '{}')) } catch { setTinDenomNotes({}) }
|
||||||
}).finally(() => setLoading(false))
|
}).finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -63,9 +71,14 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
const tinBreakdown: Record<string, number> = {}
|
const tinBreakdown: Record<string, number> = {}
|
||||||
for (const d of CHANGE_TIN_DENOMS) {
|
for (const d of CHANGE_TIN_DENOMS) {
|
||||||
const key = d.value.toFixed(2)
|
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.')
|
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)
|
||||||
|
|
@ -108,10 +121,21 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
setColumns(next)
|
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>
|
if (loading) return <div style={{ padding: '1.5rem', color: 'var(--text-mid)' }}>Loading…</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: '680px' }}>
|
<div style={{ padding: '1.5rem', maxWidth: '720px' }}>
|
||||||
<PageHeader title="Settings" />
|
<PageHeader title="Settings" />
|
||||||
|
|
||||||
{msg && (
|
{msg && (
|
||||||
|
|
@ -131,8 +155,7 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook PMS</h2>
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook PMS</h2>
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.25rem' }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.25rem' }}>
|
||||||
Credentials are managed in the{' '}
|
Credentials are managed in the{' '}
|
||||||
<a href="/settings" target="_blank" rel="noreferrer"
|
<a href="/settings" target="_blank" rel="noreferrer" style={{ color: 'var(--gold)' }}>Settings service</a>.
|
||||||
style={{ color: 'var(--gold)' }}>Settings service</a>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{can(user, 'settings') && (
|
{can(user, 'settings') && (
|
||||||
|
|
@ -165,22 +188,50 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
|
|
||||||
{/* Change tin target breakdown */}
|
{/* Change tin target breakdown */}
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Change Tin Target</h2>
|
<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 how many sealed bags of each denomination to keep in the change tin.</p>
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '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 => {
|
{CHANGE_TIN_DENOMS.map(d => {
|
||||||
const key = d.value.toFixed(2)
|
const key = d.value.toFixed(2)
|
||||||
const bags = tinTargetBags[key] ?? 0
|
const qty = tinTargetUnits[key] ?? 0
|
||||||
|
const total = qty * d.unitValue
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<tr key={key} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
<label style={labelSt}>{d.label} bags <span style={{ fontWeight: 400 }}>(= £{(bags * d.bagValue).toFixed(2)} each)</span></label>
|
<td style={tdS}>
|
||||||
<input type="number" min="0" step="1" value={bags}
|
<span style={{ fontWeight: 600 }}>{d.label}</span>
|
||||||
onChange={e => setTinTargetBags(prev => ({ ...prev, [key]: parseInt(e.target.value) || 0 }))}
|
<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} />
|
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>
|
</Card>
|
||||||
|
|
||||||
{/* Sales breakdown GL columns — requires settings capability */}
|
{/* Sales breakdown GL columns — requires settings capability */}
|
||||||
|
|
@ -188,19 +239,22 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Sales Breakdown Columns</h2>
|
<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">
|
<Btn onClick={refreshGl} disabled={refreshing} small variant="secondary">
|
||||||
{refreshing ? 'Refreshing…' : 'Sync from Newbook'}
|
{refreshing ? 'Refreshing…' : 'Sync from Newbook'}
|
||||||
</Btn>
|
</Btn>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{columns.length === 0 ? (
|
{columns.length === 0 ? (
|
||||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
{columns.map((col, i) => (
|
{columns.map((col, i) => (
|
||||||
<div key={col.gl_code} style={{
|
<div key={i} style={{
|
||||||
display: 'grid', gridTemplateColumns: '24px 1fr 140px 60px', gap: '0.5rem',
|
display: 'grid', gridTemplateColumns: '24px 1fr 140px 60px 28px', gap: '0.5rem',
|
||||||
alignItems: 'center', padding: '0.5rem', border: '1px solid var(--card-border)',
|
alignItems: 'center', padding: '0.5rem', border: '1px solid var(--card-border)',
|
||||||
borderRadius: '6px', background: col.enabled ? 'white' : 'var(--body-bg)',
|
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))} />
|
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, enabled: e.target.checked } : c))} />
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{col.display_name}</span>
|
<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.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>
|
</div>
|
||||||
<input type="text" value={col.display_name}
|
<input type="text" value={col.display_name}
|
||||||
onChange={e => setColumns(cols => cols.map((c, j) => j === i ? { ...c, display_name: e.target.value } : c))}
|
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 === 0} style={arrowBtn}>↑</button>
|
||||||
<button onClick={() => moveColumn(i, 1)} disabled={i === columns.length - 1} style={arrowBtn}>↓</button>
|
<button onClick={() => moveColumn(i, 1)} disabled={i === columns.length - 1} style={arrowBtn}>↓</button>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</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 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 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 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' }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue