Skip to contentVibraUI

Floor plan

Every room tonight drawn to its size, floor by floor with each floor's occupancy and a key that counts each state, and a room's sheet a click away that checks its guest in or out through the server; reads floorPlan().

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-rooms-floor-plan

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

Source

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/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/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>
  )
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Room rack page