Wire Newbook credentials to settings service

This commit is contained in:
jtricerolph 2026-07-01 19:33:16 +00:00
commit 63a5a72fa3
32 changed files with 3386 additions and 0 deletions

View file

@ -0,0 +1,207 @@
import { useState } from 'react'
import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { fmtGBP, today } from '../types'
interface SalesCol { gl_code: string; category: string; net_amount: number; vat_amount: number; gross_amount: number }
interface ReconRow { category: string; banked_amount: number; reported_amount: number }
interface DayData {
date: string
cash_up: { total_cash_counted: string; status: string } | null
reconciliation: ReconRow[]
daily_stats: { gross_sales: number; transaction_count: number } | null
sales_breakdown: SalesCol[]
}
interface OccupancyItem { period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number }
interface ReportResult {
report_data: DayData[]
sales_columns: Array<{ gl_code: string; display_name: string }>
occupancy_data: OccupancyItem[]
}
const RECON_LABELS: Record<string, string> = {
cash: 'Cash', gateway_visa_mc: 'Gateway V/MC', gateway_amex: 'Gateway Amex',
pdq_visa_mc: 'PDQ V/MC', pdq_amex: 'PDQ Amex', bacs: 'BACS',
}
function fmtDate(d: string) {
return new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' })
}
export function MultiDayReport() {
const [startDate, setStartDate] = useState(() => {
const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10)
})
const [numDays, setNumDays] = useState(7)
const [result, setResult] = useState<ReportResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function generate() {
setLoading(true); setError(''); setResult(null)
try {
const data = await api.post<ReportResult>('/reports/multiday', { start_date: startDate, num_days: numDays })
setResult(data)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to generate report')
} finally {
setLoading(false)
}
}
const dates = result?.report_data.map(d => d.date) ?? []
const salesCols = result?.sales_columns ?? []
return (
<div style={{ padding: '1.5rem' }}>
<PageHeader title="Weekly / Multi-Day Report" />
<Card style={{ marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Start Date</label>
<input type="date" value={startDate} onChange={e => setStartDate(e.target.value)}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' }} />
</div>
<div>
<label style={{ fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Number of Days</label>
<input type="number" value={numDays} min={1} max={365}
onChange={e => setNumDays(Math.max(1, Math.min(365, parseInt(e.target.value) || 7)))}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem', width: '80px' }} />
</div>
<Btn onClick={generate} disabled={loading}>
{loading ? 'Generating…' : 'Generate Report'}
</Btn>
</Card>
{error && (
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.75rem 1rem', marginBottom: '1rem' }}>
{error}
</div>
)}
{result && (
<>
{/* Table 1: Reconciliation Summary */}
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Reconciliation Summary</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '600px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Category</th>
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
<th style={th}>Total</th>
</tr>
</thead>
<tbody>
{Object.entries(RECON_LABELS).map(([key, label]) => {
const values = result.report_data.map(day => {
const row = day.reconciliation.find(r => r.category === key)
return row ? row.banked_amount : 0
})
const rowTotal = values.reduce((s, v) => s + v, 0)
if (values.every(v => v === 0) && rowTotal === 0) return null
return (
<tr key={key} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ ...td, fontWeight: 600 }}>{label}</td>
{values.map((v, i) => <td key={i} style={{ ...td, textAlign: 'right' }}>{v ? fmtGBP(v) : '—'}</td>)}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(rowTotal)}</td>
</tr>
)
})}
{/* Cash total row */}
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ ...td, fontWeight: 700 }}>Total Banked</td>
{result.report_data.map((day, i) => {
const dayTotal = day.reconciliation.reduce((s, r) => s + r.banked_amount, 0)
return <td key={i} style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(dayTotal)}</td>
})}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.report_data.reduce((s, d) => s + d.reconciliation.reduce((ss, r) => ss + r.banked_amount, 0), 0))}
</td>
</tr>
</tbody>
</table>
</Card>
{/* Table 2: Sales Breakdown */}
{salesCols.length > 0 && (
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Sales Breakdown (Net)</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '600px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Category</th>
{dates.map(d => <th key={d} style={th}>{fmtDate(d)}</th>)}
<th style={th}>Total</th>
</tr>
</thead>
<tbody>
{salesCols.map(col => {
const values = result.report_data.map(day => {
const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code)
return sb?.net_amount ?? 0
})
const rowTotal = values.reduce((s, v) => s + v, 0)
return (
<tr key={col.gl_code} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={td}>{col.display_name}</td>
{values.map((v, i) => <td key={i} style={{ ...td, textAlign: 'right' }}>{v ? fmtGBP(v) : '—'}</td>)}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(rowTotal)}</td>
</tr>
)
})}
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td style={{ ...td, fontWeight: 700 }}>Total</td>
{result.report_data.map((day, i) => {
const dayTotal = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0)
return <td key={i} style={{ ...td, textAlign: 'right', fontWeight: 700 }}>{fmtGBP(dayTotal)}</td>
})}
<td style={{ ...td, textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
</td>
</tr>
</tbody>
</table>
</Card>
)}
{/* Table 3: Occupancy */}
{result.occupancy_data.length > 0 && (
<Card style={{ marginBottom: '1.5rem', overflowX: 'auto' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' }}>Occupancy</h2>
<table style={{ borderCollapse: 'collapse', fontSize: '0.8rem', minWidth: '400px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left' }}>Date</th>
<th style={th}>Rooms Sold</th>
<th style={th}>Total Rooms</th>
<th style={th}>Guests</th>
<th style={th}>Occ %</th>
</tr>
</thead>
<tbody>
{result.occupancy_data.map((row, i) => {
const date = row.period ?? row.date ?? ''
const occ = row.total_rooms && row.rooms_sold ? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1) : '—'
return (
<tr key={i} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={td}>{date ? fmtDate(date) : '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.rooms_sold ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.total_rooms ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{row.total_people ?? '—'}</td>
<td style={{ ...td, textAlign: 'right' }}>{occ}{occ !== '—' ? '%' : ''}</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
</>
)}
</div>
)
}
const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600, whiteSpace: 'nowrap' }
const td: React.CSSProperties = { padding: '0.45rem 0.625rem' }