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