Leads figures
Open leads and how many are qualified, what they are worth if every one closed, new leads against the thirty days before, and the win rate; reads leadRows().
Preview
"use client"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type LeadRow } from "../data"
import { OPEN_STATUSES } from "./leads-vocabulary"
const DAY_MS = 86_400_000
const WINDOW_DAYS = 30
/** Leads created in the window ending `offset` windows before `now`. */
function createdIn(rows: LeadRow[], now: Date, offset: number): number {
const end = now.getTime() - offset * WINDOW_DAYS * DAY_MS
const start = end - WINDOW_DAYS * DAY_MS
return rows.filter((row) => {
const at = new Date(row.createdAt).getTime()
return at > start && at <= end
}).length
}
/**
* The four figures over the table, worked out from the rows the table holds —
* so converting a lead takes it out of "open" here in the same render that
* turns its pill. New leads is the one figure with an earlier window to be
* measured against, so it is the one with a pill.
*/
export function LeadsStats({ rows, now }: { rows: LeadRow[]; now: Date }) {
const open = rows.filter((row) => OPEN_STATUSES.includes(row.status))
const qualified = rows.filter((row) => row.status === "qualified").length
const won = rows.filter((row) => row.status === "won").length
const lost = rows.filter((row) => row.status === "lost").length
const value = open.reduce((sum, row) => sum + row.value, 0)
const fresh = createdIn(rows, now, 0)
const before = createdIn(rows, now, 1)
return (
<StatCardGroup data-widget="widget-crm-leads-leads-stats" columns={4}>
<StatCard label="Open leads" value={formatNumber(open.length)} description={`${qualified} qualified`} />
<StatCard
label="Open value"
value={formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
description="if every open lead closed"
/>
<StatCard
label="New leads"
value={formatNumber(fresh)}
// A rise from nothing is no percentage, so a window after an empty one carries no pill.
delta={before === 0 ? (fresh === 0 ? 0 : undefined) : fresh / before - 1}
description={`last ${WINDOW_DAYS} days`}
/>
<StatCard
label="Win rate"
value={won + lost === 0 ? "—" : formatPercent(won / (won + lost))}
description={`${won} won · ${lost} lost`}
/>
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-crm-leads-leads-statsNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type LeadRow } from "../data"
import { OPEN_STATUSES } from "./leads-vocabulary"
const DAY_MS = 86_400_000
const WINDOW_DAYS = 30
/** Leads created in the window ending `offset` windows before `now`. */
function createdIn(rows: LeadRow[], now: Date, offset: number): number {
const end = now.getTime() - offset * WINDOW_DAYS * DAY_MS
const start = end - WINDOW_DAYS * DAY_MS
return rows.filter((row) => {
const at = new Date(row.createdAt).getTime()
return at > start && at <= end
}).length
}
/**
* The four figures over the table, worked out from the rows the table holds —
* so converting a lead takes it out of "open" here in the same render that
* turns its pill. New leads is the one figure with an earlier window to be
* measured against, so it is the one with a pill.
*/
export function LeadsStats({ rows, now }: { rows: LeadRow[]; now: Date }) {
const open = rows.filter((row) => OPEN_STATUSES.includes(row.status))
const qualified = rows.filter((row) => row.status === "qualified").length
const won = rows.filter((row) => row.status === "won").length
const lost = rows.filter((row) => row.status === "lost").length
const value = open.reduce((sum, row) => sum + row.value, 0)
const fresh = createdIn(rows, now, 0)
const before = createdIn(rows, now, 1)
return (
<StatCardGroup data-widget="widget-crm-leads-leads-stats" columns={4}>
<StatCard label="Open leads" value={formatNumber(open.length)} description={`${qualified} qualified`} />
<StatCard
label="Open value"
value={formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
description="if every open lead closed"
/>
<StatCard
label="New leads"
value={formatNumber(fresh)}
// A rise from nothing is no percentage, so a window after an empty one carries no pill.
delta={before === 0 ? (fresh === 0 ? 0 : undefined) : fresh / before - 1}
description={`last ${WINDOW_DAYS} days`}
/>
<StatCard
label="Win rate"
value={won + lost === 0 ? "—" : formatPercent(won / (won + lost))}
description={`${won} won · ${lost} lost`}
/>
</StatCardGroup>
)
}/**
* What the leads page reads: every `db.leads` row, with the rep who owns it
* resolved against `db.members` so the table can show their face. The four
* figures over the table are worked out from these same rows in the island
* that holds them, so a converted lead moves them the moment it converts.
* "Now" is `REFERENCE_DATE`; nothing here reads a clock.
*/
import { formatDate, getInitials } from "@/lib/format"
import { avatarFor, db, REFERENCE_DATE, type Lead, type Member } from "@/lib/sample-data"
/** One row of the leads table. */
export type LeadRow = {
id: string
name: string
email: string
avatarUrl?: string
company: string
source: Lead["source"]
status: Lead["status"]
/** What the deal would be worth, in whole dollars. */
value: number
createdAt: Date
owner: { name: string; avatarUrl?: string }
}
/** Every lead on the books, newest first; the table filters, sorts and pages them itself. */
export function leadRows(): LeadRow[] {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.leads
.all()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map((lead) => {
const owner = members.get(lead.ownerId)
const ownerName = owner?.name ?? "Unassigned"
return {
id: lead.id,
name: lead.name,
email: lead.email,
avatarUrl: lead.avatarUrl ?? avatarFor(lead.name),
company: lead.company,
source: lead.source,
status: lead.status,
value: lead.valueCents / 100,
createdAt: lead.createdAt,
owner: { name: ownerName, avatarUrl: owner ? (owner.avatarUrl ?? avatarFor(owner.name)) : undefined },
}
})
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `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 }))
}import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words this page puts on a source and a status. Vocabulary, not data: it
* lives beside the islands that print it, because `data.ts` reads `db` and
* must never reach the browser. The CRM overview prints the same words.
*/
export const SOURCE_LABELS: Record<string, string> = {
website: "Website",
referral: "Referral",
ads: "Ads",
social: "Social",
event: "Event",
}
export const STATUS_LABELS: Record<string, string> = {
new: "New",
contacted: "Contacted",
qualified: "Qualified",
won: "Won",
lost: "Lost",
}
/** A funnel's own words are not status words, so every one of them is mapped. */
export const LEAD_STATUS_MAP: Record<string, StatusVariant> = {
new: "info",
contacted: "primary",
qualified: "warning",
won: "success",
lost: "danger",
}
/** The statuses a lead can still be converted from. */
export const OPEN_STATUSES = ["new", "contacted", "qualified"]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 Leads page