Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-crm-leads-leads-stats

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

Source

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/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/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"]

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