/hotel/guestsGuest book
Every guest on the book, worked out of the bookings under their name — stays, nights, spend, last and next stay — biggest spenders first, with search, four figures and a sheet of every booking a guest has made.
Installs at /hotel/guests. A guest is a name: every db.bookings row under the same name is the same guest, and the face is the one the row carries, avatarFor of that name. The page is an async server component; guestsView groups the book by name, works each guest's stays (the bookings that held a room — a cancellation or a no-show stays on the record but is not a stay), nights, spend, last stay begun and next stay ahead out of their own rows, then searches, sorts and pages the list, the biggest spenders first; the island asks fetchGuests for every page after that, so the grouping never runs in the browser. The four figures count every name, the guests who have stayed twice or more, what a guest's stays come to on average and who is due in over the next seven days. A row, clicked or opened with Enter, shows the guest in a sheet — first stay, the room they usually take, the channel they book through — and every booking under their name, the cancelled ones included, newest first; the row already carries them, so the sheet costs no second read. Composes AppShell, PageHeader, StatCardGroup, StatCard, FilterBar, SearchInput, DataTableRows, DataTablePagination, DataTableColumnHeader, DataTableViewOptions, UserCell, NumberCell, CurrencyCell, InspectorPanel, DescriptionList, SectionHeader, SimpleTable and StatusBadge.
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 { GuestsTable } from "./components/guests-table"
import { DEFAULT_QUERY, currentUser, guestsView, lastUpdated, shellNotifications } from "./data"
/**
* The guest book. The page is an async server component: it groups the book
* by name through `db.bookings`, reads the first page of guests — the biggest
* spenders first — and hands it to the island, which asks the server for
* every page after that.
*/
export default async function GuestsPage() {
const initial = await guestsView(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.guests}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="hotel-guests-title"
title="Guests"
description="Everyone who has stayed or is booked, by what their stays come to."
meta={lastUpdated()}
/>
<GuestsTable titleId="hotel-guests-title" initial={initial} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/hotel-guestsNeeds 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 { GuestsTable } from "./components/guests-table"
import { DEFAULT_QUERY, currentUser, guestsView, lastUpdated, shellNotifications } from "./data"
/**
* The guest book. The page is an async server component: it groups the book
* by name through `db.bookings`, reads the first page of guests — the biggest
* spenders first — and hands it to the island, which asks the server for
* every page after that.
*/
export default async function GuestsPage() {
const initial = await guestsView(DEFAULT_QUERY)
return (
<AppShell
nav={NAV}
activeHref={ROUTES.guests}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="hotel-guests-title"
title="Guests"
description="Everyone who has stayed or is booked, by what their stays come to."
meta={lastUpdated()}
/>
<GuestsTable titleId="hotel-guests-title" initial={initial} />
</AppShell>
)
}/**
* What this page reads. A guest is a name: every `db.bookings` row under the
* same name is the same guest, and the face is the one the row carries,
* `avatarFor` of that name. `guestsView` groups the book by name, works each
* guest's stays, nights, spend and last and next stay out of their own rows,
* then searches, sorts and pages the list on the server; each row carries the
* guest's bookings, so opening them costs no second read. A stay is a booking
* that held its room — a cancellation or a no-show is on the guest's record
* but is not a stay. "Now" is `REFERENCE_DATE`; a night is a UTC day.
*
* The client islands import only the types below.
*/
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]))
/** One booking under a guest's name, as their record lists it. */
export type GuestBooking = {
id: string
reference: string
room: string
roomType: Room["type"]
checkIn: Date
checkOut: Date
nights: number
totalCents: number
source: Booking["source"]
status: Booking["status"]
}
export type GuestRow = {
/** The guest is the name. */
name: string
avatarUrl: string
stays: number
nights: number
spendCents: number
/** The most recent stay that has begun, by its arrival. */
lastStay?: Date
/** The next stay still to begin, by its arrival. */
nextStay?: Date
/** Every booking under the name, the newest first. */
bookings: GuestBooking[]
}
export type GuestsQuery = {
page: number
pageSize: number
sort: SortSpec
search: string
}
export type GuestFigures = {
guests: number
/** Guests with two stays or more. */
returning: number
/** What a guest's stays come to, on average, in minor units. */
averageSpendCents: number
/** Guests arriving in the seven days from today. */
arriving: number
}
export type GuestsView = { query: GuestsQuery; page: Page<GuestRow>; figures: GuestFigures }
export const PAGE_SIZE = 10
export const DEFAULT_QUERY: GuestsQuery = {
page: 1,
pageSize: PAGE_SIZE,
sort: { id: "spendCents", desc: true },
search: "",
}
/** A cancellation or a no-show holds no room; everything else is a stay. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"
function toBooking(booking: Booking): GuestBooking {
const room = ROOM.get(booking.roomId)
return {
id: booking.id,
reference: booking.reference,
room: room?.number ?? "—",
roomType: room?.type ?? "standard",
checkIn: booking.checkIn,
checkOut: booking.checkOut,
nights: booking.nights,
totalCents: booking.totalCents,
source: booking.source,
status: booking.status,
}
}
/** Every guest on the book, worked out of the bookings under their name. */
function guests(): GuestRow[] {
const byName = new Map<string, Booking[]>()
for (const booking of db.bookings.all()) {
const rows = byName.get(booking.guestName)
if (rows) rows.push(booking)
else byName.set(booking.guestName, [booking])
}
return [...byName.entries()].map(([name, rows]) => {
const stays = rows.filter(held)
const begun = stays.filter((booking) => utcDay(booking.checkIn) <= TODAY).map((booking) => booking.checkIn.getTime())
const ahead = stays.filter((booking) => utcDay(booking.checkIn) > TODAY).map((booking) => booking.checkIn.getTime())
return {
name,
avatarUrl: rows[0].guestAvatar,
stays: stays.length,
nights: stays.reduce((sum, booking) => sum + booking.nights, 0),
spendCents: stays.reduce((sum, booking) => sum + booking.totalCents, 0),
...(begun.length > 0 ? { lastStay: new Date(Math.max(...begun)) } : {}),
...(ahead.length > 0 ? { nextStay: new Date(Math.min(...ahead)) } : {}),
bookings: [...rows].sort((a, b) => b.checkIn.getTime() - a.checkIn.getTime()).map(toBooking),
}
})
}
// A guest with no stay yet, or none still to come, sorts after every guest
// who has one, whichever way the column is turned.
const date = (pick: (row: GuestRow) => Date | undefined) => (a: GuestRow, b: GuestRow, direction: number) => {
const left = pick(a)
const right = pick(b)
if (!left || !right) return Number(!left) - Number(!right)
return direction * (left.getTime() - right.getTime())
}
const number = (pick: (row: GuestRow) => number) => (a: GuestRow, b: GuestRow, direction: number) => direction * (pick(a) - pick(b))
// The table's column ids on the left, how two guests compare on the right.
const SORTS: Record<string, (a: GuestRow, b: GuestRow, direction: number) => number> = {
name: (a, b, direction) => direction * a.name.localeCompare(b.name),
stays: number((row) => row.stays),
nights: number((row) => row.nights),
spendCents: number((row) => row.spendCents),
lastStay: date((row) => row.lastStay),
nextStay: date((row) => row.nextStay),
}
function guestFigures(rows: GuestRow[]): GuestFigures {
const staying = rows.filter((row) => row.stays > 0)
const week = new Set(
db.bookings
.all()
.filter((booking) => held(booking) && utcDay(booking.checkIn) >= TODAY && utcDay(booking.checkIn) < TODAY + 7 * DAY)
.map((booking) => booking.guestName)
)
return {
guests: rows.length,
returning: rows.filter((row) => row.stays >= 2).length,
averageSpendCents:
staying.length === 0 ? 0 : Math.round(staying.reduce((sum, row) => sum + row.spendCents, 0) / staying.length),
arriving: week.size,
}
}
/** One page of the guest list for the query the reader has built up, with the figures above it. */
export async function guestsView(query: GuestsQuery): Promise<GuestsView> {
const all = guests()
const needle = query.search.trim().toLowerCase()
const matched = needle ? all.filter((row) => row.name.toLowerCase().includes(needle)) : all
const compare = ownKey(SORTS, query.sort.id) ? SORTS[query.sort.id] : undefined
const direction = query.sort.desc ? -1 : 1
// Ties fall back on the name, so a page never reshuffles between reads.
const sorted = [...matched].sort((a, b) => (compare ? compare(a, b, direction) : 0) || a.name.localeCompare(b.name))
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), total: sorted.length, page, pageSize: size, pageCount },
figures: guestFigures(all),
}
}
/** 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 { DEFAULT_QUERY, guestsView, type GuestsQuery, type GuestsView } from "./data"
/**
* What this page asks the server for. The page renders its first view on the
* server; every search, sort and page after that comes back through
* `fetchGuests`, because grouping the book by name is the server's work.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** One page of the guest list for the query the reader has built up. */
export async function fetchGuests(query: GuestsQuery): Promise<GuestsView> {
return guestsView(tableQuery(query, DEFAULT_QUERY))
}"use client"
import { STAY_DAY } from "@/lib/dashboards/hotel/vocabulary"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { CurrencyCell, NumberCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type GuestRow } from "../data"
const day = (date: Date | undefined) =>
date ? <span className="whitespace-nowrap tabular-nums">{STAY_DAY.format(date)}</span> : <span className="text-muted-foreground">—</span>
/**
* Every column is sorted by the server. A row opens the guest's bookings, so
* there is no menu and no selection: the one thing to do with a guest here is
* look them up.
*/
export const GUEST_COLUMNS: DataTableColumnDef<GuestRow>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Guest" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.name} src={row.original.avatarUrl} />,
meta: { label: "Guest" },
},
{
accessorKey: "stays",
header: ({ column }) => <DataTableColumnHeader column={column} title="Stays" />,
cell: ({ row }) => <NumberCell value={row.original.stays} />,
meta: { align: "right", label: "Stays" },
},
{
accessorKey: "nights",
header: ({ column }) => <DataTableColumnHeader column={column} title="Nights" />,
cell: ({ row }) => <NumberCell value={row.original.nights} />,
meta: { align: "right", label: "Nights" },
},
{
accessorKey: "spendCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Spend" />,
cell: ({ row }) => <CurrencyCell value={row.original.spendCents / 100} />,
meta: { align: "right", label: "Spend" },
},
{
accessorKey: "lastStay",
header: ({ column }) => <DataTableColumnHeader column={column} title="Last stay" />,
cell: ({ row }) => day(row.original.lastStay),
meta: { label: "Last stay" },
},
{
accessorKey: "nextStay",
header: ({ column }) => <DataTableColumnHeader column={column} title="Next stay" />,
cell: ({ row }) => day(row.original.nextStay),
meta: { label: "Next stay" },
},
]import { CalendarClockIcon, RepeatIcon, UsersIcon, WalletIcon } 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 GuestFigures as Figures } from "../data"
/** The guest book in four numbers, each counted from the bookings under every name. */
export function GuestFigures({ figures }: { figures: Figures }) {
return (
<StatCardGroup data-widget="widget-hotel-guests-guest-figures" columns={4} role="region" aria-label="Guests in figures">
<StatCard
label="Guests"
value={formatNumber(figures.guests)}
description="Every name on the book"
icon={<UsersIcon />}
/>
<StatCard
label="Returning"
value={formatNumber(figures.returning)}
description={`${formatPercent(figures.guests === 0 ? 0 : figures.returning / figures.guests, { maximumFractionDigits: 0 })} have stayed twice or more`}
icon={<RepeatIcon />}
/>
<StatCard
label="Spend per guest"
value={formatCurrency(figures.averageSpendCents / 100, "USD", { maximumFractionDigits: 0 })}
description="Across every stay on the book"
icon={<WalletIcon />}
/>
<StatCard
label="Arriving this week"
value={formatNumber(figures.arriving)}
description="Guests due in over the next seven days"
icon={<CalendarClockIcon />}
/>
</StatCardGroup>
)
}"use client"
import * as React from "react"
import {
ROOM_TYPE_LABELS,
SOURCE_LABELS,
STAY_DAY,
STAY_STATUS_LABELS,
STAY_STATUS_MAP,
} from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type GuestBooking, type GuestRow } from "../data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/** The value a guest picks most often across their stays, the earlier one on a tie. */
function usual<T extends string>(bookings: GuestBooking[], pick: (booking: GuestBooking) => T): T | undefined {
const counts = new Map<T, number>()
for (const booking of bookings) counts.set(pick(booking), (counts.get(pick(booking)) ?? 0) + 1)
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
}
export type GuestInspectorProps = { guest: GuestRow | null; onClose: () => void }
/**
* One guest, opened over the list: who they are to the house — how often and
* how long they stay, what they take and how they book — and every booking
* under their name, the cancelled ones included, newest first.
*/
export function GuestInspector({ guest, onClose }: GuestInspectorProps) {
// The sheet outlives the selection by one animation, so the last guest
// stays drawn while it slides out instead of blanking mid-flight.
const [last, setLast] = React.useState(guest)
if (guest && guest !== last) setLast(guest)
const shown = guest ?? last
if (!shown) return null
const stays = shown.bookings.filter((booking) => booking.status !== "cancelled" && booking.status !== "no_show")
const room = usual(stays, (booking) => booking.roomType)
const channel = usual(stays, (booking) => booking.source)
const first = stays.at(-1)
return (
<InspectorPanel
open={guest !== null}
mode="overlay"
width="lg"
onOpenChange={(open) => {
if (!open) onClose()
}}
title={shown.name}
description={`${shown.stays === 1 ? "1 stay" : `${shown.stays} stays`} · ${shown.nights === 1 ? "1 night" : `${shown.nights} nights`} · ${money(shown.spendCents)}`}
>
<div className="flex flex-col gap-6">
<UserCell
size="default"
name={shown.name}
src={shown.avatarUrl}
description={first ? `Guest since ${STAY_DAY.format(first.checkIn)}` : "No stay yet"}
/>
<DescriptionList
size="sm"
items={[
{ term: "Last stay", description: shown.lastStay ? STAY_DAY.format(shown.lastStay) : "—" },
{ term: "Next stay", description: shown.nextStay ? STAY_DAY.format(shown.nextStay) : "Nothing booked" },
{ term: "Usual room", description: room ? ROOM_TYPE_LABELS[room] : "—" },
{ term: "Books through", description: channel ? SOURCE_LABELS[channel] : "—" },
]}
/>
<section className="flex flex-col gap-3">
<SectionHeader as="h2" size="sm" title="Bookings" />
<SimpleTable
size="sm"
rows={shown.bookings}
rowKey="id"
columns={[
{ key: "reference", header: "Reference", cell: (booking) => <span className="font-mono text-xs">{booking.reference}</span> },
{
key: "checkIn",
header: "Stay",
cell: (booking) => (
<span className="flex flex-col">
<span className="whitespace-nowrap tabular-nums">{`${STAY_DAY.format(booking.checkIn)} – ${STAY_DAY.format(booking.checkOut)}`}</span>
<span className="text-xs text-muted-foreground tabular-nums">{`Room ${booking.room} · ${ROOM_TYPE_LABELS[booking.roomType]}`}</span>
</span>
),
},
{ key: "totalCents", header: "Amount", align: "right", cell: (booking) => <span className="tabular-nums">{money(booking.totalCents)}</span> },
{
key: "status",
header: "Status",
cell: (booking) => <StatusBadge status={booking.status} label={STAY_STATUS_LABELS[booking.status]} map={STAY_STATUS_MAP} size="sm" />,
},
]}
/>
</section>
</div>
</InspectorPanel>
)
}"use client"
import * as React from "react"
import { useServerTable } from "@/hooks/use-server-table"
import {
DataTablePagination,
DataTableRows,
DataTableViewOptions,
} from "@/components/ui/data-table"
import { FilterBar } from "@/components/ui/filter-bar"
import { SearchInput } from "@/components/ui/search-input"
import { fetchGuests } from "../actions"
import { type GuestRow, type GuestsView } from "../data"
import { GUEST_COLUMNS } from "./guest-columns"
import { GuestFigures } from "./guest-figures"
import { GuestInspector } from "./guest-inspector"
/**
* The guest list. The search, the sort and the pager rewrite one query and ask
* the server for the page it describes — the grouping of the book by name is
* the server's work, never the browser's. useServerTable holds the query: the
* rows on show stay, dimmed, until the answer to the latest question arrives,
* and the view it answers with carries the figures above the table as well as
* the page. A row, clicked or opened with Enter, shows the guest's bookings;
* the row already holds them.
*/
export function GuestsTable({ titleId, initial }: { titleId: string; initial: GuestsView }) {
const [open, setOpen] = React.useState<GuestRow | null>(null)
const { table, query, answer: view, pending, apply, summary } = useServerTable({
initialQuery: initial.query,
initialPage: initial,
fetchPage: fetchGuests,
pageOf: (answer) => answer.page,
columns: GUEST_COLUMNS,
getRowId: (row) => row.name,
// A cleared sort goes back to the biggest spenders first.
defaultSort: { id: "spendCents", desc: true },
noun: ["guest", "guests"],
})
return (
<div data-slot="guests-table" className="flex flex-col gap-4">
<GuestFigures figures={view.figures} />
<div className="flex flex-col gap-3">
<FilterBar activeCount={query.search ? 1 : 0} onClearAll={query.search ? () => apply({ search: "" }) : undefined}>
<SearchInput
size="sm"
value={query.search}
onValueChange={(search) => apply({ search })}
debounce={200}
placeholder="Guest name"
aria-label="Search guests"
className="w-full max-w-64"
/>
<p data-slot="guests-summary" aria-live="polite" className="text-sm tabular-nums text-muted-foreground">
{summary}
</p>
<DataTableViewOptions table={table} />
</FilterBar>
<DataTableRows
table={table}
aria-labelledby={titleId}
busy={pending}
onRowClick={setOpen}
emptyMessage="No guest by that name."
footer={<DataTablePagination table={table} showSelectedCount={false} pageSizeOptions={[10, 25, 50]} />}
/>
</div>
<GuestInspector guest={open} onClose={() => setOpen(null)} />
</div>
)
}