room-planner/frontend/src/pages/Planner.tsx
jtricerolph 1e658b6a48 feat: add room-planner app — 3-day HK room view with NewBook integration
NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist
WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner.

- 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook
- Task completion ticks back to NewBook; room status patches NewBook directly
- 23px border sliver CSS system for adjacent-day booking status
- 3-state filter cycling (off→inclusive→exclusive) for categories and flow types
- Stat filters for outstanding tasks and clean/dirty status
- Rolling 48h activity log with checkout/checkin/status/tasks events
- newbook_pings event bus for future NewBook poller integration
- Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons
- Placeholder sections for future linen-count and routine-tasks modules
- Settings page: task type colours, twin/extra-bed detection, category exclusions
- Mobile-first layout (sidebar desktop, compact top bar mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 13:07:00 +00:00

331 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { RefreshCw } from 'lucide-react'
import type { RoomData, AppConfig, Category, FilterState, StatFilters, FlowType, ActivityEntry } from '../types'
import { useAuth } from '../components/AuthGate'
import CategoryGroup from '../components/CategoryGroup'
import { FilterBar, StatFilterBar } from '../components/FilterBar'
import ActivityPanel from '../components/ActivityPanel'
import RoomModal from '../components/RoomModal'
import CheckoutNotification, { useToasts } from '../components/CheckoutNotification'
import { hasOutstandingTasks, isDirty } from '../lib/booking-flow'
import * as api from '../api'
const POLL_INTERVAL = 60000 // rooms refresh every 60s
const EVENT_INTERVAL = 30000 // event ping every 30s
const ACTIVITY_INTERVAL = 60000
function todayStr() {
return new Date().toISOString().slice(0, 10)
}
function formatDateLabel(dateStr: string) {
const d = new Date(dateStr + 'T00:00:00')
const today = todayStr()
if (dateStr === today) return `Today — ${d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}`
return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', year: 'numeric' })
}
export default function Planner() {
const { user } = useAuth()
const [viewDate, setViewDate] = useState(todayStr)
const [rooms, setRooms] = useState<RoomData[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [config, setConfig] = useState<AppConfig | null>(null)
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedRoom, setSelectedRoom] = useState<RoomData | null>(null)
const [filters, setFilters] = useState<FilterState>({ categories: {}, flowTypes: {} })
const [statFilters, setStatFilters] = useState<StatFilters>({ newbookTasks: 'off', cleanDirty: 'off' })
const lastEventTime = useRef(new Date().toISOString())
const { toasts, addToast, dismiss: dismissToast } = useToasts(
(config?.checkout_notification_timeout ?? 30) * 1000
)
const loadRooms = useCallback(async (date: string, silent = false) => {
if (!silent) setLoading(true)
else setRefreshing(true)
setError(null)
try {
const data = await api.fetchRooms(date)
setRooms(data.rooms)
setCategories(data.categories)
// Detect fresh checkouts vs prior load (depart rooms that just became dirty)
if (silent && config) {
const deptRooms = data.rooms.filter(r => r.flow_type === 'depart')
deptRooms.forEach(r => {
const prev = rooms.find(p => p.site_id === r.site_id)
if (!prev) {
addToast(r.site_name, 'Guest has checked out')
}
})
}
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [config, rooms])
const loadActivity = useCallback(async (date: string) => {
try {
const data = await api.fetchActivity(date)
setActivity(data)
} catch { /* non-critical */ }
}, [])
// Initial load
useEffect(() => {
Promise.all([
api.fetchConfig().then(setConfig).catch(() => {}),
]).then(() => {
loadRooms(viewDate)
loadActivity(viewDate)
})
}, [])
// Reload on date change
useEffect(() => {
setRooms([])
loadRooms(viewDate)
loadActivity(viewDate)
}, [viewDate])
// Periodic room poll
useEffect(() => {
const timer = setInterval(() => loadRooms(viewDate, true), POLL_INTERVAL)
return () => clearInterval(timer)
}, [viewDate, loadRooms])
// Activity poll
useEffect(() => {
const timer = setInterval(() => loadActivity(viewDate), ACTIVITY_INTERVAL)
return () => clearInterval(timer)
}, [viewDate, loadActivity])
// Event pings poll
useEffect(() => {
const timer = setInterval(async () => {
try {
const { pings, server_time } = await api.fetchEvents(lastEventTime.current)
lastEventTime.current = server_time
if (pings.length > 0) {
loadRooms(viewDate, true)
}
} catch { /* ignore */ }
}, EVENT_INTERVAL)
return () => clearInterval(timer)
}, [viewDate, loadRooms])
// Date navigation
const navigate = (delta: number) => {
const d = new Date(viewDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + delta)
setViewDate(d.toISOString().slice(0, 10))
}
// Filter logic
const toggleCategory = useCallback((id: string) => {
setFilters(f => {
const cur = f.categories[id] ?? 'off'
const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off'
return { ...f, categories: { ...f.categories, [id]: next } }
})
}, [])
const toggleFlow = useCallback((flow: FlowType) => {
setFilters(f => {
const cur = f.flowTypes[flow] ?? 'off'
const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off'
return { ...f, flowTypes: { ...f.flowTypes, [flow]: next } }
})
}, [])
const toggleStatFilter = useCallback((key: keyof StatFilters) => {
setStatFilters(f => {
const cur = f[key]
const next = cur === 'off' ? 'show-only' : cur === 'show-only' ? 'hide' : 'off'
return { ...f, [key]: next }
})
}, [])
// Apply filters — inclusive/exclusive logic for categories and flows
const visibleRooms = useMemo(() => {
const inclCats = Object.entries(filters.categories).filter(([, m]) => m === 'inclusive').map(([k]) => k)
const exclCats = Object.entries(filters.categories).filter(([, m]) => m === 'exclusive').map(([k]) => k)
const inclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'inclusive').map(([k]) => k) as FlowType[]
const exclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'exclusive').map(([k]) => k) as FlowType[]
return rooms.filter(room => {
// Category filters: if any inclusive → must be in inclusive set; exclusive → must not be in exclusive set
if (inclCats.length && !inclCats.includes(room.category_id)) return false
if (exclCats.includes(room.category_id)) return false
// Flow type filters
if (inclFlows.length && !inclFlows.includes(room.flow_type)) return false
if (exclFlows.includes(room.flow_type)) return false
// Stat filters
const tasks = statFilters.newbookTasks
if (tasks === 'show-only' && !hasOutstandingTasks(room, viewDate)) return false
if (tasks === 'hide' && hasOutstandingTasks(room, viewDate)) return false
const cd = statFilters.cleanDirty
if (cd === 'show-only' && !isDirty(room)) return false
if (cd === 'hide' && isDirty(room)) return false
return true
})
}, [rooms, filters, statFilters, viewDate])
// Group by category
const groupedRooms = useMemo(() => {
const map = new Map<string, RoomData[]>()
for (const room of visibleRooms) {
const key = room.category_id || '__none__'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(room)
}
return map
}, [visibleRooms])
// Modal handlers
const handleTaskToggle = useCallback((taskId: string, completed: boolean, siteStatus: string | null) => {
setRooms(prev => prev.map(r => ({
...r,
site_status: siteStatus && r.site_id === selectedRoom?.site_id ? siteStatus : r.site_status,
tasks: r.tasks.map(t =>
t.task_id === taskId
? { ...t, completed_on: completed ? new Date().toISOString() : null }
: t
),
})))
if (selectedRoom) {
setSelectedRoom(prev => prev ? {
...prev,
site_status: siteStatus ?? prev.site_status,
tasks: prev.tasks.map(t =>
t.task_id === taskId
? { ...t, completed_on: completed ? new Date().toISOString() : null }
: t
),
} : null)
}
}, [selectedRoom])
const handleStatusUpdate = useCallback((roomId: string, status: string) => {
setRooms(prev => prev.map(r => r.site_id === roomId ? { ...r, site_status: status } : r))
setSelectedRoom(prev => prev?.site_id === roomId ? { ...prev, site_status: status } : prev)
loadActivity(viewDate)
}, [viewDate, loadActivity])
// Render
const orderedCategories = useMemo(() =>
[...categories].sort((a, b) => a.order - b.order),
[categories]
)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Date bar */}
<div className="date-bar">
<button className="date-nav-btn" onClick={() => navigate(-1)}></button>
<input
type="date"
value={viewDate}
onChange={e => setViewDate(e.target.value)}
/>
<span className="date-label">{formatDateLabel(viewDate)}</span>
{viewDate !== todayStr() && (
<button className="date-today-btn" onClick={() => setViewDate(todayStr())}>Today</button>
)}
<button
className={`refresh-btn${refreshing ? ' spinning' : ''}`}
onClick={() => loadRooms(viewDate, true)}
title="Refresh"
>
<RefreshCw size={16} strokeWidth={1.75} />
</button>
</div>
{/* Filters */}
<FilterBar
filters={filters}
categories={orderedCategories}
rooms={rooms}
viewDate={viewDate}
onToggleCategory={toggleCategory}
onToggleFlow={toggleFlow}
/>
<StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />
{/* Main area */}
<div className="planner-main">
<div className="planner-rooms-area">
{loading ? (
<div className="loading-spinner">
<RefreshCw size={18} strokeWidth={1.75} />
Loading rooms
</div>
) : error ? (
<div className="error-banner">{error}</div>
) : visibleRooms.length === 0 ? (
<div className="empty-state">No rooms match the current filters</div>
) : (
<div className="planner-body">
{orderedCategories.map(cat => {
const catRooms = groupedRooms.get(cat.id)
if (!catRooms?.length) return null
return (
<CategoryGroup
key={cat.id}
categoryId={cat.id}
categoryName={cat.name}
rooms={catRooms.sort((a, b) => a.site_order - b.site_order)}
viewDate={viewDate}
config={config}
onRoomClick={setSelectedRoom}
/>
)
})}
{/* Rooms without a category */}
{groupedRooms.has('__none__') && (
<CategoryGroup
categoryId="__none__"
categoryName="Uncategorised"
rooms={groupedRooms.get('__none__')!.sort((a, b) => a.site_order - b.site_order)}
viewDate={viewDate}
config={config}
onRoomClick={setSelectedRoom}
/>
)}
</div>
)}
</div>
<ActivityPanel entries={activity} />
</div>
{/* Room modal */}
{selectedRoom && (
<RoomModal
room={selectedRoom}
viewDate={viewDate}
config={config}
user={user}
onClose={() => setSelectedRoom(null)}
onTaskToggle={handleTaskToggle}
onStatusUpdate={handleStatusUpdate}
/>
)}
{/* Checkout toasts */}
<CheckoutNotification toasts={toasts} onDismiss={dismissToast} />
</div>
)
}