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