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

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
}