Guest flow
Today's arrivals, the house and the departures in three tabs, each guest checked in or out through the server from their row; reads guestFlow().
Preview
"use client"
import * as React from "react"
import { LogInIcon, LogOutIcon } from "lucide-react"
import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { NavTabs } from "@/components/ui/nav-tabs"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { checkIn, checkOut } from "@/lib/dashboards/hotel/actions"
import { DESK_STATUS_LABELS, ROOM_TYPE_LABELS, STAY_STATUS_MAP } from "@/lib/dashboards/hotel/vocabulary"
import { type GuestFlow, type StayRow } from "../data"
const TABS = [
{ id: "arrivals", label: "Arrivals" },
{ id: "inHouse", label: "In house" },
{ id: "departures", label: "Departures" },
] as const
type TabId = (typeof TABS)[number]["id"]
const utc = (date: Date) => formatDate(date, "short", { timeZone: "UTC" })
export type GuestFlowPanelProps = {
flow: GuestFlow
/**
* A check-in or check-out settled. The page passes this: the arrivals sheet
* reads the same stays, so the rows are the page's to hold. Drawn alone,
* with no other card to tell, the panel holds them itself.
*/
onSettle?: (row: StayRow) => void
}
/** The three lists with `row` in place of the stay it settled, wherever that stay is listed. */
export function settleStay(flow: GuestFlow, row: StayRow): GuestFlow {
const swap = (stays: StayRow[]) => stays.map((stay) => (stay.id === row.id ? row : stay))
return { arrivals: swap(flow.arrivals), inHouse: swap(flow.inHouse), departures: swap(flow.departures) }
}
/**
* The desk's three lists, and the two things it does with them. Both actions
* write through the server and report a `Result`; the guest and their room
* lands in a status line, because a row that quietly changed pill would not
* tell anyone anything, and the reason a check-in or check-out was refused
* gets its own alert rather than sharing that line — a refusal is not a
* notice, and a reader skimming past a muted status line is the reason this
* finding exists.
*
* On the page the rows are the caller's: another view (the arrivals sheet)
* reads the same stays, so the state that a settle changes is lifted rather
* than kept here, where only this panel could see it move. Drawn alone, with
* no `onSettle`, the panel keeps the rows it was handed and settles them
* itself.
*/
export function GuestFlowPanel({ flow: handed, onSettle }: GuestFlowPanelProps) {
const [own, setOwn] = React.useState(handed)
const flow = onSettle ? handed : own
const settle = onSettle ?? ((row: StayRow) => setOwn((current) => settleStay(current, row)))
const [tab, setTab] = React.useState<TabId>("arrivals")
const [notice, setNotice] = React.useState<string>("")
const [refusal, setRefusal] = React.useState<string>("")
const panelId = React.useId()
const rows = flow[tab]
// The desk's own verbs, shared with the other Hotel pages. Each answers
// with the stay as the store now holds it — a refusal too — and the row
// here takes its status, so a list never shows a state the desk was told
// is wrong.
async function arrive(row: StayRow) {
setRefusal("")
setNotice("")
const result = await checkIn(row.id)
if (!result.ok) {
if (result.current) settle({ ...row, status: result.current.status })
return setRefusal(result.error.message)
}
settle({ ...row, status: result.data.status })
setNotice(`${result.data.guestName} is in room ${result.data.room}.`)
}
async function leave(row: StayRow) {
setRefusal("")
setNotice("")
const result = await checkOut(row.id)
if (!result.ok) {
if (result.current) settle({ ...row, status: result.current.status })
return setRefusal(result.error.message)
}
settle({ ...row, status: result.data.status })
setNotice(`Room ${result.data.room} is free — ${result.data.guestName} has checked out.`)
}
const next = flow.arrivals.find((stay) => stay.status === "confirmed")
const leaving = flow.departures.find((stay) => stay.status === "checked_in")
return (
<Card data-widget="widget-hotel-overview-guest-flow" className="h-full">
<CardHeader>
<SectionHeader as="h2" title="Today" description="Who is coming in, who is here, who is going out." />
<div data-slot="quick-actions" className="flex flex-wrap gap-2" aria-label="Quick actions">
<Button size="sm" variant="outline" disabled={!next} onClick={() => next && arrive(next)}>
<LogInIcon />
Check in next arrival
</Button>
<Button size="sm" variant="outline" disabled={!leaving} onClick={() => leaving && leave(leaving)}>
<LogOutIcon />
Check out next departure
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<NavTabs
role="tablist"
aria-label="Guest flow"
items={TABS.map((entry) => ({
label: entry.label,
href: entry.id,
count: flow[entry.id].length,
}))}
activeHref={tab}
renderLink={(item, children, className) => (
<button
type="button"
role="tab"
data-slot="nav-tabs-item"
data-active={item.href === tab || undefined}
aria-selected={item.href === tab}
aria-controls={panelId}
className={className}
onClick={() => setTab(item.href as TabId)}
>
{children}
</button>
)}
/>
<div id={panelId} role="tabpanel" aria-label={TABS.find((entry) => entry.id === tab)!.label}>
<DataList divided>
{rows.map((stay) => (
<DataListItem
key={stay.id}
leading={
<Avatar size="sm">
<AvatarImage src={stay.guestAvatar} alt="" />
<AvatarFallback>{getInitials(stay.guestName)}</AvatarFallback>
</Avatar>
}
title={stay.guestName}
description={`Room ${stay.room} · ${ROOM_TYPE_LABELS[stay.roomType]} · ${stay.nights} nights`}
meta={`${utc(stay.checkIn)} — ${utc(stay.checkOut)}`}
actions={
<div className="flex items-center gap-2">
<StatusBadge status={stay.status} label={DESK_STATUS_LABELS[stay.status]} map={STAY_STATUS_MAP} />
{stay.status === "confirmed" ? (
<Button size="xs" variant="outline" onClick={() => arrive(stay)}>
Check in
</Button>
) : null}
{stay.status === "checked_in" && tab === "departures" ? (
<Button size="xs" variant="outline" onClick={() => leave(stay)}>
Check out
</Button>
) : null}
</div>
}
/>
))}
</DataList>
{rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Nothing on this list today.</p>
) : null}
</div>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-overview-guest-flowNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { LogInIcon, LogOutIcon } from "lucide-react"
import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { NavTabs } from "@/components/ui/nav-tabs"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { checkIn, checkOut } from "@/lib/dashboards/hotel/actions"
import { DESK_STATUS_LABELS, ROOM_TYPE_LABELS, STAY_STATUS_MAP } from "@/lib/dashboards/hotel/vocabulary"
import { type GuestFlow, type StayRow } from "../data"
const TABS = [
{ id: "arrivals", label: "Arrivals" },
{ id: "inHouse", label: "In house" },
{ id: "departures", label: "Departures" },
] as const
type TabId = (typeof TABS)[number]["id"]
const utc = (date: Date) => formatDate(date, "short", { timeZone: "UTC" })
export type GuestFlowPanelProps = {
flow: GuestFlow
/**
* A check-in or check-out settled. The page passes this: the arrivals sheet
* reads the same stays, so the rows are the page's to hold. Drawn alone,
* with no other card to tell, the panel holds them itself.
*/
onSettle?: (row: StayRow) => void
}
/** The three lists with `row` in place of the stay it settled, wherever that stay is listed. */
export function settleStay(flow: GuestFlow, row: StayRow): GuestFlow {
const swap = (stays: StayRow[]) => stays.map((stay) => (stay.id === row.id ? row : stay))
return { arrivals: swap(flow.arrivals), inHouse: swap(flow.inHouse), departures: swap(flow.departures) }
}
/**
* The desk's three lists, and the two things it does with them. Both actions
* write through the server and report a `Result`; the guest and their room
* lands in a status line, because a row that quietly changed pill would not
* tell anyone anything, and the reason a check-in or check-out was refused
* gets its own alert rather than sharing that line — a refusal is not a
* notice, and a reader skimming past a muted status line is the reason this
* finding exists.
*
* On the page the rows are the caller's: another view (the arrivals sheet)
* reads the same stays, so the state that a settle changes is lifted rather
* than kept here, where only this panel could see it move. Drawn alone, with
* no `onSettle`, the panel keeps the rows it was handed and settles them
* itself.
*/
export function GuestFlowPanel({ flow: handed, onSettle }: GuestFlowPanelProps) {
const [own, setOwn] = React.useState(handed)
const flow = onSettle ? handed : own
const settle = onSettle ?? ((row: StayRow) => setOwn((current) => settleStay(current, row)))
const [tab, setTab] = React.useState<TabId>("arrivals")
const [notice, setNotice] = React.useState<string>("")
const [refusal, setRefusal] = React.useState<string>("")
const panelId = React.useId()
const rows = flow[tab]
// The desk's own verbs, shared with the other Hotel pages. Each answers
// with the stay as the store now holds it — a refusal too — and the row
// here takes its status, so a list never shows a state the desk was told
// is wrong.
async function arrive(row: StayRow) {
setRefusal("")
setNotice("")
const result = await checkIn(row.id)
if (!result.ok) {
if (result.current) settle({ ...row, status: result.current.status })
return setRefusal(result.error.message)
}
settle({ ...row, status: result.data.status })
setNotice(`${result.data.guestName} is in room ${result.data.room}.`)
}
async function leave(row: StayRow) {
setRefusal("")
setNotice("")
const result = await checkOut(row.id)
if (!result.ok) {
if (result.current) settle({ ...row, status: result.current.status })
return setRefusal(result.error.message)
}
settle({ ...row, status: result.data.status })
setNotice(`Room ${result.data.room} is free — ${result.data.guestName} has checked out.`)
}
const next = flow.arrivals.find((stay) => stay.status === "confirmed")
const leaving = flow.departures.find((stay) => stay.status === "checked_in")
return (
<Card data-widget="widget-hotel-overview-guest-flow" className="h-full">
<CardHeader>
<SectionHeader as="h2" title="Today" description="Who is coming in, who is here, who is going out." />
<div data-slot="quick-actions" className="flex flex-wrap gap-2" aria-label="Quick actions">
<Button size="sm" variant="outline" disabled={!next} onClick={() => next && arrive(next)}>
<LogInIcon />
Check in next arrival
</Button>
<Button size="sm" variant="outline" disabled={!leaving} onClick={() => leaving && leave(leaving)}>
<LogOutIcon />
Check out next departure
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<NavTabs
role="tablist"
aria-label="Guest flow"
items={TABS.map((entry) => ({
label: entry.label,
href: entry.id,
count: flow[entry.id].length,
}))}
activeHref={tab}
renderLink={(item, children, className) => (
<button
type="button"
role="tab"
data-slot="nav-tabs-item"
data-active={item.href === tab || undefined}
aria-selected={item.href === tab}
aria-controls={panelId}
className={className}
onClick={() => setTab(item.href as TabId)}
>
{children}
</button>
)}
/>
<div id={panelId} role="tabpanel" aria-label={TABS.find((entry) => entry.id === tab)!.label}>
<DataList divided>
{rows.map((stay) => (
<DataListItem
key={stay.id}
leading={
<Avatar size="sm">
<AvatarImage src={stay.guestAvatar} alt="" />
<AvatarFallback>{getInitials(stay.guestName)}</AvatarFallback>
</Avatar>
}
title={stay.guestName}
description={`Room ${stay.room} · ${ROOM_TYPE_LABELS[stay.roomType]} · ${stay.nights} nights`}
meta={`${utc(stay.checkIn)} — ${utc(stay.checkOut)}`}
actions={
<div className="flex items-center gap-2">
<StatusBadge status={stay.status} label={DESK_STATUS_LABELS[stay.status]} map={STAY_STATUS_MAP} />
{stay.status === "confirmed" ? (
<Button size="xs" variant="outline" onClick={() => arrive(stay)}>
Check in
</Button>
) : null}
{stay.status === "checked_in" && tab === "departures" ? (
<Button size="xs" variant="outline" onClick={() => leave(stay)}>
Check out
</Button>
) : null}
</div>
}
/>
))}
</DataList>
{rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Nothing on this list today.</p>
) : null}
</div>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What this page reads. Every stay comes from `db.bookings` and every room
* from the house's own `ROOMS`; nothing here is written out, and "now" is
* `REFERENCE_DATE` — tonight is the night that starts on its UTC day, which is
* what occupancy, ADR and RevPAR are all measured over.
*
* The client islands import only the types below; `db` never crosses into the
* browser.
*/
import { SOURCE_LABELS, SOURCE_ORDER } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency, formatPercent, getInitials } from "@/lib/format"
import { REFERENCE_DATE, ROOMS, db, type Booking, type Member, type Room } from "@/lib/sample-data"
const DAY = 86_400_000
/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
function utcDay(date: Date): number {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
/** Tonight: the night that begins on the reference date's own UTC day. */
export const TONIGHT = utcDay(REFERENCE_DATE)
const ROOM = new Map(ROOMS.map((room) => [room.id, room]))
export type StayStatus = Booking["status"]
export type StayRow = {
id: string
reference: string
guestName: string
guestAvatar: string
room: string
roomType: Room["type"]
status: StayStatus
checkIn: Date
checkOut: Date
nights: number
guests: number
ratePerNightCents: number
totalCents: number
source: Booking["source"]
}
/** One stay as this page reads it. */
function toRow(booking: Booking): StayRow {
const room = ROOM.get(booking.roomId)
return {
id: booking.id,
reference: booking.reference,
guestName: booking.guestName,
guestAvatar: booking.guestAvatar,
room: room?.number ?? "—",
roomType: room?.type ?? "standard",
status: booking.status,
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
guests: booking.adults + booking.children,
ratePerNightCents: booking.ratePerNightCents,
totalCents: booking.totalCents,
source: booking.source,
}
}
/** A cancellation or a no-show holds no room; everything else does. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"
/** The stays that cover the night beginning at `night`. */
function occupying(night: number): Booking[] {
return db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) <= night && utcDay(booking.checkOut) > night)
}
export type FrontDeskStat = { id: string; label: string; value: string; description: string }
/** The four numbers a duty manager reads first, all over tonight. */
export function frontDeskStats(): FrontDeskStat[] {
const tonight = occupying(TONIGHT)
const sold = new Set(tonight.map((booking) => booking.roomId)).size
const revenue = tonight.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
const arrivals = db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) === TONIGHT)
const adr = tonight.length > 0 ? revenue / tonight.length : 0
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
return [
{
id: "occupancy",
label: "Occupancy",
value: formatPercent(sold / ROOMS.length, { maximumFractionDigits: 0 }),
description: `${sold} of ${ROOMS.length} rooms sold`,
},
{ id: "adr", label: "ADR", value: money(adr), description: "Average daily rate tonight" },
{
id: "revpar",
label: "RevPAR",
value: money(revenue / ROOMS.length),
description: "Revenue per available room",
},
{
id: "arrivals",
label: "Arrivals",
value: String(arrivals.length),
description: `${arrivals.filter((booking) => booking.status === "confirmed").length} still to check in`,
},
]
}
export type GuestFlow = { arrivals: StayRow[]; inHouse: StayRow[]; departures: StayRow[] }
/** Who is coming in, who is here, and who is going out — today. */
export function guestFlow(): GuestFlow {
const rows = db.bookings.all().filter(held)
const by = (a: StayRow, b: StayRow) => a.room.localeCompare(b.room)
return {
arrivals: rows.filter((booking) => utcDay(booking.checkIn) === TONIGHT).map(toRow).sort(by),
inHouse: rows
.filter(
(booking) => utcDay(booking.checkIn) < TONIGHT && utcDay(booking.checkOut) > TONIGHT
)
.map(toRow)
.sort(by),
departures: rows.filter((booking) => utcDay(booking.checkOut) === TONIGHT).map(toRow).sort(by),
}
}
export type AvailabilityDay = { key: string; free: number }
/** How many rooms are free each night of the month tonight falls in. */
export function availability(): { month: Date; rooms: number; days: AvailabilityDay[] } {
const start = new Date(TONIGHT)
const first = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)
const length = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate()
const days = Array.from({ length }, (_, index) => {
const night = first + index * DAY
const sold = new Set(occupying(night).map((booking) => booking.roomId)).size
return { key: new Date(night).toISOString().slice(0, 10), free: ROOMS.length - sold }
})
return { month: new Date(first), rooms: ROOMS.length, days }
}
export type SourceGrid = { rows: string[]; columns: string[]; values: number[][] }
const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** Arrivals per channel over the eight weeks around tonight, Monday to Monday. */
export function sourceGrid(): SourceGrid {
const monday = TONIGHT - ((new Date(TONIGHT).getUTCDay() + 6) % 7) * DAY
const weeks = Array.from({ length: 8 }, (_, index) => monday + (index - 4) * 7 * DAY)
const rows = db.bookings.all().filter(held)
return {
rows: SOURCE_ORDER.map((source) => SOURCE_LABELS[source]),
columns: weeks.map((week) => WEEK_LABEL.format(new Date(week))),
values: SOURCE_ORDER.map((source) =>
weeks.map(
(week) =>
rows.filter(
(booking) =>
booking.source === source &&
utcDay(booking.checkIn) >= week &&
utcDay(booking.checkIn) < week + 7 * DAY
).length
)
),
}
}
/** The line under the title: tonight's shape in one sentence. */
export function deskSummary(): string {
const { arrivals, inHouse, departures } = guestFlow()
return `${arrivals.length} arrivals · ${inHouse.length} in house · ${departures.length} departures`
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever is on the desk. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Front desk page