Leads
Every lead on the books, newest first — searchable, filterable by source, sortable and paged, each convertible to a deal; reads leadRows().
Preview
"use client"
import * as React from "react"
import { HandshakeIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Callout } from "@/components/ui/callout"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { convertLead } from "@/lib/dashboards/crm/actions"
import { type LeadRow } from "../data"
import { LEAD_STATUS_MAP, SOURCE_LABELS, money } from "./crm-vocabulary"
const SOURCE_FACET: DataTableFacet<LeadRow>[] = [
{
columnId: "source",
title: "Source",
options: Object.entries(SOURCE_LABELS).map(([value, label]) => ({ label, value })),
},
]
export type LeadsTableProps = { rows: LeadRow[] }
/**
* Every lead, newest first. The search box reads the name and the company
* together, because those are the two things a rep remembers a lead by. The
* one row action converts a lead through the server: the row's pill turns to
* won and the status line names the deal that opened, while a refusal — a
* lead already closed — gets its own alert above the table.
*/
export function LeadsTable({ rows }: LeadsTableProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [leads, setLeads] = React.useState(rows)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const convert = React.useCallback(async (row: LeadRow) => {
setRefusal("")
setNotice("")
const result = await convertLead(row.id)
if (!result.ok) return setRefusal(result.error.message)
setLeads((current) =>
current.map((lead) => (lead.id === row.id ? { ...lead, status: "won" } : lead))
)
setNotice(`Converted ${result.data.name}: ${result.data.dealName} is on the pipeline.`)
}, [])
const columns = React.useMemo<DataTableColumnDef<LeadRow>[]>(
() => [
{
id: "lead",
// Name and company in one accessor, so the search box matches either.
accessorFn: (row) => `${row.name} ${row.company} ${row.email}`,
header: ({ column }) => <DataTableColumnHeader column={column} title="Lead" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />,
meta: { label: "Lead" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Company" />,
cell: ({ row }) => <TruncateCell maxWidth={180}>{row.original.company}</TruncateCell>,
meta: { label: "Company" },
},
{
accessorKey: "source",
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
cell: ({ row }) => <Badge variant="outline">{SOURCE_LABELS[row.original.source]}</Badge>,
meta: { label: "Source" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={LEAD_STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "valueCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Value" />,
cell: ({ row }) => <span className="tabular-nums">{money(row.original.valueCents)}</span>,
meta: { align: "right", label: "Value" },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => <DateCell date={row.original.createdAt} className="text-muted-foreground" />,
meta: { align: "right", label: "Created" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Convert to deal", icon: <HandshakeIcon />, onSelect: () => void convert(row.original) },
]}
/>
),
},
],
[convert]
)
return (
<Widget titleId={titleId} data-widget="widget-crm-overview-leads-table" title="Leads" description="Every lead on the books, newest first">
<div className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={leads}
pageSize={8}
searchKey="lead"
searchPlaceholder="Search leads…"
facets={SOURCE_FACET}
enableRowSelection={false}
getRowId={(lead) => lead.id}
initialSorting={[{ id: "createdAt", desc: true }]}
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</div>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-crm-overview-leads-tableNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { HandshakeIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Callout } from "@/components/ui/callout"
import {
DataTable,
DataTableColumnHeader,
DataTableRowActions,
type DataTableColumnDef,
type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"
import { convertLead } from "@/lib/dashboards/crm/actions"
import { type LeadRow } from "../data"
import { LEAD_STATUS_MAP, SOURCE_LABELS, money } from "./crm-vocabulary"
const SOURCE_FACET: DataTableFacet<LeadRow>[] = [
{
columnId: "source",
title: "Source",
options: Object.entries(SOURCE_LABELS).map(([value, label]) => ({ label, value })),
},
]
export type LeadsTableProps = { rows: LeadRow[] }
/**
* Every lead, newest first. The search box reads the name and the company
* together, because those are the two things a rep remembers a lead by. The
* one row action converts a lead through the server: the row's pill turns to
* won and the status line names the deal that opened, while a refusal — a
* lead already closed — gets its own alert above the table.
*/
export function LeadsTable({ rows }: LeadsTableProps) {
// The title names the table too, so a screen reader announces it by name.
const titleId = React.useId()
const [leads, setLeads] = React.useState(rows)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const convert = React.useCallback(async (row: LeadRow) => {
setRefusal("")
setNotice("")
const result = await convertLead(row.id)
if (!result.ok) return setRefusal(result.error.message)
setLeads((current) =>
current.map((lead) => (lead.id === row.id ? { ...lead, status: "won" } : lead))
)
setNotice(`Converted ${result.data.name}: ${result.data.dealName} is on the pipeline.`)
}, [])
const columns = React.useMemo<DataTableColumnDef<LeadRow>[]>(
() => [
{
id: "lead",
// Name and company in one accessor, so the search box matches either.
accessorFn: (row) => `${row.name} ${row.company} ${row.email}`,
header: ({ column }) => <DataTableColumnHeader column={column} title="Lead" />,
cell: ({ row }) => <UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />,
meta: { label: "Lead" },
},
{
accessorKey: "company",
header: ({ column }) => <DataTableColumnHeader column={column} title="Company" />,
cell: ({ row }) => <TruncateCell maxWidth={180}>{row.original.company}</TruncateCell>,
meta: { label: "Company" },
},
{
accessorKey: "source",
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
cell: ({ row }) => <Badge variant="outline">{SOURCE_LABELS[row.original.source]}</Badge>,
meta: { label: "Source" },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
cell: ({ row }) => <StatusBadge status={row.original.status} map={LEAD_STATUS_MAP} />,
meta: { label: "Status" },
},
{
accessorKey: "valueCents",
header: ({ column }) => <DataTableColumnHeader column={column} title="Value" />,
cell: ({ row }) => <span className="tabular-nums">{money(row.original.valueCents)}</span>,
meta: { align: "right", label: "Value" },
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => <DateCell date={row.original.createdAt} className="text-muted-foreground" />,
meta: { align: "right", label: "Created" },
},
{
id: "actions",
size: 44,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<DataTableRowActions
label={`Open menu for ${row.original.name}`}
actions={[
{ label: "Convert to deal", icon: <HandshakeIcon />, onSelect: () => void convert(row.original) },
]}
/>
),
},
],
[convert]
)
return (
<Widget titleId={titleId} data-widget="widget-crm-overview-leads-table" title="Leads" description="Every lead on the books, newest first">
<div className="flex flex-col gap-3">
{refusal ? (
<Callout variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={leads}
pageSize={8}
searchKey="lead"
searchPlaceholder="Search leads…"
facets={SOURCE_FACET}
enableRowSelection={false}
getRowId={(lead) => lead.id}
initialSorting={[{ id: "createdAt", desc: true }]}
/>
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</div>
</Widget>
)
}/**
* What /crm reads. The leads are `db.leads`, the pipeline and the quarter's
* won value are `db.deals`, the customer count is `db.customers`, and the
* target is a rule over the won deals rather than a number written down.
* "Now" is `REFERENCE_DATE`, and the quarter is the one it falls in.
*
* The client islands import only the types below; `db` never crosses into the
* browser.
*/
import { formatDate, formatNumber, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Deal, type Lead, type Member } from "@/lib/sample-data"
import { money } from "./money"
const DAY = 86_400_000
const NOW = REFERENCE_DATE.getTime()
/** Midnight UTC on the first day of the quarter `offset` quarters from the current one. */
function quarterStart(offset: number): number {
const month = Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + offset * 3
return Date.UTC(REFERENCE_DATE.getUTCFullYear(), month, 1)
}
/** Midnight UTC on the first of the month `offset` months from the current one. */
function monthStart(offset: number): number {
return Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + offset, 1)
}
/** "Q3 2026" — the quarter every number on the page is measured over. */
export const QUARTER_LABEL = `Q${Math.floor(REFERENCE_DATE.getUTCMonth() / 3) + 1} ${REFERENCE_DATE.getUTCFullYear()}`
const OPEN_STAGES: Deal["stage"][] = ["lead", "qualified", "proposal", "negotiation"]
/** 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)
/** Closed-won value for the deals that closed inside a window; a won deal closes on its expected day. */
function wonBetween(from: number, to: number): number {
return db.deals
.all()
.filter((deal) => deal.stage === "won")
.filter((deal) => deal.expectedCloseAt.getTime() >= from && deal.expectedCloseAt.getTime() < to)
.reduce((sum, deal) => sum + deal.amountCents, 0)
}
export type QuarterTarget = {
wonCents: number
targetCents: number
/** Whole percent of the target reached, capped at 100 for the ring. */
percent: number
}
/**
* The quarter's target, set from the quarter itself: a third again over what
* has been won so far, rounded to a round figure — the number a sales lead
* would put on the wall rather than one the seed wrote.
*/
export function quarterTarget(): QuarterTarget {
const wonCents = wonBetween(quarterStart(0), NOW)
const targetCents = Math.round((wonCents * 1.35) / 1000) * 1000
const percent = targetCents === 0 ? 0 : Math.min(100, Math.round((wonCents / targetCents) * 100))
return { wonCents, targetCents, percent }
}
export type CrmStat = {
key: "customers" | "open" | "won"
label: string
value: string
/** Change against the period before, as a ratio. */
delta?: number
description: string
}
/** The three counted headline numbers; the target beside them is `quarterTarget`. */
export function crmStats(): CrmStat[] {
const customers = db.customers.all()
const atLastMonthEnd = customers.filter((customer) => customer.createdAt.getTime() < monthStart(0)).length
const open = db.deals.all().filter((deal) => OPEN_STAGES.includes(deal.stage))
const openValue = open.reduce((sum, deal) => sum + deal.amountCents, 0)
const won = wonBetween(quarterStart(0), NOW)
const wonBefore = wonBetween(quarterStart(-1), quarterStart(0))
return [
{
key: "customers",
label: "Customers",
value: formatNumber(customers.length),
delta: change(customers.length, atLastMonthEnd),
description: "vs last month",
},
{
key: "open",
label: "Open deals",
value: formatNumber(open.length),
description: `${money(openValue)} in play`,
},
{
key: "won",
label: "Won this quarter",
value: money(won),
delta: change(won, wonBefore),
description: "vs last quarter",
},
]
}
export type SourceCount = { source: Lead["source"]; count: number }
/** How many leads each channel brought in, largest first. */
export function leadsBySource(): SourceCount[] {
const counts = new Map<Lead["source"], number>()
for (const lead of db.leads.all()) counts.set(lead.source, (counts.get(lead.source) ?? 0) + 1)
return [...counts.entries()]
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count)
}
export type FollowUp = {
id: string
name: string
company: string
dueAt: Date
/** Whole days from today to the due day; negative when it has slipped past. */
daysUntil: number
overdue: boolean
}
const utcDay = (at: number) => Math.floor(at / DAY)
/** Every lead whose next action falls inside the week, soonest first. */
export function followUps(): FollowUp[] {
return db.leads
.all()
// Unset and null both mean no follow-up; only a Date is one to list.
.filter((lead): lead is Lead & { nextActionAt: Date } => lead.nextActionAt instanceof Date)
.filter((lead) => lead.nextActionAt.getTime() <= NOW + 7 * DAY)
.sort((a, b) => a.nextActionAt.getTime() - b.nextActionAt.getTime())
.map((lead) => ({
id: lead.id,
name: lead.name,
company: lead.company,
dueAt: lead.nextActionAt,
daysUntil: utcDay(lead.nextActionAt.getTime()) - utcDay(NOW),
overdue: lead.nextActionAt.getTime() < NOW,
}))
}
export type PipelineStage = {
stage: Exclude<Deal["stage"], "lost">
count: number
valueCents: number
/** This stage's share of the pipeline's value, as a whole percent. */
share: number
}
const PIPELINE: PipelineStage["stage"][] = ["lead", "qualified", "proposal", "negotiation", "won"]
/** The pipeline stage by stage, lost deals aside — they are not on it any more. */
export function pipeline(): PipelineStage[] {
const deals = db.deals.all().filter((deal) => deal.stage !== "lost")
const total = deals.reduce((sum, deal) => sum + deal.amountCents, 0)
return PIPELINE.map((stage) => {
const rows = deals.filter((deal) => deal.stage === stage)
const valueCents = rows.reduce((sum, deal) => sum + deal.amountCents, 0)
return {
stage,
count: rows.length,
valueCents,
share: total === 0 ? 0 : Math.round((valueCents / total) * 100),
}
})
}
/** One row of the leads table. */
export type LeadRow = Pick<
Lead,
"id" | "name" | "email" | "avatarUrl" | "company" | "source" | "status" | "valueCents" | "createdAt"
>
/** Every lead on the books; the table sorts and pages them itself. */
export function leadRows(): LeadRow[] {
return db.leads
.all()
.map(({ id, name, email, company, source, status, valueCents, createdAt }) => ({
id,
name,
email,
company,
source,
status,
valueCents,
createdAt,
}))
}
/** 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 }
}/**
* Whole dollars from minor units: a lead's value is an estimate, and cents
* on it would be false precision. The one formatter both sides of the block
* print with — `data.ts` for the figures the server page hands down, the
* islands for the rows they draw — so it lives beside `data.ts` rather than
* in the vocabulary, which is the browser's, and reaches `db` from neither.
*/
import { formatCurrency } from "@/lib/format"
export const money = (cents: number): string => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words this page puts on a source, a status and a stage. Vocabulary, not
* data — it lives beside the islands that print it rather than in `data.ts`,
* which reads `db` and must never reach the browser. The money formatter the
* server page shares with the islands is `../money`, re-exported here so the
* islands read every word they print from one place.
*/
export { money } from "../money"
export const SOURCE_LABELS: Record<string, string> = {
website: "Website",
referral: "Referral",
ads: "Ads",
social: "Social",
event: "Event",
}
/** 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",
}
export const STAGE_LABELS: Record<string, string> = {
lead: "Lead",
qualified: "Qualified",
proposal: "Proposal",
negotiation: "Negotiation",
won: "Closed won",
}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 CRM dashboard page