/hotel/roomsRoom rack
The house tonight as a floor plan: thirty-six rooms on three floors, each tile drawn to its room and filled by tonight's state, with a legend that counts them, each floor's occupancy, a sheet per room that checks its guest in or out, the day's turnovers and the rack rates against what tonight pays.
Installs at /hotel/rooms. The page is a server component: floorPlan lays every one of ROOMS out with the stays that touch today — the one arriving, the one staying over, the one leaving — and the next one due, reading db.bookings and counting only a stay that still holds its room (confirmed or checked in). roomState in vocabulary.ts reads tonight's state off a slot by one rule, run on the server for the header and in the browser after every action: a guest due in today who has not reached the desk makes a room arriving, a stay across REFERENCE_DATE makes it occupied, a stay ending today with no one behind it makes it departing, and anything else is free. Solid is sold tonight — grey for a guest already in, the info tone for one on the way — and light is empty tonight: the warning tint for a room being vacated, a dashed outline for one to sell. The grey and the blue share a lightness, so every state also carries its own icon — a bed, a way in, a way out, an open door — on the tiles and in the legend, which keys each state as a small tile with that icon, its word and its count, so the plan reads in greyscale too. A tile is as wide as its room, a standard on two of the plan's thirty-two columns, a deluxe on three and a suite on four. The plan is one ARIA grid of three rows, the floors, and one tab stop: Left and Right walk the rooms along a floor, Up and Down move between floors to the same place on the next — at the narrow width too, where a floor wraps onto three lines and Up and Down still go to the floor above or below rather than to the line — Home and End jump to a floor's ends, and Enter opens the room's sheet, which checks its arrival in or its departure out through the Hotel dashboard's own actions from actions-hotel — the tile, the legend's counts and the turnovers all follow, because they read the same slots, and a refused action moves them to the state the store reports. Composes AppShell, PageHeader, DashboardGrid, DashboardGridItem, Card, ProgressStat, InspectorPanel, SectionHeader, UserCell, DescriptionList, StatusBadge, AsyncButton, Callout, DataListItem, AvatarGroup and SimpleTable.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
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 { FloorPlan } from "./components/floor-plan"
import { RackRates } from "./components/rack-rates"
import { RoomRackProvider } from "./components/room-rack"
import { Turnovers } from "./components/turnovers"
import { currentUser, floorPlan, nightName, rackRates, shellNotifications, tonightLine } from "./data"
/**
* The room rack. The page is a server component: it reads the house and
* tonight's stays through `db`, lays every room out with the stays that
* touch today, and hands those slots to the rack — the provider the plan, the
* turnovers and the room sheet read, so a check-in made from a room reaches
* all three. The rack-rate table has no state, so it renders here.
*/
export default function RoomsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.rooms}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader title="Rooms" description="The house tonight, floor by floor." meta={tonightLine()} />
<RoomRackProvider slots={floorPlan()}>
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<FloorPlan night={nightName()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<RackRates rates={rackRates()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<Turnovers />
</DashboardGridItem>
</DashboardGrid>
</RoomRackProvider>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/hotel-roomsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
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 { FloorPlan } from "./components/floor-plan"
import { RackRates } from "./components/rack-rates"
import { RoomRackProvider } from "./components/room-rack"
import { Turnovers } from "./components/turnovers"
import { currentUser, floorPlan, nightName, rackRates, shellNotifications, tonightLine } from "./data"
/**
* The room rack. The page is a server component: it reads the house and
* tonight's stays through `db`, lays every room out with the stays that
* touch today, and hands those slots to the rack — the provider the plan, the
* turnovers and the room sheet read, so a check-in made from a room reaches
* all three. The rack-rate table has no state, so it renders here.
*/
export default function RoomsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.rooms}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader title="Rooms" description="The house tonight, floor by floor." meta={tonightLine()} />
<RoomRackProvider slots={floorPlan()}>
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<FloorPlan night={nightName()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<RackRates rates={rackRates()} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<Turnovers />
</DashboardGridItem>
</DashboardGrid>
</RoomRackProvider>
</AppShell>
)
}/**
* What this page reads: the house's own `ROOMS` and `db.bookings`. For every
* room it finds the stays that touch today — the one arriving, the one staying
* over, the one leaving — and the next one due, and hands those slots to the
* floor plan, which reads each room's state off them with `roomState` in
* `vocabulary.ts`; the same rule runs here for the header's count. Only a
* stay that still holds the room counts: a cancellation, a no-show or a stay
* already checked out holds none. "Now" is `REFERENCE_DATE`, tonight the
* night that begins on its UTC day.
*
* The client islands import only the types; `db` never crosses into the browser.
*/
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, ROOMS, db, type Booking, type Member } from "@/lib/sample-data"
import { isSold, roomState, type RoomSlot, type RoomType, type SlotStay } from "./vocabulary"
/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
const utcDay = (date: Date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
/** Tonight: the night that begins on the reference date's own UTC day. */
const TONIGHT = utcDay(REFERENCE_DATE)
const holds = (booking: Booking) => booking.status === "confirmed" || booking.status === "checked_in"
function toStay(booking: Booking): SlotStay {
return {
id: booking.id,
reference: booking.reference,
guestName: booking.guestName,
guestAvatar: booking.guestAvatar,
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
ratePerNightCents: booking.ratePerNightCents,
status: booking.status,
}
}
/** Every room in the house, door order, with the stays that touch today and the next one due. */
export function floorPlan(): RoomSlot[] {
const book = db.bookings.all().filter(holds)
return ROOMS.map((room) => {
const stays = book.filter((booking) => booking.roomId === room.id)
const next = stays
.filter((booking) => utcDay(booking.checkIn) > TONIGHT)
.sort((a, b) => a.checkIn.getTime() - b.checkIn.getTime())[0]
const arrival = stays.find((booking) => utcDay(booking.checkIn) === TONIGHT)
const stayover = stays.find((booking) => utcDay(booking.checkIn) < TONIGHT && utcDay(booking.checkOut) > TONIGHT)
const departure = stays.find((booking) => utcDay(booking.checkOut) === TONIGHT)
return {
id: room.id,
number: room.number,
type: room.type,
floor: room.floor,
beds: room.beds,
rackRateCents: room.rackRateCents,
...(arrival ? { arrival: toStay(arrival) } : {}),
...(stayover ? { stayover: toStay(stayover) } : {}),
...(departure ? { departure: toStay(departure) } : {}),
...(next ? { next: toStay(next) } : {}),
}
})
}
export type RackRate = {
type: RoomType
rooms: number
beds: number
rackRateCents: number
/** Rooms of this type someone sleeps in tonight. */
sold: number
/** What the stays in them pay tonight, in all and on average, in minor units; zero when none is sold. */
paidCents: number
averageCents: number
}
/** What each room type asks, and what the stays in it tonight actually pay. */
export function rackRates(): RackRate[] {
const tonight = db.bookings
.all()
.filter((booking) => holds(booking) && utcDay(booking.checkIn) <= TONIGHT && utcDay(booking.checkOut) > TONIGHT)
return (["standard", "deluxe", "suite"] as const).map((type) => {
const rooms = ROOMS.filter((room) => room.type === type)
const ids = new Set(rooms.map((room) => room.id))
const stays = tonight.filter((booking) => ids.has(booking.roomId))
const paid = stays.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
return {
type,
rooms: rooms.length,
beds: rooms[0].beds,
rackRateCents: rooms[0].rackRateCents,
sold: new Set(stays.map((booking) => booking.roomId)).size,
paidCents: paid,
averageCents: stays.length === 0 ? 0 : Math.round(paid / stays.length),
}
})
}
const NIGHT = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** "Friday, September 4": the night the plan is of. */
export function nightName(): string {
return NIGHT.format(new Date(TONIGHT))
}
/** The line under the title: which night, and how much of the house is sold for it. */
export function tonightLine(): string {
const sold = floorPlan().filter((slot) => isSold(roomState(slot))).length
return `${nightName()} · ${sold} of ${ROOMS.length} rooms sold`
}
/** 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 check-in and check-out
* the room sheet offers are the Hotel dashboard's own actions, shared with
* the front desk and the bookings table, and live in
* `@/lib/dashboards/hotel/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import { type Booking, type Room } from "@/lib/sample-data"
/**
* The shape of a room tonight, the rule that reads its state and the words
* the page prints for a state. Plain types and functions with no rows behind
* them — the entity types above are erased — so `data.ts` builds the slots on
* the server and the floor plan re-reads each slot's state in the browser
* after a desk action: one rule for both, so a tile never disagrees with the
* count beside it. The words for a room type and a night are the Hotel
* dashboard's, in `@/lib/dashboards/hotel/vocabulary`.
*/
export type RoomType = Room["type"]
export type StayStatus = Booking["status"]
/** One stay as the room rack shows it. */
export type SlotStay = {
id: string
reference: string
guestName: string
guestAvatar: string
checkIn: Date
checkOut: Date
nights: number
ratePerNightCents: number
status: StayStatus
}
/** A room and the stays that touch today: the one arriving, the one staying over, the one leaving, and the next. */
export type RoomSlot = {
id: string
/** The number on the door. */
number: string
type: RoomType
floor: number
beds: number
rackRateCents: number
arrival?: SlotStay
stayover?: SlotStay
departure?: SlotStay
next?: SlotStay
}
export type RoomState = "arriving" | "occupied" | "departing" | "free"
/** A stay that still holds the room: booked, or in it. A cancellation, a no-show or a finished stay does not. */
export function live(stay: SlotStay | undefined): SlotStay | undefined {
return stay && (stay.status === "confirmed" || stay.status === "checked_in") ? stay : undefined
}
/**
* Tonight's state of a room. A guest due in today who has not reached the
* desk makes it arriving; someone sleeping in it tonight — a stay-over, or
* today's arrival once checked in — makes it occupied; a guest leaving today
* with no one behind them makes it departing; anything else is free.
*/
export function roomState(slot: RoomSlot): RoomState {
const arrival = live(slot.arrival)
if (arrival?.status === "confirmed") return "arriving"
if (arrival || live(slot.stayover)) return "occupied"
if (live(slot.departure)) return "departing"
return "free"
}
/** Sold tonight: someone sleeps in the room, whether already in it or on the way. */
export const isSold = (state: RoomState) => state === "arriving" || state === "occupied"
/** The legend's order: tonight's house, then today's movement, then what is left. */
export const STATES: RoomState[] = ["occupied", "arriving", "departing", "free"]
export const STATE_LABELS: Record<RoomState, string> = {
arriving: "Arriving",
occupied: "Occupied",
departing: "Departing",
free: "Free",
}
export const STATE_HINTS: Record<RoomState, string> = {
arriving: "Due in today",
occupied: "In the room tonight",
departing: "Due out today",
free: "To sell tonight",
}
/** The floors top down, the way the building stands. */
export const FLOORS = [3, 2, 1]"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { ProgressStat } from "@/components/ui/progress-stat"
import { FLOORS, isSold, roomState, type RoomSlot } from "../vocabulary"
import { RoomTile, SPAN } from "./room-tile"
const KEYS = new Set(["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"])
export type FloorGridProps = {
slots: RoomSlot[]
/** "Rooms on Friday, September 4" — the grid's name. */
label: string
onOpen: (slot: RoomSlot) => void
}
/**
* The house as a grid: one row per floor, top floor first the way the
* building stands, each row headed by the floor and how much of it is sold,
* and one cell per room. It is one tab stop: the arrow keys walk the rooms —
* left and right along a floor, up and down to the same place on the next —
* Home and End jump to a floor's ends, and Enter opens the room.
*
* Below a wide container a floor wraps onto three lines — its standards, its
* deluxes, its suites — on a twelve-column grid; from `@3xl` it is one line of
* thirty-two, with the floor's header in a column of its own.
*/
export function FloorGrid({ slots, label, onOpen }: FloorGridProps) {
const floors = React.useMemo(
() =>
FLOORS.map((floor) => ({
floor,
rooms: slots.filter((slot) => slot.floor === floor).sort((a, b) => a.number.localeCompare(b.number)),
})),
[slots]
)
const [active, setActive] = React.useState(() => floors[0]?.rooms[0]?.id)
const tiles = React.useRef(new Map<string, HTMLButtonElement | null>())
function move(to: RoomSlot | undefined) {
if (!to) return
setActive(to.id)
tiles.current.get(to.id)?.focus()
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, row: number, column: number) {
if (!KEYS.has(event.key)) return
event.preventDefault()
const rooms = floors[row].rooms
// The same place on another floor: every floor is laid out alike.
const sameSpot = (r: number) => floors[r]?.rooms[Math.min(column, floors[r].rooms.length - 1)]
switch (event.key) {
case "ArrowRight":
return move(rooms[column + 1])
case "ArrowLeft":
return move(rooms[column - 1])
case "ArrowDown":
return move(sameSpot(row + 1))
case "ArrowUp":
return move(sameSpot(row - 1))
case "Home":
return move(event.ctrlKey ? floors[0].rooms[0] : rooms[0])
case "End":
return move(event.ctrlKey ? floors.at(-1)?.rooms.at(-1) : rooms.at(-1))
}
}
return (
<div role="grid" aria-label={label} aria-rowcount={floors.length} className="@container/plan flex flex-col gap-4">
{floors.map(({ floor, rooms }, row) => {
const sold = rooms.filter((slot) => isSold(roomState(slot))).length
return (
<div
key={floor}
role="row"
aria-rowindex={row + 1}
className="grid grid-cols-12 items-end gap-1.5 @3xl/plan:grid-cols-[9rem_repeat(32,minmax(0,1fr))]"
>
<div role="rowheader" className="col-span-12 pb-1 @3xl/plan:col-span-1 @3xl/plan:pr-4 @3xl/plan:pb-2">
<ProgressStat
size="sm"
tone="default"
label={`Floor ${floor}`}
value={sold}
target={rooms.length}
format={(value) => String(value)}
/>
</div>
{rooms.map((slot, column) => (
<div key={slot.id} role="gridcell" className={cn(SPAN[slot.type])}>
<RoomTile
ref={(node) => {
tiles.current.set(slot.id, node)
}}
room={slot}
state={roomState(slot)}
tabIndex={slot.id === active ? 0 : -1}
onFocus={() => setActive(slot.id)}
onKeyDown={(event) => handleKeyDown(event, row, column)}
onClick={() => onOpen(slot)}
/>
</div>
))}
</div>
)
})}
</div>
)
}"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { STATE_LABELS, STATES, roomState } from "../vocabulary"
import { FloorGrid } from "./floor-grid"
import { useRoomRack } from "./room-rack"
import { StateSwatch } from "./room-tile"
export type FloorPlanProps = {
/** "Friday, September 4" — the night the plan is of. */
night: string
}
/**
* The house tonight, one tile a room, with its key: each state as a small
* tile — its fill and its icon, the same as on the plan — its word, and how
* many rooms are in it, so the key reads in greyscale as well as in colour. The counts are read off
* the same slots the tiles are, so a check-in turns a tile and moves a count
* in the one render. The slots are the rack's, and so is the sheet a room
* opens onto.
*/
export function FloorPlan({ night }: FloorPlanProps) {
const { slots, open } = useRoomRack()
const counts = Object.fromEntries(STATES.map((state) => [state, 0])) as Record<(typeof STATES)[number], number>
for (const slot of slots) counts[roomState(slot)] += 1
return (
<Card data-widget="widget-hotel-rooms-floor-plan" role="region" aria-label="Floor plan">
<CardHeader>
<CardTitle>Floor plan</CardTitle>
<CardDescription>Every room tonight, drawn to its size — standard, deluxe, suite. Pick one to open it.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<ul aria-label="Legend" className="flex flex-wrap gap-x-6 gap-y-2">
{STATES.map((state) => (
<li key={state} className="flex items-center gap-2 text-sm">
<StateSwatch state={state} />
<span className="text-muted-foreground">{STATE_LABELS[state]}</span>{" "}
<span className="font-medium tabular-nums">{counts[state]}</span>
</li>
))}
</ul>
<FloorGrid slots={slots} label={`Rooms on ${night}`} onOpen={open} />
</CardContent>
</Card>
)
}import { ROOM_TYPE_LABELS } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SimpleTable, simpleTableCellVariants } from "@/components/ui/simple-table"
import { TableCell } from "@/components/ui/table"
import { type RackRate } from "../data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** "5% under rack": how far what a night pays sits from what it asks, in words. */
function againstRack(paid: number, asked: number): string {
const off = Math.round((1 - paid / asked) * 100)
if (off === 0) return "At rack"
return off > 0 ? `${off}% under rack` : `${-off}% over rack`
}
const cell = (align?: "right") => simpleTableCellVariants({ size: "sm", align })
/**
* What each room type asks and what it got tonight: the rack rate, the rooms
* of that type sold, the average the stays in them pay and how far under the
* rack that is — a channel's commission and a corporate rate, read off the
* night rather than a price list. No state, so it renders on the server.
*/
export function RackRates({ rates }: { rates: RackRate[] }) {
// The house as one more row: every room, every sale, and the rate the night
// pays across them against the rack those same rooms ask.
const rooms = rates.reduce((sum, rate) => sum + rate.rooms, 0)
const sold = rates.reduce((sum, rate) => sum + rate.sold, 0)
const paid = rates.reduce((sum, rate) => sum + rate.paidCents, 0)
const asked = rates.reduce((sum, rate) => sum + rate.rackRateCents * rate.sold, 0)
return (
<Card data-widget="widget-hotel-rooms-rack-rates" role="region" aria-label="Rack rates" className="h-full">
<CardHeader>
<CardTitle>Rack rates</CardTitle>
<CardDescription>What each room type asks, and what the rooms sold tonight pay.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<SimpleTable
size="sm"
rows={rates}
rowKey="type"
columns={[
{
key: "type",
header: "Room type",
cell: (rate) => (
<span className="flex flex-col">
<span className="font-medium">{ROOM_TYPE_LABELS[rate.type]}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{`${rate.rooms} rooms · ${rate.beds === 1 ? "1 bed" : `${rate.beds} beds`}`}
</span>
</span>
),
},
{ key: "rackRateCents", header: "Rack", align: "right", cell: (rate) => <span className="tabular-nums">{money(rate.rackRateCents)}</span> },
{
key: "sold",
header: "Sold tonight",
align: "right",
cell: (rate) => <span className="whitespace-nowrap tabular-nums">{`${rate.sold} of ${rate.rooms}`}</span>,
},
{
key: "averageCents",
header: "Paid tonight",
align: "right",
cell: (rate) =>
rate.sold === 0 ? (
<span className="text-muted-foreground">—</span>
) : (
<span className="flex flex-col items-end">
<span className="tabular-nums">{money(rate.averageCents)}</span>
<span className="text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{againstRack(rate.averageCents, rate.rackRateCents)}
</span>
</span>
),
},
]}
footer={
<>
<TableCell className={cell()}>
<span className="flex flex-col">
<span className="font-medium">The house</span>
<span className="text-xs font-normal text-muted-foreground tabular-nums">{`${rooms} rooms`}</span>
</span>
</TableCell>
<TableCell className={cell("right")} />
<TableCell className={cell("right")}>{`${sold} of ${rooms}`}</TableCell>
<TableCell className={cell("right")}>
{sold === 0 ? (
"—"
) : (
<span className="flex flex-col items-end">
<span>{money(paid / sold)}</span>
<span className="text-xs font-normal whitespace-nowrap text-muted-foreground">{againstRack(paid, asked)}</span>
</span>
)}
</TableCell>
</>
}
/>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { LogInIcon, LogOutIcon } from "lucide-react"
import { checkIn, checkOut, type StayAnswer, type StayChange } from "@/lib/dashboards/hotel/actions"
import { ROOM_TYPE_LABELS, STAY_DAY } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { STATE_HINTS, STATE_LABELS, live, roomState, type RoomSlot, type SlotStay, type StayStatus } from "../vocabulary"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const STATE_MAP = { arriving: "info", occupied: "neutral", departing: "warning", free: "success" } as const
/** One guest on the room's sheet: the face, the reference, the nights and what they pay against the rack. */
function Stay({ title, stay, rackRateCents }: { title: string; stay: SlotStay; rackRateCents: number }) {
const off = 1 - stay.ratePerNightCents / rackRateCents
return (
<section className="flex flex-col gap-3">
<SectionHeader as="h2" size="sm" title={title} />
<UserCell name={stay.guestName} src={stay.guestAvatar} description={<span className="font-mono">{stay.reference}</span>} />
<DescriptionList
size="sm"
items={[
{
term: "Stay",
description: (
<span className="tabular-nums">
{`${STAY_DAY.format(stay.checkIn)} – ${STAY_DAY.format(stay.checkOut)} · ${stay.nights === 1 ? "1 night" : `${stay.nights} nights`}`}
</span>
),
},
{
term: "Rate",
description: (
<span className="tabular-nums">
{`${money(stay.ratePerNightCents)} a night${off > 0.005 ? ` · ${Math.round(off * 100)}% under rack` : " · at rack"}`}
</span>
),
},
]}
/>
</section>
)
}
export type RoomInspectorProps = {
slot: RoomSlot | null
onClose: () => void
/** A desk action went through: the stay `stayId` in room `roomId` now has `status`. */
onSettle: (roomId: string, stayId: string, status: StayStatus) => void
}
/**
* One room, opened over the plan: tonight's state, who is arriving, in it or
* leaving, who is next, and the one or two things the desk can do about it.
* The verbs are the Hotel dashboard's own; a success is said in the status
* line at the foot of the sheet and a refusal in its own alert above the
* buttons, and the sheet stays open so the reader sees the room's new state —
* after a refusal too, which carries the stay as the store holds it.
*/
export function RoomInspector({ slot, onClose, onSettle }: RoomInspectorProps) {
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
// The sheet outlives the selection by one animation, so the last room stays
// drawn while it slides out; a different room is a new conversation.
const [last, setLast] = React.useState(slot)
if (slot && slot !== last) {
if (slot.id !== last?.id) {
setNotice("")
setRefusal("")
}
setLast(slot)
}
const room = slot ?? last
if (!room) return null
const state = roomState(room)
const arrival = live(room.arrival)
const leaving = live(room.departure)
const inRoom = arrival?.status === "checked_in" ? arrival : live(room.stayover)
async function run(verb: (id: string) => Promise<StayAnswer>, stay: SlotStay, say: (change: StayChange) => string) {
setNotice("")
setRefusal("")
const result = await verb(stay.id)
if (!result.ok) {
// The desk learnt the stay is not what the sheet showed: show what it is.
if (result.current) onSettle(room!.id, stay.id, result.current.status)
return setRefusal(result.error.message)
}
onSettle(room!.id, stay.id, result.data.status)
setNotice(say(result.data))
}
const checkingIn = arrival?.status === "confirmed" ? arrival : undefined
const checkingOut = leaving?.status === "checked_in" ? leaving : undefined
return (
<InspectorPanel
open={slot !== null}
mode="overlay"
width="lg"
onOpenChange={(open) => {
if (!open) onClose()
}}
title={`Room ${room.number}`}
description={`${ROOM_TYPE_LABELS[room.type]} · Floor ${room.floor} · ${room.beds === 1 ? "1 bed" : `${room.beds} beds`} · ${money(room.rackRateCents)} rack`}
footer={
<div className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<div className="flex flex-wrap gap-2">
{checkingIn ? (
<AsyncButton onClick={() => run(checkIn, checkingIn, (stay) => `${stay.guestName} is in room ${stay.room}.`)}>
<LogInIcon data-icon="inline-start" />
{`Check in ${checkingIn.guestName}`}
</AsyncButton>
) : null}
{checkingOut ? (
<AsyncButton
variant={checkingIn ? "outline" : "default"}
onClick={() => run(checkOut, checkingOut, (stay) => `${stay.guestName} has checked out of room ${stay.room}.`)}
>
<LogOutIcon data-icon="inline-start" />
{`Check out ${checkingOut.guestName}`}
</AsyncButton>
) : null}
</div>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</div>
}
>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<StatusBadge status={state} label={STATE_LABELS[state]} map={STATE_MAP} />
<span className="text-sm text-muted-foreground">{STATE_HINTS[state]}</span>
</div>
{arrival?.status === "confirmed" ? <Stay title="Arriving today" stay={arrival} rackRateCents={room.rackRateCents} /> : null}
{inRoom ? <Stay title="In the room" stay={inRoom} rackRateCents={room.rackRateCents} /> : null}
{leaving ? <Stay title="Leaving today" stay={leaving} rackRateCents={room.rackRateCents} /> : null}
{room.next ? (
<Stay title="Next arrival" stay={room.next} rackRateCents={room.rackRateCents} />
) : (
<p className="text-sm text-muted-foreground">Nothing else is booked into this room yet.</p>
)}
</div>
</InspectorPanel>
)
}"use client"
import * as React from "react"
import { type RoomSlot, type StayStatus } from "../vocabulary"
import { RoomInspector } from "./room-inspector"
type RoomRack = {
/** Every room tonight, with whatever the desk has done to its stays since the server read them. */
slots: RoomSlot[]
/** Opens a room's sheet. */
open: (slot: RoomSlot) => void
}
const RoomRackContext = React.createContext<RoomRack | null>(null)
const SLOT_STAYS = ["arrival", "stayover", "departure", "next"] as const
/**
* The rack: tonight's rooms, held once for everything that reads them. The
* floor plan, the turnovers and the room sheet read the same slots, so a
* check-in made from a room has to reach all three: the slots are held here,
* and a settled action rewrites the one stay it moved. Each tile's state is
* then read again off its slot by the same rule the server used. The room
* sheet belongs to the rack rather than to the plan, so whatever card opens a
* room — the plan on the page, or the plan drawn alone — opens the one sheet.
*/
export function RoomRackProvider({ slots: initial, children }: { slots: RoomSlot[]; children: React.ReactNode }) {
const [slots, setSlots] = React.useState(initial)
const [openId, setOpenId] = React.useState<string | null>(null)
function settle(roomId: string, stayId: string, status: StayStatus) {
setSlots((current) =>
current.map((slot) => {
if (slot.id !== roomId) return slot
const next = { ...slot }
for (const key of SLOT_STAYS) {
const stay = next[key]
if (stay?.id === stayId) next[key] = { ...stay, status }
}
return next
})
)
}
const rack = React.useMemo<RoomRack>(() => ({ slots, open: (slot) => setOpenId(slot.id) }), [slots])
return (
<RoomRackContext.Provider value={rack}>
{children}
<RoomInspector slot={slots.find((slot) => slot.id === openId) ?? null} onClose={() => setOpenId(null)} onSettle={settle} />
</RoomRackContext.Provider>
)
}
/** The rack the nearest `RoomRackProvider` holds. */
export function useRoomRack(): RoomRack {
const rack = React.useContext(RoomRackContext)
if (!rack) throw new Error("useRoomRack needs a RoomRackProvider above it")
return rack
}"use client"
import * as React from "react"
import { BedDoubleIcon, DoorOpenIcon, LogInIcon, LogOutIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { ROOM_TYPE_LABELS } from "@/lib/dashboards/hotel/vocabulary"
import { STATE_LABELS, live, type RoomSlot, type RoomState } from "../vocabulary"
/**
* The fill a state paints. Solid is sold tonight — grey for a guest already
* in, the info tone for one on the way — and light is empty tonight: the
* warning tint for a room being vacated, a dashed outline for one to sell. So
* the house's occupancy reads off the plan before a single tile is read. The
* grey and the blue sit at one lightness, so a fill alone would not tell them
* apart in greyscale: every state also carries its own icon, on the tiles and
* in the legend alike.
*/
export const STATE_FILL: Record<RoomState, string> = {
occupied: "bg-muted-foreground text-background",
arriving: "bg-info text-info-foreground",
departing: "bg-warning-muted text-foreground ring-1 ring-inset ring-warning/40",
free: "border border-dashed border-input text-foreground",
}
/** The shape each state is drawn with: a bed for a guest in, a way in, a way out, an open door. */
export const STATE_ICON: Record<RoomState, React.ReactNode> = {
occupied: <BedDoubleIcon />,
arriving: <LogInIcon />,
departing: <LogOutIcon className="text-warning" />,
free: <DoorOpenIcon className="text-muted-foreground" />,
}
/** A state's key in small: the tile's fill and its icon, for a legend. The word beside it names it. */
export function StateSwatch({ state, className }: { state: RoomState; className?: string }) {
return (
<span
aria-hidden="true"
data-slot="state-swatch"
data-state={state}
className={cn("flex h-5 w-7 shrink-0 items-center justify-center rounded-md [&_svg]:size-3.5", STATE_FILL[state], className)}
>
{STATE_ICON[state]}
</span>
)
}
// A tile is as wide as its room: a standard on two columns of the plan, a
// deluxe on three, a suite on four — the floor reads like the floor.
export const SPAN: Record<RoomSlot["type"], string> = {
standard: "col-span-2",
deluxe: "col-span-3",
suite: "col-span-4",
}
/** Who the state is about: the guest arriving, in the room, or leaving. */
export function guestOf(slot: RoomSlot, state: RoomState): string | undefined {
if (state === "arriving") return slot.arrival?.guestName
if (state === "occupied") return (live(slot.arrival) ?? slot.stayover)?.guestName
if (state === "departing") return slot.departure?.guestName
return undefined
}
// `room` rather than `slot`: a button already has a `slot` attribute of its own.
export type RoomTileProps = Omit<React.ComponentProps<"button">, "children"> & {
room: RoomSlot
state: RoomState
}
/** One room on the plan: its door number and tonight's state, a button that opens the room. */
export function RoomTile({ room, state, className, ...props }: RoomTileProps) {
const guest = guestOf(room, state)
return (
<button
type="button"
data-slot="room-tile"
data-state={state}
data-type={room.type}
aria-haspopup="dialog"
aria-label={`Room ${room.number}, ${ROOM_TYPE_LABELS[room.type]}, ${STATE_LABELS[state].toLowerCase()}${guest ? `: ${guest}` : ""}`}
className={cn(
"flex h-12 w-full flex-col justify-between rounded-lg px-2 py-1.5 text-left focus-ring @3xl/plan:h-14",
"motion-safe:transition-[translate,box-shadow] motion-safe:duration-(--duration-fast) hover:shadow-(--shadow-float) motion-safe:hover:-translate-y-px",
STATE_FILL[state],
className
)}
{...props}
>
<span className="text-sm font-semibold tabular-nums">{room.number}</span>
<span aria-hidden="true" className="flex h-3.5 items-center [&_svg]:size-3.5">
{STATE_ICON[state]}
</span>
</button>
)
}"use client"
import { ROOM_TYPE_LABELS } from "@/lib/dashboards/hotel/vocabulary"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DataListItem } from "@/components/ui/data-list"
import { StatusBadge } from "@/components/ui/status-badge"
import { live } from "../vocabulary"
import { useRoomRack } from "./room-rack"
/**
* The rooms changing hands today: who goes out, and who — if anyone — comes
* in behind them, a room with a guest behind it first, because housekeeping
* has a deadline on that one.
* The rows are the rack's slots, the ones the plan draws, so a check-out made
* from a room reads here at once.
*/
export function Turnovers() {
const { slots } = useRoomRack()
const rooms = slots
.filter((slot) => slot.departure)
.sort((a, b) => Number(Boolean(live(b.arrival))) - Number(Boolean(live(a.arrival))) || a.number.localeCompare(b.number))
return (
<Card data-widget="widget-hotel-rooms-turnovers" role="region" aria-label="Turnovers" className="h-full">
<CardHeader>
<CardTitle>Turnovers</CardTitle>
<CardDescription>Rooms changing hands today, the ones with a guest behind them first.</CardDescription>
</CardHeader>
<CardContent>
{rooms.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Nobody leaves today.</p>
) : (
// Two columns from md: a row per room, the rule between rows on each column's own.
<div role="list" aria-label="Rooms changing hands" className="grid md:grid-cols-2 md:gap-x-6">
{rooms.map((slot) => {
const out = slot.departure!
const next = live(slot.arrival)
const gone = out.status === "checked_out"
return (
<DataListItem
key={slot.id}
role="listitem"
className="border-t px-0 first:border-t-0 md:[&:nth-child(2)]:border-t-0"
leading={
<AvatarGroup
size="sm"
users={[out, ...(next ? [next] : [])].map((stay) => ({ name: stay.guestName, src: stay.guestAvatar }))}
/>
}
title={`Room ${slot.number} · ${ROOM_TYPE_LABELS[slot.type]}`}
description={`${out.guestName} → ${next ? next.guestName : "back on sale"}`}
actions={
<StatusBadge
status={gone ? "checked_out" : "due_out"}
label={gone ? "Checked out" : "Due out"}
map={{ checked_out: "neutral", due_out: "warning" }}
/>
}
/>
)
})}
</div>
)}
</CardContent>
</Card>
)
}