From a695bd843fb84edaf038e5f39fb7455f6bfc336a Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 20:14:01 +0000 Subject: [PATCH] Initial commit: twin-optimiser sub-app (housekeeping category) Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 18 ++ .gitignore | 6 + backend/Dockerfile | 7 + backend/package.json | 16 ++ backend/src/auth.js | 35 +++ backend/src/db.js | 54 ++++ backend/src/index.js | 24 ++ backend/src/ip-check.js | 80 ++++++ backend/src/lib/newbook.js | 220 +++++++++++++++++ backend/src/routes/grid.js | 25 ++ backend/src/routes/settings.js | 53 ++++ docker-compose.yml | 32 +++ frontend/Dockerfile | 13 + frontend/index.html | 13 + frontend/nginx.conf | 37 +++ frontend/package.json | 24 ++ frontend/src/App.tsx | 28 +++ frontend/src/api.ts | 36 +++ frontend/src/components/AuthGate.tsx | 99 ++++++++ frontend/src/components/Layout.tsx | 69 ++++++ frontend/src/index.css | 348 ++++++++++++++++++++++++++ frontend/src/main.tsx | 10 + frontend/src/pages/Settings.tsx | 220 +++++++++++++++++ frontend/src/pages/TwinOptimiser.tsx | 356 +++++++++++++++++++++++++++ frontend/src/types.ts | 45 ++++ frontend/tsconfig.json | 15 ++ frontend/vite.config.ts | 7 + seed-app.js | 23 ++ 28 files changed, 1913 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/auth.js create mode 100644 backend/src/db.js create mode 100644 backend/src/index.js create mode 100644 backend/src/ip-check.js create mode 100644 backend/src/lib/newbook.js create mode 100644 backend/src/routes/grid.js create mode 100644 backend/src/routes/settings.js create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/TwinOptimiser.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 seed-app.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..383db36 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# PostgreSQL connection string for the twin optimiser DB +DATABASE_URL=postgres://user:pass@host:5432/twin_optimiser + +# Shared secret used to verify hnf_session JWTs (same as auth service) +CENTRAL_AUTH_SECRET=change-me + +# Settings service (for Newbook credentials) +SETTINGS_URL=http://settings-backend:3001 +SETTINGS_SECRET=change-me + +# Hotel name shown on the login page +VITE_HOTEL_NAME=Hotel Name + +# Host port to expose the frontend on (default 3080) +FRONTEND_PORT=3080 + +# IP check: 'disabled', 'auto', a CIDR (192.168.1.0/24), or a hostname +OFFICE_IP_CHECK=disabled diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b0e737 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +node_modules/ +frontend/dist/ +backend/node_modules/ +frontend/node_modules/ +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..35a6156 --- /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 0000000..aa48d6c --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "hnf-hk-twin-optimiser-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 0000000..41f8197 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,35 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'twin-optimiser' +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' }) + } + } + + request.user = { + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin ?? false, + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..90c345a --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,54 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +const DEFAULTS = { + custom_field_names: 'Bed Type', + custom_field_values: 'twin, 2 x single', + notes_search_terms: '', + excluded_terms: '', + normal_color: '#9e9e9e', + twin_color: '#26b823', + potential_twin_color: '#ffc670', +} + +export async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS twin_settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL DEFAULT 'null'::jsonb + ) + `) +} + +export async function getSetting(key) { + const { rows } = await pool.query('SELECT value FROM twin_settings WHERE key = $1', [key]) + return rows.length ? rows[0].value : null +} + +export async function getAllSettings() { + const { rows } = await pool.query('SELECT key, value FROM twin_settings') + const stored = Object.fromEntries(rows.map(r => [r.key, r.value])) + return { ...DEFAULTS, ...stored } +} + +export async function saveSettings(settings) { + const client = await pool.connect() + try { + await client.query('BEGIN') + for (const [key, value] of Object.entries(settings)) { + await client.query( + `INSERT INTO twin_settings (key, value) VALUES ($1, $2::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [key, JSON.stringify(value)] + ) + } + await client.query('COMMIT') + } catch (e) { + await client.query('ROLLBACK') + throw e + } finally { + client.release() + } +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..51c0d8d --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,24 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { gridRoutes } from './routes/grid.js' +import { settingsRoutes } from './routes/settings.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(gridRoutes) +await app.register(settingsRoutes) + +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 0000000..4d8cb19 --- /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 0000000..efd94a4 --- /dev/null +++ b/backend/src/lib/newbook.js @@ -0,0 +1,220 @@ +const API_BASE = 'https://api.newbook.cloud/rest/' + +const gridCache = new Map() + +export function todayStr() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +export function offsetDate(dateStr, days) { + const [y, m, d] = dateStr.split('-').map(Number) + const dt = new Date(y, m - 1, d) + dt.setDate(dt.getDate() + days) + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` +} + +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}`) + const s = await res.json() + return { + username: s.username || '', + password: s.password || '', + apiKey: s.api_key || '', + region: s.region || 'eu', + } +} + +async function callApi(endpoint, data = {}) { + const creds = await getCredentials() + if (!creds.username || !creds.password || !creds.apiKey) { + throw new Error('Newbook API credentials not configured') + } + + const body = { ...data, region: creds.region, api_key: creds.apiKey } + 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 + } +} + +export async function fetchBookings(startDate, endDate) { + return callApi('bookings_list', { + period_from: startDate + ' 00:00:00', + period_to: endDate + ' 23:59:59', + list_type: 'staying', + data_offset: 0, + data_limit: 2000, + }) +} + +export async function fetchSitesList() { + return callApi('sites_list', {}) +} + +export async function testConnection() { + try { + const resp = await fetchSitesList() + if (resp.data) return { ok: true, message: `Connected. Found ${resp.data.length} site(s).` } + return { ok: false, error: resp.error || 'No data returned' } + } catch (err) { + return { ok: false, error: err.message } + } +} + +// ── Twin detection ───────────────────────────────────────────────────────────── + +function classifyBooking(booking, settings) { + const fieldNames = (settings.custom_field_names || '').split(',').map(s => s.trim()).filter(Boolean) + const fieldValues = (settings.custom_field_values || '').split(',').map(s => s.trim()).filter(Boolean) + + // Primary: configured custom fields + if (fieldNames.length && fieldValues.length) { + const customFields = booking.booking_custom_fields || [] + for (const fieldName of fieldNames) { + const field = customFields.find(f => f.name === fieldName) + if (!field?.value) continue + const valueLower = field.value.toLowerCase() + for (const searchValue of fieldValues) { + if (valueLower.includes(searchValue.toLowerCase())) { + return { type: 'twin', field_name: fieldName, field_value: field.value, matched_term: searchValue } + } + } + } + } + + // Legacy: "Bed Type" label field + const legacyFields = [...(booking.custom_fields || []), ...(booking.booking_custom_fields || [])] + const bedTypeField = legacyFields.find(f => f.label === 'Bed Type' || f.name === 'Bed Type') + if (bedTypeField?.value) { + const v = bedTypeField.value.toLowerCase() + if (v.includes('twin')) { + return { type: 'twin', field_name: 'Bed Type (Legacy)', field_value: bedTypeField.value, matched_term: 'twin' } + } + if (/2\s*x?\s*single/i.test(v)) { + return { type: 'twin', field_name: 'Bed Type (Legacy)', field_value: bedTypeField.value, matched_term: '2 x single' } + } + } + + // Potential: notes search + const noteTerms = (settings.notes_search_terms || '').split(',').map(s => s.trim()).filter(Boolean) + const excludeTerms = (settings.excluded_terms || '').split(',').map(s => s.trim()).filter(Boolean) + + if (noteTerms.length) { + const notes = booking.notes || [] + for (const note of notes) { + let content = note.content || '' + for (const excl of excludeTerms) content = content.split(excl).join('') + const contentLower = content.toLowerCase() + for (const term of noteTerms) { + if (contentLower.includes(term.toLowerCase())) { + return { type: 'potential_twin', note_content: note.content, matched_term: term } + } + } + } + } + + return { type: 'normal' } +} + +function isEarlyCheckin(booking) { + for (const timeStr of [booking.booking_arrival, booking.booking_eta]) { + if (timeStr && timeStr.length > 10) { + const [h, m] = timeStr.slice(11, 16).split(':').map(Number) + if (!isNaN(h) && (h * 60 + (m || 0)) < 15 * 60) return true + } + } + return false +} + +// ── Grid builder ─────────────────────────────────────────────────────────────── + +export async function fetchGridData(startDate, days, settings, forceRefresh = false) { + const endDate = offsetDate(startDate, days - 1) + const cacheKey = `${startDate}_${days}` + + if (!forceRefresh) { + const hit = gridCache.get(cacheKey) + if (hit && Date.now() < hit.expiry) return hit.data + } + + const resp = await fetchBookings(startDate, endDate) + if (!resp?.data) throw new Error(resp?.error || 'No booking data returned') + + const dates = [] + for (let i = 0; i < days; i++) dates.push(offsetDate(startDate, i)) + + const grid = {} + const rooms = [] + + for (const booking of resp.data) { + const siteId = booking.site_id || '' + const siteName = booking.site_name || '' + if (!siteId || !siteName) continue + + if (!grid[siteId]) { + grid[siteId] = { + site_name: siteName, + category: (booking.category_name || 'Uncategorized').trim(), + cells: {}, + } + rooms.push(siteId) + } + + const checkin = (booking.booking_arrival || '').slice(0, 10) + const checkout = (booking.booking_departure || '').slice(0, 10) + if (!checkin || !checkout) continue + + const detection = classifyBooking(booking, settings) + const early = isEarlyCheckin(booking) + const locked = String(booking.booking_locked) === '1' + + for (const date of dates) { + if (date >= checkin && date < checkout && !grid[siteId].cells[date]) { + grid[siteId].cells[date] = { + booking_id: booking.booking_id, + booking_ref: booking.booking_reference_id, + checkin, + checkout, + detection, + is_early_checkin: early, + is_locked: locked, + } + } + } + } + + // Sort by category then room name + rooms.sort((a, b) => { + const ac = grid[a].category, bc = grid[b].category + if (ac !== bc) return ac.localeCompare(bc) + return grid[a].site_name.localeCompare(grid[b].site_name) + }) + + const result = { dates, rooms, grid } + gridCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 }) + return result +} diff --git a/backend/src/routes/grid.js b/backend/src/routes/grid.js new file mode 100644 index 0000000..4a78f82 --- /dev/null +++ b/backend/src/routes/grid.js @@ -0,0 +1,25 @@ +import { requireAuth } from '../auth.js' +import { getAllSettings } from '../db.js' +import { fetchGridData, todayStr } from '../lib/newbook.js' + +export async function gridRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/grid', async (req, reply) => { + let startDate = req.query.start_date || todayStr() + if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) startDate = todayStr() + + let days = parseInt(req.query.days, 10) || 14 + if (days < 1 || days > 30) days = 14 + + const force = req.query.force === '1' + + try { + const settings = await getAllSettings() + return await fetchGridData(startDate, days, settings, force) + } catch (err) { + app.log.error(err) + return reply.status(500).send({ error: err.message }) + } + }) +} diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..2ee1dad --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,53 @@ +import { requireAuth } from '../auth.js' +import { getAllSettings, saveSettings } from '../db.js' +import { testConnection } from '../lib/newbook.js' + +const ALLOWED_KEYS = [ + 'custom_field_names', + 'custom_field_values', + 'notes_search_terms', + 'excluded_terms', + 'normal_color', + 'twin_color', + 'potential_twin_color', +] + +function isValidHex(s) { + return /^#[0-9a-fA-F]{6}$/.test(s) +} + +export async function settingsRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/settings', async () => { + return getAllSettings() + }) + + app.post('/api/settings', async (req, reply) => { + if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + + const body = req.body || {} + const update = {} + + for (const key of ALLOWED_KEYS) { + if (!(key in body)) continue + const val = body[key] + if (key.endsWith('_color')) { + if (!isValidHex(val)) return reply.status(400).send({ error: `Invalid hex color for ${key}` }) + } else { + if (typeof val !== 'string') return reply.status(400).send({ error: `${key} must be a string` }) + } + update[key] = val + } + + if (!Object.keys(update).length) return reply.status(400).send({ error: 'Nothing to update' }) + + await saveSettings(update) + return getAllSettings() + }) + + app.post('/api/newbook/test', async (req, reply) => { + if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + return testConnection() + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c70c47a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + twin-optimiser-backend: + build: ./backend + container_name: twin-optimiser-backend + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + CENTRAL_AUTH_SECRET: ${CENTRAL_AUTH_SECRET} + SETTINGS_URL: ${SETTINGS_URL} + SETTINGS_SECRET: ${SETTINGS_SECRET} + APP_SLUG: twin-optimiser + OFFICE_IP_CHECK: ${OFFICE_IP_CHECK:-disabled} + networks: + - hnf_net + + twin-optimiser-frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME} + container_name: twin-optimiser-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + - twin-optimiser-backend + networks: + - hnf_net + +networks: + hnf_net: + external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..38b5575 --- /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="Number Four at Stow" +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/twin-optimiser +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e8abde2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Twin Optimiser + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e924f23 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + location = /twin-optimiser/manifest.json { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location /twin-optimiser/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 /twin-optimiser/health { + proxy_pass http://backend:3001/health; + } + + location ~* /twin-optimiser/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /twin-optimiser/ { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /twin-optimiser/index.html; + } + + location = / { + return 301 /twin-optimiser/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1d3b28f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-hk-twin-optimiser-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.23.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "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 0000000..9c8809d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthGate } from './components/AuthGate' +import { Layout } from './components/Layout' +import { TwinOptimiser } from './pages/TwinOptimiser' +import { Settings } from './pages/Settings' +import type { User } from './types' + +function AppRoutes({ user }: { user: User }) { + return ( + + + } /> + : } /> + } /> + + + ) +} + +export default function App() { + return ( + + + {user => } + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..3ae9074 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,36 @@ +import type { GridData, AppSettings } from './types' + +const BASE = '/twin-optimiser/api' + +async function request(path: string, opts?: RequestInit): Promise { + const res = await fetch(BASE + path, { credentials: 'include', ...opts }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string } + throw new Error(body.error || `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export function getGrid(startDate?: string, days = 14, force = false): Promise { + const p = new URLSearchParams() + if (startDate) p.set('start_date', startDate) + p.set('days', String(days)) + if (force) p.set('force', '1') + return request(`/grid?${p}`) +} + +export function getSettings(): Promise { + return request('/settings') +} + +export function postSettings(settings: Partial): Promise { + return request('/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) +} + +export function testNewbook(): Promise<{ ok: boolean; message?: string; error?: string }> { + return request('/newbook/test', { method: 'POST' }) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..06c0f7b --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import type { User } from '../types' + +interface Props { + children: (user: User) => React.ReactNode +} + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} +const btnStyle: React.CSSProperties = { + background: 'var(--app-color)', color: '#fff', border: 'none', + borderRadius: '6px', padding: '0.625rem', fontSize: '1rem', + fontWeight: 600, marginTop: '0.25rem', width: '100%', +} + +export function AuthGate({ children }: Props) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') + const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetch('/api/auth/verify?app=twin-optimiser', { credentials: 'include' }) + .then(async r => { + if (r.ok) { setUser(await r.json()); setState('authed') } + else setState('login') + }) + .catch(() => setState('login')) + }, []) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/api/auth/verify?app=twin-optimiser', { credentials: 'include' }) + if (verify.ok) { setUser(await verify.json()); setState('authed') } + else setError("You don't have access to this app.") + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { + return ( +
+
Loading…
+
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ Twin Optimiser +

+

+ {import.meta.env.VITE_HOTEL_NAME} +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" style={inputStyle} /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" style={inputStyle} /> + {error &&

{error}

} + +
+
+
+ ) + } + + return <>{children(user!)} +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..67f23cb --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,69 @@ +import { NavLink } from 'react-router-dom' +import { LayoutGrid, Settings, LogOut } from 'lucide-react' +import type { User } from '../types' + +interface Props { + user: User + children: React.ReactNode +} + +export function Layout({ user, children }: Props) { + async function logout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + window.location.href = '/' + } + + return ( +
+ + +
+ {children} +
+
+ ) +} + +function NavItem({ to, icon: Icon, label, end }: { to: string; icon: typeof LayoutGrid; label: string; end?: boolean }) { + return ( + ({ + display: 'flex', alignItems: 'center', gap: '0.625rem', + padding: '0.625rem 1rem', textDecoration: 'none', + color: isActive ? 'var(--app-color)' : 'var(--text)', + background: isActive ? 'var(--surface)' : 'transparent', + borderLeft: isActive ? '2px solid var(--app-color)' : '2px solid transparent', + fontSize: '0.875rem', transition: 'background 0.15s', + })}> + + {label} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..45dd0ff --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,348 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --navy: #1a1a2e; + --navy-dark: #0f0f20; + --surface: rgba(255,255,255,0.07); + --surface-2: rgba(255,255,255,0.08); + --text: rgba(255,255,255,0.88); + --text-muted: rgba(255,255,255,0.48); + + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --card-border: #e4e8ee; + --text-dark: #1e293b; + --text-mid: #64748b; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + + --danger: #dc2626; + --success: #16a34a; + --warning: #d97706; + --app-color: #c9841a; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +body { + background: var(--body-bg); + color: var(--text-dark); + font-family: var(--font); + min-height: 100dvh; +} + +button { cursor: pointer; font-family: inherit; } +input, textarea, select { font-family: inherit; } + +/* Grid table */ +.twin-grid { + border-collapse: collapse; + font-size: 0.78rem; + white-space: nowrap; + min-width: 100%; +} + +.twin-grid th { + background: var(--navy); + color: var(--text); + padding: 0.4rem 0.3rem; + font-weight: 600; + text-align: center; + position: sticky; + top: 0; + z-index: 2; +} + +.twin-grid th.col-room { + text-align: left; + width: 120px; + min-width: 100px; + position: sticky; + left: 0; + z-index: 3; +} + +.twin-grid td { + border: 1px solid var(--card-border); + padding: 0; + height: 36px; + min-width: 38px; + vertical-align: middle; +} + +.twin-grid td.col-room { + padding: 0.25rem 0.5rem; + background: var(--card-bg); + font-weight: 500; + color: var(--text-dark); + position: sticky; + left: 0; + z-index: 1; + border-right: 2px solid var(--card-border); +} + +/* Category header row */ +.row-category td { + background: var(--navy); + color: var(--text); + padding: 0.3rem 0.75rem; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + cursor: pointer; + user-select: none; +} + +.row-category td:hover { background: rgba(255,255,255,0.08); } + +.cat-arrow { + display: inline-block; + margin-right: 0.4rem; + transition: transform 0.15s; + font-style: normal; +} +.cat-arrow.collapsed { transform: rotate(-90deg); } + +/* Booking cells */ +.tc-vacant { + background: #f8f9fa; +} + +.tc-booked { + cursor: default; +} + +.tc-inner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 2px 4px; + border-radius: 2px; + margin: 1px; + border-bottom: 2px solid rgba(0,0,0,0.15); +} + +.tc-ref { + font-size: 0.7rem; + font-weight: 600; + color: #fff; + text-shadow: 0 1px 2px rgba(0,0,0,0.4); + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.tc-indicators { + display: flex; + gap: 2px; + margin-bottom: 1px; +} + +.tc-icon { + display: flex; + align-items: center; + color: rgba(255,255,255,0.9); +} + +.tc-twin, +.tc-potential, +.tc-normal { + cursor: pointer; +} + +.tc-normal { cursor: default; } + +/* Grid scroll wrapper */ +.grid-scroll { + overflow-x: auto; + overflow-y: visible; + border-radius: var(--radius); + border: 1px solid var(--card-border); + box-shadow: var(--shadow-sm); +} + +/* Date header */ +.date-label { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.2; +} +.date-day { font-size: 0.65rem; opacity: 0.7; } +.date-num { font-size: 0.8rem; } + +/* Legend */ +.legend { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + font-size: 0.78rem; + color: var(--text-mid); +} +.legend-item { + display: flex; + align-items: center; + gap: 0.4rem; +} +.legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; +} + +.modal { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + width: 100%; + max-width: 380px; + overflow: hidden; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--card-border); +} + +.modal-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + font-size: 0.95rem; + color: var(--text-dark); +} + +.modal-close { + background: none; + border: none; + color: var(--text-mid); + font-size: 1.4rem; + line-height: 1; + padding: 0.25rem; +} +.modal-close:hover { color: var(--text-dark); } + +.modal-body { + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.modal-row { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.modal-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-mid); +} +.modal-value { + font-size: 0.88rem; + color: var(--text-dark); + word-break: break-word; +} +.modal-highlight { + background: #ffeb3b; + padding: 1px 3px; + border-radius: 2px; +} + +/* Settings form */ +.settings-section { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--radius); + padding: 1.25rem; + margin-bottom: 1rem; +} + +.settings-section h3 { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-dark); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--card-border); +} + +.field-group { + margin-bottom: 1rem; +} +.field-group:last-child { margin-bottom: 0; } + +.field-label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-dark); + margin-bottom: 0.35rem; +} +.field-hint { + font-size: 0.72rem; + color: var(--text-mid); + margin-top: 0.25rem; + line-height: 1.4; +} + +.field-input { + width: 100%; + border: 1px solid var(--card-border); + border-radius: 6px; + padding: 0.45rem 0.6rem; + font-size: 0.85rem; + color: var(--text-dark); + background: var(--body-bg); +} +.field-input:focus { + outline: 2px solid var(--app-color); + border-color: transparent; +} + +.color-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} +.color-field { + display: flex; + align-items: center; + gap: 0.5rem; +} +.color-label { + font-size: 0.78rem; + color: var(--text-mid); +} +.color-input { + width: 48px; + height: 32px; + border: 1px solid var(--card-border); + border-radius: 4px; + cursor: pointer; + padding: 2px; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..520b520 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..673fc28 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from 'react' +import { CheckCircle, XCircle, Loader } from 'lucide-react' +import { getSettings, postSettings, testNewbook } from '../api' +import type { AppSettings } from '../types' + +type TestState = 'idle' | 'testing' | 'ok' | 'error' + +export function Settings() { + const [form, setForm] = useState(null) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [testState, setTest] = useState('idle') + const [testMsg, setTestMsg] = useState('') + + useEffect(() => { + getSettings() + .then(s => setForm(s)) + .catch(e => setError(e.message)) + }, []) + + function patch(key: keyof AppSettings, value: string) { + setForm(prev => prev ? { ...prev, [key]: value } : prev) + } + + async function save(e: React.FormEvent) { + e.preventDefault() + if (!form) return + setSaving(true) + setError(null) + setSaved(false) + try { + const updated = await postSettings(form) + setForm(updated) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + async function runTest() { + setTest('testing') + setTestMsg('') + try { + const r = await testNewbook() + setTest(r.ok ? 'ok' : 'error') + setTestMsg(r.message || r.error || '') + } catch (e) { + setTest('error') + setTestMsg(e instanceof Error ? e.message : 'Connection failed') + } + } + + if (!form) { + return ( +
+ {error ? `Error: ${error}` : 'Loading…'} +
+ ) + } + + const inputStyle: React.CSSProperties = { + width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px', + padding: '0.45rem 0.6rem', fontSize: '0.85rem', color: 'var(--text-dark)', + background: 'var(--body-bg)', + } + + return ( +
+

+ Settings +

+ +
+ {/* Twin detection */} +
+

Twin Detection

+ +
+ + patch('custom_field_names', e.target.value)} + placeholder="Bed Type, Room Configuration" + /> +

NewBook booking custom field names to check for twin indicators.

+
+ +
+ + patch('custom_field_values', e.target.value)} + placeholder="twin, 2 x single, 2x single" + /> +

Case-insensitive partial match against the field values above. These trigger a confirmed twin.

+
+ +
+ + patch('notes_search_terms', e.target.value)} + placeholder="twin bed, two singles, separate beds" + /> +

If no custom field match is found, these are searched in booking notes. Triggers a potential twin (amber).

+
+ +
+ + patch('excluded_terms', e.target.value)} + placeholder="Double or Twin:, Suite or Twin:" + /> +

Removed from notes before searching — use to strip ambiguous boilerplate like "Double or Twin:" so it doesn't trigger a false positive.

+
+
+ + {/* Colors */} +
+

Display colours

+
+
+ patch('normal_color', e.target.value)} + /> + Normal booking +
+
+ patch('twin_color', e.target.value)} + /> + Confirmed twin +
+
+ patch('potential_twin_color', e.target.value)} + /> + Potential twin +
+
+
+ + {/* Save */} +
+ + {saved && ( + + Saved + + )} + {error && {error}} +
+
+ + {/* Newbook test */} +
+

Newbook connection

+

+ Credentials are configured in the Settings service. This tests the connection. +

+
+ + {testState === 'ok' && {testMsg}} + {testState === 'error' && {testMsg}} +
+
+ + {/* Detection legend */} +
+

How detection works

+
    +
  1. Confirmed twin — custom field names matched against field values above (case-insensitive, partial match).
  2. +
  3. Legacy fallback — "Bed Type" field containing "twin" or "2 x single" (always active).
  4. +
  5. Potential twin — notes search terms found in booking notes, after excluded terms are stripped out.
  6. +
+
+
+ ) +} diff --git a/frontend/src/pages/TwinOptimiser.tsx b/frontend/src/pages/TwinOptimiser.tsx new file mode 100644 index 0000000..a2b6272 --- /dev/null +++ b/frontend/src/pages/TwinOptimiser.tsx @@ -0,0 +1,356 @@ +import { useEffect, useState, useCallback } from 'react' +import { Clock, Lock, CheckCircle, HelpCircle, RefreshCw, AlertCircle } from 'lucide-react' +import { getGrid, getSettings } from '../api' +import type { GridData, GridCell, GridRoom, AppSettings, Detection } from '../types' + +function todayStr() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function adjustBrightness(hex: string, pct: number): string { + const h = hex.replace('#', '') + const adj = (ch: string) => { + const v = parseInt(ch, 16) + return Math.min(255, Math.max(0, Math.round(v + v * pct / 100))).toString(16).padStart(2, '0') + } + return `#${adj(h.slice(0, 2))}${adj(h.slice(2, 4))}${adj(h.slice(4, 6))}` +} + +function formatDateLabel(dateStr: string) { + const d = new Date(dateStr + 'T00:00:00') + return { + day: d.toLocaleDateString('en-GB', { weekday: 'short' }), + num: d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' }), + } +} + +// ── Row cell builder ─────────────────────────────────────────────────────────── + +type RowCell = { date: string; cell: GridCell | null; colspan: number } + +function buildRowCells(room: GridRoom, dates: string[]): RowCell[] { + const result: RowCell[] = [] + let activeId: string | null = null + + for (const date of dates) { + const cell = room.cells[date] ?? null + const id = cell?.booking_id ?? null + + if (id && id === activeId) { + result[result.length - 1].colspan++ + } else { + activeId = id + result.push({ date, cell, colspan: 1 }) + } + } + return result +} + +// ── Modal ────────────────────────────────────────────────────────────────────── + +function TwinModal({ cell, onClose }: { cell: GridCell; onClose: () => void }) { + const { detection } = cell + const isConfirmed = detection.type === 'twin' + + function highlightTerm(text: string, term: string) { + if (!term) return <>{text} + const parts = text.split(new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')) + return <>{parts.map((p, i) => i % 2 === 1 ? {p} : p)} + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+
+ {isConfirmed + ? + : } + {isConfirmed ? 'Confirmed Twin' : 'Potential Twin'} +
+ +
+
+
+ Booking Ref + {cell.booking_ref} +
+
+ Stay + + {cell.checkin.split('-').reverse().join('/')} – {cell.checkout.split('-').reverse().join('/')} + +
+ {isConfirmed && detection.field_name && ( +
+ Detected via + {detection.field_name} +
+ )} + {isConfirmed && detection.field_value && ( +
+ Field value + {detection.field_value} +
+ )} + {detection.matched_term && ( +
+ Matched term + {detection.matched_term} +
+ )} + {!isConfirmed && detection.note_content && ( +
+ Note content + + {detection.matched_term + ? highlightTerm(detection.note_content, detection.matched_term) + : detection.note_content} + +
+ )} +
+
+
+ ) +} + +// ── Grid table ───────────────────────────────────────────────────────────────── + +function TwinGrid({ + data, settings, onCellClick, +}: { + data: GridData + settings: AppSettings + onCellClick: (cell: GridCell) => void +}) { + const [collapsed, setCollapsed] = useState>(new Set()) + + function toggleCategory(cat: string) { + setCollapsed(prev => { + const next = new Set(prev) + if (next.has(cat)) next.delete(cat) + else next.add(cat) + return next + }) + } + + // Group rooms by category preserving server order + const groups: { category: string; rooms: string[] }[] = [] + const seenCats = new Set() + for (const roomId of data.rooms) { + const cat = data.grid[roomId]?.category ?? 'Uncategorized' + if (!seenCats.has(cat)) { seenCats.add(cat); groups.push({ category: cat, rooms: [] }) } + groups[groups.length - 1].rooms.push(roomId) + } + + function cellClass(detection: Detection) { + if (detection.type === 'twin') return 'tc-booked tc-twin' + if (detection.type === 'potential_twin') return 'tc-booked tc-potential' + return 'tc-booked tc-normal' + } + + const colSpan = data.dates.length + 1 + + return ( + <> + + +
+ + + + + {data.dates.map(date => { + const { day, num } = formatDateLabel(date) + return ( + + ) + })} + + + + {groups.map(({ category, rooms }) => ( + <> + toggleCategory(category)}> + + + {!collapsed.has(category) && rooms.map(roomId => { + const room = data.grid[roomId] + const rowCells = buildRowCells(room, data.dates) + return ( + + + {rowCells.map(({ date, cell, colspan }) => { + if (!cell) { + return + ) + })} + + ) + })} + + ))} + +
Room +
+ {day} + {num} +
+
+ + {category} +
{room.site_name} + } + const isTwin = cell.detection.type !== 'normal' + return ( + onCellClick(cell) : undefined} + style={isTwin ? { cursor: 'pointer' } : undefined} + > +
+ {(cell.is_early_checkin || cell.is_locked) && ( +
+ {cell.is_early_checkin && } + {cell.is_locked && } +
+ )} + {cell.booking_ref} +
+
+
+ + ) +} + +// ── Page ─────────────────────────────────────────────────────────────────────── + +export function TwinOptimiser() { + const [startDate, setStartDate] = useState(todayStr) + const [gridData, setGridData] = useState(null) + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedCell, setSelectedCell] = useState(null) + + const load = useCallback(async (date: string, force = false) => { + setLoading(true) + setError(null) + try { + const [grid, s] = await Promise.all([ + getGrid(date, 14, force), + settings ? Promise.resolve(settings) : getSettings(), + ]) + setGridData(grid) + if (!settings) setSettings(s) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load data') + } finally { + setLoading(false) + } + }, [settings]) + + useEffect(() => { load(startDate) }, [startDate]) // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {/* Header */} +
+
+ + setStartDate(e.target.value)} + style={{ + border: '1px solid var(--card-border)', borderRadius: '6px', + padding: '0.35rem 0.5rem', fontSize: '0.85rem', + color: 'var(--text-dark)', background: 'var(--card-bg)', + }} + /> +
+ + + {/* Legend */} + {settings && ( +
+ + + Vacant + + + + Booked + + + + Twin + + + + Potential twin + +
+ )} +
+ + {/* Content */} + {error && ( +
+ + {error} +
+ )} + + {loading && !gridData && ( +
+ Loading bookings… +
+ )} + + {!loading && gridData && gridData.rooms.length === 0 && ( +
+ No bookings found for this date range. +
+ )} + + {gridData && settings && gridData.rooms.length > 0 && ( + + )} + + {selectedCell && ( + setSelectedCell(null)} /> + )} +
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..1088526 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,45 @@ +export interface User { + email: string + name: string + is_admin: boolean +} + +export interface Detection { + type: 'twin' | 'potential_twin' | 'normal' + field_name?: string + field_value?: string + matched_term?: string + note_content?: string +} + +export interface GridCell { + booking_id: string + booking_ref: string + checkin: string + checkout: string + detection: Detection + is_early_checkin: boolean + is_locked: boolean +} + +export interface GridRoom { + site_name: string + category: string + cells: Record +} + +export interface GridData { + dates: string[] + rooms: string[] + grid: Record +} + +export interface AppSettings { + custom_field_names: string + custom_field_values: string + notes_search_terms: string + excluded_terms: string + normal_color: string + twin_color: string + potential_twin_color: string +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1538675 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d2cf4cc --- /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: '/twin-optimiser/', + plugins: [react()], +}) diff --git a/seed-app.js b/seed-app.js new file mode 100644 index 0000000..8425246 --- /dev/null +++ b/seed-app.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Run from hk-twin-optimiser/ dir: DATABASE_URL=... node seed-app.js +import pg from 'pg' + +const { Pool } = pg +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +await pool.query(` + INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) + VALUES ('twin-optimiser', 'Twin Optimiser', 'Identify twin room opportunities from booking grid', '/twin-optimiser', 'LayoutGrid', '#c9841a', 'Housekeeping', '10.10.10.119', 3080) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + base_path = EXCLUDED.base_path, + icon = EXCLUDED.icon, + theme_color = EXCLUDED.theme_color, + category = EXCLUDED.category, + internal_host = EXCLUDED.internal_host, + internal_port = EXCLUDED.internal_port +`) + +console.log('twin-optimiser app seeded.') +await pool.end()