Initial commit: twin-optimiser sub-app (housekeeping category)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 20:14:01 +00:00
commit a695bd843f
28 changed files with 1913 additions and 0 deletions

18
.env.example Normal file
View file

@ -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

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
node_modules/
frontend/dist/
backend/node_modules/
frontend/node_modules/
*.log

7
backend/Dockerfile Normal file
View file

@ -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"]

16
backend/package.json Normal file
View file

@ -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"
}
}

35
backend/src/auth.js Normal file
View file

@ -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,
}
}

54
backend/src/db.js Normal file
View file

@ -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()
}
}

24
backend/src/index.js Normal file
View file

@ -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)
}

80
backend/src/ip-check.js Normal file
View file

@ -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
}

220
backend/src/lib/newbook.js Normal file
View file

@ -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
}

View file

@ -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 })
}
})
}

View file

@ -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()
})
}

32
docker-compose.yml Normal file
View file

@ -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

13
frontend/Dockerfile Normal file
View file

@ -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

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#c9841a" />
<title>Twin Optimiser</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

37
frontend/nginx.conf Normal file
View file

@ -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/;
}
}

24
frontend/package.json Normal file
View file

@ -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"
}
}

28
frontend/src/App.tsx Normal file
View file

@ -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 (
<Layout user={user}>
<Routes>
<Route path="/" element={<TwinOptimiser />} />
<Route path="/settings" element={user.is_admin ? <Settings /> : <Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
)
}
export default function App() {
return (
<BrowserRouter basename="/twin-optimiser">
<AuthGate>
{user => <AppRoutes user={user} />}
</AuthGate>
</BrowserRouter>
)
}

36
frontend/src/api.ts Normal file
View file

@ -0,0 +1,36 @@
import type { GridData, AppSettings } from './types'
const BASE = '/twin-optimiser/api'
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
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<T>
}
export function getGrid(startDate?: string, days = 14, force = false): Promise<GridData> {
const p = new URLSearchParams()
if (startDate) p.set('start_date', startDate)
p.set('days', String(days))
if (force) p.set('force', '1')
return request<GridData>(`/grid?${p}`)
}
export function getSettings(): Promise<AppSettings> {
return request<AppSettings>('/settings')
}
export function postSettings(settings: Partial<AppSettings>): Promise<AppSettings> {
return request<AppSettings>('/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' })
}

View file

@ -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<User | null>(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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100dvh' }}>
<div style={{ color: 'var(--text-muted)' }}>Loading</div>
</div>
)
}
if (state === 'login') {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
background: 'var(--navy-dark)',
}}>
<div style={{
background: 'var(--navy)', borderRadius: 'var(--radius)',
padding: '2rem', width: '100%', maxWidth: '360px',
border: '1px solid var(--surface-2)',
}}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem', color: 'var(--app-color)' }}>
Twin Optimiser
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: '1.5rem' }}>
{import.meta.env.VITE_HOTEL_NAME}
</p>
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required autoComplete="email" style={inputStyle} />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required autoComplete="current-password" style={inputStyle} />
{error && <p style={{ color: '#f87171', fontSize: '0.875rem' }}>{error}</p>}
<button type="submit" disabled={loading} style={btnStyle}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
)
}
return <>{children(user!)}</>
}

View file

@ -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 (
<div style={{ display: 'flex', height: '100dvh', overflow: 'hidden' }}>
<nav style={{
width: '200px', flexShrink: 0, background: 'var(--navy)',
display: 'flex', flexDirection: 'column', padding: '1rem 0',
borderRight: '1px solid var(--surface-2)',
}}>
<div style={{ padding: '0 1rem 1rem', borderBottom: '1px solid var(--surface-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<LayoutGrid size={20} strokeWidth={1.75} color="var(--app-color)" />
<span style={{ color: 'var(--app-color)', fontWeight: 700, fontSize: '0.95rem' }}>Twin Optimiser</span>
</div>
<p style={{ color: 'var(--text-muted)', fontSize: '0.72rem', marginTop: '0.2rem' }}>{user.name}</p>
</div>
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
<NavItem to="/" icon={LayoutGrid} label="Grid" end />
{user.is_admin && <NavItem to="/settings" icon={Settings} label="Settings" />}
</div>
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>
<button onClick={logout} style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
background: 'none', border: 'none', color: 'var(--text-muted)',
fontSize: '0.875rem', padding: '0.375rem 0', width: '100%', cursor: 'pointer',
}}>
<LogOut size={14} strokeWidth={1.75} />
Sign out
</button>
</div>
</nav>
<main style={{ flex: 1, overflow: 'auto', background: 'var(--body-bg)' }}>
{children}
</main>
</div>
)
}
function NavItem({ to, icon: Icon, label, end }: { to: string; icon: typeof LayoutGrid; label: string; end?: boolean }) {
return (
<NavLink to={to} end={end} style={({ isActive }) => ({
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',
})}>
<Icon size={15} strokeWidth={1.75} />
{label}
</NavLink>
)
}

348
frontend/src/index.css Normal file
View file

@ -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;
}

10
frontend/src/main.tsx Normal file
View file

@ -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(
<StrictMode>
<App />
</StrictMode>
)

View file

@ -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<AppSettings | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [testState, setTest] = useState<TestState>('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 (
<div style={{ padding: '2rem', color: 'var(--text-mid)', fontSize: '0.875rem' }}>
{error ? `Error: ${error}` : 'Loading…'}
</div>
)
}
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 (
<div style={{ padding: '1.25rem', maxWidth: '640px' }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '1.25rem' }}>
Settings
</h2>
<form onSubmit={save}>
{/* Twin detection */}
<div className="settings-section">
<h3>Twin Detection</h3>
<div className="field-group">
<label className="field-label">Custom field names (comma-separated)</label>
<input
className="field-input"
type="text"
value={form.custom_field_names}
onChange={e => patch('custom_field_names', e.target.value)}
placeholder="Bed Type, Room Configuration"
/>
<p className="field-hint">NewBook booking custom field names to check for twin indicators.</p>
</div>
<div className="field-group">
<label className="field-label">Values to detect (comma-separated)</label>
<input
className="field-input"
type="text"
value={form.custom_field_values}
onChange={e => patch('custom_field_values', e.target.value)}
placeholder="twin, 2 x single, 2x single"
/>
<p className="field-hint">Case-insensitive partial match against the field values above. These trigger a confirmed twin.</p>
</div>
<div className="field-group">
<label className="field-label">Notes search terms (comma-separated)</label>
<input
className="field-input"
type="text"
value={form.notes_search_terms}
onChange={e => patch('notes_search_terms', e.target.value)}
placeholder="twin bed, two singles, separate beds"
/>
<p className="field-hint">If no custom field match is found, these are searched in booking notes. Triggers a potential twin (amber).</p>
</div>
<div className="field-group" style={{ marginBottom: 0 }}>
<label className="field-label">Excluded terms (comma-separated, case-sensitive)</label>
<input
className="field-input"
type="text"
value={form.excluded_terms}
onChange={e => patch('excluded_terms', e.target.value)}
placeholder="Double or Twin:, Suite or Twin:"
/>
<p className="field-hint">Removed from notes before searching use to strip ambiguous boilerplate like "Double or Twin:" so it doesn't trigger a false positive.</p>
</div>
</div>
{/* Colors */}
<div className="settings-section">
<h3>Display colours</h3>
<div className="color-row">
<div className="color-field">
<input
type="color"
className="color-input"
value={form.normal_color}
onChange={e => patch('normal_color', e.target.value)}
/>
<span className="color-label">Normal booking</span>
</div>
<div className="color-field">
<input
type="color"
className="color-input"
value={form.twin_color}
onChange={e => patch('twin_color', e.target.value)}
/>
<span className="color-label">Confirmed twin</span>
</div>
<div className="color-field">
<input
type="color"
className="color-input"
value={form.potential_twin_color}
onChange={e => patch('potential_twin_color', e.target.value)}
/>
<span className="color-label">Potential twin</span>
</div>
</div>
</div>
{/* Save */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button
type="submit"
disabled={saving}
style={{
background: 'var(--app-color)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem', fontWeight: 600,
}}
>
{saving ? 'Saving…' : 'Save settings'}
</button>
{saved && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#16a34a', fontSize: '0.875rem' }}>
<CheckCircle size={15} /> Saved
</span>
)}
{error && <span style={{ color: '#dc2626', fontSize: '0.875rem' }}>{error}</span>}
</div>
</form>
{/* Newbook test */}
<div className="settings-section" style={{ marginTop: '1.5rem' }}>
<h3>Newbook connection</h3>
<p className="field-hint" style={{ marginBottom: '0.75rem' }}>
Credentials are configured in the Settings service. This tests the connection.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<button
onClick={runTest}
disabled={testState === 'testing'}
type="button"
style={{
background: 'var(--navy)', color: 'var(--text)', border: '1px solid var(--surface-2)',
borderRadius: '6px', padding: '0.45rem 1rem', fontSize: '0.82rem', fontWeight: 600,
}}
>
{testState === 'testing' ? <Loader size={14} className="spin" /> : 'Test connection'}
</button>
{testState === 'ok' && <span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#16a34a', fontSize: '0.82rem' }}><CheckCircle size={14} />{testMsg}</span>}
{testState === 'error' && <span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', color: '#dc2626', fontSize: '0.82rem' }}><XCircle size={14} />{testMsg}</span>}
</div>
</div>
{/* Detection legend */}
<div className="settings-section" style={{ marginTop: '1rem' }}>
<h3>How detection works</h3>
<ol style={{ fontSize: '0.82rem', color: 'var(--text-mid)', lineHeight: 1.6, paddingLeft: '1.1rem' }}>
<li><strong>Confirmed twin</strong> custom field names matched against field values above (case-insensitive, partial match).</li>
<li><strong>Legacy fallback</strong> "Bed Type" field containing "twin" or "2 x single" (always active).</li>
<li><strong>Potential twin</strong> notes search terms found in booking notes, after excluded terms are stripped out.</li>
</ol>
</div>
</div>
)
}

View file

@ -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 ? <mark key={i} className="modal-highlight">{p}</mark> : p)}</>
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<div className="modal-title">
{isConfirmed
? <CheckCircle size={18} color="#16a34a" />
: <HelpCircle size={18} color="#d97706" />}
{isConfirmed ? 'Confirmed Twin' : 'Potential Twin'}
</div>
<button className="modal-close" onClick={onClose}>&times;</button>
</div>
<div className="modal-body">
<div className="modal-row">
<span className="modal-label">Booking Ref</span>
<span className="modal-value">{cell.booking_ref}</span>
</div>
<div className="modal-row">
<span className="modal-label">Stay</span>
<span className="modal-value">
{cell.checkin.split('-').reverse().join('/')} {cell.checkout.split('-').reverse().join('/')}
</span>
</div>
{isConfirmed && detection.field_name && (
<div className="modal-row">
<span className="modal-label">Detected via</span>
<span className="modal-value">{detection.field_name}</span>
</div>
)}
{isConfirmed && detection.field_value && (
<div className="modal-row">
<span className="modal-label">Field value</span>
<span className="modal-value">{detection.field_value}</span>
</div>
)}
{detection.matched_term && (
<div className="modal-row">
<span className="modal-label">Matched term</span>
<span className="modal-value">{detection.matched_term}</span>
</div>
)}
{!isConfirmed && detection.note_content && (
<div className="modal-row">
<span className="modal-label">Note content</span>
<span className="modal-value" style={{ fontSize: '0.82rem' }}>
{detection.matched_term
? highlightTerm(detection.note_content, detection.matched_term)
: detection.note_content}
</span>
</div>
)}
</div>
</div>
</div>
)
}
// ── Grid table ─────────────────────────────────────────────────────────────────
function TwinGrid({
data, settings, onCellClick,
}: {
data: GridData
settings: AppSettings
onCellClick: (cell: GridCell) => void
}) {
const [collapsed, setCollapsed] = useState<Set<string>>(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<string>()
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 (
<>
<style>{`
.tc-twin .tc-inner { background: ${settings.twin_color}; border-bottom-color: ${adjustBrightness(settings.twin_color, -20)}; }
.tc-potential .tc-inner { background: ${settings.potential_twin_color}; border-bottom-color: ${adjustBrightness(settings.potential_twin_color, -20)}; }
.tc-normal .tc-inner { background: ${settings.normal_color}; border-bottom-color: ${adjustBrightness(settings.normal_color, -20)}; }
`}</style>
<div className="grid-scroll">
<table className="twin-grid">
<thead>
<tr>
<th className="col-room">Room</th>
{data.dates.map(date => {
const { day, num } = formatDateLabel(date)
return (
<th key={date}>
<div className="date-label">
<span className="date-day">{day}</span>
<span className="date-num">{num}</span>
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{groups.map(({ category, rooms }) => (
<>
<tr key={`cat-${category}`} className="row-category" onClick={() => toggleCategory(category)}>
<td colSpan={colSpan}>
<i className={`cat-arrow${collapsed.has(category) ? ' collapsed' : ''}`}></i>
{category}
</td>
</tr>
{!collapsed.has(category) && rooms.map(roomId => {
const room = data.grid[roomId]
const rowCells = buildRowCells(room, data.dates)
return (
<tr key={roomId}>
<td className="col-room">{room.site_name}</td>
{rowCells.map(({ date, cell, colspan }) => {
if (!cell) {
return <td key={date} className="tc-vacant" colSpan={colspan} />
}
const isTwin = cell.detection.type !== 'normal'
return (
<td
key={date}
className={cellClass(cell.detection)}
colSpan={colspan}
onClick={isTwin ? () => onCellClick(cell) : undefined}
style={isTwin ? { cursor: 'pointer' } : undefined}
>
<div className="tc-inner">
{(cell.is_early_checkin || cell.is_locked) && (
<div className="tc-indicators">
{cell.is_early_checkin && <span className="tc-icon" title="Early check-in"><Clock size={10} strokeWidth={2} color="rgba(255,255,255,0.9)" /></span>}
{cell.is_locked && <span className="tc-icon" title="Locked to room"><Lock size={10} strokeWidth={2} color="rgba(255,255,255,0.9)" /></span>}
</div>
)}
<span className="tc-ref">{cell.booking_ref}</span>
</div>
</td>
)
})}
</tr>
)
})}
</>
))}
</tbody>
</table>
</div>
</>
)
}
// ── Page ───────────────────────────────────────────────────────────────────────
export function TwinOptimiser() {
const [startDate, setStartDate] = useState(todayStr)
const [gridData, setGridData] = useState<GridData | null>(null)
const [settings, setSettings] = useState<AppSettings | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCell, setSelectedCell] = useState<GridCell | null>(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 (
<div style={{ padding: '1.25rem', maxWidth: '100%' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ fontSize: '0.82rem', fontWeight: 600, color: 'var(--text-mid)' }}>
Start date
</label>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
style={{
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.35rem 0.5rem', fontSize: '0.85rem',
color: 'var(--text-dark)', background: 'var(--card-bg)',
}}
/>
</div>
<button
onClick={() => load(startDate, true)}
disabled={loading}
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
background: 'var(--app-color)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem', fontWeight: 600,
}}
>
<RefreshCw size={13} strokeWidth={2} className={loading ? 'spin' : ''} />
Refresh
</button>
{/* Legend */}
{settings && (
<div className="legend" style={{ marginLeft: 'auto' }}>
<span className="legend-item">
<span className="legend-swatch" style={{ background: '#f8f9fa', border: '1px solid #e4e8ee' }} />
Vacant
</span>
<span className="legend-item">
<span className="legend-swatch" style={{ background: settings.normal_color }} />
Booked
</span>
<span className="legend-item">
<span className="legend-swatch" style={{ background: settings.twin_color }} />
Twin
</span>
<span className="legend-item">
<span className="legend-swatch" style={{ background: settings.potential_twin_color }} />
Potential twin
</span>
</div>
)}
</div>
{/* Content */}
{error && (
<div style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
background: '#fef2f2', border: '1px solid #fecaca',
borderRadius: 'var(--radius)', padding: '0.75rem 1rem',
color: '#dc2626', fontSize: '0.875rem',
}}>
<AlertCircle size={16} />
{error}
</div>
)}
{loading && !gridData && (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-mid)', fontSize: '0.875rem' }}>
Loading bookings
</div>
)}
{!loading && gridData && gridData.rooms.length === 0 && (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-mid)', fontSize: '0.875rem' }}>
No bookings found for this date range.
</div>
)}
{gridData && settings && gridData.rooms.length > 0 && (
<TwinGrid
data={gridData}
settings={settings}
onCellClick={setSelectedCell}
/>
)}
{selectedCell && (
<TwinModal cell={selectedCell} onClose={() => setSelectedCell(null)} />
)}
</div>
)
}

45
frontend/src/types.ts Normal file
View file

@ -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<string, GridCell | null>
}
export interface GridData {
dates: string[]
rooms: string[]
grid: Record<string, GridRoom>
}
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
}

15
frontend/tsconfig.json Normal file
View file

@ -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"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/twin-optimiser/',
plugins: [react()],
})

23
seed-app.js Normal file
View file

@ -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()