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:
parent
3fc23eaecc
commit
25d6323aa4
4 changed files with 61 additions and 15 deletions
|
|
@ -25,16 +25,31 @@ function stripCosts(task) {
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOccupiedSiteIds() {
|
// 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.
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoomOccupancy() {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const bookings = await fetchBookings(today, today)
|
const bookings = await fetchBookings(today, today)
|
||||||
const occupied = new Set()
|
const occupied = new Set()
|
||||||
|
const arriving = new Set()
|
||||||
for (const b of bookings) {
|
for (const b of bookings) {
|
||||||
// NewBook returns booking_site_id on some account configurations, site_id on others
|
|
||||||
const siteId = b.booking_site_id ?? b.site_id
|
const siteId = b.booking_site_id ?? b.site_id
|
||||||
if (siteId != null) occupied.add(String(siteId))
|
if (siteId == null) continue
|
||||||
|
const id = String(siteId)
|
||||||
|
if (classifyBookingStatus(b.booking_status) === 'occupied') occupied.add(id)
|
||||||
|
else arriving.add(id)
|
||||||
}
|
}
|
||||||
return occupied
|
// A site that is both (e.g. multi-booking day) should be treated as occupied
|
||||||
|
for (const id of occupied) arriving.delete(id)
|
||||||
|
return { occupied, arriving }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function taskRoutes(app) {
|
export async function taskRoutes(app) {
|
||||||
|
|
@ -266,11 +281,16 @@ export async function taskRoutes(app) {
|
||||||
return { ok: true, added_to_template: addedToTemplate }
|
return { ok: true, added_to_template: addedToTemplate }
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET /api/occupancy — today's in-house NewBook site ids (for the unoccupied filter UI)
|
// 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)
|
||||||
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
|
app.get('/api/occupancy', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const occupied = await fetchOccupiedSiteIds()
|
const { occupied, arriving } = await fetchRoomOccupancy()
|
||||||
return { date: new Date().toISOString().slice(0, 10), occupied_site_ids: [...occupied] }
|
return {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
occupied_site_ids: [...occupied],
|
||||||
|
arriving_site_ids: [...arriving],
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
|
return reply.status(502).send({ error: `NewBook error: ${err.message}` })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }> {
|
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 }) })
|
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')
|
return request('/occupancy')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
.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); }
|
.muted { color: var(--text-mid); }
|
||||||
.overdue { color: var(--danger); font-weight: 600; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export default function Summary() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
|
const [occupiedSiteIds, setOccupiedSiteIds] = useState<Set<string>>(new Set())
|
||||||
|
const [arrivingSiteIds, setArrivingSiteIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
const [statusFilter, setStatusFilter] = useState<TaskStatus | null>(null)
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
const [categoryFilter, setCategoryFilter] = useState<number | null>(null)
|
||||||
|
|
@ -45,6 +46,7 @@ export default function Summary() {
|
||||||
.then(([t, occ]) => {
|
.then(([t, occ]) => {
|
||||||
setTasks(t)
|
setTasks(t)
|
||||||
setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set())
|
setOccupiedSiteIds(occ ? new Set(occ.occupied_site_ids) : new Set())
|
||||||
|
setArrivingSiteIds(occ ? new Set(occ.arriving_site_ids) : new Set())
|
||||||
setError(null)
|
setError(null)
|
||||||
})
|
})
|
||||||
.catch(err => setError(err.message))
|
.catch(err => setError(err.message))
|
||||||
|
|
@ -69,11 +71,17 @@ export default function Summary() {
|
||||||
return c
|
return c
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
// Count of rooms tasks with no in-house guest right now
|
// Rooms chip counts: occupied = guest in room, arriving = booked today not yet in
|
||||||
const freeRoomsCount = useMemo(() => {
|
const roomsCounts = useMemo(() => {
|
||||||
if (!roomsView) return null
|
if (!roomsView) return null
|
||||||
return tasks.filter(t => !t.newbook_site_id || !occupiedSiteIds.has(String(t.newbook_site_id))).length
|
let occupied = 0, arriving = 0
|
||||||
}, [tasks, roomsView, occupiedSiteIds])
|
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
|
const roomsCategoryExists = !!roomsCat
|
||||||
|
|
||||||
|
|
@ -126,8 +134,8 @@ export default function Summary() {
|
||||||
onClick={toggleRoomsView}
|
onClick={toggleRoomsView}
|
||||||
>
|
>
|
||||||
<BedDouble size={13} strokeWidth={1.75} />
|
<BedDouble size={13} strokeWidth={1.75} />
|
||||||
{roomsView
|
{roomsView && roomsCounts
|
||||||
? `Rooms (${tasks.length}${freeRoomsCount !== null ? ` • ${freeRoomsCount} free` : ''})`
|
? `Rooms (${roomsCounts.free} free${roomsCounts.arriving ? ` • ${roomsCounts.arriving} arriving` : ''}${roomsCounts.occupied ? ` • ${roomsCounts.occupied} occupied` : ''})`
|
||||||
: 'Rooms Accessible View'}
|
: 'Rooms Accessible View'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -138,7 +146,9 @@ export default function Summary() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tasks.map(t => {
|
{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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|
@ -150,6 +160,7 @@ export default function Summary() {
|
||||||
<div className="task-card-title">
|
<div className="task-card-title">
|
||||||
{t.title}
|
{t.title}
|
||||||
{isOccupied && <span className="badge badge-outline"><BedDouble size={10} strokeWidth={1.75} /> Occupied</span>}
|
{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.unusable && <UnusableBadge />}
|
||||||
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
{t.template_id && <span className="badge badge-outline">Recurring</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue