Agency figures
The agents holding listings and the cities they cover, what is on the market, what closed this year — sold and let — and the enquiries every listing drew; reads agencyTotals(agents).
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-real-estate-agents-agent-figuresNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}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 Agents page