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>
331 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|