Initial commit: twin-optimiser sub-app (housekeeping category)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
a695bd843f
28 changed files with 1913 additions and 0 deletions
18
.env.example
Normal file
18
.env.example
Normal 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
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.env
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
backend/node_modules/
|
||||
frontend/node_modules/
|
||||
*.log
|
||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal 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
16
backend/package.json
Normal 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
35
backend/src/auth.js
Normal 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
54
backend/src/db.js
Normal 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
24
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
220
backend/src/lib/newbook.js
Normal 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
|
||||
}
|
||||
25
backend/src/routes/grid.js
Normal file
25
backend/src/routes/grid.js
Normal 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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
53
backend/src/routes/settings.js
Normal file
53
backend/src/routes/settings.js
Normal 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
32
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
37
frontend/nginx.conf
Normal 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
24
frontend/package.json
Normal 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
28
frontend/src/App.tsx
Normal 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
36
frontend/src/api.ts
Normal 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' })
|
||||
}
|
||||
99
frontend/src/components/AuthGate.tsx
Normal file
99
frontend/src/components/AuthGate.tsx
Normal 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!)}</>
|
||||
}
|
||||
69
frontend/src/components/Layout.tsx
Normal file
69
frontend/src/components/Layout.tsx
Normal 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
348
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
220
frontend/src/pages/Settings.tsx
Normal file
220
frontend/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
356
frontend/src/pages/TwinOptimiser.tsx
Normal file
356
frontend/src/pages/TwinOptimiser.tsx
Normal 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}>×</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
45
frontend/src/types.ts
Normal 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
15
frontend/tsconfig.json
Normal 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
7
frontend/vite.config.ts
Normal 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
23
seed-app.js
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue