Skip to contentVibraUI
Part of the Hotel dashboardinstalls at /hotel

Front desk

Tonight's house: occupancy, ADR and RevPAR, an availability calendar, booking channels by week, the three guest lists and an arrivals sheet.

Open the live page

Installs at /hotel. Tonight is the night beginning on REFERENCE_DATE's own UTC day, and all four numbers are measured over it: occupancy is the distinct rooms sold against the house, ADR the average rate of the stays in it, RevPAR the same revenue spread over every room whether it sold or not — which is why RevPAR is always the smaller number and why both are worth showing. The room list is a constant beside the bookings rather than a fifth entity, because a hotel's inventory is fixed while its book is not; the seeded book is laid out room by room, so no two guests are ever in one room on the same night. A stay arriving today is confirmed rather than checked in, which is what leaves the desk an arrivals list to work through: checking a guest in and checking one out are the Hotel dashboard's own server actions, shared with the Bookings and Rooms pages through actions-hotel, and the answer — the guest and their room in the status line, or the reason it was refused in an alert, the list then showing the stay as the store holds it — lands beside the lists. The availability calendar's three fills are a rule over the number of rooms free, and that number is printed under it, so a night is never read by colour alone.

Preview

Install

npx shadcn@latest add @vibra/hotel-overview

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

Source

app/hotel/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { AvailabilityPanel } from "./components/availability-panel"
import { BookingSources } from "./components/booking-sources"
import { DeskFlow } from "./components/desk-flow"
import { DeskStats } from "./components/desk-stats"
import {
  TONIGHT,
  availability,
  currentUser,
  deskSummary,
  frontDeskStats,
  guestFlow,
  shellNotifications,
  sourceGrid,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/hotel/nav"

/**
 * The front desk. The page is a server component: it reads every stay through
 * `db`, works out tonight's numbers, and hands plain rows to the islands that
 * need state — the calendar, the three lists and the arrivals sheet.
 */
export default function FrontDeskPage() {
  const rooms = availability()
  const flow = guestFlow()
  const channels = sourceGrid()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Front desk"
        description="Tonight's house: what is sold, who is arriving, and what is left."
        meta={deskSummary()}
      />

      <DeskStats stats={frontDeskStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <AvailabilityPanel
            month={rooms.month}
            rooms={rooms.rooms}
            days={rooms.days}
            today={new Date(TONIGHT)}
          />
        </DashboardGridItem>
        <DeskFlow flow={flow} sources={<BookingSources grid={channels} />} />
      </DashboardGrid>
    </AppShell>
  )
}
app/hotel/data.ts
/**
 * What this page reads. Every stay comes from `db.bookings` and every room
 * from the house's own `ROOMS`; nothing here is written out, and "now" is
 * `REFERENCE_DATE` — tonight is the night that starts on its UTC day, which is
 * what occupancy, ADR and RevPAR are all measured over.
 *
 * The client islands import only the types below; `db` never crosses into the
 * browser.
 */
import { SOURCE_LABELS, SOURCE_ORDER } from "@/lib/dashboards/hotel/vocabulary"
import { formatCurrency, formatPercent, getInitials } from "@/lib/format"
import { REFERENCE_DATE, ROOMS, db, type Booking, type Member, type Room } from "@/lib/sample-data"

const DAY = 86_400_000

/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
function utcDay(date: Date): number {
  return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}

/** Tonight: the night that begins on the reference date's own UTC day. */
export const TONIGHT = utcDay(REFERENCE_DATE)

const ROOM = new Map(ROOMS.map((room) => [room.id, room]))

export type StayStatus = Booking["status"]

export type StayRow = {
  id: string
  reference: string
  guestName: string
  guestAvatar: string
  room: string
  roomType: Room["type"]
  status: StayStatus
  checkIn: Date
  checkOut: Date
  nights: number
  guests: number
  ratePerNightCents: number
  totalCents: number
  source: Booking["source"]
}

/** One stay as this page reads it. */
function toRow(booking: Booking): StayRow {
  const room = ROOM.get(booking.roomId)
  return {
    id: booking.id,
    reference: booking.reference,
    guestName: booking.guestName,
    guestAvatar: booking.guestAvatar,
    room: room?.number ?? "—",
    roomType: room?.type ?? "standard",
    status: booking.status,
    checkIn: booking.checkIn,
    checkOut: booking.checkOut,
    nights: booking.nights,
    guests: booking.adults + booking.children,
    ratePerNightCents: booking.ratePerNightCents,
    totalCents: booking.totalCents,
    source: booking.source,
  }
}

/** A cancellation or a no-show holds no room; everything else does. */
const held = (booking: Booking) => booking.status !== "cancelled" && booking.status !== "no_show"

/** The stays that cover the night beginning at `night`. */
function occupying(night: number): Booking[] {
  return db.bookings
    .all()
    .filter((booking) => held(booking) && utcDay(booking.checkIn) <= night && utcDay(booking.checkOut) > night)
}

export type FrontDeskStat = { id: string; label: string; value: string; description: string }

/** The four numbers a duty manager reads first, all over tonight. */
export function frontDeskStats(): FrontDeskStat[] {
  const tonight = occupying(TONIGHT)
  const sold = new Set(tonight.map((booking) => booking.roomId)).size
  const revenue = tonight.reduce((sum, booking) => sum + booking.ratePerNightCents, 0)
  const arrivals = db.bookings
    .all()
    .filter((booking) => held(booking) && utcDay(booking.checkIn) === TONIGHT)

  const adr = tonight.length > 0 ? revenue / tonight.length : 0
  const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })

  return [
    {
      id: "occupancy",
      label: "Occupancy",
      value: formatPercent(sold / ROOMS.length, { maximumFractionDigits: 0 }),
      description: `${sold} of ${ROOMS.length} rooms sold`,
    },
    { id: "adr", label: "ADR", value: money(adr), description: "Average daily rate tonight" },
    {
      id: "revpar",
      label: "RevPAR",
      value: money(revenue / ROOMS.length),
      description: "Revenue per available room",
    },
    {
      id: "arrivals",
      label: "Arrivals",
      value: String(arrivals.length),
      description: `${arrivals.filter((booking) => booking.status === "confirmed").length} still to check in`,
    },
  ]
}

export type GuestFlow = { arrivals: StayRow[]; inHouse: StayRow[]; departures: StayRow[] }

/** Who is coming in, who is here, and who is going out — today. */
export function guestFlow(): GuestFlow {
  const rows = db.bookings.all().filter(held)
  const by = (a: StayRow, b: StayRow) => a.room.localeCompare(b.room)

  return {
    arrivals: rows.filter((booking) => utcDay(booking.checkIn) === TONIGHT).map(toRow).sort(by),
    inHouse: rows
      .filter(
        (booking) => utcDay(booking.checkIn) < TONIGHT && utcDay(booking.checkOut) > TONIGHT
      )
      .map(toRow)
      .sort(by),
    departures: rows.filter((booking) => utcDay(booking.checkOut) === TONIGHT).map(toRow).sort(by),
  }
}

export type AvailabilityDay = { key: string; free: number }

/** How many rooms are free each night of the month tonight falls in. */
export function availability(): { month: Date; rooms: number; days: AvailabilityDay[] } {
  const start = new Date(TONIGHT)
  const first = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)
  const length = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 0)).getUTCDate()

  const days = Array.from({ length }, (_, index) => {
    const night = first + index * DAY
    const sold = new Set(occupying(night).map((booking) => booking.roomId)).size
    return { key: new Date(night).toISOString().slice(0, 10), free: ROOMS.length - sold }
  })

  return { month: new Date(first), rooms: ROOMS.length, days }
}

export type SourceGrid = { rows: string[]; columns: string[]; values: number[][] }

const WEEK_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** Arrivals per channel over the eight weeks around tonight, Monday to Monday. */
export function sourceGrid(): SourceGrid {
  const monday = TONIGHT - ((new Date(TONIGHT).getUTCDay() + 6) % 7) * DAY
  const weeks = Array.from({ length: 8 }, (_, index) => monday + (index - 4) * 7 * DAY)
  const rows = db.bookings.all().filter(held)

  return {
    rows: SOURCE_ORDER.map((source) => SOURCE_LABELS[source]),
    columns: weeks.map((week) => WEEK_LABEL.format(new Date(week))),
    values: SOURCE_ORDER.map((source) =>
      weeks.map(
        (week) =>
          rows.filter(
            (booking) =>
              booking.source === source &&
              utcDay(booking.checkIn) >= week &&
              utcDay(booking.checkIn) < week + 7 * DAY
          ).length
      )
    ),
  }
}

/** The line under the title: tonight's shape in one sentence. */
export function deskSummary(): string {
  const { arrivals, inHouse, departures } = guestFlow()
  return `${arrivals.length} arrivals · ${inHouse.length} in house · ${departures.length} departures`
}

/** 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 }
}
app/hotel/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * What this page changes on its own: the session. The desk's verbs — check
 * in, check out, cancel — are the Hotel dashboard's, shared by every page
 * that moves a stay, and live in `@/lib/dashboards/hotel/actions`.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/hotel/components/arrival-columns.tsx
"use client"

import { DESK_STATUS_LABELS, ROOM_TYPE_LABELS, SOURCE_LABELS, STAY_STATUS_MAP } from "@/lib/dashboards/hotel/vocabulary"
import { formatDate } from "@/lib/format"
import { DataTableColumnHeader, type DataTableColumnDef } 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 StayRow } from "../data"

// A night is a calendar day, so a departure is printed in UTC rather than
// through DateCell, whose format follows the reader's own zone — midnight UTC
// on the 7th is the evening of the 6th in Los Angeles, and a desk sheet that
// moved a checkout back a day would be worse than useless.
const utc = (date: Date) => formatDate(date, "short", { timeZone: "UTC" })

/** The arrivals sheet the desk prints: who is due in, into which room, for how long. */
export const ARRIVAL_COLUMNS: DataTableColumnDef<StayRow>[] = [
  {
    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} />,
    meta: { label: "Guest" },
  },
  {
    accessorKey: "room",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Room" />,
    cell: ({ row }) => (
      <span className="whitespace-nowrap">
        {row.original.room} · {ROOM_TYPE_LABELS[row.original.roomType]}
      </span>
    ),
    meta: { label: "Room" },
  },
  {
    accessorKey: "checkOut",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Out" />,
    cell: ({ row }) => <span className="whitespace-nowrap">{utc(row.original.checkOut)}</span>,
    meta: { label: "Out" },
  },
  {
    accessorKey: "nights",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Nights" />,
    cell: ({ row }) => <NumberCell value={row.original.nights} />,
    meta: { align: "right", label: "Nights" },
  },
  {
    accessorKey: "guests",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Guests" />,
    cell: ({ row }) => <NumberCell value={row.original.guests} />,
    meta: { align: "right", label: "Guests" },
  },
  {
    accessorKey: "source",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Channel" />,
    cell: ({ row }) => (
      <span className="text-muted-foreground">{SOURCE_LABELS[row.original.source]}</span>
    ),
    meta: { label: "Channel" },
  },
  {
    accessorKey: "totalCents",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Total" />,
    cell: ({ row }) => <CurrencyCell value={row.original.totalCents / 100} />,
    meta: { align: "right", label: "Total" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => (
      <StatusBadge
        status={row.original.status}
        label={DESK_STATUS_LABELS[row.original.status]}
        map={STAY_STATUS_MAP}
        dot
      />
    ),
    meta: { label: "Status" },
  },
]
app/hotel/components/arrivals-table.tsx
"use client"

import * as React from "react"

import { SOURCE_LABELS, SOURCE_ORDER } from "@/lib/dashboards/hotel/vocabulary"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataTable } from "@/components/ui/data-table"
import { SectionHeader } from "@/components/ui/section-header"

import { type StayRow } from "../data"

import { ARRIVAL_COLUMNS } from "./arrival-columns"

export type ArrivalsTableProps = { rows: StayRow[] }

/** Tonight's arrivals in full: the sheet the desk works down. */
export function ArrivalsTable({ rows }: ArrivalsTableProps) {
  // The heading names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  return (
    <Card data-widget="widget-hotel-overview-arrivals-table">
      <CardHeader>
        <SectionHeader
          as="h2"
          title="Arrivals sheet"
          titleId={titleId}
          description="Every guest due in tonight, with the room they are going into."
        />
      </CardHeader>
      <CardContent>
        <DataTable
          aria-labelledby={titleId}
          columns={ARRIVAL_COLUMNS}
          data={rows}
          getRowId={(row) => row.id}
          searchKey="guestName"
          searchPlaceholder="Find a guest…"
          pageSize={10}
          emptyMessage="Nobody is due in tonight."
          facets={[
            {
              columnId: "source",
              title: "Channel",
              options: SOURCE_ORDER.map((value) => ({ value, label: SOURCE_LABELS[value] })),
            },
          ]}
        />
      </CardContent>
    </Card>
  )
}
app/hotel/components/availability-panel.tsx
"use client"

import * as React from "react"

import { AvailabilityCalendar } from "@/components/ui/availability-calendar"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { SectionHeader } from "@/components/ui/section-header"

import { type AvailabilityDay } from "../data"

export type AvailabilityPanelProps = {
  month: Date
  rooms: number
  days: AvailabilityDay[]
  /** Tonight, at midnight UTC — the day the calendar opens on. */
  today: Date
}

const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" })

/** How full a night reads: none left is full, a third or fewer is limited. */
function fillFor(free: number, rooms: number): "available" | "partial" | "full" {
  if (free <= 0) return "full"
  return free <= rooms / 3 ? "partial" : "available"
}

/**
 * The month's availability. The three fills are a rule over the number of
 * rooms free, not a colour picked per day, and the line under the calendar
 * says the number itself — colour is never the only encoding.
 */
export function AvailabilityPanel({ month, rooms, days, today }: AvailabilityPanelProps) {
  const [shown, setShown] = React.useState(month)
  const [picked, setPicked] = React.useState<Date | undefined>(today)

  const free = React.useMemo(
    () => new Map(days.map((day) => [day.key, day.free])),
    [days]
  )

  const marks = React.useMemo(() => {
    const out: Record<string, MiniCalendarMark> = {}
    for (const day of days) out[day.key] = { fill: fillFor(day.free, rooms) }
    return out
  }, [days, rooms])

  const key = picked?.toISOString().slice(0, 10)
  const left = key !== undefined ? free.get(key) : undefined

  return (
    <Card data-widget="widget-hotel-overview-availability-panel">
      <CardHeader>
        <SectionHeader as="h2" title="Availability" description="Rooms free, night by night." />
      </CardHeader>
      <CardContent className="flex flex-col gap-3">
        <AvailabilityCalendar
          month={shown}
          onMonthChange={setShown}
          value={picked}
          onValueChange={setPicked}
          marks={marks}
          today={today}
          legendLabels={{ full: "Sold out" }}
          className="w-full"
        />
        <p data-slot="availability-readout" className="text-xs text-muted-foreground">
          {picked === undefined
            ? "Pick a night."
            : left === undefined
              ? `${FULL.format(picked)} is outside the book.`
              : `${FULL.format(picked)} — ${left} of ${rooms} rooms free${left === 0 ? ", the house is sold out" : ""}.`}
        </p>
      </CardContent>
    </Card>
  )
}
app/hotel/components/booking-sources.tsx
import { ChartCard } from "@/components/ui/chart-card"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"

import { type SourceGrid } from "../data"

export type BookingSourcesProps = { grid: SourceGrid }

/**
 * Where the arrivals came from, week by week. No state and no handlers — and,
 * deliberately, no formatter either: a function cannot cross from a server
 * component into a client one, and a 24px cell has room for the number itself
 * rather than a sentence about it.
 */
export function BookingSources({ grid }: BookingSourcesProps) {
  return (
    <ChartCard
      data-widget="widget-hotel-overview-booking-sources"
      title="Booking channels"
      description="Arrivals per channel, eight weeks around tonight"
      height={220}
    >
      <HeatmapGrid
        aria-label="Arrivals per channel by week"
        rows={grid.rows}
        columns={grid.columns}
        values={grid.values}
        cellSize={24}
        showValues
      />
    </ChartCard>
  )
}
app/hotel/components/desk-flow.tsx
"use client"

import * as React from "react"

import { DashboardGridItem } from "@/components/ui/dashboard-grid"

import { type GuestFlow, type StayRow } from "../data"

import { ArrivalsTable } from "./arrivals-table"
import { GuestFlowPanel, settleStay } from "./guest-flow"

export type DeskFlowProps = { flow: GuestFlow; sources: React.ReactNode }

/**
 * The guest-flow panel and the arrivals sheet read the same stays, so a
 * check-in or check-out has to reach both — the panel owning the rows on its
 * own left the sheet bound to the server's frozen props, so a guest read "in
 * house" above and "due in" below the moment the desk acted. One piece of
 * state here, shared by both, is what a `router.refresh()` cannot be for a
 * framed preview's static data. Booking sources sits between them so the page
 * keeps its original order; it arrives as a slot from the server page, so it
 * stays a server component instead of riding into the client bundle.
 */
export function DeskFlow({ flow, sources }: DeskFlowProps) {
  const [lists, setLists] = React.useState(flow)

  function settle(row: StayRow) {
    setLists((current) => settleStay(current, row))
  }

  return (
    <>
      <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
        <GuestFlowPanel flow={lists} onSettle={settle} />
      </DashboardGridItem>

      <DashboardGridItem colSpan={12}>
        {sources}
      </DashboardGridItem>

      <DashboardGridItem colSpan={12}>
        <ArrivalsTable rows={lists.arrivals} />
      </DashboardGridItem>
    </>
  )
}
app/hotel/components/desk-stats.tsx
import * as React from "react"
import { BedDoubleIcon, DoorOpenIcon, ReceiptIcon, TrendingUpIcon } from "lucide-react"

import { StatCard, type StatCardProps } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type FrontDeskStat } from "../data"

export type DeskStatsProps = { stats: FrontDeskStat[] }

// Each tile in its own tint, with the icon that names it: a duty manager
// finds tonight's occupancy by the blue frame before reading a word.
const TILE: Record<string, { tone: NonNullable<StatCardProps["tone"]>; icon: React.ReactNode }> = {
  occupancy: { tone: "info", icon: <BedDoubleIcon /> },
  adr: { tone: "success", icon: <ReceiptIcon /> },
  revpar: { tone: "warning", icon: <TrendingUpIcon /> },
  arrivals: { tone: "danger", icon: <DoorOpenIcon /> },
}

/** Tonight in four numbers. No state, so the tiles render on the server. */
export function DeskStats({ stats }: DeskStatsProps) {
  return (
    <StatCardGroup data-widget="widget-hotel-overview-desk-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.id}
          id={stat.id}
          label={stat.label}
          value={stat.value}
          description={stat.description}
          tone={TILE[stat.id]?.tone}
          icon={TILE[stat.id]?.icon}
        />
      ))}
    </StatCardGroup>
  )
}
app/hotel/components/guest-flow.tsx
"use client"

import * as React from "react"
import { LogInIcon, LogOutIcon } from "lucide-react"

import { formatDate, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { NavTabs } from "@/components/ui/nav-tabs"
import { SectionHeader } from "@/components/ui/section-header"
import { StatusBadge } from "@/components/ui/status-badge"

import { checkIn, checkOut } from "@/lib/dashboards/hotel/actions"
import { DESK_STATUS_LABELS, ROOM_TYPE_LABELS, STAY_STATUS_MAP } from "@/lib/dashboards/hotel/vocabulary"

import { type GuestFlow, type StayRow } from "../data"

const TABS = [
  { id: "arrivals", label: "Arrivals" },
  { id: "inHouse", label: "In house" },
  { id: "departures", label: "Departures" },
] as const

type TabId = (typeof TABS)[number]["id"]

const utc = (date: Date) => formatDate(date, "short", { timeZone: "UTC" })

export type GuestFlowPanelProps = {
  flow: GuestFlow
  /**
   * A check-in or check-out settled. The page passes this: the arrivals sheet
   * reads the same stays, so the rows are the page's to hold. Drawn alone,
   * with no other card to tell, the panel holds them itself.
   */
  onSettle?: (row: StayRow) => void
}

/** The three lists with `row` in place of the stay it settled, wherever that stay is listed. */
export function settleStay(flow: GuestFlow, row: StayRow): GuestFlow {
  const swap = (stays: StayRow[]) => stays.map((stay) => (stay.id === row.id ? row : stay))
  return { arrivals: swap(flow.arrivals), inHouse: swap(flow.inHouse), departures: swap(flow.departures) }
}

/**
 * The desk's three lists, and the two things it does with them. Both actions
 * write through the server and report a `Result`; the guest and their room
 * lands in a status line, because a row that quietly changed pill would not
 * tell anyone anything, and the reason a check-in or check-out was refused
 * gets its own alert rather than sharing that line — a refusal is not a
 * notice, and a reader skimming past a muted status line is the reason this
 * finding exists.
 *
 * On the page the rows are the caller's: another view (the arrivals sheet)
 * reads the same stays, so the state that a settle changes is lifted rather
 * than kept here, where only this panel could see it move. Drawn alone, with
 * no `onSettle`, the panel keeps the rows it was handed and settles them
 * itself.
 */
export function GuestFlowPanel({ flow: handed, onSettle }: GuestFlowPanelProps) {
  const [own, setOwn] = React.useState(handed)
  const flow = onSettle ? handed : own
  const settle = onSettle ?? ((row: StayRow) => setOwn((current) => settleStay(current, row)))
  const [tab, setTab] = React.useState<TabId>("arrivals")
  const [notice, setNotice] = React.useState<string>("")
  const [refusal, setRefusal] = React.useState<string>("")
  const panelId = React.useId()

  const rows = flow[tab]

  // The desk's own verbs, shared with the other Hotel pages. Each answers
  // with the stay as the store now holds it — a refusal too — and the row
  // here takes its status, so a list never shows a state the desk was told
  // is wrong.
  async function arrive(row: StayRow) {
    setRefusal("")
    setNotice("")
    const result = await checkIn(row.id)
    if (!result.ok) {
      if (result.current) settle({ ...row, status: result.current.status })
      return setRefusal(result.error.message)
    }
    settle({ ...row, status: result.data.status })
    setNotice(`${result.data.guestName} is in room ${result.data.room}.`)
  }

  async function leave(row: StayRow) {
    setRefusal("")
    setNotice("")
    const result = await checkOut(row.id)
    if (!result.ok) {
      if (result.current) settle({ ...row, status: result.current.status })
      return setRefusal(result.error.message)
    }
    settle({ ...row, status: result.data.status })
    setNotice(`Room ${result.data.room} is free — ${result.data.guestName} has checked out.`)
  }

  const next = flow.arrivals.find((stay) => stay.status === "confirmed")
  const leaving = flow.departures.find((stay) => stay.status === "checked_in")

  return (
    <Card data-widget="widget-hotel-overview-guest-flow" className="h-full">
      <CardHeader>
        <SectionHeader as="h2" title="Today" description="Who is coming in, who is here, who is going out." />
        <div data-slot="quick-actions" className="flex flex-wrap gap-2" aria-label="Quick actions">
          <Button size="sm" variant="outline" disabled={!next} onClick={() => next && arrive(next)}>
            <LogInIcon />
            Check in next arrival
          </Button>
          <Button size="sm" variant="outline" disabled={!leaving} onClick={() => leaving && leave(leaving)}>
            <LogOutIcon />
            Check out next departure
          </Button>
        </div>
      </CardHeader>

      <CardContent className="flex flex-col gap-2">
        {refusal ? (
          <Callout variant="danger" role="alert">
            {refusal}
          </Callout>
        ) : null}

        <NavTabs
          role="tablist"
          aria-label="Guest flow"
          items={TABS.map((entry) => ({
            label: entry.label,
            href: entry.id,
            count: flow[entry.id].length,
          }))}
          activeHref={tab}
          renderLink={(item, children, className) => (
            <button
              type="button"
              role="tab"
              data-slot="nav-tabs-item"
              data-active={item.href === tab || undefined}
              aria-selected={item.href === tab}
              aria-controls={panelId}
              className={className}
              onClick={() => setTab(item.href as TabId)}
            >
              {children}
            </button>
          )}
        />

        <div id={panelId} role="tabpanel" aria-label={TABS.find((entry) => entry.id === tab)!.label}>
          <DataList divided>
            {rows.map((stay) => (
              <DataListItem
                key={stay.id}
                leading={
                  <Avatar size="sm">
                    <AvatarImage src={stay.guestAvatar} alt="" />
                    <AvatarFallback>{getInitials(stay.guestName)}</AvatarFallback>
                  </Avatar>
                }
                title={stay.guestName}
                description={`Room ${stay.room} · ${ROOM_TYPE_LABELS[stay.roomType]} · ${stay.nights} nights`}
                meta={`${utc(stay.checkIn)} — ${utc(stay.checkOut)}`}
                actions={
                  <div className="flex items-center gap-2">
                    <StatusBadge status={stay.status} label={DESK_STATUS_LABELS[stay.status]} map={STAY_STATUS_MAP} />
                    {stay.status === "confirmed" ? (
                      <Button size="xs" variant="outline" onClick={() => arrive(stay)}>
                        Check in
                      </Button>
                    ) : null}
                    {stay.status === "checked_in" && tab === "departures" ? (
                      <Button size="xs" variant="outline" onClick={() => leave(stay)}>
                        Check out
                      </Button>
                    ) : null}
                  </div>
                }
              />
            ))}
          </DataList>
          {rows.length === 0 ? (
            <p className="py-6 text-center text-sm text-muted-foreground">Nothing on this list today.</p>
          ) : null}
        </div>

        <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
          {notice}
        </p>
      </CardContent>
    </Card>
  )
}