Wire Newbook credentials to settings service
This commit is contained in:
commit
63a5a72fa3
32 changed files with 3386 additions and 0 deletions
200
frontend/src/pages/Settings.tsx
Normal file
200
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
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' }
|
||||
Loading…
Add table
Add a link
Reference in a new issue