Skip to contentVibraUI

Turnovers

The rooms changing hands today — who goes out and who, if anyone, comes in behind them — the rooms with a guest behind them first; reads floorPlan().

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-rooms-turnovers

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

Source

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

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