diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index 2e5ce88..87dd8c2 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -33,13 +33,16 @@ function stripCosts(task) { } // Returns guests physically in the room right now vs those booked to arrive today. -// NewBook status strings vary by account; we normalise and check for 'arriv' / 'inhouse' -// / 'checkedin' to identify in-room guests. Everything else in the 'staying' list is -// treated as "arriving today" — confirmed but not yet checked in. +// NewBook status strings vary by account; we normalise and check for 'depart' / +// 'checkedout' (already checked out — room's free, whatever the date window says), +// then 'arriv' / 'inhouse' / 'checkedin' to identify in-room guests. Everything else +// in the 'staying' list is treated as "arriving today" — confirmed but not yet +// checked in. function classifyBookingStatus(rawStatus) { const s = String(rawStatus ?? '').toLowerCase().replace(/[\s_\-]/g, '') - if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'occupied' - return 'arriving' + if (s.includes('depart') || s.includes('checkedout')) return 'departed' + if (s.includes('arriv') || s.includes('inhouse') || s.includes('checkedin')) return 'inhouse' + return 'confirmed' } async function fetchRoomOccupancy() { @@ -52,23 +55,35 @@ async function fetchRoomOccupancy() { // filter locally against each booking's own arrival/departure dates instead. const bookings = await fetchBookings(yesterday, today) const occupied = new Set() + const departing = new Set() const arriving = new Set() for (const b of bookings) { const siteId = b.booking_site_id ?? b.site_id if (siteId == null) continue const arrival = String(b.booking_arrival ?? '').slice(0, 10) const departure = String(b.booking_departure ?? '').slice(0, 10) - // Only bookings whose stay actually covers today belong in either bucket — the + // Only bookings whose stay actually covers today belong in any bucket — the // wider window also pulls back yesterday's now-departed booking, which shouldn't - // count towards either set. + // count towards any of them. if (arrival > today || departure < today) continue const id = String(siteId) - if (classifyBookingStatus(b.booking_status) === 'occupied') occupied.add(id) - else arriving.add(id) + const kind = classifyBookingStatus(b.booking_status) + if (kind === 'departed') continue // already checked out — room's free now + if (kind === 'inhouse') { + // In the room right now: still a stayover if checking out later, or + // "departing" (checking out today but not yet gone) if this is their last day. + if (departure === today) departing.add(id) + else occupied.add(id) + } else { + arriving.add(id) + } } - // A site that is both (e.g. multi-booking day) should be treated as occupied + // A site can have more than one booking on a turnover day (e.g. a departing guest + // plus a same-day incoming one) — physically-present-right-now always wins over a + // booking that's merely confirmed to arrive later. + for (const id of departing) { occupied.delete(id); arriving.delete(id) } for (const id of occupied) arriving.delete(id) - return { occupied, arriving } + return { occupied, departing, arriving } } export async function taskRoutes(app) { @@ -106,13 +121,16 @@ export async function taskRoutes(app) { // "Unoccupied rooms only": keep only rooms-category tasks whose NewBook room // has no in-house (arrived) booking right now. if (q.unoccupied === 'true') { - let occupied + let occupied, departing try { - ({ occupied } = await fetchRoomOccupancy()) + ({ occupied, departing } = await fetchRoomOccupancy()) } catch (err) { return reply.status(502).send({ error: `NewBook error: ${err.message}` }) } - rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id)) + // A "departing today" room is still guest-occupied right now, so it counts as + // occupied for this filter even though the Rooms Accessible View shows it + // separately. + rows = rows.filter(t => t.is_rooms && t.newbook_site_id && !occupied.has(t.newbook_site_id) && !departing.has(t.newbook_site_id)) } if (!hasCap(req, 'costs')) rows = rows.map(stripCosts) @@ -334,13 +352,15 @@ export async function taskRoutes(app) { }) // GET /api/occupancy — room state from NewBook for the Rooms Accessible View - // Returns two lists: occupied (guest in room) and arriving (booked today, not yet checked in) + // Returns three lists: occupied (staying, guest in room), departing (guest in room + // but checking out today — accessible later) and arriving (booked today, not yet checked in) app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => { try { - const { occupied, arriving } = await fetchRoomOccupancy() + const { occupied, departing, arriving } = await fetchRoomOccupancy() return { date: new Date().toISOString().slice(0, 10), occupied_site_ids: [...occupied], + departing_site_ids: [...departing], arriving_site_ids: [...arriving], } } catch (err) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index afd4793..423a1c7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -91,7 +91,7 @@ export function resolveTask(id: number, body: { export function addComment(id: number, note: string, addToTemplate = false): Promise<{ ok: boolean; added_to_template: boolean }> { return request(`/tasks/${id}/comments`, { method: 'POST', body: JSON.stringify({ note, add_to_template: addToTemplate }) }) } -export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; arriving_site_ids: string[] }> { +export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; departing_site_ids: string[]; arriving_site_ids: string[] }> { return request('/occupancy') } diff --git a/frontend/src/pages/Summary.tsx b/frontend/src/pages/Summary.tsx index 0e56c62..1ea6f77 100644 --- a/frontend/src/pages/Summary.tsx +++ b/frontend/src/pages/Summary.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { Plus, RefreshCw, Camera, BedDouble, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' +import { Plus, RefreshCw, Camera, BedDouble, LogOut, Layers, ChevronDown, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react' import type { Task, Category, Location, AppConfig, TaskStatus, Priority } from '../types' import { STATUS_LABELS, can } from '../types' import { fetchTasks, fetchLocations, fetchConfig, fetchOccupancy } from '../api' @@ -20,6 +20,7 @@ export default function Summary() { const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [occupiedSiteIds, setOccupiedSiteIds] = useState>(new Set()) + const [departingSiteIds, setDepartingSiteIds] = useState>(new Set()) const [arrivingSiteIds, setArrivingSiteIds] = useState>(new Set()) const [statusFilter, setStatusFilter] = useState(null) @@ -70,6 +71,7 @@ export default function Summary() { .then(([t, occ]) => { setTasks(t) setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set()) + setDepartingSiteIds(occ ? new Set(occ.departing_site_ids) : new Set()) setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set()) setError(null) }) @@ -115,17 +117,19 @@ export default function Summary() { return c }, [tasks]) - // Rooms chip counts: occupied = guest in room, arriving = booked today not yet in + // Rooms chip counts: occupied = guest staying, departing = guest checking out + // today (accessible later), arriving = booked today not yet in const roomsCounts = useMemo(() => { if (!roomsView) return null - let occupied = 0, arriving = 0 + let occupied = 0, departing = 0, arriving = 0 for (const t of tasks) { const sid = t.newbook_site_id ? String(t.newbook_site_id) : null if (sid && occupiedSiteIds.has(sid)) occupied++ + else if (sid && departingSiteIds.has(sid)) departing++ else if (sid && arrivingSiteIds.has(sid)) arriving++ } - return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving } - }, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds]) + return { total: tasks.length, occupied, departing, arriving, free: tasks.length - occupied - departing - arriving } + }, [tasks, roomsView, occupiedSiteIds, departingSiteIds, arrivingSiteIds]) const sortedTasks = useMemo(() => { if (sortMode === 'default') return tasks @@ -226,7 +230,7 @@ export default function Summary() { > {roomsView && roomsCounts - ? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})` + ? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.departing ? ` • ${roomsCounts.departing} departing` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})` : 'Rooms Accessible View'} )} @@ -253,18 +257,23 @@ export default function Summary() { const renderTask = (t: Task) => { const sid = t.newbook_site_id ? String(t.newbook_site_id) : null const isOccupied = roomsView && !!sid && occupiedSiteIds.has(sid) - const isArriving = roomsView && !!sid && !isOccupied && arrivingSiteIds.has(sid) + const isDeparting = roomsView && !!sid && !isOccupied && departingSiteIds.has(sid) + const isArriving = roomsView && !!sid && !isOccupied && !isDeparting && arrivingSiteIds.has(sid) return (
setOpenTask(t.id)} >
{t.title} {isOccupied && Occupied} + {isDeparting && Departing} {isArriving && Arrival today} {t.unusable && } {t.template_id && Recurring}