/crm/leadsLeads
Every lead with their face, company, source, status, owner and value — facets on source and status, four figures that recount as you work, and a row menu that converts a lead into a qualified deal on the pipeline.
Every row is a db.leads record with its rep resolved against db.members, so both the lead and the owner are drawn with their avatarFor faces. The rows and REFERENCE_DATE go to one client island that renders the figures and the table together: open leads, their value, new leads over the last thirty days against the thirty before, and the win rate are counted from the same rows the table shows, so a conversion moves them in the render that turns its pill. Search reads the name, the company and the email together; the facets are source and status. Convert to deal runs convertLead, the dashboard's shared server action from actions-crm — the same one the CRM overview's leads table calls — returning Result: the lead is marked won and a qualified deal opens under its company for its value, owned by its rep, forecast a month out; a lead already won or lost, or gone from the store, is refused in a danger callout above the rows, and a success names the deal in an always-mounted status line with a link to the pipeline. Composes AppShell, PageHeader, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, DataTableRowActions, UserCell, Badge, StatusBadge, NumberCell, TruncateCell, Callout and LeadsView (block-local).
Preview
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 { LeadsView } from "./components/leads-view"
import { currentUser, lastUpdated, leadRows, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"
/**
* Every lead. A server component: it reads `db.leads` with each rep resolved,
* and hands the rows — and "now" — to the one island that counts them,
* filters them and converts them.
*/
export default async function LeadsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.leads}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="crm-leads-title"
title="Leads"
description="Everyone who raised a hand: where they came from, how far they got, what they could be worth."
meta={lastUpdated()}
/>
<LeadsView titleId="crm-leads-title" rows={leadRows()} now={REFERENCE_DATE} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/crm-leadsNeeds the @vibra registry in your components.json — set it up once.
Source
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 { LeadsView } from "./components/leads-view"
import { currentUser, lastUpdated, leadRows, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"
/**
* Every lead. A server component: it reads `db.leads` with each rep resolved,
* and hands the rows — and "now" — to the one island that counts them,
* filters them and converts them.
*/
export default async function LeadsPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.leads}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="crm-leads-title"
title="Leads"
description="Everyone who raised a hand: where they came from, how far they got, what they could be worth."
meta={lastUpdated()}
/>
<LeadsView titleId="crm-leads-title" rows={leadRows()} now={REFERENCE_DATE} />
</AppShell>
)
}/**
* 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 }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing only this page changes. Converting a lead is the dashboard's
* — the overview's leads table does it too — so it lives once, as
* `convertLead` in `@/lib/dashboards/crm/actions`, and the
* leads table imports it from there.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { HandshakeIcon } from "lucide-react"
import { formatCurrency, formatDate } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import {
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { type LeadRow } from "../data"
import { LEAD_STATUS_MAP, SOURCE_LABELS, STATUS_LABELS } from "./leads-vocabulary"
/**
* Columns step in as the table's own frame allows — a container query, not
* the viewport, because at 768 and 1024 the sidebar leaves the table less
* room than the window suggests. Below 28rem: the lead, its status and its
* menu; then the value, the company, the owner, the day it came in, and
* last the source.
*/
const FROM = {
value: "hidden @md/leads:table-cell",
company: "hidden @min-[43rem]/leads:table-cell",
owner: "hidden @min-[53rem]/leads:table-cell",
created: "hidden @min-[60rem]/leads:table-cell",
source: "hidden @min-[65rem]/leads:table-cell",
} as const
/**
* The leads table's columns. The lead column searches the name, the company
* and the email together, because those are what a rep remembers a lead by.
* Convert is offered on every row — the server owns which leads it applies
* to, and a refusal says why, which is more use than a greyed-out item.
*/
export function leadColumns({ onConvert }: { onConvert: (row: LeadRow) => void }): DataTableColumnDef<LeadRow>[] {
return [
{
id: "lead",
accessorFn: (row) => `${row.name} ${row.company} ${row.email}`,
header: ({ column }) => <DataTableColumnHeader column={column} title="Lead" />,
cell: ({ row }) => (
// In a narrow frame the email truncates, so the status and the menu stay in view.
<div className="max-w-[10.5rem] @min-[33rem]/leads:max-w-none">
<UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />
</div>
),
meta: { label: "Lead" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Company" />,
cell: ({ row }) => <TruncateCell maxWidth={180}>{row.original.company}</TruncateCell>,
meta: { label: "Company", className: FROM.company },
},
{
accessorKey: "source",
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
cell: ({ row }) => <Badge variant="outline">{SOURCE_LABELS[row.original.source]}</Badge>,
meta: { label: "Source", className: FROM.source },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => (
<StatusBadge status={row.original.status} map={LEAD_STATUS_MAP} label={STATUS_LABELS[row.original.status]} />
),
meta: { label: "Status" },
},
{
id: "owner",
accessorFn: (row) => row.owner.name,
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.owner.name} src={row.original.owner.avatarUrl} />,
meta: { label: "Owner", className: FROM.owner },
},
{
accessorKey: "value",
header: ({ column }) => <DataTableColumnHeader column={column} title="Value" />,
cell: ({ row }) => (
<NumberCell
value={row.original.value}
format={(value) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
/>
),
meta: { align: "right", label: "Value", className: FROM.value },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => (
<time
dateTime={new Date(row.original.createdAt).toISOString()}
className="block text-right whitespace-nowrap text-muted-foreground tabular-nums"
>
{formatDate(row.original.createdAt, "medium", { timeZone: "UTC" })}
</time>
),
meta: { align: "right", label: "Created", className: FROM.created },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Actions for ${row.original.name}`}
actions={[{ label: "Convert to deal", icon: <HandshakeIcon />, onSelect: () => onConvert(row.original) }]}
/>
),
},
]
}"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>
)
}"use client"
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { ROUTES } from "@/lib/dashboards/crm/nav"
import { formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DataTable, type DataTableFacet } from "@/components/ui/data-table"
import { convertLead } from "@/lib/dashboards/crm/actions"
import { type LeadRow } from "../data"
import { leadColumns } from "./lead-columns"
import { LeadsStats } from "./leads-stats"
import { SOURCE_LABELS, STATUS_LABELS } from "./leads-vocabulary"
const FACETS: DataTableFacet<LeadRow>[] = [
{
columnId: "source",
title: "Source",
options: Object.entries(SOURCE_LABELS).map(([value, label]) => ({ label, value })),
},
{
columnId: "status",
title: "Status",
options: Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value })),
},
]
/**
* The leads, and the four figures over them, held in one island so they
* cannot disagree. Converting goes through the server: the row takes the
* status the `Result` carries, the figures are recounted from the rows, the
* status line names the deal that opened, and a refusal — a lead already won
* or lost, a lead gone from the store — gets its own alert above the table.
*/
export function LeadsView({ titleId, rows: initial, now }: { titleId: string; rows: LeadRow[]; now: Date }) {
const [rows, setRows] = React.useState(initial)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
const convert = React.useCallback(async (row: LeadRow) => {
setNotice("")
setRefusal(null)
const result = await convertLead(row.id)
if (!result.ok) return setRefusal(result.error.message)
const { status, name, dealName, amountCents, ownerName } = result.data
setRows((current) => current.map((entry) => (entry.id === row.id ? { ...entry, status } : entry)))
setNotice(
`Converted ${name}: “${dealName}” is on the pipeline at ${formatCurrency(amountCents / 100, "USD", { maximumFractionDigits: 0 })}, owned by ${ownerName}.`
)
}, [])
const columns = React.useMemo(() => leadColumns({ onConvert: (row) => void convert(row) }), [convert])
return (
<div className="flex flex-col gap-[var(--density-gap,1rem)]">
<LeadsStats rows={rows} now={now} />
{/* The container the table's columns measure themselves against. */}
<div className="@container/leads flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert" title="Not converted">
{refusal}
</Callout>
) : null}
<div className={cn("flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground", !notice && "sr-only")}>
<p data-slot="leads-status" role="status" aria-live="polite">
{notice}
</p>
{notice ? (
<Link href={ROUTES.pipeline} className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}>
Open the pipeline
</Link>
) : null}
</div>
<DataTable
aria-labelledby={titleId}
columns={columns}
data={rows}
facets={FACETS}
searchKey="lead"
searchPlaceholder="Search leads"
getRowId={(row) => row.id}
enableRowSelection={false}
emptyMessage="No leads match these filters."
pageSizeOptions={[10, 25, 50]}
/>
</div>
</div>
)
}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"]