Skip to contentVibraUI

Pipeline

Every open deal by stage, lost ones aside, with the count and value a stage and its share of the whole; reads pipeline().

Preview

Install

npx shadcn@latest add @vibra/widget-crm-overview-pipeline

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

Source

app/crm/components/pipeline.tsx
import { cn } from "@/lib/utils"
import { formatNumber } from "@/lib/format"
import { CHART_BG, CHART_TOKENS, PercentageBar } from "@/components/ui/percentage-bar"
import { Progress } from "@/components/ui/progress"
import { Widget } from "@/components/ui/widget"

import { type PipelineStage } from "../data"

import { STAGE_LABELS, money } from "./crm-vocabulary"

export type PipelineProps = { stages: PipelineStage[] }

/**
 * The pipeline as one segmented bar and a row per stage. The bar and the rows
 * take the palette in the same order, so a segment can be read back to its
 * row by colour; the row says the count, the money and the share in words.
 */
export function Pipeline({ stages }: PipelineProps) {
  const deals = stages.reduce((sum, stage) => sum + stage.count, 0)

  return (
    <Widget
      data-widget="widget-crm-overview-pipeline"
      title="Pipeline"
      description="Every deal by stage, lost ones aside"
      footer={`${formatNumber(deals)} deals on the board`}
      className="h-full"
    >
      <PercentageBar
        segments={stages.map((stage) => ({ label: STAGE_LABELS[stage.stage], value: stage.valueCents }))}
        showLegend={false}
        aria-label="Pipeline value by stage"
      />
      <ul data-slot="pipeline-stages" className="mt-4 flex flex-col gap-3">
        {stages.map((stage, index) => (
          <li key={stage.stage} className="flex items-center gap-3">
            <span
              aria-hidden="true"
              className={cn("size-2 shrink-0 rounded-full", CHART_BG[CHART_TOKENS[index % CHART_TOKENS.length]])}
            />
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-medium">{STAGE_LABELS[stage.stage]}</p>
              <p className="truncate text-xs text-muted-foreground">
                {`${formatNumber(stage.count)} deals · ${money(stage.valueCents)}`}
              </p>
            </div>
            <div className="flex w-24 shrink-0 items-center gap-2">
              <Progress
                value={stage.share}
                aria-label={`${STAGE_LABELS[stage.stage]} share of the pipeline`}
                className="min-w-0 flex-1"
              />
              <span className="w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
                {`${stage.share}%`}
              </span>
            </div>
          </li>
        ))}
      </ul>
    </Widget>
  )
}
app/crm/data.ts
/**
 * 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 }
}
app/crm/money.ts
/**
 * 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 })
app/crm/components/crm-vocabulary.ts
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