Scaffold custom reports app (LXC 122 · /reports)

Framework for categorised custom reports pulling from NewBook, ResOS,
SambaPOS, and internal data. Report tree with collapsible categories
and subcategories; date-range params; run-audit log in reports_db.

Initial reports: NewBook arrivals/departures/stayovers/bookings-by-source,
ResOS covers/revenue, SambaPOS sales-summary/top-products, internal run-history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-06 12:58:19 +00:00
commit 0511ac8d82
31 changed files with 1818 additions and 0 deletions

View file

@ -0,0 +1,343 @@
import { useEffect, useState, useCallback } from 'react'
import {
BarChart2, Building2, UtensilsCrossed, ShoppingCart, Database,
ChevronRight, ChevronDown, Play, Download, Loader2, AlertCircle,
FileBarChart,
} from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { fetchReports, runReport } from '../api'
import type { ReportMeta, ReportResult } from '../types'
// ── helpers ───────────────────────────────────────────────────────────────────
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
newbook: <Building2 size={14} strokeWidth={1.75} />,
resos: <UtensilsCrossed size={14} strokeWidth={1.75} />,
samba: <ShoppingCart size={14} strokeWidth={1.75} />,
internal: <Database size={14} strokeWidth={1.75} />,
}
function today(): string {
return new Date().toISOString().split('T')[0]
}
function daysAgo(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString().split('T')[0]
}
function exportCsv(result: ReportResult, reportName: string) {
const header = result.columns.map(c => `"${c.label}"`).join(',')
const dataRows = result.rows.map(row =>
result.columns.map(c => {
const val = row[c.key]
if (val === null || val === undefined) return ''
return `"${String(val).replace(/"/g, '""')}"`
}).join(',')
)
const csv = [header, ...dataRows].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${reportName.toLowerCase().replace(/\s+/g, '-')}-${today()}.csv`
a.click()
URL.revokeObjectURL(url)
}
function formatCell(value: unknown, type?: string): string {
if (value === null || value === undefined || value === '') return '—'
if (type === 'currency') return `£${parseFloat(String(value)).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
return String(value)
}
// ── category tree ─────────────────────────────────────────────────────────────
interface TreeNode {
label: string
category: string
subcategories: { label: string; reports: ReportMeta[] }[]
flatReports: ReportMeta[]
}
function buildTree(reports: ReportMeta[]): TreeNode[] {
const catMap = new Map<string, TreeNode>()
for (const r of reports) {
if (!catMap.has(r.category)) {
catMap.set(r.category, {
label: r.categoryLabel,
category: r.category,
subcategories: [],
flatReports: [],
})
}
const cat = catMap.get(r.category)!
if (r.subcategory) {
let sub = cat.subcategories.find(s => s.label === r.subcategory)
if (!sub) { sub = { label: r.subcategory, reports: [] }; cat.subcategories.push(sub) }
sub.reports.push(r)
} else {
cat.flatReports.push(r)
}
}
return Array.from(catMap.values())
}
// ── sidebar ───────────────────────────────────────────────────────────────────
interface SidebarProps {
tree: TreeNode[]
selectedId: string | null
onSelect: (r: ReportMeta) => void
}
function Sidebar({ tree, selectedId, onSelect }: SidebarProps) {
const { user } = useAuth()
const [openCats, setOpenCats] = useState<Set<string>>(new Set(tree.map(t => t.category)))
const [openSubs, setOpenSubs] = useState<Set<string>>(new Set())
const toggleCat = (key: string) =>
setOpenCats(prev => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n })
const toggleSub = (key: string) =>
setOpenSubs(prev => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n })
return (
<aside className="sidebar">
<div className="sidebar-logo">
<BarChart2 size={18} strokeWidth={1.75} />
Reports
</div>
<nav className="sidebar-nav" style={{ padding: 0 }}>
{tree.map(cat => {
const isOpen = openCats.has(cat.category)
return (
<div key={cat.category} className="report-category">
<button className="report-cat-header" onClick={() => toggleCat(cat.category)}>
<span className="report-cat-icon">{CATEGORY_ICONS[cat.category] ?? <FileBarChart size={14} strokeWidth={1.75} />}</span>
<span className="report-cat-label">{cat.label}</span>
{isOpen
? <ChevronDown size={12} strokeWidth={2} />
: <ChevronRight size={12} strokeWidth={2} />}
</button>
{isOpen && (
<div className="report-cat-body">
{cat.subcategories.map(sub => {
const subKey = `${cat.category}:${sub.label}`
const subOpen = openSubs.has(subKey)
return (
<div key={sub.label}>
<button className="report-sub-header" onClick={() => toggleSub(subKey)}>
{subOpen
? <ChevronDown size={11} strokeWidth={2} />
: <ChevronRight size={11} strokeWidth={2} />}
{sub.label}
</button>
{subOpen && sub.reports.map(r => (
<button
key={r.id}
className={`report-item${selectedId === r.id ? ' active' : ''}`}
onClick={() => onSelect(r)}
>
{r.name}
</button>
))}
</div>
)
})}
{cat.flatReports.map(r => (
<button
key={r.id}
className={`report-item${selectedId === r.id ? ' active' : ''}`}
onClick={() => onSelect(r)}
>
{r.name}
</button>
))}
</div>
)}
</div>
)
})}
</nav>
<div className="sidebar-user">{user.name}</div>
</aside>
)
}
// ── results table ─────────────────────────────────────────────────────────────
function ResultsTable({ result }: { result: ReportResult }) {
if (result.rows.length === 0) {
return (
<div className="empty-state">
<FileBarChart size={32} strokeWidth={1} style={{ opacity: 0.3, marginBottom: 8 }} />
No data found for this date range
</div>
)
}
return (
<div className="results-table-wrap">
<table className="results-table">
<thead>
<tr>
{result.columns.map(c => <th key={c.key}>{c.label}</th>)}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr key={i}>
{result.columns.map(c => (
<td
key={c.key}
className={c.type === 'number' || c.type === 'currency' ? 'num' : ''}
>
{formatCell(row[c.key], c.type)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── main page ─────────────────────────────────────────────────────────────────
export default function ReportsPage() {
const [reports, setReports] = useState<ReportMeta[]>([])
const [selected, setSelected] = useState<ReportMeta | null>(null)
const [dateFrom, setDateFrom] = useState(daysAgo(7))
const [dateTo, setDateTo] = useState(today())
const [result, setResult] = useState<ReportResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchReports().then(setReports).catch(err => setError(err.message))
}, [])
const tree = buildTree(reports)
const handleSelect = useCallback((r: ReportMeta) => {
setSelected(r)
setResult(null)
setError(null)
}, [])
const handleRun = async () => {
if (!selected) return
setLoading(true)
setError(null)
setResult(null)
try {
const res = await runReport(selected.id, dateFrom, dateTo)
setResult(res)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}
return (
<div className="app-shell">
<Sidebar tree={tree} selectedId={selected?.id ?? null} onSelect={handleSelect} />
{/* Mobile top bar */}
<div className="top-bar">
<BarChart2 size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Reports</span>
</div>
<main className="page-content">
{!selected ? (
<div className="welcome-state">
<BarChart2 size={48} strokeWidth={1} style={{ color: 'var(--border)', marginBottom: 16 }} />
<h2>Custom Reports</h2>
<p>Select a report from the sidebar to get started.</p>
</div>
) : (
<div className="report-view">
<div className="report-header">
<div>
<h1 className="report-title">{selected.name}</h1>
<p className="report-description">{selected.description}</p>
</div>
</div>
<div className="report-controls">
<div className="date-group">
<label>From</label>
<input
type="date"
className="date-input"
value={dateFrom}
max={dateTo}
onChange={e => setDateFrom(e.target.value)}
/>
</div>
<div className="date-group">
<label>To</label>
<input
type="date"
className="date-input"
value={dateTo}
min={dateFrom}
onChange={e => setDateTo(e.target.value)}
/>
</div>
<button className="btn-run" onClick={handleRun} disabled={loading}>
{loading
? <><Loader2 size={14} strokeWidth={2} className="spin" /> Running</>
: <><Play size={14} strokeWidth={2} /> Run Report</>}
</button>
{result && (
<button className="btn-export" onClick={() => exportCsv(result, selected.name)}>
<Download size={14} strokeWidth={2} /> Export CSV
</button>
)}
</div>
{error && (
<div className="report-error">
<AlertCircle size={16} strokeWidth={1.75} />
{error}
</div>
)}
{result && (
<>
{result.summary && result.summary.length > 0 && (
<div className="summary-bar">
{result.summary.map((s, i) => (
<div key={i} className="summary-stat">
<span className="summary-label">{s.label}</span>
<span className="summary-value">{s.value}</span>
</div>
))}
<div className="summary-stat">
<span className="summary-label">Rows</span>
<span className="summary-value">{result.rows.length.toLocaleString()}</span>
</div>
</div>
)}
<ResultsTable result={result} />
</>
)}
</div>
)}
</main>
</div>
)
}