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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-hotel-rooms-floor-planNeeds the @vibra registry in your components.json — set it up once.
Source
"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 { 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 * 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>
)
}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