Skip to contentVibraUI
Part of the Real estate dashboardinstalls at /real-estate

Real estate dashboard

An estate agency's book: active listings, the month's enquiries, the quarter's closings and the commission on them, closings against viewings by week, month or year, the most-enquired listing in a spotlight, closed against in progress, the next viewings, the agents, sales by channel, the portfolio and a table of what is on the market beside a calendar of viewings.

Open the live page

Installs at /real-estate; the page reads db.listings and the db.members who hold one, on the server. A listing records its interest as totals, so every figure read by period is the share of the listing's life that falls inside the window — the month's enquiries against the same days of last month, the chart's viewings — while closings are read off closedAt and revenue is three percent of the quarter's. performance.ts holds that rule and the seeded history: each of the last twelve months is blended between what the book records and the pace of its mature months by the share of a mature book live when the month began, so the join has no cliff, and a year the months do not cover is drawn at that pace times twelve; the W / M / Y control re-buckets on the client. The spotlight shows a sparkline of the most-enquired listing's last thirty days of views where a photograph would go, and the agents come from the dashboard's one agent summary (agents-real-estate), whose numbers are a rule over their place in the directory on the fictional 555-01xx exchange; the words and the money formatter are the dashboard's vocabulary (vocabulary-real-estate). Nothing here mutates beyond signing out. Composes AppShell, PageHeader, DashboardGrid, StatCardGroup, StatCard, AreaChart, DonutChart, PercentageBar, ProgressStat, Sparkline, MiniCalendar, SegmentedControl, Widget, DataList, DataTable, DateCell, DescriptionList, StatusBadge, Badge, AvatarGroup, UserCell and Button.

Preview

Install

npx shadcn@latest add @vibra/real-estate-overview

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

Source

app/real-estate/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 { AgentsList } from "./components/agents-list"
import { ClosedVsInProgress } from "./components/closed-band"
import { ListingStats } from "./components/listing-stats"
import { ListingsTable } from "./components/listings-table"
import { PerformanceChart } from "./components/performance-chart"
import { PortfolioCard } from "./components/portfolio-card"
import { SalesByChannel } from "./components/sales-by-channel"
import { SpotlightCard } from "./components/spotlight-card"
import { ViewingCalendar } from "./components/viewing-calendar"
import { ViewingsList } from "./components/viewings-list"
import {
  TODAY,
  activeRows,
  agents,
  closedBand,
  currentUser,
  lastUpdated,
  listingStats,
  performance,
  portfolio,
  salesByChannel,
  shellNotifications,
  spotlight,
  viewings,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"

/**
 * The listings page. A server component: it reads the book and the agents
 * through `db`, works every rate out, and hands plain rows to the islands that
 * hold state — the chart's bucket, the viewings fold and the calendar.
 */
export default function ListingsPage() {
  const booked = viewings()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Listings"
        description="The book, the closings, and who is viewing what."
        meta={lastUpdated()}
      />

      <ListingStats stats={listingStats()} />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <PerformanceChart series={performance()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <SpotlightCard listing={spotlight()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <ClosedVsInProgress band={closedBand()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <ViewingsList viewings={booked} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
          <AgentsList agents={agents()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <SalesByChannel channels={salesByChannel()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, md: 6, lg: 3 }}>
          <PortfolioCard slices={portfolio()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 9 }}>
          <ListingsTable rows={activeRows()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 3 }}>
          <ViewingCalendar viewings={booked} today={new Date(TODAY)} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/real-estate/data.ts
/**
 * What /real-estate reads. The book is `db.listings`; the agents are the
 * `db.members` who hold one. A listing records its views and enquiries as
 * totals, so anything the page reads by period is a rule over the row —
 * interest accrues evenly while a listing is live, and a window gets the
 * share of the listing's life that falls inside it; `performance.ts` holds
 * that rule, the closings read off `closedAt`, and the seeded history the
 * chart, the channel split and the spotlight draw on. Commission is three
 * percent of what closed. "Now" is `REFERENCE_DATE`.
 *
 * The client islands import only the types below; `db` never crosses into the
 * browser.
 */
import { agentSummaries } from "@/lib/dashboards/real-estate/agents"
import { isOpen, money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"

import { channelShares, enquiriesIn, isClosed, spotlightDailyViews, starOf } from "./performance"

export { performance, type PerformanceBucket, type PerformancePoint } from "./performance"

const NOW = REFERENCE_DATE.getTime()

/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

export const TODAY = utcDay(REFERENCE_DATE)

/** The agency's cut of a closing. */
const COMMISSION = 0.03

const listings = () => db.listings.all()

/** The change from `before` to `now` as a ratio; flat when there was nothing before. */
const change = (now: number, before: number): number => (before === 0 ? 0 : (now - before) / before)

/** Midnight UTC on the first of the month `offset` months from the current one. */
const monthStart = (offset: number): number =>
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)

/** Midnight UTC on the first day of the quarter `offset` quarters from the current one. */
const quarterStart = (offset: number): number =>
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + offset * 3, 1)

/** "Q3 2026" — the quarter the closings are counted over. */
export const QUARTER_LABEL = `Q${Math.floor(REFERENCE_DATE.getUTCMonth() / 3) + 1} ${REFERENCE_DATE.getUTCFullYear()}`

/* -------------------------------------------------------------------------- */
/* Headlines                                                                   */
/* -------------------------------------------------------------------------- */

export type ListingStat = {
  key: "active" | "enquiries" | "closed" | "revenue"
  label: string
  value: string
  /** Change against the period before, as a ratio. */
  delta?: number
  description: string
}


/** The four headline numbers. */
export function listingStats(): ListingStat[] {
  const rows = listings()
  const active = rows.filter(isOpen)
  const closedThisQuarter = rows.filter((row) => isClosed(row) && row.closedAt.getTime() >= quarterStart(0))
  // Month to date against the same days of last month: four days against a
  // whole month would read as a collapse.
  const enquiries = enquiriesIn(monthStart(0), NOW)
  const enquiriesBefore = enquiriesIn(monthStart(-1), monthStart(-1) + (NOW - monthStart(0)))
  const commission = Math.round(closedThisQuarter.reduce((sum, row) => sum + row.priceCents, 0) * COMMISSION)

  return [
    {
      key: "active",
      label: "Active listings",
      value: String(active.length),
      description: `${active.filter((row) => row.status === "under_offer").length} under offer`,
    },
    {
      key: "enquiries",
      label: "Enquiries",
      value: String(enquiries),
      delta: change(enquiries, enquiriesBefore),
      description: "vs the same days last month",
    },
    {
      key: "closed",
      label: "Closed this quarter",
      value: String(closedThisQuarter.length),
      description: `${closedThisQuarter.filter((row) => row.status === "sold").length} sold · ${closedThisQuarter.filter((row) => row.status === "rented").length} rented`,
    },
    {
      key: "revenue",
      label: "Revenue",
      value: money(commission),
      description: "3% commission on the quarter's closings",
    },
  ]
}

/* -------------------------------------------------------------------------- */
/* Spotlight, the band, viewings, agents, channels, portfolio                  */
/* -------------------------------------------------------------------------- */

export type Spotlight = {
  id: string
  title: string
  kind: Listing["kind"]
  city: string
  country: string
  beds: number
  baths: number
  sqm: number
  priceCents: number
  status: Listing["status"]
  enquiries: number
  agent: string
  /** The agent's whole book: what they have sold and let, and how many views it drew. */
  book: { sold: number; rented: number; views: number }
  /** Views on each of the last thirty days, oldest first. */
  dailyViews: number[]
}

/** The most-enquired listing, its facts, and its agent's book. */
export function spotlight(): Spotlight {
  const rows = listings()
  const star = starOf(rows)
  const agent = db.members.all().find((member) => member.id === star.agentId)
  const book = rows.filter((row) => row.agentId === star.agentId)
  return {
    id: star.id,
    title: star.title,
    kind: star.kind,
    city: star.city,
    country: star.country,
    beds: star.beds,
    baths: star.baths,
    sqm: star.sqm,
    priceCents: star.priceCents,
    status: star.status,
    enquiries: star.enquiries,
    agent: agent?.name ?? "Unassigned",
    book: {
      sold: book.filter((row) => row.status === "sold").length,
      rented: book.filter((row) => row.status === "rented").length,
      views: book.reduce((sum, row) => sum + row.views, 0),
    },
    dailyViews: spotlightDailyViews(),
  }
}

export type ClosedBand = { closed: number; inProgress: number; total: number }

/** What has closed against what is still being worked. */
export function closedBand(): ClosedBand {
  const rows = listings()
  const closed = rows.filter(isClosed).length
  return { closed, inProgress: rows.length - closed, total: rows.length }
}

export type Viewing = { id: string; title: string; city: string; kind: Listing["kind"]; agent: string; agentAvatar?: string; at: Date }

/** Every booked viewing, soonest first. */
export function viewings(): Viewing[] {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return listings()
    .filter((row): row is Listing & { nextViewingAt: Date } => Boolean(row.nextViewingAt))
    .sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime())
    .map((row) => ({
      id: row.id,
      title: row.title,
      city: row.city,
      kind: row.kind,
      agent: members.get(row.agentId)?.name ?? "Unassigned",
      agentAvatar: members.get(row.agentId)?.avatarUrl,
      at: row.nextViewingAt,
    }))
}

export type Agent = { id: string; name: string; avatarUrl?: string; city: string; listings: number; phone: string }

/**
 * Everyone holding a listing, with the city most of their book is in and a
 * number to call — the dashboard's one agent summary
 * (`@/lib/dashboards/real-estate/agents`), so the number and
 * the city here are the ones the Agents page and every listing's agent card
 * print.
 */
export function agents(): Agent[] {
  return agentSummaries().map(({ id, name, avatarUrl, city, listings, phone }) => ({ id, name, avatarUrl, city, listings, phone }))
}

export type ChannelShare = { channel: string; closings: number }

/** The quarter's closings split by channel, from the seeded shares, summing to the count. */
export function salesByChannel(): ChannelShare[] {
  const total = listings().filter((row) => isClosed(row) && row.closedAt.getTime() >= quarterStart(0)).length
  const shares = channelShares()
  let assigned = 0
  return shares.map(({ channel, share }, index) => {
    const closings = index === shares.length - 1 ? total - assigned : Math.round(total * share)
    assigned += closings
    return { channel, closings }
  })
}

export type PortfolioSlice = { status: Listing["status"]; count: number; share: number }

const STATUSES: Listing["status"][] = ["listed", "under_offer", "sold", "rented"]

/** The book by status, each with its share of the whole. */
export function portfolio(): PortfolioSlice[] {
  const rows = listings()
  return STATUSES.map((status) => {
    const count = rows.filter((row) => row.status === status).length
    return { status, count, share: rows.length === 0 ? 0 : Math.round((count / rows.length) * 100) }
  })
}

/** One row of the active listings table. */
export type ListingRow = {
  id: string
  title: string
  kind: Listing["kind"]
  city: string
  priceCents: number
  status: Listing["status"]
  agent: string
  listedAt: Date
}

/** Every listing still on the market; the table sorts and pages them itself. */
export function activeRows(): ListingRow[] {
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return listings()
    .filter(isOpen)
    .map((row) => ({
      id: row.id,
      title: row.title,
      kind: row.kind,
      city: row.city,
      priceCents: row.priceCents,
      status: row.status,
      agent: members.get(row.agentId)?.name ?? "Unassigned",
      listedAt: row.listedAt,
    }))
}

/** The line under the title: the quarter, and when the numbers were read. */
export function lastUpdated(): string {
  return `${QUARTER_LABEL} to date · 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 owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
app/real-estate/performance.ts
/**
 * The rules with no rows of their own, and the history they build. A listing
 * records its views and enquiries as totals, so a window gets the share of
 * the listing's life that falls inside it; closings are read off `closedAt`.
 * The months before the book's first listing, the channel split and the
 * spotlight's daily views come once from `seeded("dashboard-real-estate")`.
 * Server-only: this module reads `db`, and only `data.ts` and the page import
 * it — the islands take the computed series as props.
 */
import { REFERENCE_DATE, db, seeded, type Listing } from "@/lib/sample-data"

const DAY = 86_400_000
const WEEK = 7 * DAY
const NOW = REFERENCE_DATE.getTime()

/** Midnight UTC on the day `date` falls on. */
const utcDay = (date: Date): number => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())

/** Monday of the week today falls in. */
const MONDAY = utcDay(REFERENCE_DATE) - ((new Date(utcDay(REFERENCE_DATE)).getUTCDay() + 6) % 7) * DAY

const listings = () => db.listings.all()

/** Sold or let, and carrying the day it closed — the guard the closings are read through. */
export const isClosed = (row: Listing): row is Listing & { closedAt: Date } =>
  (row.status === "sold" || row.status === "rented") && row.closedAt instanceof Date

const between = (at: number, from: number, to: number): boolean => at >= from && at < to

/** Midnight UTC on the first of the month `offset` months from the current one. */
const monthStart = (offset: number): number =>
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)

const yearStart = (offset: number): number => Date.UTC(REFERENCE_DATE.getUTCFullYear() + offset, 0, 1)

/**
 * The share of a listing's total interest that fell inside a window: its
 * life runs from the day it was listed to the day it closed, or to now, and
 * interest is taken to accrue evenly across it.
 */
function accrued(row: Listing, key: "views" | "enquiries", from: number, to: number): number {
  const start = row.listedAt.getTime()
  const end = row.closedAt?.getTime() ?? NOW
  const live = Math.max(1, (end - start) / DAY)
  const overlap = Math.max(0, Math.min(end, to) - Math.max(start, from)) / DAY
  return (row[key] * overlap) / live
}

export const enquiriesIn = (from: number, to: number): number =>
  Math.round(listings().reduce((sum, row) => sum + accrued(row, "enquiries", from, to), 0))

const viewingsIn = (from: number, to: number): number =>
  Math.round(listings().reduce((sum, row) => sum + accrued(row, "views", from, to), 0))

/** The asking prices, in cents, of what closed inside a window. */
const closingsIn = (from: number, to: number): number =>
  listings()
    .filter((row) => isClosed(row) && between(row.closedAt.getTime(), from, to))
    .reduce((sum, row) => sum + row.priceCents, 0)

/* -------------------------------------------------------------------------- */
/* The seeded pieces                                                           */
/* -------------------------------------------------------------------------- */

type Bucket = { start: number; closingsCents: number; viewings: number }

// Not exported: an island would carry `db` into the browser by importing it;
// each row names its channel for the island to print.
const CHANNELS = ["Direct", "Portal", "Referral", "Network"] as const

type Seeded = {
  /** The last twelve complete calendar months, oldest first. */
  months: Bucket[]
  /** Three calendar years, oldest first; the current one to date. */
  years: (Bucket & { label: string })[]
  /** Each channel's share of the closings, summing to one. */
  channelShares: number[]
  /** The spotlight listing's views on each of the last thirty days. */
  spotlightViews: number[]
}

/** The listing the spotlight falls on: the most enquired, and the lowest id on a tie. */
export function starOf(rows: Listing[]): Listing {
  return [...rows].sort((a, b) => b.enquiries - a.enquiries || a.id.localeCompare(b.id))[0]
}

/** How many listings live at a month's start count as a mature book: half of it. */
const MATURE_LIVE = 30

/** How many listings were on the market when `at` came. */
const liveAt = (rows: Listing[], at: number): number =>
  rows.filter((row) => row.listedAt.getTime() < at && (row.closedAt?.getTime() ?? NOW) >= at).length

/**
 * Everything on the page with no rows behind it, from one generator. The
 * book was empty a year ago and filled through the spring, so a month's
 * value is blended between what the book records and the pace the mature
 * months set — drawn a third either way — by the share of a mature book
 * that was live when the month began: an empty month is all drawn, a full
 * one all measured, and the join between them is a slope rather than a
 * cliff. A year the twelve months do not cover is drawn at that pace times
 * twelve; the current year is the sum of its months so far. The channel
 * shares fall off from the first channel, and the spotlight's daily views
 * scatter around its own daily rate.
 */
function generate(): Seeded {
  const rand = seeded("dashboard-real-estate")
  const rows = listings()

  // The last twelve complete months: the current one is four days old and
  // would read as a cliff at the end of every series.
  const starts = Array.from({ length: 12 }, (_, index) => monthStart(index - 12))
  const measured = starts.map((start, index) => {
    const end = monthStart(index - 11)
    return {
      start,
      weight: Math.min(1, liveAt(rows, start) / MATURE_LIVE),
      closingsCents: closingsIn(start, end),
      viewings: viewingsIn(start, end),
    }
  })
  const mature = measured.filter((month) => month.weight === 1)
  const pace = {
    closingsCents: mature.reduce((sum, month) => sum + month.closingsCents, 0) / Math.max(1, mature.length),
    viewings: mature.reduce((sum, month) => sum + month.viewings, 0) / Math.max(1, mature.length),
  }
  const drawn = (value: number) => Math.round(value * (0.65 + rand() * 0.7))
  const blend = (weight: number, measuredValue: number, drawnValue: number) =>
    Math.round(weight * measuredValue + (1 - weight) * drawnValue)

  const months = measured.map((month) => ({
    start: month.start,
    closingsCents: blend(month.weight, month.closingsCents, drawn(pace.closingsCents)),
    viewings: blend(month.weight, month.viewings, drawn(pace.viewings)),
  }))

  const years = [-2, -1, 0].map((offset) => {
    const from = yearStart(offset)
    const to = yearStart(offset + 1)
    const inYear = months.filter((month) => between(month.start, from, to))
    // Covered when every complete month of the year is in the twelve.
    const complete = offset === 0 ? REFERENCE_DATE.getUTCMonth() : 12
    const label = `${new Date(from).getUTCFullYear()}${offset === 0 ? " YTD" : ""}`
    if (inYear.length > 0 && inYear.length === complete) {
      return {
        start: from,
        label,
        closingsCents: inYear.reduce((sum, month) => sum + month.closingsCents, 0),
        viewings: inYear.reduce((sum, month) => sum + month.viewings, 0),
      }
    }
    return { start: from, label, closingsCents: drawn(pace.closingsCents * 12), viewings: drawn(pace.viewings * 12) }
  })

  // Four shares of one, each about half the one before, plus a little noise.
  const raw = CHANNELS.map((_, index) => 0.55 ** index * (0.9 + rand() * 0.2))
  const total = raw.reduce((sum, value) => sum + value, 0)
  const channelShares = raw.map((value) => value / total)

  const star = starOf(rows)
  const life = Math.max(1, ((star.closedAt?.getTime() ?? NOW) - star.listedAt.getTime()) / DAY)
  const daily = star.views / life
  const spotlightViews = Array.from({ length: 30 }, () => Math.max(0, Math.round(daily * (0.6 + rand() * 0.8))))

  return { months, years, channelShares, spotlightViews }
}

// Drawn per call: the draws are a fixed number in a fixed order whatever the
// book holds, so they read the same every time, while the months, the star and
// its views come off the listings as they stand — a closing recorded since the
// server started is in the next render.

/** Each channel's share of the closings, in the channels' order. */
export function channelShares(): { channel: string; share: number }[] {
  const shares = generate().channelShares
  return CHANNELS.map((channel, index) => ({ channel, share: shares[index] }))
}

/** The spotlight listing's views on each of the last thirty days, oldest first. */
export function spotlightDailyViews(): number[] {
  return generate().spotlightViews
}

/* -------------------------------------------------------------------------- */
/* Performance                                                                 */
/* -------------------------------------------------------------------------- */

export type PerformanceBucket = "W" | "M" | "Y"

/** One plotted point: closings in thousands of dollars, and viewings. */
export type PerformancePoint = { label: string; closings: number; viewings: number }

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

const thousands = (cents: number): number => Math.round(cents / 100_000)

/**
 * Closings against viewings at the three bucket sizes the control offers:
 * the last twelve complete weeks off the book, and the twelve months and
 * three years of the seeded history.
 */
export function performance(): Record<PerformanceBucket, PerformancePoint[]> {
  const weeks = Array.from({ length: 12 }, (_, index) => {
    const start = MONDAY - (12 - index) * WEEK
    const end = start + WEEK
    return { label: WEEK_LABEL.format(new Date(start)), closings: thousands(closingsIn(start, end)), viewings: viewingsIn(start, end) }
  })

  const drawn = generate()
  const months = drawn.months.map((month) => ({
    label: MONTH_LABEL.format(new Date(month.start)),
    closings: thousands(month.closingsCents),
    viewings: month.viewings,
  }))

  const years = drawn.years.map((year) => ({
    label: year.label,
    closings: thousands(year.closingsCents),
    viewings: year.viewings,
  }))

  return { W: weeks, M: months, Y: years }
}
app/real-estate/actions.ts
"use server"

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

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/real-estate/components/agents-list.tsx
import { PhoneIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

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

export type AgentsListProps = { agents: Agent[] }

/**
 * Everyone holding a listing, the city most of their book is in, and a way to
 * call them. The call is a plain `tel:` link styled as an icon button — a
 * link, because that is what it is, and the phone app is where it goes.
 */
export function AgentsList({ agents }: AgentsListProps) {
  return (
    <Widget data-widget="widget-real-estate-overview-agents-list" title="Agents" description={`${formatNumber(agents.length)} with a book`} className="h-full">
      <ul data-slot="agents" className="grid gap-x-6 sm:grid-cols-2">
        {agents.map((agent) => (
          <li key={agent.id} className="flex items-center gap-3 border-b py-2">
            <UserCell
              size="sm"
              name={agent.name}
              src={agent.avatarUrl}
              description={`${agent.city} · ${formatNumber(agent.listings)} listing${agent.listings === 1 ? "" : "s"}`}
              className="min-w-0 flex-1"
            />
            <a
              href={`tel:${agent.phone}`}
              aria-label={`Call ${agent.name}`}
              className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
            >
              <PhoneIcon aria-hidden="true" />
            </a>
          </li>
        ))}
      </ul>
    </Widget>
  )
}
app/real-estate/components/closed-band.tsx
import { formatNumber } from "@/lib/format"
import { ProgressStat } from "@/components/ui/progress-stat"
import { Widget } from "@/components/ui/widget"

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

export type ClosedBandProps = { band: ClosedBand }

/** What has closed against the whole book, as one band, with what is still in progress beside it. */
export function ClosedVsInProgress({ band }: ClosedBandProps) {
  return (
    <Widget data-widget="widget-real-estate-overview-closed-band" title="Closed vs in progress" description="The book, closed against still working" className="h-full">
      <div className="flex flex-col gap-3">
        <ProgressStat label="Closed" value={band.closed} target={band.total} tone="default" />
        <p className="text-xs text-muted-foreground">
          <span className="font-medium text-foreground tabular-nums">{`${formatNumber(band.inProgress)} in progress`}</span>
          {" · listed or under offer"}
        </p>
      </div>
    </Widget>
  )
}
app/real-estate/components/listing-stats.tsx
import { BuildingIcon, CheckCircle2Icon, MessageSquareIcon, ReceiptIcon } from "lucide-react"

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

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

// One icon per headline, keyed by the stat rather than its position, so the
// row can be reordered without the icons following the wrong numbers.
const ICONS: Record<ListingStat["key"], React.ReactNode> = {
  active: <BuildingIcon />,
  enquiries: <MessageSquareIcon />,
  closed: <CheckCircle2Icon />,
  revenue: <ReceiptIcon />,
}

export type ListingStatsProps = { stats: ListingStat[] }

/** The four headline numbers. No state, so the tiles render on the server. */
export function ListingStats({ stats }: ListingStatsProps) {
  return (
    <StatCardGroup data-widget="widget-real-estate-overview-listing-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          id={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/real-estate/components/listings-table.tsx
"use client"

import * as React from "react"

import { KIND_LABELS, LISTING_STATUS_LABELS, LISTING_STATUS_MAP, money } from "@/lib/dashboards/real-estate/vocabulary"
import { Badge } from "@/components/ui/badge"
import { DataTable, DataTableColumnHeader, type DataTableColumnDef, type DataTableFacet } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell } from "@/components/ui/table-cells"
import { Widget } from "@/components/ui/widget"

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

const KIND_FACET: DataTableFacet<ListingRow>[] = [
  {
    columnId: "kind",
    title: "Kind",
    options: Object.entries(KIND_LABELS).map(([value, label]) => ({ label, value })),
  },
]

const COLUMNS: DataTableColumnDef<ListingRow>[] = [
  {
    id: "listing",
    // Title and city in one accessor, so the search box matches either.
    accessorFn: (row) => `${row.title} ${row.city}`,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Listing" />,
    cell: ({ row }) => <span className="font-medium whitespace-nowrap">{row.original.title}</span>,
    meta: { label: "Listing" },
  },
  {
    accessorKey: "kind",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Kind" />,
    cell: ({ row }) => <Badge variant="outline">{KIND_LABELS[row.original.kind]}</Badge>,
    meta: { label: "Kind" },
  },
  {
    accessorKey: "city",
    header: ({ column }) => <DataTableColumnHeader column={column} title="City" />,
    cell: ({ row }) => <span className="whitespace-nowrap">{row.original.city}</span>,
    meta: { label: "City" },
  },
  {
    accessorKey: "priceCents",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Price" />,
    cell: ({ row }) => <span className="tabular-nums">{money(row.original.priceCents)}</span>,
    meta: { align: "right", label: "Price" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => (
      <StatusBadge
        status={row.original.status}
        label={LISTING_STATUS_LABELS[row.original.status]}
        map={LISTING_STATUS_MAP}
      />
    ),
    meta: { label: "Status" },
  },
  {
    accessorKey: "agent",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Agent" />,
    cell: ({ row }) => <span className="whitespace-nowrap text-muted-foreground">{row.original.agent}</span>,
    meta: { label: "Agent" },
  },
  {
    accessorKey: "listedAt",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Listed" />,
    cell: ({ row }) => <DateCell date={row.original.listedAt} className="text-muted-foreground" />,
    meta: { align: "right", label: "Listed" },
  },
]

export type ListingsTableProps = { rows: ListingRow[] }

/** Every listing still on the market, newest first, searchable by title or city, with a facet on kind. */
export function ListingsTable({ rows }: ListingsTableProps) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  return (
    <Widget titleId={titleId} data-widget="widget-real-estate-overview-listings-table" title="Active listings" description="On the market, newest first" className="h-full">
      <DataTable
        aria-labelledby={titleId}
        size="sm"
        columns={COLUMNS}
        data={rows}
        pageSize={8}
        searchKey="listing"
        searchPlaceholder="Search listings…"
        facets={KIND_FACET}
        enableRowSelection={false}
        getRowId={(listing) => listing.id}
        initialSorting={[{ id: "listedAt", desc: true }]}
      />
    </Widget>
  )
}
app/real-estate/components/listings-vocabulary.ts
/**
 * The three bucket sizes the performance chart offers. The words the page
 * puts on a listing's kind and status, and how it writes a price, are the
 * dashboard's own, in `@/lib/dashboards/real-estate/vocabulary`.
 */
export const BUCKETS = [
  { value: "W", short: "W", label: "Week", unit: "week" },
  { value: "M", short: "M", label: "Month", unit: "month" },
  { value: "Y", short: "Y", label: "Year", unit: "year" },
] as const
app/real-estate/components/performance-chart.tsx
"use client"

import * as React from "react"

import { formatNumber } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Widget } from "@/components/ui/widget"

import { type PerformanceBucket, type PerformancePoint } from "../data"

import { BUCKETS } from "./listings-vocabulary"

// Closings are in thousands of dollars so the two series share one axis; the
// unit rides on the label rather than on a second axis.
const SERIES = [
  { key: "closings", label: "Closings ($k)" },
  { key: "viewings", label: "Viewings" },
]

export type PerformanceChartProps = { series: Record<PerformanceBucket, PerformancePoint[]> }

/**
 * Closings against viewings at the bucket the control names. The bucket is
 * client state; the three series arrive computed from the server page, so
 * switching is a re-render rather than a round trip. The control shows a
 * letter and says the word, so the options read as W / M / Y and are heard
 * as week, month and year.
 */
export function PerformanceChart({ series }: PerformanceChartProps) {
  const [bucket, setBucket] = React.useState<PerformanceBucket>("M")
  const option = BUCKETS.find((entry) => entry.value === bucket) ?? BUCKETS[1]

  return (
    <Widget
      data-widget="widget-real-estate-overview-performance-chart"
      title="Performance"
      description={`Closings against viewings, by ${option.unit}`}
      className="h-full"
      actions={
        <SegmentedControl
          size="sm"
          aria-label="Bucket"
          value={bucket}
          onValueChange={(next) => setBucket(next as PerformanceBucket)}
          options={BUCKETS.map((entry) => ({
            value: entry.value,
            label: (
              <>
                <span aria-hidden="true">{entry.short}</span>
                <span className="sr-only">{entry.label}</span>
              </>
            ),
          }))}
        />
      }
    >
      <AreaChart
        data={series[bucket]}
        index="label"
        series={SERIES}
        height={240}
        valueFormatter={(value) => formatNumber(value)}
        aria-label={`Closings against viewings by ${option.unit}`}
      />
    </Widget>
  )
}
app/real-estate/components/portfolio-card.tsx
import { LISTING_STATUS_LABELS } from "@/lib/dashboards/real-estate/vocabulary"
import { formatNumber } from "@/lib/format"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

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

export type PortfolioCardProps = { slices: PortfolioSlice[] }

/**
 * The whole book by status as one segmented bar, then the two tiles a
 * principal reads first — what is listed and what has sold — the kit's small
 * stat tiles in one divided group, each with its share as a pill on the
 * strip. No state, so it renders on the server.
 */
export function PortfolioCard({ slices }: PortfolioCardProps) {
  const total = slices.reduce((sum, slice) => sum + slice.count, 0)
  const tiles = slices.filter((slice) => slice.status === "listed" || slice.status === "sold")

  return (
    <Widget data-widget="widget-real-estate-overview-portfolio-card" title="Portfolio" description={`${formatNumber(total)} listings on the book`} className="h-full">
      <PercentageBar
        segments={slices.map((slice) => ({ label: LISTING_STATUS_LABELS[slice.status], value: slice.count }))}
        aria-label="The book by status"
      />
      <StatCardGroup columns={2} divided role="group" aria-label="Listed and sold" className="mt-4">
        {tiles.map((slice) => (
          <StatCard
            key={slice.status}
            size="sm"
            label={LISTING_STATUS_LABELS[slice.status]}
            value={formatNumber(slice.count)}
            icon={
              <StatusBadge
                status={slice.status === "sold" ? "success" : "info"}
                label={`${slice.share}%`}
                size="sm"
              />
            }
          />
        ))}
      </StatCardGroup>
    </Widget>
  )
}
app/real-estate/components/sales-by-channel.tsx
"use client"

import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { DonutChart } from "@/components/ui/donut-chart"
import { CHART_BG, CHART_TOKENS } from "@/components/ui/percentage-bar"
import { Widget } from "@/components/ui/widget"

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

export type SalesByChannelProps = { channels: ChannelShare[] }

/**
 * The quarter's closings by the channel that brought the buyer: a ring, and
 * one count per channel under it in the palette's order, so the legend is
 * the ring's own reading. A client island only because the ring is one.
 */
export function SalesByChannel({ channels }: SalesByChannelProps) {
  const total = channels.reduce((sum, channel) => sum + channel.closings, 0)

  return (
    <Widget data-widget="widget-real-estate-overview-sales-by-channel" title="Sales by channel" description="Where this quarter's closings came from" className="h-full">
      <DonutChart
        data={channels.map((channel) => ({ name: channel.channel, value: channel.closings }))}
        height={180}
        innerRadius={54}
        centerLabel="Closed"
        centerValue={formatNumber(total)}
        valueFormatter={(value) => formatNumber(value)}
        showLegend={false}
      />
      <ul data-slot="channel-counts" aria-label="Closings by channel" className="mt-3 grid grid-cols-4 gap-1">
        {channels.map((channel, index) => (
          <li key={channel.channel} className="flex min-w-0 flex-col items-center gap-0.5 text-center">
            <span
              aria-hidden="true"
              className={cn("size-2 rounded-full", CHART_BG[CHART_TOKENS[index % CHART_TOKENS.length]])}
            />
            <span className="truncate text-xs text-muted-foreground">{channel.channel}</span>
            <span className="text-base font-semibold tabular-nums">{formatNumber(channel.closings)}</span>
          </li>
        ))}
      </ul>
    </Widget>
  )
}
app/real-estate/components/spotlight-card.tsx
import { KIND_LABELS, LISTING_STATUS_LABELS, LISTING_STATUS_MAP, money } from "@/lib/dashboards/real-estate/vocabulary"
import { formatNumber } from "@/lib/format"
import { DescriptionList } from "@/components/ui/description-list"
import { Sparkline } from "@/components/ui/sparkline"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

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

/** "3 beds · 2 baths · 140 m²" — an office has no beds to count. */
const layout = (listing: Spotlight): string =>
  [
    listing.beds > 0 ? `${listing.beds} bed${listing.beds === 1 ? "" : "s"}` : null,
    `${listing.baths} bath${listing.baths === 1 ? "" : "s"}`,
    `${formatNumber(listing.sqm)} m²`,
  ]
    .filter(Boolean)
    .join(" · ")

export type SpotlightCardProps = { listing: Spotlight }

/**
 * The most-enquired listing. Where a listings page would show a photograph,
 * this shows the listing's last thirty days of views as a sparkline: the
 * picture a reader acts on is the interest, not the façade. Under the facts,
 * three of the kit's small stat tiles for the agent's whole book. No state,
 * so it renders on the server.
 */
export function SpotlightCard({ listing }: SpotlightCardProps) {
  return (
    <Widget
      data-widget="widget-real-estate-overview-spotlight-card"
      title="Spotlight"
      description={`${formatNumber(listing.enquiries)} enquiries — the most on the book`}
      className="h-full"
    >
      <div className="flex flex-col gap-4">
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <p className="truncate text-base font-semibold">{listing.title}</p>
            <p className="text-xs text-muted-foreground">{`${KIND_LABELS[listing.kind]} · ${listing.city}, ${listing.country}`}</p>
          </div>
          <StatusBadge
            status={listing.status}
            label={LISTING_STATUS_LABELS[listing.status]}
            map={LISTING_STATUS_MAP}
          />
        </div>

        <div className="rounded-lg bg-surface p-3 ring-1 ring-border">
          <Sparkline
            data={listing.dailyViews}
            type="area"
            height={56}
            aria-label={`${listing.title}, views over the last 30 days`}
          />
          <p className="mt-1 text-xs text-muted-foreground">Views, last 30 days</p>
        </div>

        <DescriptionList
          size="sm"
          items={[
            { term: "Layout", description: layout(listing) },
            { term: "Asking", description: money(listing.priceCents) },
            { term: "Agent", description: listing.agent },
          ]}
        />

        <StatCardGroup columns={3} divided role="group" aria-label="The agent's book">
          {[
            { label: "Sold", value: listing.book.sold },
            { label: "Rented", value: listing.book.rented },
            { label: "Views", value: listing.book.views },
          ].map((tile) => (
            <StatCard key={tile.label} size="sm" label={tile.label} value={formatNumber(tile.value)} />
          ))}
        </StatCardGroup>
      </div>
    </Widget>
  )
}
app/real-estate/components/viewing-calendar.tsx
"use client"

import * as React from "react"

import { DataList, DataListItem } from "@/components/ui/data-list"
import { MiniCalendar, type MiniCalendarMark } from "@/components/ui/mini-calendar"
import { Widget } from "@/components/ui/widget"

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

const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const PICKED = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })

/** The calendar's own key for a day: `yyyy-mm-dd`, in UTC. */
const keyOf = (date: Date): string => date.toISOString().slice(0, 10)

export type ViewingCalendarProps = {
  viewings: Viewing[]
  /** Today, at midnight UTC — the day the calendar opens on. */
  today: Date
}

/**
 * The month with a count on every day that has a viewing booked, and the
 * picked day's viewings under it. Every date is read through its UTC parts,
 * so a viewing at nine lands on the same day for every reader.
 */
export function ViewingCalendar({ viewings, today }: ViewingCalendarProps) {
  const [month, setMonth] = React.useState(today)
  const [picked, setPicked] = React.useState<Date | undefined>(today)

  const marks = React.useMemo(() => {
    const out: Record<string, MiniCalendarMark> = {}
    for (const viewing of viewings) {
      const key = keyOf(viewing.at)
      out[key] = { count: (out[key]?.count ?? 0) + 1, tone: "brand" }
    }
    return out
  }, [viewings])

  const key = picked ? keyOf(picked) : undefined
  const onDay = key === undefined ? [] : viewings.filter((viewing) => keyOf(viewing.at) === key)

  return (
    <Widget data-widget="widget-real-estate-overview-viewing-calendar" title="Viewing calendar" description="Pick a day to see what is booked" className="h-full">
      <div className="flex flex-col gap-3">
        <MiniCalendar
          size="sm"
          month={month}
          onMonthChange={setMonth}
          value={picked}
          onValueChange={setPicked}
          marks={marks}
          today={today}
          className="w-full"
        />
        <p data-slot="picked-day" className="text-xs font-medium text-muted-foreground">
          {picked ? PICKED.format(picked) : "Pick a day."}
        </p>
        {onDay.length > 0 ? (
          <DataList divided>
            {onDay.map((viewing) => (
              <DataListItem
                key={viewing.id}
                title={viewing.title}
                description={`${viewing.city} · ${viewing.agent}`}
                meta={<span className="tabular-nums">{TIME.format(viewing.at)}</span>}
              />
            ))}
          </DataList>
        ) : (
          <p className="py-4 text-center text-sm text-muted-foreground">No viewings that day.</p>
        )}
      </div>
    </Widget>
  )
}
app/real-estate/components/viewings-list.tsx
"use client"

import * as React from "react"

import { KIND_LABELS } from "@/lib/dashboards/real-estate/vocabulary"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import { Widget } from "@/components/ui/widget"

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

const SHOWN = 3

const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { day: "numeric", timeZone: "UTC" })
const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })

export type ViewingsListProps = { viewings: Viewing[] }

/**
 * The next viewings, soonest first: a date tile, the listing, and the agent
 * showing it. Three at first; the button folds the rest out in place rather
 * than leaving the page, because there is no page to go to.
 */
export function ViewingsList({ viewings }: ViewingsListProps) {
  const [open, setOpen] = React.useState(false)
  const shown = open ? viewings : viewings.slice(0, SHOWN)
  const rest = viewings.length - SHOWN

  return (
    <Widget
      data-widget="widget-real-estate-overview-viewings-list"
      title="Viewings"
      description="Booked, soonest first"
      className="h-full"
      actions={
        rest > 0 ? (
          <Button size="sm" variant="ghost" onClick={() => setOpen((current) => !current)}>
            {open ? "Show fewer" : `Show all ${viewings.length}`}
          </Button>
        ) : undefined
      }
    >
      {viewings.length === 0 ? (
        <p className="py-6 text-center text-sm text-muted-foreground">Nothing booked.</p>
      ) : (
        <ul data-slot="viewings" className="flex flex-col">
          {shown.map((viewing) => (
            <li key={viewing.id} className="flex items-center gap-3 border-b py-2.5 first:pt-0 last:border-b-0 last:pb-0">
              <div
                data-slot="date-tile"
                className="flex w-12 shrink-0 flex-col items-center rounded-lg bg-surface py-1 ring-1 ring-border"
              >
                <span className="text-2xs uppercase text-muted-foreground">{MONTH.format(viewing.at)}</span>
                <span className="text-lg font-semibold leading-tight tabular-nums">{DAY.format(viewing.at)}</span>
              </div>
              <div className="min-w-0 flex-1">
                <p className="truncate text-sm font-medium">{viewing.title}</p>
                <p className="truncate text-xs text-muted-foreground">
                  {`${TIME.format(viewing.at)} · ${KIND_LABELS[viewing.kind]} · ${viewing.city}`}
                </p>
              </div>
              <AvatarGroup users={[{ name: viewing.agent, src: viewing.agentAvatar }]} size="sm" aria-label={`Shown by ${viewing.agent}`} />
            </li>
          ))}
        </ul>
      )}
    </Widget>
  )
}