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

Deal pipeline

Deals as a board by stage, each column summed in money under its title, with a drawer that prints the deal a reader opens.

Open the live page

Installs at /crm/pipeline. The page reads db.deals on the server; the island owns the board, the drawer and the move. Each column's second line is what the deals in it add up to, formatted from minor units through the format lib, so the board answers "how much is at this stage" without a second chart — and the sum follows a card as soon as it is dropped. Moving a deal rewrites the probability the forecast reads, because a stage is a forecast; moving one out of won or lost is refused, since those two are what the forecast is built on and dragging a card is not how either is undone. Opening a deal is its own button on the card rather than a click on it: the card is the drag surface, and a control inside it keeps its own press — which is the pattern to copy wherever a draggable card has to hold an action.

Preview

Install

npx shadcn@latest add @vibra/crm-pipeline

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

Source

app/crm/pipeline/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 { PipelineView } from "./components/pipeline-view"
import { currentUser, dealRows, pipelineSummary, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/crm/nav"

/**
 * The sales board. The page is a server component: it reads every deal through
 * `db` and hands them to one client island, which owns the drawer and the move
 * that writes a stage.
 */
export default function DealsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.pipeline}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Deals"
        description="Every opportunity by the stage it is at, and what each stage is worth."
        meta={pipelineSummary()}
      />

      <PipelineView rows={dealRows()} />
    </AppShell>
  )
}
app/crm/pipeline/data.ts
/**
 * What this page reads. Every opportunity, who owns it and what it is worth
 * comes from `db.deals` and `db.members`; nothing here is written out, and
 * "now" is `REFERENCE_DATE`, which is what a close date is measured against.
 *
 * The client island imports only the types below; the stages and the words
 * beside them live in `components/pipeline-vocabulary.ts`, so nothing that
 * reads `db` is pulled into the browser.
 */
import { formatCurrency, getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Deal, type Member } from "@/lib/sample-data"

export type DealStage = Deal["stage"]

export type DealRow = {
  id: string
  name: string
  company: string
  stage: DealStage
  /** Contract value in minor units — cents, not dollars. */
  amountCents: number
  currency: string
  ownerId: string
  ownerName: string
  ownerAvatar?: string
  ownerInitials: string
  probability: number
  source: Deal["source"]
  expectedCloseAt: Date
  lastActivityAt: Date
  /** Days since anyone touched it, against REFERENCE_DATE. */
  idleDays: number
}

const DAY = 86_400_000

// Read per call, never held at module scope: a member renamed since the
// server started is the name on the next render.
const membersById = () => new Map(db.members.all().map((member) => [member.id, member]))

/** One deal as this page reads it. Exported because the move action returns one. */
export function toRow(deal: Deal, members = membersById()): DealRow {
  const owner = members.get(deal.owner)
  return {
    id: deal.id,
    name: deal.name,
    company: deal.company,
    stage: deal.stage,
    amountCents: deal.amountCents,
    currency: deal.currency,
    ownerId: deal.owner,
    ownerName: owner?.name ?? "Unassigned",
    ownerAvatar: owner?.avatarUrl,
    ownerInitials: getInitials(owner?.name ?? "Unassigned"),
    probability: deal.probability,
    source: deal.source,
    expectedCloseAt: deal.expectedCloseAt,
    lastActivityAt: deal.lastActivityAt,
    idleDays: Math.floor((REFERENCE_DATE.getTime() - deal.lastActivityAt.getTime()) / DAY),
  }
}

/** Every deal on the board, biggest first inside each stage. */
export function dealRows(): DealRow[] {
  const members = membersById()
  return db.deals
    .all()
    .map((deal) => toRow(deal, members))
    .sort((a, b) => b.amountCents - a.amountCents)
}

/** The line under the title: what is open, and what it is worth. */
export function pipelineSummary(): string {
  const rows = db.deals.all()
  const open = rows.filter((deal) => deal.stage !== "won" && deal.stage !== "lost")
  const value = open.reduce((sum, deal) => sum + deal.amountCents, 0)
  // The same formatCurrency the board's own cards use (pipeline-view.tsx),
  // so the summary line and the cards can never quietly drift apart.
  const dollars = formatCurrency(value / 100, "USD", { compact: true, maximumFractionDigits: 1 })
  return `${open.length} open · ${dollars} in play · ${rows.length - open.length} closed`
}

/** 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/pipeline/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, invalidInput, ownKey, type Result } from "@/lib/sample-data"

import { toRow, type DealRow, type DealStage } from "./data"

/**
 * The two things this page changes. Server actions, so the page can stay a
 * server component and still hand its client island something to call, and
 * `Result` so every caller reads one success-or-error shape.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** How likely each stage is to close, which is what a move rewrites. */
const PROBABILITY: Record<DealStage, number> = {
  lead: 10,
  qualified: 30,
  proposal: 55,
  negotiation: 75,
  won: 100,
  lost: 0,
}

/**
 * Moves a deal to another stage. A closed deal is the one thing this refuses:
 * won and lost are what the forecast is built on, and dragging a card is not
 * how either of them is undone.
 */
export async function moveDeal(id: string, stage: DealStage): Promise<Result<DealRow>> {
  // The stage is the caller's word for it: one of the board's own columns, read
  // as a key of PROBABILITY itself — "constructor" is a key every object
  // inherits, and it used to store the Object function as the probability.
  if (!ownKey(PROBABILITY, stage)) return invalidInput("Move the deal to one of the board's stages.", "stage")
  const deal = await db.deals.get(id)
  if (!deal) {
    return { ok: false, error: { code: "not_found", message: `No deal with id "${id}"` } }
  }

  if ((deal.stage === "won" || deal.stage === "lost") && stage !== deal.stage) {
    return {
      ok: false,
      error: {
        code: "not_allowed",
        message: `"${deal.company}" is already ${deal.stage}. Reopen it from the deal record rather than from the board.`,
      },
    }
  }

  const updated = await db.deals.update(id, {
    stage,
    probability: PROBABILITY[stage],
    lastActivityAt: REFERENCE_DATE,
  })
  if (!updated.ok) return updated

  return { ok: true, data: toRow(updated.data) }
}
app/crm/pipeline/components/pipeline-vocabulary.ts
/**
 * What the pipeline is made of, as the reader sees it: the stages in the order
 * a deal walks them, and the words for where it came from. Vocabulary, not
 * data — it lives beside the island that draws it rather than in `data.ts`,
 * which reads `db` and must never reach the browser.
 */
export const STAGES = [
  { id: "lead", title: "Lead" },
  { id: "qualified", title: "Qualified" },
  { id: "proposal", title: "Proposal" },
  { id: "negotiation", title: "Negotiation" },
  { id: "won", title: "Won" },
  { id: "lost", title: "Lost" },
] as const

export const STAGE_LABELS: Record<string, string> = Object.fromEntries(
  STAGES.map((stage) => [stage.id, stage.title])
)

export const SOURCE_LABELS: Record<string, string> = {
  inbound: "Inbound",
  outbound: "Outbound",
  referral: "Referral",
  partner: "Partner",
  event: "Event",
}
app/crm/pipeline/components/pipeline-view.tsx
"use client"

import * as React from "react"
import { ArrowUpRightIcon } from "lucide-react"

import { formatCurrency, formatDate, formatPercent } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel, InspectorPanelBody } from "@/components/ui/inspector-panel"
import { KanbanBoard, type KanbanColumnSpec } from "@/components/ui/kanban-board"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"

import { moveDeal } from "../actions"
import { type DealRow, type DealStage } from "../data"

import { SOURCE_LABELS, STAGES, STAGE_LABELS } from "./pipeline-vocabulary"

export type PipelineViewProps = { rows: DealRow[] }

type Card = DealRow & { column: string }

const money = (cents: number, compact = false) =>
  formatCurrency(cents / 100, "USD", { compact, maximumFractionDigits: compact ? 1 : 0 })

const utc = (date: Date) => formatDate(date, "medium", { timeZone: "UTC" })

/**
 * The pipeline. Each column's second line is what the deals in it add up to,
 * so the board answers "how much is at this stage" without a second chart.
 *
 * A move is optimistic — the card lands where it was dropped and the action
 * writes behind it — and a refusal puts it back with the reason, because a
 * card that sprang back silently would read as a bug rather than as a rule.
 * Opening a deal is its own button rather than a click on the card: the card
 * is the drag surface, and a control inside it keeps its own press.
 */
export function PipelineView({ rows }: PipelineViewProps) {
  const [deals, setDeals] = React.useState<Card[]>(() =>
    rows.map((row) => ({ ...row, column: row.stage }))
  )
  const [openId, setOpenId] = React.useState<string | null>(null)
  const [refusal, setRefusal] = React.useState<string | null>(null)

  const open = deals.find((deal) => deal.id === openId)

  const columns: KanbanColumnSpec[] = STAGES.map((stage) => {
    const inStage = deals.filter((deal) => deal.column === stage.id)
    const total = inStage.reduce((sum, deal) => sum + deal.amountCents, 0)
    return { id: stage.id, title: stage.title, meta: money(total, true) }
  })

  async function move(id: string, column: string, index: number) {
    const before = deals
    setDeals((current) => {
      const moving = current.find((deal) => deal.id === id)
      if (!moving) return current
      const rest = current.filter((deal) => deal.id !== id)
      const landing = rest.filter((deal) => deal.column === column)
      landing.splice(index, 0, { ...moving, column, stage: column as DealStage })
      return STAGES.flatMap((stage) =>
        stage.id === column ? landing : rest.filter((deal) => deal.column === stage.id)
      )
    })
    setRefusal(null)

    const result = await moveDeal(id, column as DealStage)
    if (result.ok) {
      // The action rewrites more than the stage — probability, last activity —
      // so the optimistic card is refreshed with what it actually wrote rather
      // than kept as the guess made before the stage was known.
      setDeals((current) => current.map((deal) => (deal.id === id ? { ...deal, ...result.data, column } : deal)))
    } else {
      setDeals(before)
      setRefusal(result.error.message)
    }
  }

  return (
    <div className="flex flex-col gap-4">
      {refusal ? (
        <Callout variant="danger" title="That move was refused" role="alert">
          {refusal}
        </Callout>
      ) : null}

      <KanbanBoard<DealRow>
        // The stages are the page's second level: nothing sits between them and its h1.
        headingLevel={2}
        aria-label="Pipeline"
        columns={columns}
        cards={deals}
        cardLabel={(deal) => `${deal.company}, ${money(deal.amountCents)}`}
        onMove={move}
        renderCard={(deal) => (
          <div className="flex flex-col gap-1.5">
            <div className="flex items-start justify-between gap-2">
              <p className="truncate text-sm font-medium">{deal.company}</p>
              <Button
                variant="ghost"
                size="icon-xs"
                aria-label={`Open ${deal.company}`}
                onClick={() => setOpenId(deal.id)}
              >
                <ArrowUpRightIcon />
              </Button>
            </div>
            <p className="type-numeral text-base">{money(deal.amountCents)}</p>
            <p className="text-2xs text-muted-foreground">
              {deal.ownerName} · closes {utc(deal.expectedCloseAt)}
            </p>
          </div>
        )}
      />

      <InspectorPanel
        open={open !== undefined}
        onOpenChange={(next) => {
          if (!next) setOpenId(null)
        }}
        title={open?.company ?? "Deal"}
        description={open?.name}
      >
        <InspectorPanelBody>
          {open ? (
            <DescriptionList
              items={[
                { term: "Stage", description: <StatusBadge status={STAGE_LABELS[open.column]} /> },
                { term: "Value", description: money(open.amountCents) },
                { term: "Probability", description: formatPercent(open.probability / 100, { maximumFractionDigits: 0 }) },
                { term: "Owner", description: <UserCell size="sm" name={open.ownerName} src={open.ownerAvatar} /> },
                { term: "Source", description: SOURCE_LABELS[open.source] },
                { term: "Expected close", description: utc(open.expectedCloseAt) },
                {
                  term: "Last activity",
                  description: `${utc(open.lastActivityAt)} · ${open.idleDays} days ago`,
                },
              ]}
            />
          ) : null}
        </InspectorPanelBody>
      </InspectorPanel>
    </div>
  )
}