Differentiate arrived vs arriving-today in Rooms Accessible View

A confirmed booking for today that hasn't checked in yet is accessible —
staff can get in before the guest arrives. Previously it was lumped with
in-room guests and greyed out.

Backend: classifyBookingStatus() splits NewBook 'staying' bookings by
status string (normalised). Statuses containing 'arriv', 'inhouse' or
'checkedin' → occupied; everything else → arriving. /api/occupancy now
returns both occupied_site_ids and arriving_site_ids.

Frontend:
- Occupied rooms: greyed out + 'Occupied' badge (guest physically in room)
- Arriving today: normal display + amber 'Arrival today' badge (access now,
  prioritise before guest checks in)
- Chip label shows 'Rooms (X free • Y arriving • Z occupied)' breakdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-09 12:29:26 +00:00
parent 3fc23eaecc
commit 25d6323aa4
4 changed files with 61 additions and 15 deletions

View file

@ -84,7 +84,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[] }> {
export function fetchOccupancy(): Promise<{ date: string; occupied_site_ids: string[]; arriving_site_ids: string[] }> {
return request('/occupancy')
}

View file

@ -375,3 +375,18 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
.section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin: 18px 0 8px; }
.muted { color: var(--text-mid); }
.overdue { color: var(--danger); font-weight: 600; }
/* Sidebar scrollbar */
.nav-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar,
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.nav-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track,
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.nav-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb,
.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; }
.nav-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover,
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; }

View file

@ -19,6 +19,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 [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
@ -45,6 +46,7 @@ export default function Summary() {
.then(([t, occ]) => {
setTasks(t)
setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set())
setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set())
setError(null)
})
.catch(err => setError(err.message))
@ -69,11 +71,17 @@ export default function Summary() {
return c
}, [tasks])
// Count of rooms tasks with no in-house guest right now
const freeRoomsCount = useMemo(() => {
// Rooms chip counts: occupied = guest in room, arriving = booked today not yet in
const roomsCounts = useMemo(() => {
if (!roomsView) return null
return tasks.filter(t => !t.newbook_site_id || !occupiedSiteIds.has(String(t.newbook_site_id))).length
}, [tasks, roomsView, occupiedSiteIds])
let occupied = 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 && arrivingSiteIds.has(sid)) arriving++
}
return { total: tasks.length, occupied, arriving, free: tasks.length - occupied - arriving }
}, [tasks, roomsView, occupiedSiteIds, arrivingSiteIds])
const roomsCategoryExists = !!roomsCat
@ -126,8 +134,8 @@ export default function Summary() {
onClick={toggleRoomsView}
>
<BedDouble size={13} strokeWidth={1.75} />
{roomsView
? `Rooms (${tasks.length}${freeRoomsCount !== null ? `${freeRoomsCount} free` : ''})`
{roomsView && roomsCounts
? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? `${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? `${roomsCounts.occupied} occupied` : ''})`
: 'Rooms Accessible View'}
</button>
)}
@ -138,7 +146,9 @@ export default function Summary() {
)}
{tasks.map(t => {
const isOccupied = roomsView && !!t.newbook_site_id && occupiedSiteIds.has(String(t.newbook_site_id))
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)
return (
<div
key={t.id}
@ -150,6 +160,7 @@ export default function Summary() {
<div className="task-card-title">
{t.title}
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</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>}
</div>