Add a Departing state to the Rooms Accessible View
A guest checking out today but not yet gone was previously lumped in with ordinary stayovers — fully greyed out, no way to tell "the guest is gone in 20 minutes" apart from "the guest isn't leaving at all". Split that out into its own bucket: Backend: classifyBookingStatus() now also recognises 'depart'/'checkedout' statuses (already checked out — room's free regardless of the date window). fetchRoomOccupancy() returns occupied/departing/arriving; an in-house booking whose departure date is today lands in 'departing' instead of 'occupied'. The ?unoccupied=true task filter and /api/occupancy both updated for the new three-way split. Frontend: departing rooms get a light dim (opacity only, no greyscale) instead of the hard grey-out, plus a purple 'Departing' badge — matching the booked=green/occupied=blue/departing=purple colour language hvac already uses for NewBook room states. Chip label breakdown now reads "X free • Y arriving • Z departing • W occupied". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
96674c58c8
commit
8ddb99d2d7
3 changed files with 54 additions and 25 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
|
||||
const [departingSiteIds, setDepartingSiteIds] = useState<Set<string>>(new Set())
|
||||
const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(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() {
|
|||
>
|
||||
<BedDouble size={13} strokeWidth={1.75} />
|
||||
{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'}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`card task-card ${t.priority === 'urgent' ? 'urgent' : t.priority === 'high' ? 'high' : ''} ${t.unusable ? 'unusable' : ''}`}
|
||||
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : undefined}
|
||||
// Occupied (stayover) rooms grey out hard — not accessible today. Departing
|
||||
// rooms are lightly dimmed only: guest is still in there right now, but the
|
||||
// room frees up later today so it's worth flagging rather than hiding.
|
||||
style={isOccupied ? { opacity: 0.5, filter: 'grayscale(0.3)' } : isDeparting ? { opacity: 0.8 } : undefined}
|
||||
onClick={() => setOpenTask(t.id)}
|
||||
>
|
||||
<div className="task-card-main">
|
||||
<div className="task-card-title">
|
||||
{t.title}
|
||||
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
|
||||
{isDeparting && <span className="badge" style={{ background: '#ede9fe', color: '#6d28d9', border: '1px solid #c4b5fd' }}><LogOut size={10} strokeWidth={1.75} /> Departing</span>}
|
||||
{isArriving && <span className="badge" style={{ background: '#fef3c7', color: '#92400e', border: '1px solid #fcd34d' }}><BedDouble size={10} strokeWidth={1.75} /> Arrival today</span>}
|
||||
{t.unusable && <UnusableBadge />}
|
||||
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue