commit 0511ac8d823c721efe7f88d1d66f1a9640eaf1e0 Author: jtricerolph Date: Mon Jul 6 12:58:19 2026 +0000 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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..35a61560 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..3b9b7936 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "hnf-reports-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "pg": "^8.13.1" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 00000000..1f3a49f4 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,57 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'reports' +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') + +export async function requireAuth(request, reply) { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + + let payload + try { + const { payload: p } = await jwtVerify(token, secret) + payload = p + } catch { + return reply.status(401).send({ error: 'Invalid session' }) + } + + if (!payload.apps?.includes(APP_SLUG)) { + return reply.status(403).send({ error: 'No permission for this app' }) + } + + if (!payload.offsite_allowed) { + const clientIP = request.headers['x-real-ip'] || request.ip + if (!(await isOnsite(clientIP))) { + return reply.status(403).send({ error: 'Access restricted to site network' }) + } + } + + const prefix = `${APP_SLUG}:` + let caps + if (Array.isArray(payload.caps)) { + caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length)) + } else { + // Legacy token — grant view cap until re-login + caps = ['view'] + } + + request.user = { + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin ?? false, + caps, + } +} + +export function hasCap(request, cap) { + return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true +} + +export function requireCap(cap) { + return async (request, reply) => { + if (!hasCap(request, cap)) { + return reply.status(403).send({ error: `Missing capability: ${cap}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 00000000..befc5008 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,23 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS report_runs ( + id SERIAL PRIMARY KEY, + report_id TEXT NOT NULL, + report_name TEXT NOT NULL, + user_email TEXT NOT NULL, + date_from DATE, + date_to DATE, + row_count INTEGER, + duration_ms INTEGER, + error TEXT, + ran_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS report_runs_ran_at_idx ON report_runs (ran_at DESC); + CREATE INDEX IF NOT EXISTS report_runs_report_id_idx ON report_runs (report_id); + `) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 00000000..e890a3d8 --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,25 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { reportRoutes } from './routes/reports.js' + +const app = Fastify({ logger: true, trustProxy: true }) + +await app.register(cookie) +await app.register(cors, { + origin: process.env.CORS_ORIGIN || false, + credentials: true, +}) + +app.get('/health', async () => ({ status: 'healthy' })) + +await app.register(reportRoutes) + +try { + await initDb() + await app.listen({ port: 3001, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 00000000..4d8cb19b --- /dev/null +++ b/backend/src/ip-check.js @@ -0,0 +1,80 @@ +import dns from 'dns/promises' + +const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim() +const matchers = raw.split(',').map(s => s.trim()).filter(Boolean) + +const TTL = 5 * 60 * 1000 +const cache = new Map() + +const PUBLIC_IP_URLS = [ + 'https://api.ipify.org', + 'https://ifconfig.co/ip', + 'https://icanhazip.com', +] + +function normalizeIP(ip) { + return ip?.startsWith('::ffff:') ? ip.slice(7) : ip +} + +function isIPv4(s) { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(s) +} + +function ipInCidr(ip, cidr) { + const [range, bits] = cidr.split('/') + if (!isIPv4(ip) || !isIPv4(range)) return false + const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 + const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 + return (toInt(ip) & mask) === (toInt(range) & mask) +} + +async function fetchPublicIP() { + for (const url of PUBLIC_IP_URLS) { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 4000) + const res = await fetch(url, { signal: ctrl.signal }) + clearTimeout(timer) + if (!res.ok) continue + const ip = (await res.text()).trim() + if (isIPv4(ip)) return ip + } catch { + // try next + } + } + return null +} + +async function resolveDynamic(key, resolver) { + const hit = cache.get(key) + if (hit && Date.now() < hit.expiry) return hit.ip + const ip = await resolver() + if (ip) { + cache.set(key, { ip, expiry: Date.now() + TTL }) + return ip + } + return hit ? hit.ip : null +} + +export async function isOnsite(requestIP) { + if (matchers.length === 0 || matchers.includes('disabled')) return true + const ip = normalizeIP(requestIP) + if (!ip) return false + + for (const m of matchers) { + if (m === 'auto') { + const pub = await resolveDynamic('auto', fetchPublicIP) + if (pub && ip === pub) return true + } else if (m.includes('/')) { + if (ipInCidr(ip, m)) return true + } else if (/[a-zA-Z]/.test(m)) { + const resolved = await resolveDynamic(m, async () => { + try { return (await dns.resolve4(m))[0] } catch { return null } + }) + if (resolved && ip === resolved) return true + } else { + if (ip === m) return true + } + } + return false +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 00000000..76f78841 --- /dev/null +++ b/backend/src/lib/newbook.js @@ -0,0 +1,50 @@ +const API_BASE = 'https://api.newbook.cloud/rest/' + +async function getCredentials() { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching NewBook credentials`) + const s = await res.json() + return { + username: s.username || '', + password: s.password || '', + apiKey: s.api_key || '', + region: s.region || 'eu', + } +} + +export async function callApi(endpoint, data = {}) { + const creds = await getCredentials() + if (!creds.username || !creds.password || !creds.apiKey) { + throw new Error('NewBook API credentials not configured — set them in Settings → Integrations') + } + + const locationId = process.env.NEWBOOK_LOCATION_ID + const body = { ...data, region: creds.region, api_key: creds.apiKey } + if (locationId) body.location_id = locationId + + const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 30000) + + try { + const res = await fetch(API_BASE + endpoint, { + method: 'POST', + signal: ctrl.signal, + headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` }, + body: JSON.stringify(body), + }) + clearTimeout(timer) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`NewBook API ${res.status}: ${text.slice(0, 200)}`) + } + return await res.json() + } catch (err) { + clearTimeout(timer) + throw err + } +} diff --git a/backend/src/reports/index.js b/backend/src/reports/index.js new file mode 100644 index 00000000..05ae46ba --- /dev/null +++ b/backend/src/reports/index.js @@ -0,0 +1,21 @@ +import newbookArrivals from './newbook/arrivals.js' +import newbookDepartures from './newbook/departures.js' +import newbookStayovers from './newbook/stayovers.js' +import newbookBySource from './newbook/bookings-by-source.js' +import resoCovers from './resos/covers.js' +import resosRevenue from './resos/revenue.js' +import sambaSalesSummary from './samba/sales-summary.js' +import sambaTopProducts from './samba/top-products.js' +import internalRunHistory from './internal/run-history.js' + +export const registry = [ + newbookArrivals, + newbookDepartures, + newbookStayovers, + newbookBySource, + resoCovers, + resosRevenue, + sambaSalesSummary, + sambaTopProducts, + internalRunHistory, +] diff --git a/backend/src/reports/internal/run-history.js b/backend/src/reports/internal/run-history.js new file mode 100644 index 00000000..e65e133e --- /dev/null +++ b/backend/src/reports/internal/run-history.js @@ -0,0 +1,52 @@ +export default { + id: 'internal-run-history', + name: 'Report Run History', + category: 'internal', + categoryLabel: 'Internal', + subcategory: 'Audit', + description: 'Log of all reports run within the selected date range, including who ran them and how many rows were returned.', + + async run({ dateFrom, dateTo }, { db }) { + const { rows } = await db.query(` + SELECT + ran_at, + user_email, + report_name, + date_from, + date_to, + row_count, + duration_ms, + error + FROM report_runs + WHERE ran_at::date BETWEEN $1 AND $2 + ORDER BY ran_at DESC + LIMIT 500 + `, [dateFrom, dateTo]) + + return { + columns: [ + { key: 'ran_at', label: 'Run At', type: 'datetime' }, + { key: 'user_email', label: 'User' }, + { key: 'report_name', label: 'Report' }, + { key: 'date_from', label: 'From', type: 'date' }, + { key: 'date_to', label: 'To', type: 'date' }, + { key: 'row_count', label: 'Rows', type: 'number' }, + { key: 'duration_ms', label: 'ms', type: 'number' }, + { key: 'error', label: 'Error' }, + ], + rows: rows.map(r => ({ + ran_at: r.ran_at instanceof Date ? r.ran_at.toISOString().replace('T', ' ').slice(0, 19) : r.ran_at, + user_email: r.user_email, + report_name: r.report_name, + date_from: r.date_from ? String(r.date_from).slice(0, 10) : '', + date_to: r.date_to ? String(r.date_to).slice(0, 10) : '', + row_count: r.row_count ?? '', + duration_ms: r.duration_ms ?? '', + error: r.error ?? '', + })), + summary: [ + { label: 'Total runs', value: String(rows.length) }, + ], + } + }, +} diff --git a/backend/src/reports/newbook/arrivals.js b/backend/src/reports/newbook/arrivals.js new file mode 100644 index 00000000..d6d5c81c --- /dev/null +++ b/backend/src/reports/newbook/arrivals.js @@ -0,0 +1,50 @@ +export default { + id: 'newbook-arrivals', + name: 'Arrivals', + category: 'newbook', + categoryLabel: 'NewBook', + subcategory: 'Reservations', + description: 'All bookings with an arrival date within the selected range, including guest name, room, rate, and booking source.', + + async run({ dateFrom, dateTo }, { newbook }) { + const data = await newbook.callApi('bookings_list', { + arrival_date_from: dateFrom, + arrival_date_to: dateTo, + }) + + const rows = (data?.data ?? []).map(b => ({ + ref: b.booking_ref ?? b.id, + guest: b.guest_name ?? `${b.first_name ?? ''} ${b.last_name ?? ''}`.trim(), + arrival: b.arrival_date, + departure: b.departure_date, + nights: b.nights ?? b.no_nights, + room: b.site_name ?? b.room_name ?? b.site_id, + adults: b.adults ?? b.no_adults, + children: b.children ?? b.no_children ?? 0, + source: b.booking_source ?? b.channel, + status: b.booking_status ?? b.status, + total: b.booking_total ?? b.total_price, + })) + + return { + columns: [ + { key: 'ref', label: 'Ref' }, + { key: 'guest', label: 'Guest' }, + { key: 'arrival', label: 'Arrival', type: 'date' }, + { key: 'departure', label: 'Departure', type: 'date' }, + { key: 'nights', label: 'Nights', type: 'number' }, + { key: 'room', label: 'Room' }, + { key: 'adults', label: 'Adults', type: 'number' }, + { key: 'children', label: 'Children', type: 'number' }, + { key: 'source', label: 'Source' }, + { key: 'status', label: 'Status' }, + { key: 'total', label: 'Total', type: 'currency' }, + ], + rows, + summary: [ + { label: 'Arrivals', value: String(rows.length) }, + { label: 'Total revenue', value: rows.reduce((s, r) => s + (parseFloat(r.total) || 0), 0).toFixed(2) }, + ], + } + }, +} diff --git a/backend/src/reports/newbook/bookings-by-source.js b/backend/src/reports/newbook/bookings-by-source.js new file mode 100644 index 00000000..d2a559bd --- /dev/null +++ b/backend/src/reports/newbook/bookings-by-source.js @@ -0,0 +1,56 @@ +export default { + id: 'newbook-bookings-by-source', + name: 'Bookings by Source', + category: 'newbook', + categoryLabel: 'NewBook', + subcategory: 'Revenue', + description: 'Breakdown of bookings and revenue grouped by booking source/channel within the selected arrival date range.', + + async run({ dateFrom, dateTo }, { newbook }) { + const data = await newbook.callApi('bookings_list', { + arrival_date_from: dateFrom, + arrival_date_to: dateTo, + }) + + const bookings = data?.data ?? [] + const grouped = {} + + for (const b of bookings) { + const source = b.booking_source ?? b.channel ?? 'Unknown' + if (!grouped[source]) grouped[source] = { bookings: 0, nights: 0, revenue: 0 } + grouped[source].bookings++ + grouped[source].nights += parseInt(b.nights ?? b.no_nights ?? 0) || 0 + grouped[source].revenue += parseFloat(b.booking_total ?? b.total_price ?? 0) || 0 + } + + const rows = Object.entries(grouped) + .sort((a, b) => b[1].revenue - a[1].revenue) + .map(([source, g]) => ({ + source, + bookings: g.bookings, + nights: g.nights, + revenue: g.revenue.toFixed(2), + avg_rate: g.nights > 0 ? (g.revenue / g.nights).toFixed(2) : '0.00', + })) + + const totals = rows.reduce((s, r) => ({ + bookings: s.bookings + r.bookings, + revenue: s.revenue + parseFloat(r.revenue), + }), { bookings: 0, revenue: 0 }) + + return { + columns: [ + { key: 'source', label: 'Source' }, + { key: 'bookings', label: 'Bookings', type: 'number' }, + { key: 'nights', label: 'Nights', type: 'number' }, + { key: 'revenue', label: 'Revenue', type: 'currency' }, + { key: 'avg_rate', label: 'Avg Rate', type: 'currency' }, + ], + rows, + summary: [ + { label: 'Total bookings', value: String(totals.bookings) }, + { label: 'Total revenue', value: totals.revenue.toFixed(2) }, + ], + } + }, +} diff --git a/backend/src/reports/newbook/departures.js b/backend/src/reports/newbook/departures.js new file mode 100644 index 00000000..f917d6db --- /dev/null +++ b/backend/src/reports/newbook/departures.js @@ -0,0 +1,46 @@ +export default { + id: 'newbook-departures', + name: 'Departures', + category: 'newbook', + categoryLabel: 'NewBook', + subcategory: 'Reservations', + description: 'All bookings with a departure date within the selected range.', + + async run({ dateFrom, dateTo }, { newbook }) { + const data = await newbook.callApi('bookings_list', { + departure_date_from: dateFrom, + departure_date_to: dateTo, + }) + + const rows = (data?.data ?? []).map(b => ({ + ref: b.booking_ref ?? b.id, + guest: b.guest_name ?? `${b.first_name ?? ''} ${b.last_name ?? ''}`.trim(), + arrival: b.arrival_date, + departure: b.departure_date, + nights: b.nights ?? b.no_nights, + room: b.site_name ?? b.room_name ?? b.site_id, + source: b.booking_source ?? b.channel, + status: b.booking_status ?? b.status, + total: b.booking_total ?? b.total_price, + })) + + return { + columns: [ + { key: 'ref', label: 'Ref' }, + { key: 'guest', label: 'Guest' }, + { key: 'arrival', label: 'Arrival', type: 'date' }, + { key: 'departure', label: 'Departure', type: 'date' }, + { key: 'nights', label: 'Nights', type: 'number' }, + { key: 'room', label: 'Room' }, + { key: 'source', label: 'Source' }, + { key: 'status', label: 'Status' }, + { key: 'total', label: 'Total', type: 'currency' }, + ], + rows, + summary: [ + { label: 'Departures', value: String(rows.length) }, + { label: 'Total revenue', value: rows.reduce((s, r) => s + (parseFloat(r.total) || 0), 0).toFixed(2) }, + ], + } + }, +} diff --git a/backend/src/reports/newbook/stayovers.js b/backend/src/reports/newbook/stayovers.js new file mode 100644 index 00000000..4b33706a --- /dev/null +++ b/backend/src/reports/newbook/stayovers.js @@ -0,0 +1,43 @@ +export default { + id: 'newbook-stayovers', + name: 'Stayovers', + category: 'newbook', + categoryLabel: 'NewBook', + subcategory: 'Reservations', + description: 'Bookings that are in-house (stayovers) on the selected date range — neither arriving nor departing.', + + async run({ dateFrom, dateTo }, { newbook }) { + const data = await newbook.callApi('bookings_list', { + stayover_date_from: dateFrom, + stayover_date_to: dateTo, + }) + + const rows = (data?.data ?? []).map(b => ({ + ref: b.booking_ref ?? b.id, + guest: b.guest_name ?? `${b.first_name ?? ''} ${b.last_name ?? ''}`.trim(), + arrival: b.arrival_date, + departure: b.departure_date, + nights: b.nights ?? b.no_nights, + room: b.site_name ?? b.room_name ?? b.site_id, + adults: b.adults ?? b.no_adults, + status: b.booking_status ?? b.status, + })) + + return { + columns: [ + { key: 'ref', label: 'Ref' }, + { key: 'guest', label: 'Guest' }, + { key: 'arrival', label: 'Arrival', type: 'date' }, + { key: 'departure', label: 'Departure', type: 'date' }, + { key: 'nights', label: 'Nights', type: 'number' }, + { key: 'room', label: 'Room' }, + { key: 'adults', label: 'Adults', type: 'number' }, + { key: 'status', label: 'Status' }, + ], + rows, + summary: [ + { label: 'Stayovers', value: String(rows.length) }, + ], + } + }, +} diff --git a/backend/src/reports/resos/covers.js b/backend/src/reports/resos/covers.js new file mode 100644 index 00000000..37538ed9 --- /dev/null +++ b/backend/src/reports/resos/covers.js @@ -0,0 +1,57 @@ +export default { + id: 'resos-covers', + name: 'Covers by Day', + category: 'resos', + categoryLabel: 'ResOS', + subcategory: 'Bookings', + description: 'Daily restaurant covers from ResOS within the selected date range. Requires RESOS_API_KEY environment variable.', + + async run({ dateFrom, dateTo }, { env }) { + const apiKey = env.RESOS_API_KEY + if (!apiKey) throw new Error('ResOS API key not configured — set RESOS_API_KEY in the .env file') + + const url = `https://api.resos.com/v1/bookings?date_from=${dateFrom}&date_to=${dateTo}&per_page=500` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`ResOS API ${res.status}: ${text.slice(0, 200)}`) + } + const data = await res.json() + const bookings = data?.data ?? data?.bookings ?? [] + + // Aggregate by date + const byDate = {} + for (const b of bookings) { + const date = b.date ?? b.booking_date + if (!date) continue + if (!byDate[date]) byDate[date] = { covers: 0, bookings: 0, no_shows: 0 } + byDate[date].bookings++ + byDate[date].covers += parseInt(b.covers ?? b.party_size ?? 0) || 0 + if (b.status === 'no_show' || b.no_show) byDate[date].no_shows++ + } + + const rows = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, g]) => ({ date, bookings: g.bookings, covers: g.covers, no_shows: g.no_shows })) + + const totalCovers = rows.reduce((s, r) => s + r.covers, 0) + const totalBookings = rows.reduce((s, r) => s + r.bookings, 0) + + return { + columns: [ + { key: 'date', label: 'Date', type: 'date' }, + { key: 'bookings', label: 'Bookings', type: 'number' }, + { key: 'covers', label: 'Covers', type: 'number' }, + { key: 'no_shows', label: 'No Shows', type: 'number' }, + ], + rows, + summary: [ + { label: 'Total bookings', value: String(totalBookings) }, + { label: 'Total covers', value: String(totalCovers) }, + ], + } + }, +} diff --git a/backend/src/reports/resos/revenue.js b/backend/src/reports/resos/revenue.js new file mode 100644 index 00000000..e9357557 --- /dev/null +++ b/backend/src/reports/resos/revenue.js @@ -0,0 +1,58 @@ +export default { + id: 'resos-revenue', + name: 'Revenue by Day', + category: 'resos', + categoryLabel: 'ResOS', + subcategory: 'Revenue', + description: 'Daily restaurant revenue from ResOS within the selected date range. Requires RESOS_API_KEY environment variable.', + + async run({ dateFrom, dateTo }, { env }) { + const apiKey = env.RESOS_API_KEY + if (!apiKey) throw new Error('ResOS API key not configured — set RESOS_API_KEY in the .env file') + + const url = `https://api.resos.com/v1/bookings?date_from=${dateFrom}&date_to=${dateTo}&per_page=500` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(15000), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`ResOS API ${res.status}: ${text.slice(0, 200)}`) + } + const data = await res.json() + const bookings = data?.data ?? data?.bookings ?? [] + + const byDate = {} + for (const b of bookings) { + const date = b.date ?? b.booking_date + if (!date) continue + if (!byDate[date]) byDate[date] = { revenue: 0, covers: 0 } + byDate[date].revenue += parseFloat(b.total ?? b.amount ?? 0) || 0 + byDate[date].covers += parseInt(b.covers ?? b.party_size ?? 0) || 0 + } + + const rows = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, g]) => ({ + date, + covers: g.covers, + revenue: g.revenue.toFixed(2), + revenue_cover: g.covers > 0 ? (g.revenue / g.covers).toFixed(2) : '0.00', + })) + + const totalRevenue = rows.reduce((s, r) => s + parseFloat(r.revenue), 0) + + return { + columns: [ + { key: 'date', label: 'Date', type: 'date' }, + { key: 'covers', label: 'Covers', type: 'number' }, + { key: 'revenue', label: 'Revenue', type: 'currency' }, + { key: 'revenue_cover', label: 'Rev/Cover', type: 'currency' }, + ], + rows, + summary: [ + { label: 'Total revenue', value: totalRevenue.toFixed(2) }, + ], + } + }, +} diff --git a/backend/src/reports/samba/sales-summary.js b/backend/src/reports/samba/sales-summary.js new file mode 100644 index 00000000..7b1fd0c6 --- /dev/null +++ b/backend/src/reports/samba/sales-summary.js @@ -0,0 +1,57 @@ +export default { + id: 'samba-sales-summary', + name: 'Sales Summary', + category: 'samba', + categoryLabel: 'SambaPOS', + subcategory: 'Sales', + description: 'Daily sales totals from SambaPOS within the selected date range. Requires SAMBA_DATABASE_URL environment variable.', + + async run({ dateFrom, dateTo }, { env }) { + const connStr = env.SAMBA_DATABASE_URL + if (!connStr) throw new Error('SambaPOS database not configured — set SAMBA_DATABASE_URL in the .env file') + + // Dynamic import to avoid pg pool creation if not needed + const { Pool } = (await import('pg')).default + const sambaPool = new Pool({ connectionString: connStr, max: 2 }) + + try { + const { rows } = await sambaPool.query(` + SELECT + CAST(Date AS DATE) AS sale_date, + COUNT(*) AS ticket_count, + SUM(TotalAmount) AS gross_total, + SUM(DiscountAmount) AS discount_total, + SUM(TotalAmount - DiscountAmount) AS net_total + FROM Tickets + WHERE CAST(Date AS DATE) BETWEEN $1 AND $2 + AND IsCancelled = 0 + GROUP BY CAST(Date AS DATE) + ORDER BY CAST(Date AS DATE) + `, [dateFrom, dateTo]) + + const totalNet = rows.reduce((s, r) => s + parseFloat(r.net_total ?? 0), 0) + + return { + columns: [ + { key: 'sale_date', label: 'Date', type: 'date' }, + { key: 'ticket_count', label: 'Tickets', type: 'number' }, + { key: 'gross_total', label: 'Gross', type: 'currency' }, + { key: 'discount_total',label: 'Discounts', type: 'currency' }, + { key: 'net_total', label: 'Net Total', type: 'currency' }, + ], + rows: rows.map(r => ({ + sale_date: r.sale_date instanceof Date ? r.sale_date.toISOString().split('T')[0] : r.sale_date, + ticket_count: r.ticket_count, + gross_total: parseFloat(r.gross_total ?? 0).toFixed(2), + discount_total: parseFloat(r.discount_total ?? 0).toFixed(2), + net_total: parseFloat(r.net_total ?? 0).toFixed(2), + })), + summary: [ + { label: 'Total net', value: totalNet.toFixed(2) }, + ], + } + } finally { + await sambaPool.end() + } + }, +} diff --git a/backend/src/reports/samba/top-products.js b/backend/src/reports/samba/top-products.js new file mode 100644 index 00000000..c645b7d5 --- /dev/null +++ b/backend/src/reports/samba/top-products.js @@ -0,0 +1,54 @@ +export default { + id: 'samba-top-products', + name: 'Top Products', + category: 'samba', + categoryLabel: 'SambaPOS', + subcategory: 'Products', + description: 'Best-selling products from SambaPOS ranked by quantity sold within the selected date range. Requires SAMBA_DATABASE_URL environment variable.', + + async run({ dateFrom, dateTo }, { env }) { + const connStr = env.SAMBA_DATABASE_URL + if (!connStr) throw new Error('SambaPOS database not configured — set SAMBA_DATABASE_URL in the .env file') + + const { Pool } = (await import('pg')).default + const sambaPool = new Pool({ connectionString: connStr, max: 2 }) + + try { + const { rows } = await sambaPool.query(` + SELECT + ti.MenuItemName AS product_name, + COUNT(*) AS orders, + SUM(ti.Quantity) AS qty_sold, + SUM(ti.Price * ti.Quantity) AS gross_total + FROM TicketItems ti + JOIN Tickets t ON t.Id = ti.TicketId + WHERE CAST(t.Date AS DATE) BETWEEN $1 AND $2 + AND t.IsCancelled = 0 + AND ti.IsVoided = 0 + GROUP BY ti.MenuItemName + ORDER BY qty_sold DESC + LIMIT 50 + `, [dateFrom, dateTo]) + + return { + columns: [ + { key: 'product_name', label: 'Product' }, + { key: 'orders', label: 'Orders', type: 'number' }, + { key: 'qty_sold', label: 'Qty Sold', type: 'number' }, + { key: 'gross_total', label: 'Gross', type: 'currency' }, + ], + rows: rows.map(r => ({ + product_name: r.product_name, + orders: r.orders, + qty_sold: parseFloat(r.qty_sold ?? 0).toFixed(0), + gross_total: parseFloat(r.gross_total ?? 0).toFixed(2), + })), + summary: [ + { label: 'Products shown', value: String(rows.length) }, + ], + } + } finally { + await sambaPool.end() + } + }, +} diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js new file mode 100644 index 00000000..9abd19f3 --- /dev/null +++ b/backend/src/routes/reports.js @@ -0,0 +1,58 @@ +import { requireAuth, hasCap } from '../auth.js' +import { pool } from '../db.js' +import { registry } from '../reports/index.js' +import * as newbook from '../lib/newbook.js' + +const PUBLIC_META = registry.map(r => ({ + id: r.id, + name: r.name, + category: r.category, + categoryLabel: r.categoryLabel, + subcategory: r.subcategory ?? null, + description: r.description, +})) + +export async function reportRoutes(fastify) { + fastify.addHook('preHandler', requireAuth) + + fastify.get('/api/reports', async () => PUBLIC_META) + + fastify.post('/api/reports/:id/run', async (request, reply) => { + if (!hasCap(request, 'view')) { + return reply.status(403).send({ error: 'Missing capability: view' }) + } + + const { id } = request.params + const { dateFrom, dateTo } = request.body ?? {} + + const report = registry.find(r => r.id === id) + if (!report) return reply.status(404).send({ error: 'Report not found' }) + + const ctx = { db: pool, newbook, env: process.env } + const start = Date.now() + + try { + const result = await report.run({ dateFrom, dateTo }, ctx) + const duration = Date.now() - start + + pool.query( + `INSERT INTO report_runs (report_id, report_name, user_email, date_from, date_to, row_count, duration_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [report.id, report.name, request.user.email, dateFrom ?? null, dateTo ?? null, result.rows.length, duration] + ).catch(() => {}) + + return result + } catch (err) { + const duration = Date.now() - start + request.log.error(err) + + pool.query( + `INSERT INTO report_runs (report_id, report_name, user_email, date_from, date_to, error, duration_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [report.id, report.name, request.user.email, dateFrom ?? null, dateTo ?? null, err.message, duration] + ).catch(() => {}) + + return reply.status(500).send({ error: err.message || 'Report failed' }) + } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..4af59259 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: ./backend + security_opt: + - apparmor=unconfined + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - SETTINGS_URL=${SETTINGS_URL} + - SETTINGS_SECRET=${SETTINGS_SECRET} + - APP_SLUG=reports + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + - RESOS_API_KEY=${RESOS_API_KEY:-} + - SAMBA_DATABASE_URL=${SAMBA_DATABASE_URL:-} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 20s + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME} + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..6572808e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +ARG VITE_HOTEL_NAME +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/reports +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..82da4d71 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,39 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + location /reports/api/auth/ { + proxy_pass http://10.10.10.101:3001/api/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /reports/api/ { + proxy_pass http://backend:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + } + + location /reports/health { + proxy_pass http://backend:3001/health; + } + + location ~* /reports/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /reports/ { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /reports/index.html; + } + + location = / { + return 301 /reports/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..7721b94e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "hnf-reports-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 00000000..8c27e6fa --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +import AuthGate from './components/AuthGate' +import ReportsPage from './pages/ReportsPage' + +export default function App() { + return ( + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 00000000..0c40c1f9 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,27 @@ +import type { ReportMeta, ReportResult } from './types' + +const BASE = '/reports/api' + +async function request(path: string, opts: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...opts, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error((err as { error?: string }).error || `Request failed: ${res.status}`) + } + return res.json() +} + +export function fetchReports(): Promise { + return request('/reports') +} + +export function runReport(id: string, dateFrom: string, dateTo: string): Promise { + return request(`/reports/${encodeURIComponent(id)}/run`, { + method: 'POST', + body: JSON.stringify({ dateFrom, dateTo }), + }) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 00000000..a8b10095 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState, createContext, useContext } from 'react' +import type { User } from '../types' + +interface AuthCtx { user: User } +const Ctx = createContext(null) + +export function useAuth() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useAuth must be used inside AuthGate') + return ctx +} + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/reports/api/auth/verify?app=reports', { credentials: 'include' }) + .then(r => { + if (r.status === 401 || r.status === 403) { + window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}` + return null + } + if (!r.ok) throw new Error(`Auth check failed: ${r.status}`) + return r.json() + }) + .then(data => { if (data) setUser(data) }) + .catch(err => setError(err.message)) + }, []) + + if (error) { + return ( +
+ Authentication error: {error} +
+ ) + } + + if (!user) { + return ( +
+ Loading… +
+ ) + } + + return {children} +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..a7824e2b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,397 @@ +/* Stack design system tokens */ +:root { + --navy: #1a1a2e; + --gold: #c9a84c; + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --text-primary: #1a1a2e; + --text-muted: #6b7280; + --border: #e5e7eb; + --radius: 8px; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); + --shadow-md: 0 4px 12px rgba(0,0,0,0.12); + + /* App theme */ + --app-primary: #1d4ed8; + --app-primary-light: #3b82f6; + --app-primary-dark: #1e40af; + + /* Layout */ + --sidebar-w: 220px; + --topbar-h: 56px; +} + +*, *::before, *::after { box-sizing: border-box; } + +html, body, #root { + height: 100%; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--body-bg); + color: var(--text-primary); + font-size: 14px; +} + +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* ── App shell ──────────────────────────────────────────────────── */ +.app-shell { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* ── Sidebar ────────────────────────────────────────────────────── */ +.sidebar { + width: var(--sidebar-w); + background: var(--navy); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; +} + +.sidebar-logo { + padding: 20px 16px 12px; + color: var(--gold); + font-size: 13px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.sidebar-nav { + flex: 1; + overflow-y: auto; +} + +.sidebar-user { + padding: 12px 16px; + border-top: 1px solid rgba(255,255,255,.08); + color: rgba(255,255,255,.4); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; +} + +/* ── Report tree ────────────────────────────────────────────────── */ +.report-category { + border-bottom: 1px solid rgba(255,255,255,.05); +} + +.report-cat-header { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + background: none; + border: none; + cursor: pointer; + color: rgba(255,255,255,.8); + font-size: 12.5px; + font-weight: 600; + letter-spacing: .03em; + text-transform: uppercase; + text-align: left; + transition: background .12s; +} +.report-cat-header:hover { background: rgba(255,255,255,.06); } + +.report-cat-icon { opacity: .7; display: flex; } +.report-cat-label { flex: 1; } + +.report-cat-body { + padding-bottom: 4px; +} + +.report-sub-header { + width: 100%; + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px 7px 22px; + background: none; + border: none; + cursor: pointer; + color: rgba(255,255,255,.55); + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + text-align: left; + transition: color .12s; +} +.report-sub-header:hover { color: rgba(255,255,255,.8); } + +.report-item { + width: 100%; + display: block; + padding: 7px 14px 7px 32px; + background: none; + border: none; + cursor: pointer; + color: rgba(255,255,255,.6); + font-size: 13px; + text-align: left; + transition: background .12s, color .12s; +} +.report-item:hover { background: rgba(255,255,255,.05); color: #fff; } +.report-item.active { background: rgba(201,168,76,.12); color: var(--gold); } + +/* ── Mobile top bar ─────────────────────────────────────────────── */ +.top-bar { + display: none; + height: var(--topbar-h); + background: var(--navy); + color: #fff; + align-items: center; + padding: 0 14px; + gap: 10px; + flex-shrink: 0; +} +.top-bar-title { + flex: 1; + font-size: 15px; + font-weight: 600; + color: var(--gold); +} + +/* ── Page content ───────────────────────────────────────────────── */ +.page-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Welcome / empty states ─────────────────────────────────────── */ +.welcome-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-muted); + padding: 40px; +} +.welcome-state h2 { margin: 0 0 8px; color: var(--text-primary); font-size: 20px; font-weight: 600; } +.welcome-state p { margin: 0; font-size: 14px; } + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: var(--text-muted); + font-size: 14px; +} + +/* ── Report view ────────────────────────────────────────────────── */ +.report-view { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 24px 28px; + gap: 16px; +} + +.report-header { + display: flex; + align-items: flex-start; + gap: 16px; +} +.report-title { + margin: 0 0 4px; + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} +.report-description { + margin: 0; + font-size: 13px; + color: var(--text-muted); + max-width: 640px; +} + +/* ── Controls bar ───────────────────────────────────────────────── */ +.report-controls { + display: flex; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} + +.date-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.date-group label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: .04em; +} +.date-input { + height: 34px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + color: var(--text-primary); + background: var(--body-bg); + cursor: pointer; +} +.date-input:focus { outline: 2px solid var(--app-primary); outline-offset: -1px; } + +.btn-run { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 16px; + background: var(--app-primary); + color: #fff; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background .15s; + white-space: nowrap; +} +.btn-run:hover:not(:disabled) { background: var(--app-primary-dark); } +.btn-run:disabled { opacity: .65; cursor: not-allowed; } + +.btn-export { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 14px; + background: none; + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: border-color .15s, color .15s; + white-space: nowrap; +} +.btn-export:hover { border-color: var(--text-muted); color: var(--text-primary); } + +/* ── Summary bar ────────────────────────────────────────────────── */ +.summary-bar { + display: flex; + gap: 20px; + flex-wrap: wrap; +} +.summary-stat { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 16px; + min-width: 110px; +} +.summary-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-muted); +} +.summary-value { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +/* ── Error ──────────────────────────────────────────────────────── */ +.report-error { + display: flex; + align-items: flex-start; + gap: 8px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--radius); + color: #991b1b; + padding: 12px 14px; + font-size: 13px; + line-height: 1.5; +} +.report-error svg { flex-shrink: 0; margin-top: 1px; } + +/* ── Results table ──────────────────────────────────────────────── */ +.results-table-wrap { + flex: 1; + overflow: auto; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.results-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + white-space: nowrap; +} +.results-table thead { + position: sticky; + top: 0; + z-index: 1; +} +.results-table th { + background: #f8f9fb; + border-bottom: 1px solid var(--border); + padding: 9px 12px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-muted); + white-space: nowrap; +} +.results-table td { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); +} +.results-table tr:last-child td { border-bottom: none; } +.results-table tr:hover td { background: #f9fafb; } +.results-table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.results-table th:has(+ .num) { text-align: right; } + +/* ── Spinner ────────────────────────────────────────────────────── */ +@keyframes spin { to { transform: rotate(360deg); } } +.spin { animation: spin .8s linear infinite; } + +/* ── Mobile ─────────────────────────────────────────────────────── */ +@media (max-width: 700px) { + .sidebar { display: none; } + .page-content { flex-direction: column; } + .top-bar { display: flex; } + .report-view { padding: 16px; } + .report-controls { gap: 8px; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..4a1b1509 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx new file mode 100644 index 00000000..06fe9896 --- /dev/null +++ b/frontend/src/pages/ReportsPage.tsx @@ -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 = { + newbook: , + resos: , + samba: , + internal: , +} + +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() + + 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>(new Set(tree.map(t => t.category))) + const [openSubs, setOpenSubs] = useState>(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 ( + + ) +} + +// ── results table ───────────────────────────────────────────────────────────── + +function ResultsTable({ result }: { result: ReportResult }) { + if (result.rows.length === 0) { + return ( +
+ + No data found for this date range +
+ ) + } + + return ( +
+ + + + {result.columns.map(c => )} + + + + {result.rows.map((row, i) => ( + + {result.columns.map(c => ( + + ))} + + ))} + +
{c.label}
+ {formatCell(row[c.key], c.type)} +
+
+ ) +} + +// ── main page ───────────────────────────────────────────────────────────────── + +export default function ReportsPage() { + const [reports, setReports] = useState([]) + const [selected, setSelected] = useState(null) + const [dateFrom, setDateFrom] = useState(daysAgo(7)) + const [dateTo, setDateTo] = useState(today()) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+ + + {/* Mobile top bar */} +
+ + Reports +
+ +
+ {!selected ? ( +
+ +

Custom Reports

+

Select a report from the sidebar to get started.

+
+ ) : ( +
+
+
+

{selected.name}

+

{selected.description}

+
+
+ +
+
+ + setDateFrom(e.target.value)} + /> +
+
+ + setDateTo(e.target.value)} + /> +
+ + {result && ( + + )} +
+ + {error && ( +
+ + {error} +
+ )} + + {result && ( + <> + {result.summary && result.summary.length > 0 && ( +
+ {result.summary.map((s, i) => ( +
+ {s.label} + {s.value} +
+ ))} +
+ Rows + {result.rows.length.toLocaleString()} +
+
+ )} + + + )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 00000000..04fd2e7a --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,31 @@ +export interface User { + email: string + name: string + is_admin: boolean + caps: string[] +} + +export function can(user: User, cap: string): boolean { + return user.is_admin || user.caps.includes(cap) +} + +export interface ReportMeta { + id: string + name: string + category: string + categoryLabel: string + subcategory: string | null + description: string +} + +export interface ReportColumn { + key: string + label: string + type?: 'text' | 'number' | 'currency' | 'date' | 'datetime' +} + +export interface ReportResult { + columns: ReportColumn[] + rows: Record[] + summary?: { label: string; value: string }[] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..79a22877 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..3fe6d6d9 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + base: '/reports/', + plugins: [react()], +})