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:
commit
0511ac8d82
31 changed files with 1818 additions and 0 deletions
343
frontend/src/pages/ReportsPage.tsx
Normal file
343
frontend/src/pages/ReportsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue