/hotel/bookingsBookings
The whole book, server-paged: the next thirty nights, new business and cancellations in four figures, the book from today, the past or all of it, a status and a room-type facet, and a menu on every row that checks a guest in, checks one out or cancels a stay.
Installs at /hotel/bookings. The page is an async server component: bookingsView reads db.bookings, narrows the book to a window — from today (every stay that has not yet left), the past, or all of it — to the statuses and room types picked and to a search over the guest and the reference, sorts it and cuts one page; the island asks fetchBookings for every view after that, so nothing but the page on show reaches the browser. The same answer carries the window counts, the facet counts and the four figures: the next thirty nights as room-nights sold against the house, the room revenue on those nights, the bookings made in the last seven days and the share of the book cancelled. Each row offers only the verbs the server says its state allows — check in on a confirmed booking whose first night has come and whose nights have not all gone by, check out on a guest in the house, cancel on a booking still to come, behind a confirmation — and they are the Hotel dashboard's own actions from actions-hotel, the same ones the front desk and the room rack call. Whatever the server answers, the view is read again with the query as it stands when the answer comes, so the row, the counts and the figures never describe the book as it was, and a search typed meanwhile is kept; a success is said in the status line and a refusal in its own alert above the rows. The words come from vocabulary-hotel. Composes AppShell, PageHeader, StatCardGroup, StatCard, QuickFilters, FilterBar, FilterMenu, SearchInput, DataTableRows, DataTablePagination, DataTableColumnHeader, DataTableRowActions, DataTableViewOptions, UserCell, StatusBadge, CurrencyCell, NumberCell, Callout and ConfirmDialog.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BookingsView } from "./components/bookings-view"
import { DEFAULT_QUERY, bookingsView, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The book. The page is an async server component: it reads the first view —
* the stays from today on, the counts and the figures — through `db.bookings`
* and hands it to the island, which asks the server for every view after
* that. Nothing but the page on show ever reaches the browser.
*/
export default async function BookingsPage() {
const initial = await bookingsView(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.bookings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="hotel-bookings-title"
title="Bookings"
description="Every stay on the book: who, which room, which nights, and where it stands."
meta={lastUpdated()}
/>
<BookingsView titleId="hotel-bookings-title" initial={initial} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/hotel-bookingsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { BookingsView } from "./components/bookings-view"
import { DEFAULT_QUERY, bookingsView, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The book. The page is an async server component: it reads the first view —
* the stays from today on, the counts and the figures — through `db.bookings`
* and hands it to the island, which asks the server for every view after
* that. Nothing but the page on show ever reaches the browser.
*/
export default async function BookingsPage() {
const initial = await bookingsView(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.bookings}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="hotel-bookings-title"
title="Bookings"
description="Every stay on the book: who, which room, which nights, and where it stands."
meta={lastUpdated()}
/>
<BookingsView titleId="hotel-bookings-title" initial={initial} />
</AppShell>
)
}/**
* What this page reads. Every row is a `db.bookings` record, and the server
* pages them: `bookingsView` narrows the book to a window (from today, the
* past, or all of it), the statuses and room types picked and a search over
* the guest and the reference, sorts it and cuts one page — and answers with
* the counts the filters carry and the four figures above the table as well,
* so a check-in or a cancellation made from a row reaches every number on the
* page in the one reply. "Now" is `REFERENCE_DATE`; a night is a UTC day.
*
* The client islands import only the types below; the words they print are
* the Hotel dashboard's, in `@/lib/dashboards/hotel/vocabulary`,
* so `db` never crosses into the browser.
*/
import { formatDate, getInitials } from "@/lib/format"
import {
REFERENCE_DATE,
ROOMS,
db,
ownKey,
type Booking,
type Member,
type Page,
type Room,
type SortSpec,
} from "@/lib/sample-data"
const DAY = 86_400_000
/** 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())
const TODAY = utcDay(REFERENCE_DATE)
const ROOM = new Map(ROOMS.map((room) => [room.id, room]))
export type RoomType = Room["type"]
export type BookingStatus = Booking["status"]
/** Which stretch of the book the table shows: stays not yet over, stays that are, or every one. */
export type BookingWindow = "ahead" | "past" | "all"
/** What the desk can still do to a booking, decided on the server from its status and its dates. */
export type BookingVerb = "checkIn" | "checkOut" | "cancel"
/** One row of the table. */
export type BookingRow = {
id: string
reference: string
guestName: string
guestAvatar: string
guests: number
/** The number on the door. */
room: string
roomType: RoomType
checkIn: Date
checkOut: Date
nights: number
totalCents: number
source: Booking["source"]
status: BookingStatus
verbs: BookingVerb[]
}
export type BookingsQuery = {
page: number
pageSize: number
/** Left out, the window's own order applies: from today soonest first, the past latest first. */
sort?: SortSpec
search: string
window: BookingWindow
statuses: string[]
roomTypes: string[]
}
/** The four figures above the table, over the whole book. */
export type BookFigures = {
/** Room-nights sold over the thirty nights from tonight, and how many the house has. */
nightsSold: number
nightsAvailable: number
revenueCents: number
/** Bookings made in the seven days to now, and what they come to. */
newBookings: number
newBookingsCents: number
cancelled: number
total: number
}
export type BookingsView = {
query: BookingsQuery & { sort: SortSpec }
page: Page<BookingRow>
/** How many bookings each window holds. */
windows: Record<BookingWindow, number>
/** How many bookings in the current window carry each status, and each room type. */
statuses: Record<string, number>
roomTypes: Record<string, number>
figures: BookFigures
}
export const PAGE_SIZE = 10
export const DEFAULT_QUERY: BookingsQuery = {
page: 1,
pageSize: PAGE_SIZE,
search: "",
window: "ahead",
statuses: [],
roomTypes: [],
}
const WINDOW_SORT: Record<BookingWindow, SortSpec> = {
ahead: { id: "checkIn", desc: false },
past: { id: "checkIn", desc: true },
all: { id: "checkIn", desc: false },
}
const typeOf = (booking: Booking): RoomType => ROOM.get(booking.roomId)?.type ?? "standard"
const doorOf = (booking: Booking): string => ROOM.get(booking.roomId)?.number ?? "—"
/** A stay not yet over leaves today or later; one that is over left before today. */
function inWindow(booking: Booking, window: BookingWindow): boolean {
if (window === "all") return true
return window === "ahead" ? utcDay(booking.checkOut) >= TODAY : utcDay(booking.checkOut) < TODAY
}
/**
* The verbs the desk's actions would accept, so a row never offers one the
* server refuses: a guest in the house can leave; a confirmed stay can be
* cancelled, and checked in once its first night has come — but not once its
* nights have all gone by, which makes it a no-show rather than an arrival.
*/
function verbsFor(booking: Booking): BookingVerb[] {
if (booking.status === "checked_in") return ["checkOut"]
if (booking.status !== "confirmed" || utcDay(booking.checkOut) <= TODAY) return []
return utcDay(booking.checkIn) <= TODAY ? ["checkIn", "cancel"] : ["cancel"]
}
function toRow(booking: Booking): BookingRow {
return {
id: booking.id,
reference: booking.reference,
guestName: booking.guestName,
guestAvatar: booking.guestAvatar,
guests: booking.adults + booking.children,
room: doorOf(booking),
roomType: typeOf(booking),
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
totalCents: booking.totalCents,
source: booking.source,
status: booking.status,
verbs: verbsFor(booking),
}
}
const text = (pick: (booking: Booking) => string) => (a: Booking, b: Booking) => pick(a).localeCompare(pick(b))
const number = (pick: (booking: Booking) => number) => (a: Booking, b: Booking) => pick(a) - pick(b)
// The table's column ids on the left, how two bookings compare on the right.
const SORTS: Record<string, (a: Booking, b: Booking) => number> = {
reference: text((booking) => booking.reference),
guestName: text((booking) => booking.guestName),
room: number((booking) => Number(doorOf(booking))),
checkIn: number((booking) => booking.checkIn.getTime()),
checkOut: number((booking) => booking.checkOut.getTime()),
nights: number((booking) => booking.nights),
source: text((booking) => booking.source),
totalCents: number((booking) => booking.totalCents),
status: text((booking) => booking.status),
}
function countBy(rows: Booking[], key: (booking: Booking) => string): Record<string, number> {
const counts: Record<string, number> = {}
for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1
return counts
}
/** A cancellation or a no-show holds no room; everything else does. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"
/** The thirty nights from tonight, what the week brought in, and what fell away. */
function bookFigures(book: Booking[]): BookFigures {
let nightsSold = 0
let revenueCents = 0
for (let offset = 0; offset < 30; offset++) {
const night = TODAY + offset * DAY
const stays = book.filter((booking) => held(booking) && utcDay(booking.checkIn) <= night && utcDay(booking.checkOut) > night)
nightsSold += new Set(stays.map((booking) => booking.roomId)).size
revenueCents += stays.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
}
const fresh = book.filter((booking) => booking.bookedAt.getTime() > REFERENCE_DATE.getTime() - 7 * DAY)
return {
nightsSold,
nightsAvailable: ROOMS.length * 30,
revenueCents,
newBookings: fresh.length,
newBookingsCents: fresh.reduce((sum, booking) => sum + booking.totalCents, 0),
cancelled: book.filter((booking) => booking.status === "cancelled").length,
total: book.length,
}
}
/** One page of the book for the query the reader has built up, with everything the page prints around it. */
export async function bookingsView(input: BookingsQuery): Promise<BookingsView> {
const book = db.bookings.all()
const query = { ...input, sort: input.sort ?? (ownKey(WINDOW_SORT, input.window) ? WINDOW_SORT[input.window] : WINDOW_SORT.ahead) }
const window = book.filter((booking) => inWindow(booking, query.window))
const needle = query.search.trim().toLowerCase()
const matched = window.filter(
(booking) =>
(query.statuses.length === 0 || query.statuses.includes(booking.status)) &&
(query.roomTypes.length === 0 || query.roomTypes.includes(typeOf(booking))) &&
(!needle || booking.guestName.toLowerCase().includes(needle) || booking.reference.toLowerCase().includes(needle))
)
const compare = ownKey(SORTS, query.sort.id) ? SORTS[query.sort.id] : undefined
const direction = query.sort.desc ? -1 : 1
// Array.prototype.sort is stable, so bookings that tie keep the book's own order.
const sorted = compare ? [...matched].sort((a, b) => direction * compare(a, b)) : matched
const size = Math.max(1, query.pageSize)
const pageCount = Math.ceil(sorted.length / size)
const page = Math.min(Math.max(1, query.page), Math.max(1, pageCount))
return {
query: { ...query, page },
page: {
rows: sorted.slice((page - 1) * size, page * size).map(toRow),
total: sorted.length,
page,
pageSize: size,
pageCount,
},
windows: {
ahead: book.filter((booking) => inWindow(booking, "ahead")).length,
past: book.filter((booking) => inWindow(booking, "past")).length,
all: book.length,
},
statuses: countBy(window, (booking) => booking.status),
roomTypes: countBy(window, typeOf),
figures: bookFigures(book),
}
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
/** 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 }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { tableQuery, type Result } from "@/lib/sample-data"
import { bookingsView, DEFAULT_QUERY, type BookingsQuery, type BookingsView } from "./data"
/**
* What this page asks the server for. The page renders its first view on the
* server; every window, filter, search, sort and page after that — and the
* re-read after a desk action — comes back through `fetchBookings`. The desk's
* verbs themselves are the Hotel dashboard's, shared with the other pages,
* and live in `@/lib/dashboards/hotel/actions`.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** One page of the book for the query the reader has built up. */
export async function fetchBookings(query: BookingsQuery): Promise<BookingsView> {
return bookingsView(tableQuery(query, DEFAULT_QUERY, { words: { window: ["ahead", "past", "all"] } }))
}"use client"
import { LogInIcon, LogOutIcon, XCircleIcon } from "lucide-react"
import {
ROOM_TYPE_LABELS,
SOURCE_LABELS,
STAY_DAY,
STAY_STATUS_LABELS,
STAY_STATUS_MAP,
} from "@/lib/dashboards/hotel/vocabulary"
import {
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableRowAction,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { CurrencyCell, NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type BookingRow } from "../data"
export type BookingVerbs = {
onCheckIn: (row: BookingRow) => void
onCheckOut: (row: BookingRow) => void
onCancel: (row: BookingRow) => void
}
/** The menu a row offers: only the verbs the server said its state allows. */
function actionsFor(row: BookingRow, verbs: BookingVerbs): DataTableRowAction[] {
const actions: DataTableRowAction[] = []
if (row.verbs.includes("checkIn")) actions.push({ label: "Check in", icon: <LogInIcon />, onSelect: () => verbs.onCheckIn(row) })
if (row.verbs.includes("checkOut")) actions.push({ label: "Check out", icon: <LogOutIcon />, onSelect: () => verbs.onCheckOut(row) })
if (row.verbs.includes("cancel")) {
actions.push({
label: "Cancel booking",
icon: <XCircleIcon />,
destructive: true,
separatorBefore: true,
onSelect: () => verbs.onCancel(row),
})
}
return actions
}
/**
* Every sortable column is sorted by the server, not in the browser. There is
* no selection column: a desk acts on one stay at a time, from its own menu.
*/
export function bookingColumns(verbs: BookingVerbs): DataTableColumnDef<BookingRow>[] {
return [
{
accessorKey: "reference",
header: ({ column }) => <DataTableColumnHeader column={column} title="Reference" />,
cell: ({ row }) => <span className="font-mono text-xs">{row.original.reference}</span>,
meta: { label: "Reference" },
},
{
accessorKey: "guestName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Guest" />,
cell: ({ row }) => (
<UserCell
size="sm"
name={row.original.guestName}
src={row.original.guestAvatar}
description={row.original.guests === 1 ? "1 guest" : `${row.original.guests} guests`}
/>
),
meta: { label: "Guest" },
},
{
id: "room",
accessorFn: (row) => Number(row.room),
header: ({ column }) => <DataTableColumnHeader column={column} title="Room" />,
cell: ({ row }) => (
<span className="whitespace-nowrap tabular-nums">
{row.original.room} · {ROOM_TYPE_LABELS[row.original.roomType]}
</span>
),
meta: { label: "Room" },
},
{
accessorKey: "checkIn",
header: ({ column }) => <DataTableColumnHeader column={column} title="Check-in" />,
cell: ({ row }) => <span className="whitespace-nowrap tabular-nums">{STAY_DAY.format(row.original.checkIn)}</span>,
meta: { label: "Check-in" },
},
{
accessorKey: "checkOut",
header: ({ column }) => <DataTableColumnHeader column={column} title="Check-out" />,
cell: ({ row }) => (
<span className="whitespace-nowrap tabular-nums text-muted-foreground">{STAY_DAY.format(row.original.checkOut)}</span>
),
meta: { label: "Check-out" },
},
{
accessorKey: "nights",
header: ({ column }) => <DataTableColumnHeader column={column} title="Nights" />,
cell: ({ row }) => <NumberCell value={row.original.nights} />,
meta: { align: "right", label: "Nights" },
},
{
accessorKey: "source",
header: ({ column }) => <DataTableColumnHeader column={column} title="Channel" />,
cell: ({ row }) => <span className="whitespace-nowrap text-muted-foreground">{SOURCE_LABELS[row.original.source]}</span>,
meta: { label: "Channel" },
},
{
accessorKey: "totalCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
cell: ({ row }) => <CurrencyCell value={row.original.totalCents / 100} />,
meta: { align: "right", label: "Amount" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge status={row.original.status} label={STAY_STATUS_LABELS[row.original.status]} map={STAY_STATUS_MAP} />
),
meta: { label: "Status" },
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
const actions = actionsFor(row.original, verbs)
return actions.length === 0 ? null : (
<DataTableRowActions label={`Open menu for ${row.original.reference}`} actions={actions} />
)
},
meta: { align: "right", label: "Actions" },
},
]
}import { BedDoubleIcon, CalendarPlusIcon, CalendarXIcon, ReceiptIcon } from "lucide-react"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BookFigures } from "../data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* The book in four numbers: how full the next month is, what it is worth, what
* the last week brought in and what fell away. The island that owns the table
* hands these down, so a cancellation made from a row moves them too.
*/
export function BookingFigures({ figures }: { figures: BookFigures }) {
return (
<StatCardGroup data-widget="widget-hotel-bookings-booking-figures" columns={4} role="region" aria-label="The book in figures">
<StatCard
label="Next 30 nights"
value={formatPercent(figures.nightsSold / figures.nightsAvailable, { maximumFractionDigits: 0 })}
description={`${formatNumber(figures.nightsSold)} of ${formatNumber(figures.nightsAvailable)} room-nights sold`}
icon={<BedDoubleIcon />}
/>
<StatCard
label="Revenue on the books"
value={money(figures.revenueCents)}
description="Room revenue, next 30 nights"
icon={<ReceiptIcon />}
/>
<StatCard
label="New bookings"
value={String(figures.newBookings)}
description={`${money(figures.newBookingsCents)} booked in the last 7 days`}
icon={<CalendarPlusIcon />}
/>
<StatCard
label="Cancellations"
value={formatPercent(figures.total === 0 ? 0 : figures.cancelled / figures.total, { maximumFractionDigits: 1 })}
description={`${figures.cancelled} of ${formatNumber(figures.total)} bookings`}
icon={<CalendarXIcon />}
/>
</StatCardGroup>
)
}"use client"
import type { RowData } from "@tanstack/react-table"
import { DataTableViewOptions, type DataTableInstance } from "@/components/ui/data-table"
import { FilterBar, FilterMenu, type FilterMenuOption } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"
export type BookingsToolbarProps<TData extends RowData> = {
table: DataTableInstance<TData>
search: string
onSearchChange: (value: string) => void
statuses: string[]
statusOptions: FilterMenuOption[]
onStatusesChange: (value: string[]) => void
roomTypes: string[]
roomTypeOptions: FilterMenuOption[]
onRoomTypesChange: (value: string[]) => void
onClearFilters: () => void
/** "1–10 of 487 bookings", built from the page the server sent back. */
summary: string
}
/**
* Everything above the rows. The search box matches a guest's name or a
* booking reference — what a guest says on the phone — and the two facets
* count the bookings in the window on show.
*/
export function BookingsToolbar<TData extends RowData>({
table,
search,
onSearchChange,
statuses,
statusOptions,
onStatusesChange,
roomTypes,
roomTypeOptions,
onRoomTypesChange,
onClearFilters,
summary,
}: BookingsToolbarProps<TData>) {
const activeCount = statuses.length + roomTypes.length + (search ? 1 : 0)
return (
<FilterBar activeCount={activeCount} onClearAll={activeCount > 0 ? onClearFilters : undefined}>
<SearchInput
size="sm"
value={search}
onValueChange={onSearchChange}
debounce={200}
placeholder="Guest or reference"
aria-label="Search bookings"
className="w-full max-w-64"
/>
<FilterMenu label="Status" size="sm" options={statusOptions} value={statuses} onValueChange={onStatusesChange} />
<FilterMenu label="Room type" size="sm" options={roomTypeOptions} value={roomTypes} onValueChange={onRoomTypesChange} />
<p data-slot="bookings-summary" aria-live="polite" className="text-sm tabular-nums text-muted-foreground">
{summary}
</p>
<DataTableViewOptions table={table} />
</FilterBar>
)
}"use client"
import * as React from "react"
import { useServerTable, type ServerTableControls } from "@/hooks/use-server-table"
import { cancelBooking, checkIn, checkOut, type StayAnswer, type StayChange } from "@/lib/dashboards/hotel/actions"
import {
ROOM_TYPE_LABELS,
ROOM_TYPE_ORDER,
STAY_DAY,
STAY_STATUS_LABELS,
STAY_STATUS_ORDER,
} from "@/lib/dashboards/hotel/vocabulary"
import { Callout } from "@/components/ui/callout"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DataTablePagination, DataTableRows } from "@/components/ui/data-table"
import { QuickFilters } from "@/components/ui/quick-filters"
import { fetchBookings } from "../actions"
import { type BookingRow, type BookingsQuery, type BookingsView as View, type BookingWindow } from "../data"
import { bookingColumns } from "./booking-columns"
import { BookingFigures } from "./booking-figures"
import { BookingsToolbar } from "./bookings-toolbar"
const WINDOWS: { value: BookingWindow; label: string }[] = [
{ value: "ahead", label: "From today" },
{ value: "past", label: "Past" },
{ value: "all", label: "All" },
]
/**
* The book. The windows, the search, the two facets, the sort and the pager
* all rewrite one query and ask the server for the view it describes; nothing
* is filtered or sorted in the browser. useServerTable holds the query: the
* rows on show stay, dimmed, until the answer to the latest question arrives
* — an older answer that lands after it is dropped — and the view the server
* answers with carries the counts and the figures as well as the page. A
* row's menu runs the desk's own verbs, and whatever the server answers, the
* view is read again through the same newest-wins path — with the query as it
* stands when the answer comes, not as it stood when the menu opened — so the
* row, the counts and the figures above describe the book as it now stands. A
* success is said in the status line and a refusal in its own alert.
*/
export function BookingsView({ titleId, initial }: { titleId: string; initial: View }) {
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [cancelling, setCancelling] = React.useState<BookingRow | null>(null)
/**
* Runs one of the desk's verbs on a row, and reads the view again either
* way — a refusal usually means the row moved under us — through the
* table's own refresh: with the query as it stands when the answer comes,
* as a question of its own, so it neither lands over a newer question's
* answer nor is overwritten by an older one.
*/
const run = React.useCallback(
async (
refresh: () => Promise<unknown>,
verb: (id: string) => Promise<StayAnswer>,
row: BookingRow,
say: (change: StayChange) => string
) => {
setNotice("")
setRefusal("")
const result = await verb(row.id)
void refresh()
if (!result.ok) return setRefusal(result.error.message)
setNotice(say(result.data))
},
[]
)
// The verbs live in the row menu, so the column defs hold them: they are
// built from the table's own refresh, once.
const columns = React.useCallback(
({ refresh }: ServerTableControls<BookingsQuery, View>) =>
bookingColumns({
onCheckIn: (row) => run(refresh, checkIn, row, (stay) => `${stay.guestName} is in room ${stay.room}.`),
onCheckOut: (row) => run(refresh, checkOut, row, (stay) => `Room ${stay.room} is free — ${stay.guestName} has checked out.`),
onCancel: setCancelling,
}),
[run]
)
const { table, query, answer: view, pending, apply, refresh, summary } = useServerTable({
initialQuery: initial.query as BookingsQuery,
initialPage: initial,
fetchPage: fetchBookings,
pageOf: (answer) => answer.page,
// The server fills in the window's own order and clamps the page.
queryOf: (answer) => answer.query,
columns,
getRowId: (row) => row.id,
noun: ["booking", "bookings"],
})
return (
<div data-slot="bookings-view" className="flex flex-col gap-4">
<BookingFigures figures={view.figures} />
<div className="flex flex-col gap-3">
<QuickFilters
aria-label="Bookings by window"
options={WINDOWS.map((window) => ({ ...window, count: view.windows[window.value] }))}
value={query.window}
// A window brings its own order: the book ahead soonest first, the past latest first.
onValueChange={(window) => apply({ window: window as BookingWindow, sort: undefined })}
/>
<BookingsToolbar
table={table}
search={query.search}
onSearchChange={(search) => apply({ search })}
statuses={query.statuses}
statusOptions={STAY_STATUS_ORDER.map((status) => ({ value: status, label: STAY_STATUS_LABELS[status], count: view.statuses[status] ?? 0 }))}
onStatusesChange={(statuses) => apply({ statuses })}
roomTypes={query.roomTypes}
roomTypeOptions={ROOM_TYPE_ORDER.map((type) => ({ value: type, label: ROOM_TYPE_LABELS[type], count: view.roomTypes[type] ?? 0 }))}
onRoomTypesChange={(roomTypes) => apply({ roomTypes })}
onClearFilters={() => apply({ search: "", statuses: [], roomTypes: [] })}
summary={summary}
/>
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
{/* A line held open even when empty, so a message arriving never pushes the rows down. */}
<p role="status" aria-live="polite" className="-my-1 min-h-4 text-xs text-muted-foreground">
{notice}
</p>
<DataTableRows
table={table}
aria-labelledby={titleId}
busy={pending}
emptyMessage="No bookings match these filters."
footer={<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[10, 25, 50]} />}
/>
</div>
<ConfirmDialog
variant="destructive"
open={cancelling !== null}
onOpenChange={(open) => {
if (!open) setCancelling(null)
}}
title={cancelling ? `Cancel ${cancelling.reference}?` : "Cancel booking?"}
description={
cancelling
? `Room ${cancelling.room} goes back on sale for the ${cancelling.nights === 1 ? "night" : `${cancelling.nights} nights`} from ${STAY_DAY.format(cancelling.checkIn)} that ${cancelling.guestName} booked. A cancelled booking cannot be reinstated.`
: undefined
}
confirmText="Cancel booking"
cancelText="Keep booking"
onConfirm={() =>
cancelling
? run(refresh, cancelBooking, cancelling, (stay) => `${stay.reference} is cancelled — room ${stay.room} is back on sale.`)
: undefined
}
/>
</div>
)
}