/real-estate/agentsAgents
Every agent holding a listing, a card each with a face and a number to call: what they have on the market, what they sold and let this year, the enquiries their book drew with twelve weeks of them as a sparkline, and their next viewing — ordered by closings, enquiries or name.
Installs at /real-estate/agents. An agent is a db.members row holding a listing, and their book is every db.listings row that names them. The page is a server component inside AppShell: agentRows starts from the dashboard's one agent summary, agentSummaries in agents-real-estate — the city most of the book is in, the number to call, the listings on the market, the sales and lettings closed this year by closedAt against REFERENCE_DATE's year, and every enquiry the whole book has drawn — which every listing's agent card reads too, so the two pages print the same figures under the same labels. On top of it the page adds each agent's last twelve complete weeks of enquiries, the sum of weeklyInterest over their book (the weeks a listing's page charts), and their next viewing, the soonest nextViewingAt on a listing of theirs still on the market. agencyTotals adds the books up for the four figures, the cities being every city the books span rather than the agents' home cities. The island orders the cards by this year's closings, by enquiries or by name; each card is an article in the named list rather than a landmark of its own — a face with the city and a tel: link on the strip, the book as a stat list, the twelve weeks as a sparkline, and the next viewing linked to its listing. Composes AppShell, PageHeader, StatCardGroup, StatCard, SegmentedControl, Card, CardHeader, CardAction, CardContent, CardFooter, UserCell, StatList and Sparkline.
Preview
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AgentFigures } from "./components/agent-figures"
import { AgentsGrid } from "./components/agents-grid"
import { agencyTotals, agentRows, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The agents. The page is a server component: it works every agent's book
* out of `db.listings` — what they have on the market, what they closed this
* year, the enquiries week by week and the next viewing — adds the books up
* for the figures, and hands the cards to the island that orders them.
*/
export default function AgentsPage() {
const agents = agentRows()
const year = REFERENCE_DATE.getUTCFullYear()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.agents}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Agents"
description="Who holds the book: what each agent has on the market, what they closed this year, and who is asking."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<AgentFigures totals={agencyTotals(agents)} year={year} />
<AgentsGrid agents={agents} year={year} />
</div>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/real-estate-agentsNeeds the @vibra registry in your components.json — set it up once.
Source
import { NAV, ROUTES } from "@/lib/dashboards/real-estate/nav"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { AgentFigures } from "./components/agent-figures"
import { AgentsGrid } from "./components/agents-grid"
import { agencyTotals, agentRows, currentUser, lastUpdated, shellNotifications } from "./data"
/**
* The agents. The page is a server component: it works every agent's book
* out of `db.listings` — what they have on the market, what they closed this
* year, the enquiries week by week and the next viewing — adds the books up
* for the figures, and hands the cards to the island that orders them.
*/
export default function AgentsPage() {
const agents = agentRows()
const year = REFERENCE_DATE.getUTCFullYear()
return (
<AppShell
nav={NAV}
activeHref={ROUTES.agents}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Agents"
description="Who holds the book: what each agent has on the market, what they closed this year, and who is asking."
meta={lastUpdated()}
/>
<div className="flex flex-col gap-4">
<AgentFigures totals={agencyTotals(agents)} year={year} />
<AgentsGrid agents={agents} year={year} />
</div>
</AppShell>
)
}/**
* What /real-estate/agents reads. An agent is a `db.members` row holding a
* listing; their book is every `db.listings` row that names them. The summary
* — the city, the number to call, the listings on the market, this year's
* sales and lettings, every enquiry the book has drawn — and the interest by
* week are the dashboard's own, in `@/lib/dashboards/real-estate/agents`,
* which every listing's agent card reads too, so the two pages cannot
* disagree. On top of them this page adds:
*
* - enquiries by week: an agent's week is the sum of their listings' weeks,
* the last twelve complete weeks;
* - the next viewing: the soonest `nextViewingAt` on a listing of theirs
* still on the market.
*
* "Now" and "this year" are `REFERENCE_DATE`'s. The client islands import
* only the types below.
*/
import { agentSummaries, mondayOf, weeklyInterest, type AgentSummary } from "@/lib/dashboards/real-estate/agents"
import { isOpen, listingHref } from "@/lib/dashboards/real-estate/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Listing, type Member } from "@/lib/sample-data"
const WEEK_MS = 7 * 86_400_000
const YEAR = REFERENCE_DATE.getUTCFullYear()
export type AgentRow = AgentSummary & {
/** Their enquiries in each of the last twelve complete weeks, oldest first. */
weekly: number[]
/** Their soonest viewing booked on a listing still on the market. */
nextViewing?: { at: Date; title: string; href: string }
}
/** The Mondays of the last twelve complete weeks, oldest first. */
function lastTwelveWeeks(): number[] {
const monday = mondayOf(REFERENCE_DATE.getTime())
return Array.from({ length: 12 }, (_, index) => monday - (12 - index) * WEEK_MS)
}
/** Everyone holding a listing, A to Z; the island sorts them the way the reader asks. */
export function agentRows(): AgentRow[] {
const books = db.listings.all()
const weeks = lastTwelveWeeks()
return agentSummaries().map((agent) => {
const book = books.filter((row) => row.agentId === agent.id)
const byWeek = book.map((row) => new Map(weeklyInterest(row).map((week) => [week.start, week.enquiries])))
const next = book
.filter((row): row is Listing & { nextViewingAt: Date } => isOpen(row) && row.nextViewingAt instanceof Date)
.sort((a, b) => a.nextViewingAt.getTime() - b.nextViewingAt.getTime())[0]
return {
...agent,
weekly: weeks.map((start) => byWeek.reduce((sum, week) => sum + (week.get(start) ?? 0), 0)),
...(next ? { nextViewing: { at: next.nextViewingAt, title: next.title, href: listingHref(next.id) } } : {}),
}
})
}
export type AgencyTotals = {
agents: number
/** The cities the agents' books span — every city a listing of theirs is in, not only their home cities. */
cities: number
active: number
sold: number
rented: number
enquiries: number
}
/** The four figures over every agent. */
export function agencyTotals(rows: AgentRow[]): AgencyTotals {
const agents = new Set(rows.map((row) => row.id))
return {
agents: rows.length,
cities: new Set(db.listings.all().filter((row) => agents.has(row.agentId)).map((row) => row.city)).size,
active: rows.reduce((sum, row) => sum + row.active, 0),
sold: rows.reduce((sum, row) => sum + row.soldThisYear, 0),
rented: rows.reduce((sum, row) => sum + row.rentedThisYear, 0),
enquiries: rows.reduce((sum, row) => sum + row.enquiries, 0),
}
}
/** The line under the title: the year the closings are counted over, and when the book was read. */
export function lastUpdated(): string {
return `${YEAR} to date · synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
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 }
}
/** 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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes: the agents are read here, and a call is a
* `tel:` link. A server action so the page can stay a server component and
* still hand the shell something to call.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import Link from "next/link"
import { CalendarClockIcon, PhoneIcon } from "lucide-react"
import { agentBookItems } from "@/lib/dashboards/real-estate/vocabulary"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Sparkline } from "@/components/ui/sparkline"
import { StatList } from "@/components/ui/stat-list"
import { UserCell } from "@/components/ui/user-cell"
import { type AgentRow } from "../data"
import { viewingSlot } from "./agent-vocabulary"
/**
* One agent: their face, their city and a number to call on the strip — a
* plain `tel:` link styled as an icon button — their book this year, printed
* with the labels every listing's agent card uses, their enquiries week by
* week, and the next viewing they are showing.
*/
export function AgentCard({ agent, year }: { agent: AgentRow; year: number }) {
const lastTwelve = agent.weekly.reduce((sum, value) => sum + value, 0)
return (
// An article, not a region: seventeen cards in a named list would crowd
// the landmark menu, and each is one self-contained entry in it.
<Card role="article" aria-label={agent.name} data-slot="agent-card" className="h-full">
<CardHeader>
<UserCell
name={agent.name}
src={agent.avatarUrl}
description={`${agent.city} · ${agent.listings} listing${agent.listings === 1 ? "" : "s"}`}
className="min-w-0"
/>
<CardAction>
<a
href={`tel:${agent.phone.replace(/\s+/g, "")}`}
aria-label={`Call ${agent.name}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
>
<PhoneIcon aria-hidden="true" />
</a>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<StatList size="sm" divided items={agentBookItems(agent, year)} />
<div className="flex flex-col gap-1">
<Sparkline data={agent.weekly} type="bar" height={36} aria-label={`${agent.name}'s enquiries by week, the last twelve weeks`} />
<p className="text-xs tabular-nums text-muted-foreground">{`${formatNumber(lastTwelve)} enquiries in the last twelve weeks`}</p>
</div>
</CardContent>
<CardFooter className="min-w-0 gap-2 text-xs text-muted-foreground">
<CalendarClockIcon aria-hidden="true" className="size-3.5 shrink-0" />
{agent.nextViewing ? (
<span className="min-w-0 truncate">
<span className="tabular-nums text-foreground">{viewingSlot(agent.nextViewing.at)}</span>
{" · "}
<Link href={agent.nextViewing.href} className="rounded-sm underline-offset-4 hover:underline focus-ring">
{agent.nextViewing.title}
</Link>
</span>
) : (
<span>No viewing booked</span>
)}
</CardFooter>
</Card>
)
}import { HandshakeIcon, MessageSquareIcon, UsersIcon, BuildingIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type AgencyTotals } from "../data"
/**
* The agency in four numbers: the agents and the cities their books span,
* what they have on the market, what they have closed this year, and the
* enquiries their books have drawn. No state, so it renders on the server.
*/
export function AgentFigures({ totals, year }: { totals: AgencyTotals; year: number }) {
return (
<StatCardGroup data-widget="widget-real-estate-agents-agent-figures" columns={4} role="region" aria-label="The agency in figures">
<StatCard
label="Agents"
value={formatNumber(totals.agents)}
description={`Holding listings in ${totals.cities} cit${totals.cities === 1 ? "y" : "ies"}`}
icon={<UsersIcon />}
/>
<StatCard label="On the market" value={formatNumber(totals.active)} description="Listed or under offer" icon={<BuildingIcon />} />
<StatCard
label={`Closed in ${year}`}
value={formatNumber(totals.sold + totals.rented)}
description={`${formatNumber(totals.sold)} sold · ${formatNumber(totals.rented)} let`}
icon={<HandshakeIcon />}
/>
<StatCard
label="Enquiries"
value={formatNumber(totals.enquiries)}
description="Drawn by every listing held"
icon={<MessageSquareIcon />}
/>
</StatCardGroup>
)
}/**
* The orders the grid offers and the day-and-time formatter the cards print
* with. Vocabulary, not data — it lives beside the islands rather than in
* `data.ts`, which reads `db` and must never reach the browser.
*/
export const ORDERS = [
{ value: "closings", label: "Closings" },
{ value: "enquiries", label: "Enquiries" },
{ value: "name", label: "Name" },
] as const
export type AgentOrder = (typeof ORDERS)[number]["value"]
/** "Tue, Sep 8 · 10:30 AM", in UTC, the zone viewings are booked in. */
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
const TIME = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
export const viewingSlot = (at: Date): string => `${DAY.format(at)} · ${TIME.format(at)}`"use client"
import * as React from "react"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type AgentRow } from "../data"
import { AgentCard } from "./agent-card"
import { ORDERS, type AgentOrder } from "./agent-vocabulary"
const ORDER: Record<AgentOrder, (a: AgentRow, b: AgentRow) => number> = {
closings: (a, b) =>
b.soldThisYear + b.rentedThisYear - (a.soldThisYear + a.rentedThisYear) ||
b.enquiries - a.enquiries ||
a.name.localeCompare(b.name),
enquiries: (a, b) => b.enquiries - a.enquiries || a.name.localeCompare(b.name),
name: (a, b) => a.name.localeCompare(b.name),
}
/**
* The agents, a card each, in the order the reader picks: this year's
* closings first by default, or the enquiries their books have drawn, or A to
* Z. The cards themselves are plain server-shaped rows.
*/
export function AgentsGrid({ agents, year }: { agents: AgentRow[]; year: number }) {
const [order, setOrder] = React.useState<AgentOrder>("closings")
const shown = React.useMemo(() => [...agents].sort(ORDER[order]), [agents, order])
return (
<div data-slot="agents-grid" className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground tabular-nums">{`${agents.length} agents holding a listing`}</p>
<SegmentedControl
size="sm"
aria-label="Order the agents by"
options={ORDERS.map((entry) => ({ value: entry.value, label: entry.label }))}
value={order}
onValueChange={(value) => setOrder(value as AgentOrder)}
/>
</div>
<ul aria-label="Agents" className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{shown.map((agent) => (
<li key={agent.id} className="min-w-0">
<AgentCard agent={agent} year={year} />
</li>
))}
</ul>
</div>
)
}