Initial commit: HK Planner app

Housekeeping workload and hours planning app — port of the WP hotel
housekeeping hours calculator plugin. 7-day occupancy planner with
Newbook PMS integration, staff rota, time requirements, and pickup tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 09:24:14 +00:00
commit 4abae5eda1
28 changed files with 2475 additions and 0 deletions

13
frontend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
ARG VITE_HOTEL_NAME="Number Four at Stow"
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html/hk-planner
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

13
frontend/index.html Normal file
View file

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

37
frontend/nginx.conf Normal file
View file

@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
location = /hk-planner/manifest.json {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /hk-planner/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 /hk-planner/health {
proxy_pass http://backend:3001/health;
}
location ~* /hk-planner/.*\.(js|css|png|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location /hk-planner/ {
add_header Cache-Control "no-cache" always;
try_files $uri $uri/ /hk-planner/index.html;
}
location = / {
return 301 /hk-planner/;
}
}

24
frontend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "hnf-hk-planner-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"
}
}

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

@ -0,0 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthGate } from './components/AuthGate'
import { Layout } from './components/Layout'
import { Planner } from './pages/Planner'
import { CategorySettings } from './pages/CategorySettings'
import type { User } from './types'
function AppRoutes({ user }: { user: User }) {
return (
<Layout user={user}>
<Routes>
<Route path="/" element={<Navigate to="/planner" replace />} />
<Route path="/planner" element={<Planner />} />
<Route path="/settings" element={user.is_admin ? <CategorySettings /> : <Navigate to="/planner" replace />} />
<Route path="*" element={<Navigate to="/planner" replace />} />
</Routes>
</Layout>
)
}
export default function App() {
return (
<BrowserRouter basename="/hk-planner">
<AuthGate>
{user => <AppRoutes user={user} />}
</AuthGate>
</BrowserRouter>
)
}

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

@ -0,0 +1,96 @@
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs } from './types'
const BASE = '/hk-planner/api'
export interface ConfigData {
time_requirements: TimeReqs
staff_data: StaffMember[]
pickup_data: PickupData
general_tasks: GeneralTask[]
last_reviewed: string
tolerance_minutes: number
}
export interface CategoryConfig {
id: string
name: string
room_count: number
excluded: boolean
}
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 getBookings(weekStart?: string, lastViewed?: string, forceRefresh = false): Promise<BookingsData> {
const p = new URLSearchParams()
if (weekStart) p.set('week_start', weekStart)
if (lastViewed) p.set('last_viewed', lastViewed)
if (forceRefresh) p.set('force_refresh', '1')
return request<BookingsData>(`/bookings?${p}`)
}
export function getConfig(): Promise<ConfigData> {
return request<ConfigData>('/config')
}
export function putTimeReq(cat: string, action: string, value: number): Promise<{ ok: boolean }> {
return request('/config/time-requirements', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cat, action, value }),
})
}
export function putStaff(staff_data: StaffMember[]): Promise<{ ok: boolean }> {
return request('/config/staff', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ staff_data }),
})
}
export function putPickup(pickup_data: PickupData): Promise<{ ok: boolean }> {
return request('/config/pickup', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pickup_data }),
})
}
export function putGeneralTasks(general_tasks: GeneralTask[]): Promise<{ ok: boolean }> {
return request('/config/general-tasks', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ general_tasks }),
})
}
export function putLastReviewed(date: string): Promise<{ ok: boolean }> {
return request('/config/last-reviewed', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date }),
})
}
export function getCategories(): Promise<{ categories: CategoryConfig[] }> {
return request('/categories')
}
export function putCategories(order: string[], excluded: string[]): Promise<{ ok: boolean }> {
return request('/categories', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order, excluded }),
})
}
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(--hk-green)', 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=hk-planner', { 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=hk-planner', { 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: '#74c69d' }}>
HK Planner
</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 { CalendarClock, 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' }}>
<CalendarClock size={20} strokeWidth={1.75} color="#74c69d" />
<span style={{ color: '#74c69d', fontWeight: 700, fontSize: '0.95rem' }}>HK Planner</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="/planner" icon={CalendarClock} label="Planner" />
{user.is_admin && <NavItem to="/settings" icon={Settings} label="Category 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 }: { to: string; icon: typeof CalendarClock; label: string }) {
return (
<NavLink to={to} style={({ isActive }) => ({
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.625rem 1rem', textDecoration: 'none',
color: isActive ? '#74c69d' : 'var(--text)',
background: isActive ? 'var(--surface)' : 'transparent',
borderLeft: isActive ? '2px solid #74c69d' : '2px solid transparent',
fontSize: '0.875rem', transition: 'background 0.15s',
})}>
<Icon size={15} strokeWidth={1.75} />
{label}
</NavLink>
)
}

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

@ -0,0 +1,155 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--navy: #1a1a2e;
--navy-dark: #0f0f20;
--gold: #c9a84c;
--gold-light: #e8c96d;
--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;
--hk-green: #2d6a4f;
--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; }
/* Planning tables */
.hk-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
table-layout: fixed;
}
.hk-table th {
background: var(--navy);
color: var(--text);
padding: 0.45rem 0.5rem;
text-align: center;
font-weight: 600;
white-space: nowrap;
}
.hk-table th.col-label {
text-align: left;
width: 140px;
}
.hk-table td {
padding: 0.35rem 0.5rem;
border-bottom: 1px solid var(--card-border);
text-align: center;
vertical-align: middle;
}
.hk-table td.col-label {
text-align: left;
font-weight: 600;
color: var(--text-dark);
background: var(--card-bg);
}
.hk-table tfoot td {
font-weight: 600;
background: #f8fafc;
}
/* Row accent colours for summary table */
.row-depart { background: #fff8f0; }
.row-stay { background: #f0f7ff; }
.row-arrive { background: #f0fff5; }
.row-depart td, .row-stay td, .row-arrive td { border-bottom: none; }
.row-group-last td { border-bottom: 2px solid var(--card-border) !important; }
/* Pickup tag */
.pickup-tag {
color: var(--hk-green);
font-size: 0.75rem;
font-weight: 600;
}
/* Delta badges */
.delta-new { color: var(--success); font-size: 0.72rem; }
.delta-canc { color: var(--danger); font-size: 0.72rem; }
/* Pickup +/- controls */
.pickup-ctrl {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
margin-top: 2px;
}
.pickup-btn {
background: none;
border: 1px solid var(--card-border);
border-radius: 3px;
padding: 0 5px;
line-height: 1.4;
font-size: 0.8rem;
color: var(--text-mid);
}
.pickup-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.pickup-num { min-width: 16px; text-align: center; font-size: 0.8rem; font-weight: 600; }
/* Diff cells */
.diff-ok { color: var(--success); font-weight: 600; }
.diff-under { color: var(--danger); font-weight: 600; }
.diff-over { color: var(--warning); font-weight: 600; }
/* Numeric inputs in tables */
.hk-num-input {
width: 60px;
border: 1px solid var(--card-border);
border-radius: 4px;
padding: 0.3rem 0.4rem;
font-size: 0.82rem;
text-align: center;
background: var(--body-bg);
color: var(--text-dark);
}
.hk-num-input:focus {
outline: 2px solid var(--hk-green);
border-color: transparent;
}
/* Text inputs in tables */
.hk-text-input {
border: 1px solid var(--card-border);
border-radius: 4px;
padding: 0.3rem 0.5rem;
font-size: 0.82rem;
background: var(--body-bg);
color: var(--text-dark);
width: 100%;
}
.hk-text-input:focus {
outline: 2px solid var(--hk-green);
border-color: transparent;
}
/* Section wrapper with horizontal scroll */
.table-scroll {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid var(--card-border);
box-shadow: var(--shadow-sm);
}

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,162 @@
import { useState, useEffect } from 'react'
import { GripVertical } from 'lucide-react'
import { getCategories, putCategories, testNewbook } from '../api'
import type { CategoryConfig } from '../api'
export function CategorySettings() {
const [cats, setCats] = useState<CategoryConfig[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [dragIdx, setDragIdx] = useState<number | null>(null)
useEffect(() => {
getCategories()
.then(r => { setCats(r.categories); setLoading(false) })
.catch(e => { setError(e.message); setLoading(false) })
}, [])
function toggleExcluded(i: number) {
setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c))
}
function onDragStart(e: React.DragEvent, i: number) {
setDragIdx(i)
e.dataTransfer.effectAllowed = 'move'
}
function onDragOver(e: React.DragEvent, i: number) {
e.preventDefault()
if (dragIdx === null || dragIdx === i) return
const next = [...cats]
const [moved] = next.splice(dragIdx, 1)
next.splice(i, 0, moved)
setCats(next)
setDragIdx(i)
}
function onDragEnd() { setDragIdx(null) }
async function save() {
setSaving(true)
setError('')
setMsg('')
try {
const order = cats.map(c => c.id)
const excluded = cats.filter(c => c.excluded).map(c => c.id)
await putCategories(order, excluded)
setMsg('Saved')
setTimeout(() => setMsg(''), 2500)
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed')
} finally {
setSaving(false)
}
}
async function handleTest() {
setTesting(true)
setError('')
setMsg('')
try {
const res = await testNewbook()
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Test failed')
} finally {
setTesting(false)
}
}
if (loading) {
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories</div>
}
return (
<div style={{ padding: '1.5rem', maxWidth: '600px' }}>
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Category Settings
</h1>
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
Drag to reorder. Toggle to exclude categories from the planner.
</p>
{error && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
{error}
</div>
)}
{msg && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
{msg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.25rem' }}>
{cats.map((cat, i) => (
<div
key={cat.id}
draggable
onDragStart={e => onDragStart(e, i)}
onDragOver={e => onDragOver(e, i)}
onDragEnd={onDragEnd}
style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.625rem 0.875rem',
border: '1px solid var(--card-border)', borderRadius: '8px',
background: cat.excluded ? '#f8fafc' : 'var(--card-bg)',
opacity: dragIdx === i ? 0.5 : 1,
}}
>
<GripVertical size={16} color="var(--text-mid)" style={{ cursor: 'grab', flexShrink: 0 }} />
<span style={{
flex: 1, fontSize: '0.9rem',
color: cat.excluded ? 'var(--text-mid)' : 'var(--text-dark)',
textDecoration: cat.excluded ? 'line-through' : 'none',
}}>
{cat.name}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginRight: '0.5rem' }}>
{cat.room_count} rooms
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>
<input
type="checkbox"
checked={!cat.excluded}
onChange={() => toggleExcluded(i)}
/>
Active
</label>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
onClick={save}
disabled={saving}
style={{
background: 'var(--hk-green)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
fontWeight: 600,
}}
>
{saving ? 'Saving…' : 'Save Order & Visibility'}
</button>
<button
onClick={handleTest}
disabled={testing}
style={{
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
}}
>
{testing ? 'Testing…' : 'Test Newbook Connection'}
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,857 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import {
getBookings, getConfig, putTimeReq, putStaff, putPickup,
putGeneralTasks, putLastReviewed,
} from '../api'
import type {
BookingsData, TimeReqs, StaffMember, GeneralTask,
PickupData, RequiredDay, DayData,
} from '../types'
// ── Date helpers ──────────────────────────────────────────────────────────────
function todayStr() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function offsetDate(s: string, days: number) {
const [y, m, d] = s.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')}`
}
function fmtDayHeader(date: string) {
const d = new Date(date + 'T00:00:00')
return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()] + ' ' + d.getDate() + ' ' +
['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]
}
function getDayName(date: string) {
const d = new Date(date + 'T00:00:00')
return ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][(d.getDay() + 6) % 7]
}
function fmtDate(date: string) {
return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', {
weekday: 'short', day: 'numeric', month: 'short',
})
}
function fmtH(n: number) { return n.toFixed(1) + 'h' }
// ── Pickup calculation ────────────────────────────────────────────────────────
function getDisplayPickup(
pickup: PickupData, catId: string, date: string, occupiedNow: number, totalRooms: number,
): number {
const vacant = Math.max(0, totalRooms - occupiedNow)
const saved = pickup[catId]?.[date]
if (!saved || !saved.count) return 0
const savedBooked = saved.total - saved.count
const count = occupiedNow > savedBooked
? Math.max(0, saved.total - occupiedNow)
: saved.count
return Math.min(count, vacant)
}
// ── Required hours calculation ────────────────────────────────────────────────
function calcRequired(
bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData, generalTasks: GeneralTask[],
): Record<string, RequiredDay> {
const result: Record<string, RequiredDay> = {}
for (const date of bookings.dates) {
const dayName = getDayName(date)
const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0)
result[date] = { booked: 0, pickup: 0, general: genHrs, total: genHrs, by_cat: {}, pickup_by_cat: {} }
}
for (const cat of bookings.categories) {
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
bookings.dates.forEach((date, di) => {
const d = cat.days[date]
const occupied = d.stays + d.arrivals
const pickupCount = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms)
const bookedHrs = (d.departs * (req.depart || 0) + d.stays * (req.stay || 0) + d.arrivals * (req.arrive || 0)) / 60
const pickupArrHrs = pickupCount * (req.arrive || 0) / 60
result[date].by_cat[cat.id] = bookedHrs
result[date].pickup_by_cat[cat.id] = (result[date].pickup_by_cat[cat.id] || 0) + pickupArrHrs
result[date].booked += bookedHrs
result[date].pickup += pickupArrHrs
result[date].total += bookedHrs + pickupArrHrs
const nextDate = bookings.dates[di + 1]
if (nextDate && pickupCount > 0) {
const pickupDepHrs = pickupCount * (req.depart || 0) / 60
result[nextDate].pickup_by_cat[cat.id] = (result[nextDate].pickup_by_cat[cat.id] || 0) + pickupDepHrs
result[nextDate].pickup += pickupDepHrs
result[nextDate].total += pickupDepHrs
}
})
}
return result
}
// ── Main Planner component ────────────────────────────────────────────────────
export function Planner() {
const [bookings, setBookings] = useState<BookingsData | null>(null)
const [timeReqs, setTimeReqs] = useState<TimeReqs>({})
const [staff, setStaff] = useState<StaffMember[]>([])
const [pickup, setPickup] = useState<PickupData>({})
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
const [tolerance, setTolerance] = useState(30)
const [weekStart, setWeekStart] = useState<string | null>(null)
const [lastViewed, setLastViewed] = useState('')
const [savedLastReviewed, setSavedLastReviewed] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saveMsg, setSaveMsg] = useState<{ text: string; err: boolean } | null>(null)
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function debounce(key: string, fn: () => void, delay = 400) {
clearTimeout(timers.current[key])
timers.current[key] = setTimeout(fn, delay)
}
function flash(text: string, err = false) {
if (saveMsgTimer.current) clearTimeout(saveMsgTimer.current)
setSaveMsg({ text, err })
saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 5000 : 2500)
}
function stampLastReviewed() {
const today = todayStr()
if (today === savedLastReviewed) return
setSavedLastReviewed(today)
putLastReviewed(today).catch(() => {})
}
const loadAll = useCallback(async (force = false) => {
setLoading(true)
setError('')
const today = todayStr()
const ws = weekStart || today
const lv = lastViewed || savedLastReviewed || offsetDate(today, -1)
try {
const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()])
setBookings(b)
setTimeReqs(cfg.time_requirements || {})
setStaff(cfg.staff_data || [])
setPickup(cfg.pickup_data || {})
setGeneralTasks(cfg.general_tasks || [])
setTolerance(cfg.tolerance_minutes ?? 30)
if (cfg.last_reviewed) {
setSavedLastReviewed(cfg.last_reviewed)
if (!lastViewed) setLastViewed(cfg.last_reviewed)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load data')
} finally {
setLoading(false)
}
}, [weekStart, lastViewed, savedLastReviewed])
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
// Beacon save on unload
useEffect(() => {
function onUnload() {
if (staff.length) {
navigator.sendBeacon('/hk-planner/api/config/staff', JSON.stringify({ staff_data: staff }))
}
if (Object.keys(pickup).length) {
navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup }))
}
if (generalTasks.length) {
navigator.sendBeacon('/hk-planner/api/config/general-tasks', JSON.stringify({ general_tasks: generalTasks }))
}
}
window.addEventListener('beforeunload', onUnload)
return () => window.removeEventListener('beforeunload', onUnload)
}, [staff, pickup, generalTasks])
function handleWeekOffset(days: number) {
const today = todayStr()
const newStart = offsetDate(weekStart || today, days)
setWeekStart(newStart)
stampLastReviewed()
setTimeout(() => loadAll(true), 0)
}
function handleWeekToday() {
setWeekStart(null)
stampLastReviewed()
setTimeout(() => loadAll(true), 0)
}
function handleRefresh() {
stampLastReviewed()
loadAll(true)
}
// ── Pickup ──────────────────────────────────────────────────────────────────
function handlePickupChange(catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record<string, DayData> }) {
const d = cat.days[date]
const occupied = d.stays + d.arrivals
const vacant = Math.max(0, cat.total_rooms - occupied)
const current = getDisplayPickup(pickup, catId, date, occupied, cat.total_rooms)
const newVal = Math.max(0, Math.min(current + delta, vacant))
const next: PickupData = {
...pickup,
[catId]: { ...(pickup[catId] || {}), [date]: { count: newVal, total: occupied + newVal } },
}
setPickup(next)
stampLastReviewed()
debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true)))
}
// ── Time requirements ───────────────────────────────────────────────────────
function handleTimeReqChange(cat: string, action: string, value: number) {
const next: TimeReqs = {
...timeReqs,
[cat]: { ...(timeReqs[cat] || { depart: 0, stay: 0, arrive: 0 }), [action]: value },
}
setTimeReqs(next)
stampLastReviewed()
debounce(`req-${cat}-${action}`, () =>
putTimeReq(cat, action, value).then(() => flash('Time requirements saved')).catch(e => flash(e.message, true))
)
}
// ── Staff ────────────────────────────────────────────────────────────────────
function handleStaffChange(next: StaffMember[]) {
setStaff(next)
stampLastReviewed()
debounce('staff', () => putStaff(next).then(() => flash('Staff hours saved')).catch(e => flash(e.message, true)))
}
function addStaffRow() {
handleStaffChange([...staff, { name: '', hours: {} }])
}
// ── General tasks ────────────────────────────────────────────────────────────
function handleTasksChange(next: GeneralTask[]) {
setGeneralTasks(next)
stampLastReviewed()
debounce('tasks', () => putGeneralTasks(next).then(() => flash('Tasks saved')).catch(e => flash(e.message, true)))
}
function addTaskRow() {
handleTasksChange([...generalTasks, { name: '', hours: {} }])
}
// ── Required hours (memoised on state changes) ────────────────────────────
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
// ── Render ──────────────────────────────────────────────────────────────────
const today = todayStr()
const displayWeekStart = weekStart || today
return (
<div style={{ padding: '1.5rem', maxWidth: '1600px' }}>
{/* Controls */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div>
<h1 style={{ fontSize: '1.15rem', fontWeight: 700, color: 'var(--text-dark)' }}>
Housekeeping Planner
</h1>
{bookings && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.1rem' }}>
{fmtDate(bookings.dates[0])} {fmtDate(bookings.dates[bookings.dates.length - 1])}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginLeft: 'auto', flexWrap: 'wrap' }}>
{/* Week navigation */}
<CtrlBtn onClick={() => handleWeekOffset(-7)}> Prev</CtrlBtn>
<CtrlBtn onClick={handleWeekToday} primary>Today</CtrlBtn>
<CtrlBtn onClick={() => handleWeekOffset(7)}>Next </CtrlBtn>
<input
type="date"
value={displayWeekStart}
onChange={e => { if (e.target.value) { setWeekStart(e.target.value); stampLastReviewed(); setTimeout(() => loadAll(true), 0) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }}
/>
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
{/* Last-reviewed */}
<label style={{ fontSize: '0.78rem', color: 'var(--text-mid)', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<span>Since</span>
<input
type="date"
value={lastViewed}
onChange={e => { if (e.target.value) { setLastViewed(e.target.value); setTimeout(() => loadAll(true), 0) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' }}
/>
</label>
<CtrlBtn onClick={() => { stampLastReviewed(); loadAll(true) }}>Update to now</CtrlBtn>
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
<button onClick={handleRefresh} title="Refresh" style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.35rem 0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem',
fontSize: '0.82rem', color: 'var(--text-dark)',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
</button>
</div>
</div>
{/* Status */}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
{saveMsg && (
<div style={{
display: 'inline-block', marginBottom: '0.75rem', padding: '0.35rem 0.75rem',
borderRadius: '6px', fontSize: '0.8rem', fontWeight: 500,
background: saveMsg.err ? '#fee2e2' : '#dcfce7',
color: saveMsg.err ? 'var(--danger)' : 'var(--success)',
}}>
{saveMsg.text}
</div>
)}
{error && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem',
}}>
Error: {error}
</div>
)}
{loading && !bookings && (
<div style={{ color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }}>
Loading bookings
</div>
)}
{bookings && required && (
<>
{/* 1 — Occupancy summary */}
<Section title="7-Day Occupancy">
<div className="table-scroll">
<SummaryTable bookings={bookings} pickup={pickup} onPickupChange={handlePickupChange} />
</div>
</Section>
{/* 2 — Required hours */}
<Section title="Required Hours">
<div className="table-scroll">
<RequiredTable bookings={bookings} required={required} />
</div>
</Section>
{/* 3 — Staff rota */}
<Section title="Staff Rota" action={<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>}>
<div className="table-scroll">
<StaffTable
bookings={bookings}
staff={staff}
required={required}
tolerance={tolerance}
onChange={handleStaffChange}
/>
</div>
</Section>
{/* 4 — General tasks */}
<Section title="General HK Tasks" action={<CtrlBtn onClick={addTaskRow}>+ Add task</CtrlBtn>}>
<div className="table-scroll">
<GeneralTasksTable tasks={generalTasks} onChange={handleTasksChange} />
</div>
</Section>
{/* 5 — Time requirements */}
<Section title="Time Requirements (minutes per room)">
<div className="table-scroll">
<TimeTable categories={bookings.categories} timeReqs={timeReqs} onChange={handleTimeReqChange} />
</div>
</Section>
</>
)}
</div>
)
}
// ── Section wrapper ───────────────────────────────────────────────────────────
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<h2 style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{title}
</h2>
{action}
</div>
{children}
</div>
)
}
function CtrlBtn({ children, onClick, primary }: { children: React.ReactNode; onClick?: () => void; primary?: boolean }) {
return (
<button onClick={onClick} style={{
background: primary ? 'var(--hk-green)' : 'var(--card-bg)',
color: primary ? '#fff' : 'var(--text-dark)',
border: `1px solid ${primary ? 'var(--hk-green)' : 'var(--card-border)'}`,
borderRadius: '6px', padding: '0.35rem 0.75rem', fontSize: '0.82rem', fontWeight: 600,
}}>
{children}
</button>
)
}
// ── Summary Table ─────────────────────────────────────────────────────────────
interface SummaryProps {
bookings: BookingsData
pickup: PickupData
onPickupChange: (catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record<string, DayData> }) => void
}
function SummaryTable({ bookings, pickup, onPickupChange }: SummaryProps) {
const { dates, categories: cats } = bookings
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label" rowSpan={2}>Category</th>
{dates.map(d => <th key={d} colSpan={2}>{fmtDayHeader(d)}</th>)}
</tr>
<tr>
{dates.map(d => (
<th key={d} colSpan={2} style={{ fontSize: '0.72rem', fontWeight: 400, background: '#1e2d42' }}>
<span style={{ padding: '0 0.5rem' }}>Occ</span>
<span style={{ borderLeft: '1px solid rgba(255,255,255,0.15)', padding: '0 0.5rem' }}>D / S / A</span>
</th>
))}
</tr>
</thead>
<tbody>
{cats.map((cat, ci) => {
const isLast = ci === cats.length - 1
return (
<CatRows key={cat.id} cat={cat} dates={dates} pickup={pickup}
onPickupChange={(date, delta) => onPickupChange(cat.id, date, delta, cat)}
isLast={isLast}
/>
)
})}
</tbody>
<tfoot>
<TotalRows dates={dates} cats={cats} pickup={pickup} />
</tfoot>
</table>
)
}
function CatRows({ cat, dates, pickup, onPickupChange, isLast }: {
cat: { id: string; name: string; total_rooms: number; days: Record<string, DayData> }
dates: string[]
pickup: PickupData
onPickupChange: (date: string, delta: number) => void
isLast: boolean
}) {
return (
<>
{/* Row 1: Occ + Departs */}
<tr className="row-depart">
<td className="col-label" rowSpan={3} style={{ borderBottom: isLast ? '2px solid var(--card-border)' : undefined }}>
{cat.name}
</td>
{dates.map((date, di) => {
const d = cat.days[date]
const occupied = d.stays + d.arrivals
const vacant = Math.max(0, cat.total_rooms - occupied)
const pu = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms)
const prevDate = dates[di - 1]
const prevOcc = prevDate ? (cat.days[prevDate].stays + cat.days[prevDate].arrivals) : 0
const prevPu = prevDate ? getDisplayPickup(pickup, cat.id, prevDate, prevOcc, cat.total_rooms) : 0
const dNew = d.delta_new || 0
const dCanc = d.delta_cancelled || 0
return (
<td key={date} colSpan={2} rowSpan={3}
style={{ verticalAlign: 'top', paddingTop: '0.5rem',
borderBottom: isLast ? '2px solid var(--card-border)' : undefined }}>
{(dNew > 0 || dCanc > 0) && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.25rem', marginBottom: '2px' }}>
{dNew > 0 && <span className="delta-new">{dNew}</span>}
{dCanc > 0 && <span className="delta-canc">{dCanc}</span>}
</div>
)}
<div style={{ textAlign: 'center', fontWeight: 700, fontSize: '1rem' }}>
{occupied}
{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
<div style={{ textAlign: 'center', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
{vacant} vac{pu > 0 && <span className="pickup-tag"> ({vacant - pu})</span>}
</div>
<div className="pickup-ctrl">
<button className="pickup-btn" disabled={pu <= 0}
onClick={() => onPickupChange(date, -1)}></button>
<span className="pickup-num">{pu}</span>
<button className="pickup-btn" disabled={pu >= vacant}
title={d.pickup_hint > 0 ? `${d.pickup_hint} late pickup${d.pickup_hint > 1 ? 's' : ''} last ${getDayName(date)}` : 'No late pickups last week'}
onClick={() => onPickupChange(date, 1)}>+</button>
</div>
{/* D/S/A breakdown: sits to the right via colSpan trick — shown as sub-rows via CSS */}
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '4px', borderTop: '1px dashed var(--card-border)', paddingTop: '3px' }}>
<span style={{ color: '#c2502e' }}>{d.departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}</span>
{' / '}
<span style={{ color: '#1d6fb8' }}>{d.stays}s</span>
{' / '}
<span style={{ color: '#1a7a4a' }}>{d.arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}</span>
</div>
</td>
)
})}
</tr>
{/* Rows 2 & 3 are consumed by rowSpan — needed for tbody structure */}
<tr className="row-stay" />
<tr className={`row-arrive${isLast ? ' row-group-last' : ''}`} />
</>
)
}
function TotalRows({ dates, cats, pickup }: {
dates: string[]
cats: BookingsData['categories']
pickup: PickupData
}) {
return (
<tr>
<td className="col-label" style={{ fontStyle: 'italic' }}>Total</td>
{dates.map((date, di) => {
let occupied = 0, vacant = 0, pu = 0, prevPu = 0, departs = 0, stays = 0, arrivals = 0
let dNew = 0, dCanc = 0
for (const cat of cats) {
const d = cat.days[date]
const occ = d.stays + d.arrivals
const vac = Math.max(0, cat.total_rooms - occ)
const p = getDisplayPickup(pickup, cat.id, date, occ, cat.total_rooms)
const prev = dates[di - 1]
const pOcc = prev ? (cat.days[prev].stays + cat.days[prev].arrivals) : 0
const pPu = prev ? getDisplayPickup(pickup, cat.id, prev, pOcc, cat.total_rooms) : 0
occupied += occ; vacant += vac; pu += p; prevPu += pPu
departs += d.departs; stays += d.stays; arrivals += d.arrivals
dNew += d.delta_new || 0
dCanc += d.delta_cancelled || 0
}
return (
<td key={date} colSpan={2}>
{(dNew > 0 || dCanc > 0) && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.25rem', marginBottom: '2px' }}>
{dNew > 0 && <span className="delta-new">{dNew}</span>}
{dCanc > 0 && <span className="delta-canc">{dCanc}</span>}
</div>
)}
<div style={{ fontWeight: 700 }}>
{occupied}{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>
{vacant} vac{pu > 0 && <span className="pickup-tag"> ({vacant - pu})</span>}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', borderTop: '1px dashed var(--card-border)', paddingTop: '3px', marginTop: '3px' }}>
<span style={{ color: '#c2502e' }}>{departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}</span>
{' / '}
<span style={{ color: '#1d6fb8' }}>{stays}s</span>
{' / '}
<span style={{ color: '#1a7a4a' }}>{arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}</span>
</div>
</td>
)
})}
</tr>
)
}
// ── Required Hours Table ──────────────────────────────────────────────────────
function RequiredTable({ bookings, required }: { bookings: BookingsData; required: Record<string, RequiredDay> }) {
const { dates, categories: cats } = bookings
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Category</th>
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
</tr>
</thead>
<tbody>
{cats.map(cat => (
<tr key={cat.id}>
<td className="col-label">{cat.name}</td>
{dates.map(date => {
const booked = required[date].by_cat[cat.id] || 0
const pu = required[date].pickup_by_cat[cat.id] || 0
return (
<td key={date}>
{booked === 0 && pu === 0 ? '—' : fmtH(booked)}
{pu > 0.001 && <span className="pickup-tag"> (+{fmtH(pu)})</span>}
</td>
)
})}
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Booked hrs</td>
{dates.map(d => <td key={d}>{fmtH(required[d].booked)}</td>)}
</tr>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Pickup hrs</td>
{dates.map(d => <td key={d}>{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}</td>)}
</tr>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>General tasks</td>
{dates.map(d => <td key={d}>{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}</td>)}
</tr>
<tr style={{ background: '#e2e8f0' }}>
<td className="col-label">Total Required</td>
{dates.map(d => (
<td key={d} style={{ fontWeight: 700 }}>
{fmtH(required[d].total)}
{required[d].pickup > 0.001 && (
<span className="pickup-tag" style={{ display: 'block', fontSize: '0.7rem' }}>
inc {fmtH(required[d].pickup)} pickup
</span>
)}
</td>
))}
</tr>
</tfoot>
</table>
)
}
// ── Staff Table ───────────────────────────────────────────────────────────────
function StaffTable({ bookings, staff, required, tolerance, onChange }: {
bookings: BookingsData
staff: StaffMember[]
required: Record<string, RequiredDay>
tolerance: number
onChange: (next: StaffMember[]) => void
}) {
const { dates } = bookings
const tolHrs = tolerance / 60
function setMemberName(i: number, name: string) {
const next = staff.map((m, idx) => idx === i ? { ...m, name } : m)
onChange(next)
}
function setMemberHours(i: number, date: string, val: string) {
const hrs = parseFloat(val)
const next = staff.map((m, idx) => {
if (idx !== i) return m
const hours = { ...m.hours }
if (!isNaN(hrs) && hrs >= 0) hours[date] = hrs
else delete hours[date]
return { ...m, hours }
})
onChange(next)
}
function removeRow(i: number) {
onChange(staff.filter((_, idx) => idx !== i))
}
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Staff Member</th>
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
<th style={{ width: '32px' }}></th>
</tr>
</thead>
<tbody>
{staff.map((member, i) => (
<tr key={i}>
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
<input
className="hk-text-input"
value={member.name}
placeholder="Staff name"
onChange={e => setMemberName(i, e.target.value)}
/>
</td>
{dates.map(date => (
<td key={date} style={{ padding: '0.3rem 0.4rem' }}>
<input
type="number" className="hk-num-input"
min={0} max={24} step={0.5}
value={member.hours[date] ?? ''}
placeholder="0"
onChange={e => setMemberHours(i, date, e.target.value)}
/>
</td>
))}
<td>
<button onClick={() => removeRow(i)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)',
fontSize: '1rem', padding: '0.2rem 0.4rem',
}}>×</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}>{fmtH(avail)}</td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
const reqHrs = required[date].booked + required[date].general
return <td key={date}><DiffCell available={avail} required={reqHrs} tolerance={tolHrs} /></td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}><DiffCell available={avail} required={required[date].total} tolerance={tolHrs} /></td>
})}
<td />
</tr>
</tfoot>
</table>
)
}
function DiffCell({ available, required, tolerance }: { available: number; required: number; tolerance: number }) {
const diff = available - required
if (required === 0 && available === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
if (diff < -tolerance) return <span className="diff-under"> {fmtH(Math.abs(diff))} short</span>
if (diff > tolerance) return <span className="diff-over"> {fmtH(diff)} spare</span>
return <span className="diff-ok"> {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
// ── General Tasks Table ───────────────────────────────────────────────────────
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function GeneralTasksTable({ tasks, onChange }: { tasks: GeneralTask[]; onChange: (next: GeneralTask[]) => void }) {
function setName(i: number, name: string) {
onChange(tasks.map((t, idx) => idx === i ? { ...t, name } : t))
}
function setHours(i: number, day: string, val: string) {
const mins = parseInt(val, 10)
onChange(tasks.map((t, idx) => {
if (idx !== i) return t
return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } }
}))
}
function removeRow(i: number) { onChange(tasks.filter((_, idx) => idx !== i)) }
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Task</th>
{WEEKDAYS.map(d => <th key={d}>{d}</th>)}
<th style={{ width: '32px' }}></th>
</tr>
</thead>
<tbody>
{tasks.length === 0 && (
<tr><td colSpan={9} style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No tasks yet add one above</td></tr>
)}
{tasks.map((task, i) => (
<tr key={i}>
<td style={{ padding: '0.3rem 0.5rem' }}>
<input className="hk-text-input" value={task.name} placeholder="Task name"
onChange={e => setName(i, e.target.value)} />
</td>
{WEEKDAYS.map(day => (
<td key={day} style={{ padding: '0.3rem 0.4rem' }}>
<input type="number" className="hk-num-input" min={0} max={999}
value={task.hours[day] || ''}
placeholder="0"
onChange={e => setHours(i, day, e.target.value)} />
</td>
))}
<td>
<button onClick={() => removeRow(i)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
}}>×</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// ── Time Requirements Table ───────────────────────────────────────────────────
function TimeTable({ categories, timeReqs, onChange }: {
categories: BookingsData['categories']
timeReqs: TimeReqs
onChange: (cat: string, action: string, value: number) => void
}) {
return (
<table className="hk-table" style={{ maxWidth: '560px' }}>
<thead>
<tr>
<th className="col-label">Category</th>
<th>Depart (mins)</th>
<th>Stay (mins)</th>
<th>Arrive (mins)</th>
</tr>
</thead>
<tbody>
{categories.map(cat => {
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
return (
<tr key={cat.id}>
<td className="col-label">{cat.name}</td>
{(['depart', 'stay', 'arrive'] as const).map(action => (
<td key={action} style={{ padding: '0.3rem 0.4rem' }}>
<input type="number" className="hk-num-input" min={0} max={999}
value={req[action] || ''}
placeholder="0"
onChange={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
onBlur={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
)
}

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

@ -0,0 +1,54 @@
export interface User {
email: string
name: string
is_admin: boolean
}
export interface DayData {
total_servicing: number
departs: number
stays: number
arrivals: number
pickup_hint: number
pickup_lead: number
prior_occ: number
prior_vac: number
delta_new: number
delta_cancelled: number
}
export interface Category {
id: string
name: string
total_rooms: number
days: Record<string, DayData>
}
export interface BookingsData {
dates: string[]
categories: Category[]
last_viewed: string
}
export type TimeReqs = Record<string, { depart: number; stay: number; arrive: number }>
export interface StaffMember {
name: string
hours: Record<string, number>
}
export interface GeneralTask {
name: string
hours: Record<string, number>
}
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
export interface RequiredDay {
booked: number
pickup: number
general: number
total: number
by_cat: Record<string, number>
pickup_by_cat: Record<string, number>
}

9
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_HOTEL_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

15
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true
},
"include": ["src"]
}

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

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