Skip to contentVibraUI

Guest flow

Today's arrivals, the house and the departures in three tabs, each guest checked in or out through the server from their row; reads guestFlow().

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-overview-guest-flow

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

Source

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

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 Front desk page