Skip to contentVibraUI

Arrivals sheet

Every guest due in tonight with the room they are going into, searchable by name and filtered by channel; reads guestFlow().

Preview

Install

npx shadcn@latest add @vibra/widget-hotel-overview-arrivals-table

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

Source

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/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/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" },
  },
]

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