Skip to contentVibraUI
Part of the CRM dashboardinstalls at /crm/leads

Leads

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.

Open the live page

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

Install

npx shadcn@latest add @vibra/crm-leads

Needs the @vibra registry in your components.json — set it up once.

Source

app/crm/leads/page.tsx
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>
  )
}
app/crm/leads/data.ts
/**
 * 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 }))
}
app/crm/leads/actions.ts
"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 } }
}
app/crm/leads/components/lead-columns.tsx
"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) }]}
        />
      ),
    },
  ]
}
app/crm/leads/components/leads-stats.tsx
"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>
  )
}
app/crm/leads/components/leads-view.tsx
"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>
  )
}
app/crm/leads/components/leads-vocabulary.ts
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"]