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:
commit
4abae5eda1
28 changed files with 2475 additions and 0 deletions
15
.env.example
Normal file
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# PostgreSQL connection string for the hk-planner DB
|
||||
DATABASE_URL=postgres://user:pass@host:5432/hk_planner
|
||||
|
||||
# Shared secret used to verify hnf_session JWTs (same as auth service)
|
||||
CENTRAL_AUTH_SECRET=change-me
|
||||
|
||||
# Settings service (for Newbook credentials)
|
||||
SETTINGS_URL=http://settings-backend:3001
|
||||
SETTINGS_SECRET=change-me
|
||||
|
||||
# Hotel name shown on the login page
|
||||
VITE_HOTEL_NAME=Hotel Name
|
||||
|
||||
# Host port to expose the frontend on (default 3080)
|
||||
FRONTEND_PORT=3080
|
||||
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/index.js"]
|
||||
16
backend/package.json
Normal file
16
backend/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "hnf-hk-planner-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"jose": "^5.9.6",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
35
backend/src/auth.js
Normal file
35
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { jwtVerify } from 'jose'
|
||||
import { isOnsite } from './ip-check.js'
|
||||
|
||||
const APP_SLUG = process.env.APP_SLUG || 'hk-planner'
|
||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||
|
||||
export async function requireAuth(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
|
||||
let payload
|
||||
try {
|
||||
const { payload: p } = await jwtVerify(token, secret)
|
||||
payload = p
|
||||
} catch {
|
||||
return reply.status(401).send({ error: 'Invalid session' })
|
||||
}
|
||||
|
||||
if (!payload.apps?.includes(APP_SLUG)) {
|
||||
return reply.status(403).send({ error: 'No permission for this app' })
|
||||
}
|
||||
|
||||
if (!payload.offsite_allowed) {
|
||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||
if (!(await isOnsite(clientIP))) {
|
||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||
}
|
||||
}
|
||||
|
||||
request.user = {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin ?? false,
|
||||
}
|
||||
}
|
||||
26
backend/src/db.js
Normal file
26
backend/src/db.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
export async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS hk_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT 'null'::jsonb
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
export async function getConfig(key, defaultVal = null) {
|
||||
const { rows } = await pool.query('SELECT value FROM hk_config WHERE key = $1', [key])
|
||||
return rows.length ? rows[0].value : defaultVal
|
||||
}
|
||||
|
||||
export async function setConfig(key, value) {
|
||||
await pool.query(
|
||||
`INSERT INTO hk_config (key, value) VALUES ($1, $2::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[key, JSON.stringify(value)]
|
||||
)
|
||||
}
|
||||
24
backend/src/index.js
Normal file
24
backend/src/index.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Fastify from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import { initDb } from './db.js'
|
||||
import { bookingRoutes } from './routes/bookings.js'
|
||||
import { configRoutes } from './routes/config.js'
|
||||
|
||||
const app = Fastify({ logger: true, trustProxy: true })
|
||||
|
||||
await app.register(cookie)
|
||||
await app.register(cors, { origin: process.env.CORS_ORIGIN || false, credentials: true })
|
||||
|
||||
app.get('/health', async () => ({ status: 'healthy' }))
|
||||
|
||||
await app.register(bookingRoutes)
|
||||
await app.register(configRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
80
backend/src/ip-check.js
Normal file
80
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import dns from 'dns/promises'
|
||||
|
||||
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
|
||||
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
const TTL = 5 * 60 * 1000
|
||||
const cache = new Map()
|
||||
|
||||
const PUBLIC_IP_URLS = [
|
||||
'https://api.ipify.org',
|
||||
'https://ifconfig.co/ip',
|
||||
'https://icanhazip.com',
|
||||
]
|
||||
|
||||
function normalizeIP(ip) {
|
||||
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
|
||||
}
|
||||
|
||||
function isIPv4(s) {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
|
||||
}
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
const [range, bits] = cidr.split('/')
|
||||
if (!isIPv4(ip) || !isIPv4(range)) return false
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
|
||||
return (toInt(ip) & mask) === (toInt(range) & mask)
|
||||
}
|
||||
|
||||
async function fetchPublicIP() {
|
||||
for (const url of PUBLIC_IP_URLS) {
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), 4000)
|
||||
const res = await fetch(url, { signal: ctrl.signal })
|
||||
clearTimeout(timer)
|
||||
if (!res.ok) continue
|
||||
const ip = (await res.text()).trim()
|
||||
if (isIPv4(ip)) return ip
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveDynamic(key, resolver) {
|
||||
const hit = cache.get(key)
|
||||
if (hit && Date.now() < hit.expiry) return hit.ip
|
||||
const ip = await resolver()
|
||||
if (ip) {
|
||||
cache.set(key, { ip, expiry: Date.now() + TTL })
|
||||
return ip
|
||||
}
|
||||
return hit ? hit.ip : null
|
||||
}
|
||||
|
||||
export async function isOnsite(requestIP) {
|
||||
if (matchers.length === 0 || matchers.includes('disabled')) return true
|
||||
const ip = normalizeIP(requestIP)
|
||||
if (!ip) return false
|
||||
|
||||
for (const m of matchers) {
|
||||
if (m === 'auto') {
|
||||
const pub = await resolveDynamic('auto', fetchPublicIP)
|
||||
if (pub && ip === pub) return true
|
||||
} else if (m.includes('/')) {
|
||||
if (ipInCidr(ip, m)) return true
|
||||
} else if (/[a-zA-Z]/.test(m)) {
|
||||
const resolved = await resolveDynamic(m, async () => {
|
||||
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||
})
|
||||
if (resolved && ip === resolved) return true
|
||||
} else {
|
||||
if (ip === m) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
355
backend/src/lib/newbook.js
Normal file
355
backend/src/lib/newbook.js
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
const API_BASE = 'https://api.newbook.cloud/rest/'
|
||||
|
||||
// In-process bookings cache: key → { data, expiry }
|
||||
const bookingsCache = new Map()
|
||||
|
||||
// ── Date helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function offsetDate(dateStr, days) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
const dt = new Date(y, m - 1, d)
|
||||
dt.setDate(dt.getDate() + days)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function dateToMs(dateStr) {
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).getTime()
|
||||
}
|
||||
|
||||
// ── API client ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function getCredentials() {
|
||||
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook`
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Settings service returned ${res.status}`)
|
||||
const s = await res.json()
|
||||
return {
|
||||
username: s.username || '',
|
||||
password: s.password || '',
|
||||
apiKey: s.api_key || '',
|
||||
region: s.region || 'eu',
|
||||
}
|
||||
}
|
||||
|
||||
async function callApi(endpoint, data = {}) {
|
||||
const creds = await getCredentials()
|
||||
if (!creds.username || !creds.password || !creds.apiKey) {
|
||||
throw new Error('Newbook API credentials not configured')
|
||||
}
|
||||
|
||||
const body = { ...data, region: creds.region, api_key: creds.apiKey }
|
||||
const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64')
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), 30000)
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE + endpoint, {
|
||||
method: 'POST',
|
||||
signal: ctrl.signal,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
clearTimeout(timer)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Newbook API ${res.status}: ${text.slice(0, 200)}`)
|
||||
}
|
||||
return await res.json()
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBookingsRange(startDate, endDate) {
|
||||
return callApi('bookings_list', {
|
||||
period_from: startDate + ' 00:00:00',
|
||||
period_to: endDate + ' 23:59:59',
|
||||
list_type: 'staying',
|
||||
data_offset: 0,
|
||||
data_limit: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchBookingsDelta(startDate, endDate) {
|
||||
return callApi('bookings_list', {
|
||||
period_from: startDate + ' 00:00:00',
|
||||
period_to: endDate + ' 23:59:59',
|
||||
list_type: 'all',
|
||||
data_offset: 0,
|
||||
data_limit: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchSitesList() {
|
||||
return callApi('sites_list', {})
|
||||
}
|
||||
|
||||
export async function testConnection() {
|
||||
try {
|
||||
const resp = await fetchSitesList()
|
||||
if (resp.data) {
|
||||
return { ok: true, message: `Connected. Found ${resp.data.length} site(s).` }
|
||||
}
|
||||
return { ok: false, error: resp.error || 'No data returned' }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booking data algorithm (port of PHP get_bookings_data) ─────────────────
|
||||
|
||||
const ACTIVE_STATUSES = ['Confirmed', 'Unconfirmed', 'Arrived', 'Departed']
|
||||
const CANCELLED_STATUSES = ['Cancelled', 'No Show']
|
||||
|
||||
export async function getBookingsData(weekStart, lastViewed, forceRefresh, getConfig) {
|
||||
const cacheKey = `bookings_${weekStart}_${lastViewed}`
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = bookingsCache.get(cacheKey)
|
||||
if (cached && Date.now() < cached.expiry) return cached.data
|
||||
}
|
||||
|
||||
const today = todayStr()
|
||||
const todayMs = dateToMs(today)
|
||||
const fetchFrom = offsetDate(weekStart, -8)
|
||||
const endDate = offsetDate(weekStart, 6)
|
||||
const lastViewedMs = dateToMs(lastViewed)
|
||||
|
||||
const [bookingsResp, deltaResp, sitesResp] = await Promise.all([
|
||||
fetchBookingsRange(fetchFrom, endDate),
|
||||
fetchBookingsDelta(lastViewed, endDate),
|
||||
fetchSitesList(),
|
||||
])
|
||||
|
||||
if (!bookingsResp.data) throw new Error(bookingsResp.error || 'No booking data returned')
|
||||
|
||||
const sites = sitesResp.data || []
|
||||
|
||||
// Build category_map and site → catKey index
|
||||
const categoryMap = {} // catKey → {name, total_rooms}
|
||||
const siteToKey = {} // siteId → catKey
|
||||
|
||||
for (const site of sites) {
|
||||
const siteId = site.site_id || ''
|
||||
const catName = (site.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
if (siteId) siteToKey[siteId] = catKey
|
||||
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
|
||||
categoryMap[catKey].total_rooms++
|
||||
}
|
||||
|
||||
// Build 7-day date window
|
||||
const dates = []
|
||||
for (let i = 0; i < 7; i++) dates.push(offsetDate(weekStart, i))
|
||||
|
||||
// day_data[date][catKey] = {departs, stays, arrivals, rooms:{}}
|
||||
const dayData = {}
|
||||
for (const d of dates) dayData[d] = {}
|
||||
|
||||
// delta_data[date][catKey] = {new:0, cancelled:0}
|
||||
const deltaData = {}
|
||||
for (const d of dates) deltaData[d] = {}
|
||||
|
||||
// prior_arrivals[catKey][dow] = [{arrival_date, placed_date}]
|
||||
const priorArrivals = {}
|
||||
|
||||
// prior_occ[catKey][date] = {rooms:{siteId:true}}
|
||||
const priorOcc = {}
|
||||
|
||||
// ── Process main bookings ──────────────────────────────────────────────
|
||||
|
||||
for (const booking of bookingsResp.data) {
|
||||
const siteId = booking.site_id || ''
|
||||
if (!siteId) continue
|
||||
|
||||
let catName
|
||||
if (booking.category_name) {
|
||||
catName = booking.category_name.trim()
|
||||
} else if (siteToKey[siteId]) {
|
||||
catName = categoryMap[siteToKey[siteId]].name
|
||||
} else {
|
||||
catName = 'Unknown'
|
||||
}
|
||||
const catKey = catName.toLowerCase()
|
||||
if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 }
|
||||
|
||||
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
|
||||
const departureDate = (booking.booking_departure || '').slice(0, 10)
|
||||
if (!arrivalDate || !departureDate) continue
|
||||
|
||||
const arrivalMs = dateToMs(arrivalDate)
|
||||
const departureMs = dateToMs(departureDate)
|
||||
|
||||
// This-week occupancy
|
||||
for (const date of dates) {
|
||||
const isArriving = arrivalDate === date
|
||||
const isDeparting = departureDate === date
|
||||
const isStaying = arrivalDate < date && departureDate > date
|
||||
|
||||
if (!isArriving && !isDeparting && !isStaying) continue
|
||||
|
||||
if (!dayData[date][catKey]) {
|
||||
dayData[date][catKey] = { departs: 0, stays: 0, arrivals: 0, rooms: {} }
|
||||
}
|
||||
if (isDeparting) dayData[date][catKey].departs++
|
||||
if (isStaying) dayData[date][catKey].stays++
|
||||
if (isArriving) dayData[date][catKey].arrivals++
|
||||
dayData[date][catKey].rooms[siteId] = true
|
||||
}
|
||||
|
||||
// Skip future arrivals for prior-week data
|
||||
if (arrivalMs >= todayMs) continue
|
||||
|
||||
// Prior-week occupancy (7 days before each display date)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const priorDate = offsetDate(today, -(7 - i))
|
||||
const priorDateMs = dateToMs(priorDate)
|
||||
if (arrivalMs <= priorDateMs && departureMs > priorDateMs) {
|
||||
if (!priorOcc[catKey]) priorOcc[catKey] = {}
|
||||
if (!priorOcc[catKey][priorDate]) priorOcc[catKey][priorDate] = { rooms: {} }
|
||||
priorOcc[catKey][priorDate].rooms[siteId] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Prior-week arrival pickup hints (keyed by weekday)
|
||||
const placedDate = (booking.booking_placed || '').slice(0, 10)
|
||||
if (!placedDate) continue
|
||||
|
||||
const dow = new Date(arrivalDate + 'T00:00:00').getDay() // 0=Sun…6=Sat
|
||||
if (!priorArrivals[catKey]) priorArrivals[catKey] = {}
|
||||
if (!priorArrivals[catKey][dow]) priorArrivals[catKey][dow] = []
|
||||
priorArrivals[catKey][dow].push({ arrival_date: arrivalDate, placed_date: placedDate })
|
||||
}
|
||||
|
||||
// ── Process delta bookings ─────────────────────────────────────────────
|
||||
|
||||
const deltaBookings = deltaResp.data || []
|
||||
|
||||
for (const booking of deltaBookings) {
|
||||
const siteId = booking.site_id || ''
|
||||
if (!siteId) continue
|
||||
|
||||
const catName = (booking.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
|
||||
const arrivalDate = (booking.booking_arrival || '').slice(0, 10)
|
||||
const departureDate = (booking.booking_departure || '').slice(0, 10)
|
||||
if (!arrivalDate || !departureDate) continue
|
||||
|
||||
const status = (booking.booking_status || '').trim()
|
||||
const isActive = ACTIVE_STATUSES.includes(status)
|
||||
const isCancelled = CANCELLED_STATUSES.includes(status)
|
||||
|
||||
const placedDateStr = (booking.booking_placed || '').slice(0, 10)
|
||||
const cancelledDateStr = (booking.booking_cancelled || '').slice(0, 10)
|
||||
const placedMs = placedDateStr ? dateToMs(placedDateStr) : 0
|
||||
const cancelledMs = cancelledDateStr ? dateToMs(cancelledDateStr) : 0
|
||||
|
||||
const isNewSince = isActive && placedMs && placedMs >= lastViewedMs
|
||||
const isCancelledSince = isCancelled && cancelledMs && cancelledMs >= lastViewedMs
|
||||
|
||||
if (!isNewSince && !isCancelledSince) continue
|
||||
|
||||
for (const date of dates) {
|
||||
const isArriving = arrivalDate === date
|
||||
const isStaying = arrivalDate < date && departureDate > date
|
||||
if (!isArriving && !isStaying) continue
|
||||
|
||||
if (!deltaData[date][catKey]) deltaData[date][catKey] = { new: 0, cancelled: 0 }
|
||||
if (isNewSince) deltaData[date][catKey].new++
|
||||
else if (isCancelledSince) deltaData[date][catKey].cancelled++
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply saved category order ─────────────────────────────────────────
|
||||
|
||||
const savedOrder = (await getConfig('category_order', [])) || []
|
||||
const excluded = (await getConfig('excluded_categories', [])) || []
|
||||
|
||||
const orderedKeys = []
|
||||
for (const key of savedOrder) {
|
||||
if (categoryMap[key]) orderedKeys.push(key)
|
||||
}
|
||||
for (const key of Object.keys(categoryMap)) {
|
||||
if (!orderedKeys.includes(key)) orderedKeys.push(key)
|
||||
}
|
||||
|
||||
// ── Build output ───────────────────────────────────────────────────────
|
||||
|
||||
const categoriesOut = []
|
||||
|
||||
for (const catKey of orderedKeys) {
|
||||
if (!categoryMap[catKey]) continue
|
||||
if (excluded.includes(catKey)) continue
|
||||
|
||||
const catDays = {}
|
||||
|
||||
for (let di = 0; di < dates.length; di++) {
|
||||
const date = dates[di]
|
||||
const dateMs = dateToMs(date)
|
||||
const leadDays = Math.round((dateMs - todayMs) / 86400000)
|
||||
const dow = new Date(date + 'T00:00:00').getDay()
|
||||
|
||||
// Pickup hint — how many last-week same-weekday arrivals were booked within leadDays
|
||||
let hintCount = 0
|
||||
if (priorArrivals[catKey]?.[dow]) {
|
||||
for (const pa of priorArrivals[catKey][dow]) {
|
||||
const arrMs = dateToMs(pa.arrival_date)
|
||||
const plMs = dateToMs(pa.placed_date)
|
||||
const daysBefore = Math.round((arrMs - plMs) / 86400000)
|
||||
if (daysBefore <= leadDays) hintCount++
|
||||
}
|
||||
}
|
||||
|
||||
const priorDate = offsetDate(date, -7)
|
||||
const priorOccCount = priorOcc[catKey]?.[priorDate]
|
||||
? Object.keys(priorOcc[catKey][priorDate].rooms).length
|
||||
: 0
|
||||
const totalRooms = categoryMap[catKey].total_rooms
|
||||
const priorVacCount = Math.max(0, totalRooms - priorOccCount)
|
||||
|
||||
const delta = deltaData[date]?.[catKey] || {}
|
||||
const dd = dayData[date]?.[catKey]
|
||||
|
||||
catDays[date] = dd ? {
|
||||
total_servicing: Object.keys(dd.rooms).length,
|
||||
departs: dd.departs,
|
||||
stays: dd.stays,
|
||||
arrivals: dd.arrivals,
|
||||
pickup_hint: hintCount,
|
||||
pickup_lead: leadDays,
|
||||
prior_occ: priorOccCount,
|
||||
prior_vac: priorVacCount,
|
||||
delta_new: delta.new || 0,
|
||||
delta_cancelled: delta.cancelled || 0,
|
||||
} : {
|
||||
total_servicing: 0, departs: 0, stays: 0, arrivals: 0,
|
||||
pickup_hint: hintCount, pickup_lead: leadDays,
|
||||
prior_occ: priorOccCount, prior_vac: priorVacCount,
|
||||
delta_new: delta.new || 0, delta_cancelled: delta.cancelled || 0,
|
||||
}
|
||||
}
|
||||
|
||||
categoriesOut.push({
|
||||
id: catKey,
|
||||
name: categoryMap[catKey].name,
|
||||
total_rooms: categoryMap[catKey].total_rooms,
|
||||
days: catDays,
|
||||
})
|
||||
}
|
||||
|
||||
const result = { dates, categories: categoriesOut, last_viewed: lastViewed }
|
||||
bookingsCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 })
|
||||
return result
|
||||
}
|
||||
26
backend/src/routes/bookings.js
Normal file
26
backend/src/routes/bookings.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { requireAuth } from '../auth.js'
|
||||
import { getConfig } from '../db.js'
|
||||
import { getBookingsData, todayStr, offsetDate } from '../lib/newbook.js'
|
||||
|
||||
export async function bookingRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
app.get('/api/bookings', async (req, reply) => {
|
||||
const today = todayStr()
|
||||
|
||||
let weekStart = req.query.week_start || today
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) weekStart = today
|
||||
|
||||
let lastViewed = req.query.last_viewed || offsetDate(today, -1)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(lastViewed)) lastViewed = offsetDate(today, -1)
|
||||
|
||||
const force = req.query.force_refresh === '1'
|
||||
|
||||
try {
|
||||
return await getBookingsData(weekStart, lastViewed, force, getConfig)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
return reply.status(500).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
}
|
||||
188
backend/src/routes/config.js
Normal file
188
backend/src/routes/config.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { requireAuth } from '../auth.js'
|
||||
import { getConfig, setConfig } from '../db.js'
|
||||
import { fetchSitesList, testConnection } from '../lib/newbook.js'
|
||||
|
||||
export async function configRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// ── GET /api/config — all settings at once ──────────────────────────────
|
||||
|
||||
app.get('/api/config', async (req) => {
|
||||
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins] =
|
||||
await Promise.all([
|
||||
getConfig('time_requirements', {}),
|
||||
getConfig('staff_data', []),
|
||||
getConfig('pickup_data', {}),
|
||||
getConfig('general_tasks', []),
|
||||
getConfig('last_reviewed', null),
|
||||
getConfig('tolerance_minutes', 30),
|
||||
])
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yestStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
return {
|
||||
time_requirements: timeReqs || {},
|
||||
staff_data: staffData || [],
|
||||
pickup_data: pickupData || {},
|
||||
general_tasks: generalTasks || [],
|
||||
last_reviewed: lastReviewed || yestStr,
|
||||
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
||||
}
|
||||
})
|
||||
|
||||
// ── PUT /api/config/time-requirements ────────────────────────────────────
|
||||
|
||||
app.put('/api/config/time-requirements', async (req, reply) => {
|
||||
const { cat, action, value } = req.body || {}
|
||||
if (!cat || !['depart', 'stay', 'arrive'].includes(action)) {
|
||||
return reply.status(400).send({ error: 'Invalid data' })
|
||||
}
|
||||
|
||||
const stored = (await getConfig('time_requirements', {})) || {}
|
||||
if (!stored[cat]) stored[cat] = { depart: 0, stay: 0, arrive: 0 }
|
||||
stored[cat][action] = Math.max(0, parseInt(value, 10) || 0)
|
||||
await setConfig('time_requirements', stored)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/staff ─────────────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/staff', async (req, reply) => {
|
||||
const { staff_data } = req.body || {}
|
||||
if (!Array.isArray(staff_data)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const clean = staff_data
|
||||
.filter(m => m && typeof m.name === 'string' && m.name.trim())
|
||||
.map(m => ({
|
||||
name: m.name.trim(),
|
||||
hours: Object.fromEntries(
|
||||
Object.entries(m.hours || {}).map(([k, v]) => [k, Math.max(0, parseFloat(v) || 0)])
|
||||
),
|
||||
}))
|
||||
|
||||
await setConfig('staff_data', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/pickup ────────────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/pickup', async (req, reply) => {
|
||||
const { pickup_data } = req.body || {}
|
||||
if (!pickup_data || typeof pickup_data !== 'object') {
|
||||
return reply.status(400).send({ error: 'Invalid data' })
|
||||
}
|
||||
|
||||
const clean = {}
|
||||
for (const [catId, dates] of Object.entries(pickup_data)) {
|
||||
if (typeof dates !== 'object') continue
|
||||
clean[catId] = {}
|
||||
for (const [date, vals] of Object.entries(dates)) {
|
||||
clean[catId][date] = {
|
||||
count: Math.max(0, parseInt(vals?.count, 10) || 0),
|
||||
total: Math.max(0, parseInt(vals?.total, 10) || 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setConfig('pickup_data', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/general-tasks ────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/general-tasks', async (req, reply) => {
|
||||
const { general_tasks } = req.body || {}
|
||||
if (!Array.isArray(general_tasks)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const clean = general_tasks
|
||||
.filter(t => t && typeof t.name === 'string' && t.name.trim())
|
||||
.map(t => ({
|
||||
name: t.name.trim(),
|
||||
hours: Object.fromEntries(DAYS.map(day => [day, Math.max(0, parseInt(t.hours?.[day], 10) || 0)])),
|
||||
}))
|
||||
|
||||
await setConfig('general_tasks', clean)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── PUT /api/config/last-reviewed ────────────────────────────────────────
|
||||
|
||||
app.put('/api/config/last-reviewed', async (req, reply) => {
|
||||
const { date } = req.body || {}
|
||||
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return reply.status(400).send({ error: 'Invalid date' })
|
||||
}
|
||||
await setConfig('last_reviewed', date)
|
||||
return { ok: true, date }
|
||||
})
|
||||
|
||||
// ── GET /api/categories — admin: fetch from Newbook + saved order ─────────
|
||||
|
||||
app.get('/api/categories', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
|
||||
let sites
|
||||
try {
|
||||
const resp = await fetchSitesList()
|
||||
if (!resp.data) throw new Error(resp.error || 'No data')
|
||||
sites = resp.data
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: err.message })
|
||||
}
|
||||
|
||||
const cats = {}
|
||||
for (const site of sites) {
|
||||
const catName = (site.category_name || 'Unknown').trim()
|
||||
const catKey = catName.toLowerCase()
|
||||
if (!cats[catKey]) cats[catKey] = { id: catKey, name: catName, room_count: 0 }
|
||||
cats[catKey].room_count++
|
||||
}
|
||||
|
||||
const savedOrder = (await getConfig('category_order', [])) || []
|
||||
const excluded = (await getConfig('excluded_categories', [])) || []
|
||||
|
||||
const ordered = []
|
||||
for (const key of savedOrder) {
|
||||
if (cats[key]) {
|
||||
ordered.push({ ...cats[key], excluded: excluded.includes(key) })
|
||||
delete cats[key]
|
||||
}
|
||||
}
|
||||
for (const [key, cat] of Object.entries(cats)) {
|
||||
ordered.push({ ...cat, excluded: excluded.includes(key) })
|
||||
}
|
||||
|
||||
return { categories: ordered }
|
||||
})
|
||||
|
||||
// ── PUT /api/categories — admin: save order + exclusions ─────────────────
|
||||
|
||||
app.put('/api/categories', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
|
||||
const { order, excluded } = req.body || {}
|
||||
if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' })
|
||||
|
||||
const cleanOrder = order.filter(k => typeof k === 'string')
|
||||
const cleanExcl = Array.isArray(excluded) ? excluded.filter(k => typeof k === 'string') : []
|
||||
|
||||
await Promise.all([
|
||||
setConfig('category_order', cleanOrder),
|
||||
setConfig('excluded_categories', cleanExcl),
|
||||
])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── POST /api/newbook/test — admin ────────────────────────────────────────
|
||||
|
||||
app.post('/api/newbook/test', async (req, reply) => {
|
||||
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
|
||||
return testConnection()
|
||||
})
|
||||
}
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
services:
|
||||
hk-planner-backend:
|
||||
build: ./backend
|
||||
container_name: hk-planner-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
CENTRAL_AUTH_SECRET: ${CENTRAL_AUTH_SECRET}
|
||||
SETTINGS_URL: ${SETTINGS_URL}
|
||||
SETTINGS_SECRET: ${SETTINGS_SECRET}
|
||||
APP_SLUG: hk-planner
|
||||
networks:
|
||||
- hnf_net
|
||||
|
||||
hk-planner-frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_HOTEL_NAME: ${VITE_HOTEL_NAME}
|
||||
container_name: hk-planner-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3080}:80"
|
||||
depends_on:
|
||||
- hk-planner-backend
|
||||
networks:
|
||||
- hnf_net
|
||||
|
||||
networks:
|
||||
hnf_net:
|
||||
external: true
|
||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
COPY . .
|
||||
ARG VITE_HOTEL_NAME="Number Four at Stow"
|
||||
ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html/hk-planner
|
||||
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="#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
37
frontend/nginx.conf
Normal 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
24
frontend/package.json
Normal 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
29
frontend/src/App.tsx
Normal 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
96
frontend/src/api.ts
Normal 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' })
|
||||
}
|
||||
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(--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!)}</>
|
||||
}
|
||||
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 { 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
155
frontend/src/index.css
Normal 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
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>
|
||||
)
|
||||
162
frontend/src/pages/CategorySettings.tsx
Normal file
162
frontend/src/pages/CategorySettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
857
frontend/src/pages/Planner.tsx
Normal file
857
frontend/src/pages/Planner.tsx
Normal 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
54
frontend/src/types.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal 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
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: '/hk-planner/',
|
||||
plugins: [react()],
|
||||
})
|
||||
23
seed-app.js
Normal file
23
seed-app.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env node
|
||||
// Run from hk-planner/ dir: DATABASE_URL=... node seed-app.js
|
||||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||
VALUES ('hk-planner', 'HK Planner', 'Housekeeping workload and hours planning', '/hk-planner', 'CalendarClock', '#2d6a4f', 'Housekeeping', '10.10.10.116', 3080)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
base_path = EXCLUDED.base_path,
|
||||
icon = EXCLUDED.icon,
|
||||
theme_color = EXCLUDED.theme_color,
|
||||
category = EXCLUDED.category,
|
||||
internal_host = EXCLUDED.internal_host,
|
||||
internal_port = EXCLUDED.internal_port
|
||||
`)
|
||||
|
||||
console.log('hk-planner app seeded.')
|
||||
await pool.end()
|
||||
Loading…
Add table
Add a link
Reference in a new issue