cashup/frontend/src/pages/Settings.tsx

200 lines
8.2 KiB
TypeScript

import { useState, useEffect } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import type { User } from '../types'
interface SettingsData {
default_report_days: string
petty_cash_float: string
sales_breakdown_columns: string
change_tin_breakdown: string
}
interface GlColumn { gl_code: string; display_name: string; enabled: boolean; sort_order: number }
export function SettingsPage({ user }: { user: User }) {
const [settings, setSettings] = useState<Partial<SettingsData>>({})
const [columns, setColumns] = useState<GlColumn[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
function flash(text: string, ok = true) {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 5000)
}
useEffect(() => {
api.get<SettingsData>('/settings').then(s => {
setSettings(s)
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
}).finally(() => setLoading(false))
}, [])
function set(key: keyof SettingsData, value: string) {
setSettings(prev => ({ ...prev, [key]: value }))
}
async function save() {
setSaving(true)
try {
await api.put('/settings', { ...settings, sales_breakdown_columns: JSON.stringify(columns) })
flash('Settings saved.')
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Save failed.', false)
} finally {
setSaving(false)
}
}
async function testConnection() {
setTesting(true)
try {
const r = await api.post<{ success: boolean; message: string }>('/settings/test-connection', {})
flash(r.message, r.success)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Test failed.', false)
} finally {
setTesting(false)
}
}
async function refreshGl() {
setRefreshing(true)
try {
const r = await api.post<{ message: string; columns: GlColumn[] }>('/settings/refresh-gl-accounts', {})
setColumns(r.columns)
flash(r.message)
} catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Refresh failed.', false)
} finally {
setRefreshing(false)
}
}
function moveColumn(idx: number, dir: -1 | 1) {
const next = [...columns]
const target = idx + dir
if (target < 0 || target >= next.length) return
;[next[idx], next[target]] = [next[target], next[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' }}>
<PageHeader title="Settings" />
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`,
borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
{/* Newbook — managed centrally */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<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>.
</p>
</div>
{user.is_admin && (
<Btn onClick={testConnection} disabled={testing} variant="secondary" small>
{testing ? 'Testing…' : 'Test Connection'}
</Btn>
)}
</div>
</Card>
{/* Report defaults */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Report Defaults</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FieldRow label="Default Report Days" value={settings.default_report_days ?? '7'}
type="number" onChange={v => set('default_report_days', v)} />
</div>
</Card>
{/* Float settings */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Float Settings</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FieldRow label="Petty Cash Float (£)" value={settings.petty_cash_float ?? '200'}
type="number" onChange={v => set('petty_cash_float', v)} />
</div>
</Card>
{/* Sales breakdown GL columns — admin only */}
{user.is_admin && (
<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>
<Btn onClick={refreshGl} disabled={refreshing} small variant="secondary">
{refreshing ? 'Refreshing…' : 'Sync from Newbook'}
</Btn>
</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.
</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',
alignItems: 'center', padding: '0.5rem', border: '1px solid var(--card-border)',
borderRadius: '6px', background: col.enabled ? 'white' : 'var(--body-bg)',
}}>
<input type="checkbox" checked={col.enabled}
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>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginLeft: '0.5rem' }}>{col.gl_code}</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))}
style={{ ...inpSt, fontSize: '0.8rem', padding: '0.25rem 0.5rem' }} />
<div style={{ display: 'flex', gap: '0.25rem' }}>
<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>
</div>
))}
</div>
)}
</Card>
)}
<Btn onClick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Btn>
</div>
)
}
function FieldRow({ label, value, onChange, type = 'text' }: {
label: string; value: string; onChange: (v: string) => void; type?: string
}) {
return (
<div>
<label style={labelSt}>{label}</label>
<input type={type} value={value} onChange={e => onChange(e.target.value)} style={inpSt} />
</div>
)
}
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' }