Skip to contentVibraUI
Part of the Hotel dashboardinstalls at /hotel/rooms

Room 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.

Open the live page

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

Install

npx shadcn@latest add @vibra/hotel-rooms

Needs the @vibra registry in your components.json — set it up once.

Source

app/hotel/rooms/page.tsx
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>
  )
}
app/hotel/rooms/data.ts
/**
 * 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 }
}
app/hotel/rooms/actions.ts
"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 } }
}
app/hotel/rooms/vocabulary.ts
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]
app/hotel/rooms/components/floor-grid.tsx
"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>
  )
}
app/hotel/rooms/components/floor-plan.tsx
"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>
  )
}
app/hotel/rooms/components/rack-rates.tsx
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>
  )
}
app/hotel/rooms/components/room-inspector.tsx
"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>
  )
}
app/hotel/rooms/components/room-rack.tsx
"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
}
app/hotel/rooms/components/room-tile.tsx
"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>
  )
}
app/hotel/rooms/components/turnovers.tsx
"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>
  )
}