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([]) const [categories, setCategories] = useState([]) const [config, setConfig] = useState(null) const [activity, setActivity] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [refreshing, setRefreshing] = useState(false) const [selectedRoom, setSelectedRoom] = useState(null) const [filters, setFilters] = useState({ categories: {}, flowTypes: {} }) const [statFilters, setStatFilters] = useState({ 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() 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 (
{/* Date bar */}
setViewDate(e.target.value)} /> {formatDateLabel(viewDate)} {viewDate !== todayStr() && ( )}
{/* Filters */} {/* Main area */}
{loading ? (
Loading rooms…
) : error ? (
{error}
) : visibleRooms.length === 0 ? (
No rooms match the current filters
) : (
{orderedCategories.map(cat => { const catRooms = groupedRooms.get(cat.id) if (!catRooms?.length) return null return ( a.site_order - b.site_order)} viewDate={viewDate} config={config} onRoomClick={setSelectedRoom} /> ) })} {/* Rooms without a category */} {groupedRooms.has('__none__') && ( a.site_order - b.site_order)} viewDate={viewDate} config={config} onRoomClick={setSelectedRoom} /> )}
)}
{/* Room modal */} {selectedRoom && ( setSelectedRoom(null)} onTaskToggle={handleTaskToggle} onStatusUpdate={handleStatusUpdate} /> )} {/* Checkout toasts */}
) }