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
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()],
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue