/hotelFront desk
Tonight's house: occupancy, ADR and RevPAR, an availability calendar, booking channels by week, the three guest lists and an arrivals sheet.
Installs at /hotel. Tonight is the night beginning on REFERENCE_DATE's own UTC day, and all four numbers are measured over it: occupancy is the distinct rooms sold against the house, ADR the average rate of the stays in it, RevPAR the same revenue spread over every room whether it sold or not — which is why RevPAR is always the smaller number and why both are worth showing. The room list is a constant beside the bookings rather than a fifth entity, because a hotel's inventory is fixed while its book is not; the seeded book is laid out room by room, so no two guests are ever in one room on the same night. A stay arriving today is confirmed rather than checked in, which is what leaves the desk an arrivals list to work through: checking a guest in and checking one out are the Hotel dashboard's own server actions, shared with the Bookings and Rooms pages through actions-hotel, and the answer — the guest and their room in the status line, or the reason it was refused in an alert, the list then showing the stay as the store holds it — lands beside the lists. The availability calendar's three fills are a rule over the number of rooms free, and that number is printed under it, so a night is never read by colour alone.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AvailabilityPanel } from "./components/availability-panel"
import { BookingSources } from "./components/booking-sources"
import { DeskFlow } from "./components/desk-flow"
import { DeskStats } from "./components/desk-stats"
import {
TONIGHT,
availability,
currentUser,
deskSummary,
frontDeskStats,
guestFlow,
shellNotifications,
sourceGrid,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
/**
* The front desk. The page is a server component: it reads every stay through
* `db`, works out tonight's numbers, and hands plain rows to the islands that
* need state — the calendar, the three lists and the arrivals sheet.
*/
export default function FrontDeskPage() {
const rooms = availability()
const flow = guestFlow()
const channels = sourceGrid()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Front desk"
description="Tonight's house: what is sold, who is arriving, and what is left."
meta={deskSummary()}
/>
<DeskStats stats={frontDeskStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<AvailabilityPanel
month={rooms.month}
rooms={rooms.rooms}
days={rooms.days}
today={new Date(TONIGHT)}
/>
</DashboardGridItem>
<DeskFlow flow={flow} sources={<BookingSources grid={channels} />} />
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/hotel-overviewNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AvailabilityPanel } from "./components/availability-panel"
import { BookingSources } from "./components/booking-sources"
import { DeskFlow } from "./components/desk-flow"
import { DeskStats } from "./components/desk-stats"
import {
TONIGHT,
availability,
currentUser,
deskSummary,
frontDeskStats,
guestFlow,
shellNotifications,
sourceGrid,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
/**
* The front desk. The page is a server component: it reads every stay through
* `db`, works out tonight's numbers, and hands plain rows to the islands that
* need state — the calendar, the three lists and the arrivals sheet.
*/
export default function FrontDeskPage() {
const rooms = availability()
const flow = guestFlow()
const channels = sourceGrid()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.overview}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Front desk"
description="Tonight's house: what is sold, who is arriving, and what is left."
meta={deskSummary()}
/>
<DeskStats stats={frontDeskStats()} />
<DashboardGrid>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<AvailabilityPanel
month={rooms.month}
rooms={rooms.rooms}
days={rooms.days}
today={new Date(TONIGHT)}
/>
</DashboardGridItem>
<DeskFlow flow={flow} sources={<BookingSources grid={channels} />} />
</DashboardGrid>
</AppShell>
)
}/**
* 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 }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* What this page changes on its own: the session. The desk's verbs — check
* in, check out, cancel — are the Hotel dashboard's, shared by every page
* that moves a stay, and live in `@/lib/dashboards/hotel/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { DESK_STATUS_LABELS, ROOM_TYPE_LABELS, SOURCE_LABELS, STAY_STATUS_MAP } from "@/lib/dashboards/hotel/vocabulary"
import { formatDate } from "@/lib/format"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type StayRow } from "../data"
// A night is a calendar day, so a departure is printed in UTC rather than
// through DateCell, whose format follows the reader's own zone — midnight UTC
// on the 7th is the evening of the 6th in Los Angeles, and a desk sheet that
// moved a checkout back a day would be worse than useless.
const utc = (date: Date) => formatDate(date, "short", { timeZone: "UTC" })
/** The arrivals sheet the desk prints: who is due in, into which room, for how long. */
export const ARRIVAL_COLUMNS: DataTableColumnDef<StayRow>[] = [
{
accessorKey: "reference",
header: ({ column }) => <DataTableColumnHeader column={column} title="Reference" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.reference}</span>,
meta: { label: "Reference" },
},
{
accessorKey: "guestName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Guest" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.guestName} src={row.original.guestAvatar} />,
meta: { label: "Guest" },
},
{
accessorKey: "room",
header: ({ column }) => <DataTableColumnHeader column={column} title="Room" />,
cell: ({ row }) => (
<span className="whitespace-nowrap">
{row.original.room} · {ROOM_TYPE_LABELS[row.original.roomType]}
</span>
),
meta: { label: "Room" },
},
{
accessorKey: "checkOut",
header: ({ column }) => <DataTableColumnHeader column={column} title="Out" />,
cell: ({ row }) => <span className="whitespace-nowrap">{utc(row.original.checkOut)}</span>,
meta: { label: "Out" },
},
{
accessorKey: "nights",
header: ({ column }) => <DataTableColumnHeader column={column} title="Nights" />,
cell: ({ row }) => <NumberCell value={row.original.nights} />,
meta: { align: "right", label: "Nights" },
},
{
accessorKey: "guests",
header: ({ column }) => <DataTableColumnHeader column={column} title="Guests" />,
cell: ({ row }) => <NumberCell value={row.original.guests} />,
meta: { align: "right", label: "Guests" },
},
{
accessorKey: "source",
header: ({ column }) => <DataTableColumnHeader column={column} title="Channel" />,
cell: ({ row }) => (
<span className="text-muted-foreground">{SOURCE_LABELS[row.original.source]}</span>
),
meta: { label: "Channel" },
},
{
accessorKey: "totalCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Total" />,
cell: ({ row }) => <CurrencyCell value={row.original.totalCents / 100} />,
meta: { align: "right", label: "Total" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge
status={row.original.status}
label={DESK_STATUS_LABELS[row.original.status]}
map={STAY_STATUS_MAP}
dot
/>
),
meta: { label: "Status" },
},
]"use client"
import * as React from "react"
import { SOURCE_LABELS, SOURCE_ORDER } from "@/lib/dashboards/hotel/vocabulary"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataTable } from "@/components/ui/data-table"
import { SectionHeader } from "@/components/ui/section-header"
import { type StayRow } from "../data"
import { ARRIVAL_COLUMNS } from "./arrival-columns"
export type ArrivalsTableProps = { rows: StayRow[] }
/** Tonight's arrivals in full: the sheet the desk works down. */
export function ArrivalsTable({ rows }: ArrivalsTableProps) {
// The heading names the table too, so a screen reader announces it by name.
const titleId = React.useId()
return (
<Card data-widget="widget-hotel-overview-arrivals-table">
<CardHeader>
<SectionHeader
as="h2"
title="Arrivals sheet"
titleId={titleId}
description="Every guest due in tonight, with the room they are going into."
/>
</CardHeader>
<CardContent>
<DataTable
aria-labelledby={titleId}
columns={ARRIVAL_COLUMNS}
data={rows}
getRowId={(row) => row.id}
searchKey="guestName"
searchPlaceholder="Find a guest…"
pageSize={10}
emptyMessage="Nobody is due in tonight."
facets={[
{
columnId: "source",
title: "Channel",
options: SOURCE_ORDER.map((value) => ({ value, label: SOURCE_LABELS[value] })),
},
]}
/>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { AvailabilityCalendar } from "@/components/ui/availability-calendar"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { SectionHeader } from "@/components/ui/section-header"
import { type AvailabilityDay } from "../data"
export type AvailabilityPanelProps = {
month: Date
rooms: number
days: AvailabilityDay[]
/** Tonight, at midnight UTC — the day the calendar opens on. */
today: Date
}
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })
/** How full a night reads: none left is full, a third or fewer is limited. */
function fillFor(free: number, rooms: number): "available" | "partial" | "full" {
if (free <= 0) return "full"
return free <= rooms / 3 ? "partial" : "available"
}
/**
* The month's availability. The three fills are a rule over the number of
* rooms free, not a colour picked per day, and the line under the calendar
* says the number itself — colour is never the only encoding.
*/
export function AvailabilityPanel({ month, rooms, days, today }: AvailabilityPanelProps) {
const [shown, setShown] = React.useState(month)
const [picked, setPicked] = React.useState<Date | undefined>(today)
const free = React.useMemo(
() => new Map(days.map((day) => [day.key, day.free])),
[days]
)
const marks = React.useMemo(() => {
const out: Record<string, MiniCalendarMark> = {}
for (const day of days) out[day.key] = { fill: fillFor(day.free, rooms) }
return out
}, [days, rooms])
const key = picked?.toISOString().slice(0, 10)
const left = key !== undefined ? free.get(key) : undefined
return (
<Card data-widget="widget-hotel-overview-availability-panel">
<CardHeader>
<SectionHeader as="h2" title="Availability" description="Rooms free, night by night." />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<AvailabilityCalendar
month={shown}
onMonthChange={setShown}
value={picked}
onValueChange={setPicked}
marks={marks}
today={today}
legendLabels={{ full: "Sold out" }}
className="w-full"
/>
<p data-slot="availability-readout" className="text-xs text-muted-foreground">
{picked === undefined
? "Pick a night."
: left === undefined
? `${FULL.format(picked)} is outside the book.`
: `${FULL.format(picked)} — ${left} of ${rooms} rooms free${left === 0 ? ", the house is sold out" : ""}.`}
</p>
</CardContent>
</Card>
)
}import { ChartCard } from "@/components/ui/chart-card"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { type SourceGrid } from "../data"
export type BookingSourcesProps = { grid: SourceGrid }
/**
* Where the arrivals came from, week by week. No state and no handlers — and,
* deliberately, no formatter either: a function cannot cross from a server
* component into a client one, and a 24px cell has room for the number itself
* rather than a sentence about it.
*/
export function BookingSources({ grid }: BookingSourcesProps) {
return (
<ChartCard
data-widget="widget-hotel-overview-booking-sources"
title="Booking channels"
description="Arrivals per channel, eight weeks around tonight"
height={220}
>
<HeatmapGrid
aria-label="Arrivals per channel by week"
rows={grid.rows}
columns={grid.columns}
values={grid.values}
cellSize={24}
showValues
/>
</ChartCard>
)
}"use client"
import * as React from "react"
import { DashboardGridItem } from "@/components/ui/dashboard-grid"
import { type GuestFlow, type StayRow } from "../data"
import { ArrivalsTable } from "./arrivals-table"
import { GuestFlowPanel, settleStay } from "./guest-flow"
export type DeskFlowProps = { flow: GuestFlow; sources: React.ReactNode }
/**
* The guest-flow panel and the arrivals sheet read the same stays, so a
* check-in or check-out has to reach both — the panel owning the rows on its
* own left the sheet bound to the server's frozen props, so a guest read "in
* house" above and "due in" below the moment the desk acted. One piece of
* state here, shared by both, is what a `router.refresh()` cannot be for a
* framed preview's static data. Booking sources sits between them so the page
* keeps its original order; it arrives as a slot from the server page, so it
* stays a server component instead of riding into the client bundle.
*/
export function DeskFlow({ flow, sources }: DeskFlowProps) {
const [lists, setLists] = React.useState(flow)
function settle(row: StayRow) {
setLists((current) => settleStay(current, row))
}
return (
<>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<GuestFlowPanel flow={lists} onSettle={settle} />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
{sources}
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<ArrivalsTable rows={lists.arrivals} />
</DashboardGridItem>
</>
)
}import * as React from "react"
import { BedDoubleIcon, DoorOpenIcon, ReceiptIcon, TrendingUpIcon } from "lucide-react"
import { StatCard, type StatCardProps } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type FrontDeskStat } from "../data"
export type DeskStatsProps = { stats: FrontDeskStat[] }
// Each tile in its own tint, with the icon that names it: a duty manager
// finds tonight's occupancy by the blue frame before reading a word.
const TILE: Record<string, { tone: NonNullable<StatCardProps["tone"]>; icon: React.ReactNode }> = {
occupancy: { tone: "info", icon: <BedDoubleIcon /> },
adr: { tone: "success", icon: <ReceiptIcon /> },
revpar: { tone: "warning", icon: <TrendingUpIcon /> },
arrivals: { tone: "danger", icon: <DoorOpenIcon /> },
}
/** Tonight in four numbers. No state, so the tiles render on the server. */
export function DeskStats({ stats }: DeskStatsProps) {
return (
<StatCardGroup data-widget="widget-hotel-overview-desk-stats" columns={4}>
{stats.map((stat) => (
<StatCard
key={stat.id}
id={stat.id}
label={stat.label}
value={stat.value}
description={stat.description}
tone={TILE[stat.id]?.tone}
icon={TILE[stat.id]?.icon}
/>
))}
</StatCardGroup>
)
}"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>
)
}