Skip to contentVibraUI

Deals

Every deal on the board, newest first — searchable by account, filterable by stage, sortable and paged; reads deals().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-sales-deals-table

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

Source

app/ecommerce/sales/components/deals-table.tsx
"use client"

import * as React from "react"
import { ArrowRightIcon, CalendarPlusIcon, HandshakeIcon } from "lucide-react"

import {
  DataTable,
  DataTableColumnHeader,
  DataTableRowActions,
  type DataTableColumnDef,
  type DataTableFacet,
} from "@/components/ui/data-table"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { CurrencyCell, DateCell, TruncateCell } from "@/components/ui/table-cells"
import { Widget } from "@/components/ui/widget"

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

// Stages are not status words, so none of them resolve by default: early stages
// read as information, the two closed outcomes as the outcome they are.
const STAGE_MAP: Record<string, StatusVariant> = {
  Qualified: "info",
  Discovery: "info",
  Proposal: "primary",
  Negotiation: "warning",
  "Closed won": "success",
  "Closed lost": "danger",
}

const STAGE_FACET: DataTableFacet<Deal>[] = [
  {
    columnId: "stage",
    title: "Stage",
    options: Object.keys(STAGE_MAP).map((stage) => ({ label: stage, value: stage })),
  },
]

export function DealsTable({ rows }: { rows: Deal[] }) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const columns = React.useMemo<DataTableColumnDef<Deal>[]>(
    () => [
      {
        accessorKey: "number",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Deal" />,
        cell: ({ row }) => <span className="font-mono text-xs">{row.original.number}</span>,
        meta: { label: "Deal" },
      },
      {
        accessorKey: "account",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Account" />,
        cell: ({ row }) => <TruncateCell maxWidth={200}>{row.original.account}</TruncateCell>,
        meta: { label: "Account" },
      },
      {
        accessorKey: "rep",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Rep" />,
        cell: ({ row }) => <span className="text-muted-foreground">{row.original.rep}</span>,
        meta: { label: "Rep" },
      },
      {
        accessorKey: "stage",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Stage" />,
        cell: ({ row }) => <StatusBadge status={row.original.stage} map={STAGE_MAP} />,
        meta: { label: "Stage" },
      },
      {
        accessorKey: "value",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Value" />,
        cell: ({ row }) => <CurrencyCell value={row.original.value} />,
        meta: { align: "right", label: "Value" },
      },
      {
        accessorKey: "placedAt",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Opened" />,
        cell: ({ row }) => (
          <DateCell date={row.original.placedAt} className="text-muted-foreground" />
        ),
        meta: { align: "right", label: "Opened" },
      },
      {
        id: "actions",
        size: 44,
        enableSorting: false,
        enableHiding: false,
        cell: ({ row }) => (
          <DataTableRowActions
            label={`Open menu for ${row.original.number}`}
            actions={[
              { label: "Open deal", icon: <ArrowRightIcon />, onSelect: () => {} },
              { label: "Log a call", icon: <CalendarPlusIcon />, onSelect: () => {} },
              {
                label: "Mark closed won",
                icon: <HandshakeIcon />,
                separatorBefore: true,
                onSelect: () => {},
              },
            ]}
          />
        ),
      },
    ],
    []
  )

  return (
    <Widget titleId={titleId} data-widget="widget-ecommerce-sales-deals-table" title="Deals" description="Every deal on the board, newest first">
      <DataTable
        aria-labelledby={titleId}
        size="sm"
        columns={columns}
        data={rows}
        pageSize={8}
        searchKey="account"
        searchPlaceholder="Search accounts…"
        facets={STAGE_FACET}
        enableRowSelection={false}
        getRowId={(deal) => deal.id}
        initialSorting={[{ id: "placedAt", desc: true }]}
      />
    </Widget>
  )
}
app/ecommerce/sales/data.ts
/**
 * What /sales reads. The deals are `db.orders`, the accounts behind them are
 * `db.customers`, and the reps are `db.members` — an order is credited to the
 * member who owns the account that placed it. Two things the rows do not record
 * are rules over the rows rather than invented numbers: the stage an open deal
 * sits in, hashed from its id so a deal always reads the same, and the
 * quarter's quota, set from the quarter before it. "Now" is REFERENCE_DATE.
 */
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Order } from "@/lib/sample-data"

// Every read is per call, never held at module scope: a deal closed on the
// till or a refund since the server started is on the board the next time.
const orders = (): Order[] => db.orders.all()
const accountsById = () => new Map(db.customers.all().map((customer) => [customer.id, customer]))
const repsById = () => new Map(db.members.all().map((member) => [member.id, member]))

const dollars = (cents: number): number => cents / 100

const isWon = (order: Order): boolean => order.status === "paid" || order.status === "fulfilled"
const isLost = (order: Order): boolean =>
  order.status === "cancelled" || order.status === "refunded"
const isOpen = (order: Order): boolean => order.status === "pending"

/** Midnight UTC on the first of the month `k` months before the current one. */
function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

/**
 * The complete months the order book covers. Orders run back 180 days from
 * REFERENCE_DATE, which lands inside March, so March is a part month and the
 * window opens in April: charting it would show a dip that is missing rows
 * rather than a slower month.
 */
export const MONTHS = 5

const WINDOW_START = monthStart(MONTHS)
const WINDOW_END = monthStart(0)

const placedIn = (order: Order, from: Date, to: Date): boolean =>
  order.placedAt >= from && order.placedAt < to

/** Closed-won value, in dollars, for the deals placed inside a window. */
function bookings(from: Date, to: Date): number {
  return dollars(
    orders().filter((order) => isWon(order) && placedIn(order, from, to)).reduce(
      (total, order) => total + order.totalCents,
      0
    )
  )
}

export type MonthPoint = { month: string; booked: number; fulfilled: number }

/**
 * Booked and fulfilled value for every complete month on the book. A deal is
 * booked in the month it was placed and fulfilled in the month it shipped, so
 * the two lines separate exactly as far as the warehouse runs behind sales.
 */
export function revenueByMonth(): MonthPoint[] {
  return Array.from({ length: MONTHS }, (_, index) => {
    const from = monthStart(MONTHS - index)
    const to = monthStart(MONTHS - index - 1)
    const fulfilled = orders().filter(
      (order) => order.fulfilledAt && order.fulfilledAt >= from && order.fulfilledAt < to
    ).reduce((total, order) => total + order.totalCents, 0)

    return {
      month: from.toISOString().slice(0, 7),
      booked: Math.round(bookings(from, to)),
      fulfilled: Math.round(dollars(fulfilled)),
    }
  })
}

/** The share of closed deals that were won, inside a window. */
function winRate(from: Date, to: Date): number {
  const closed = orders().filter((order) => !isOpen(order) && placedIn(order, from, to))
  const won = closed.filter(isWon).length
  return closed.length > 0 ? won / closed.length : 0
}

const LAST_MONTH_START = monthStart(1)
const PRIOR_MONTH_START = monthStart(2)

const open = (): Order[] => orders().filter(isOpen)

/** How many deals are still open, for the copy that counts them. */
export function openDeals(): number {
  return open().length
}

export type SalesStat = {
  key: string
  label: string
  value: number
  /** A ratio, or undefined where there is nothing to compare the number with. */
  delta?: number
  description: string
  format: "currency" | "percent"
}

/**
 * The change from `before` to `now`, as a ratio — or nothing at all when there
 * is no base to measure against. A month that booked nothing, or closed no
 * deals at all, makes every number after it infinitely better than it, which
 * is not a reading a stat card can show.
 */
function change(now: number, before: number): number | undefined {
  return before > 0 ? now / before - 1 : undefined
}

/** The three headline numbers beside the quota card. */
export function salesStats(): SalesStat[] {
  const pipeline = open()
  const booked = bookings(LAST_MONTH_START, WINDOW_END)
  const before = bookings(PRIOR_MONTH_START, LAST_MONTH_START)
  const rate = winRate(LAST_MONTH_START, WINDOW_END)
  const wasRate = winRate(PRIOR_MONTH_START, LAST_MONTH_START)

  return [
    {
      key: "bookings",
      label: "Bookings",
      value: booked,
      delta: change(booked, before),
      description: "vs the month before",
      format: "currency",
    },
    {
      key: "pipeline",
      label: "Open pipeline",
      value: dollars(pipeline.reduce((total, order) => total + order.totalCents, 0)),
      description: `${pipeline.length} deals still to close`,
      format: "currency",
    },
    {
      key: "win-rate",
      label: "Win rate",
      value: rate,
      delta: change(rate, wasRate),
      description: "of the deals closed last month",
      format: "percent",
    },
  ]
}

// Quarters, from REFERENCE_DATE rather than a clock: the quarter it falls in,
// and the one before it that sets the target.
const QUARTER_MONTH = Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3
const QUARTER_START = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), QUARTER_MONTH, 1))
const PRIOR_QUARTER_START = new Date(
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), QUARTER_MONTH - 3, 1)
)
// Day 0 of the next month is the last day of this one.
const QUARTER_END = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), QUARTER_MONTH + 3, 0))

const QUARTER_LABEL = `Q${Math.floor(QUARTER_MONTH / 3) + 1}`
const QUARTER_END_LABEL = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeZone: "UTC",
}).format(QUARTER_END)

/**
 * The quarter's number: what has closed since it opened, against a target set
 * at 15% over the quarter before it and rounded to the nearest $25k, which is
 * how a target gets written down.
 */
export function quota() {
  // Through now, inclusive: a deal closed at REFERENCE_DATE is this quarter's.
  const current = Math.round(bookings(QUARTER_START, new Date(REFERENCE_DATE.getTime() + 1)))
  const goal = Math.round((bookings(PRIOR_QUARTER_START, QUARTER_START) * 1.15) / 25_000) * 25_000

  return {
    label: `${QUARTER_LABEL} quota`,
    current,
    goal,
    deadline: `${QUARTER_LABEL} closes ${QUARTER_END_LABEL}`,
  }
}

// The four stages an open deal moves through. Which one it sits in is not a
// column on the order, so it is hashed from the id: stable across runs, and
// weighted so most of the pipeline is still early rather than spread evenly.
const STAGES = ["Qualified", "Discovery", "Proposal", "Negotiation"] as const
const STAGE_SHARE = [1, 0.62, 0.34, 0.13]

export type Stage = (typeof STAGES)[number] | "Closed won" | "Closed lost"

/** A number in [0, 1) from a row id — the same id always gives the same number. */
function hashUnit(id: string): number {
  let hash = 2_166_136_261
  for (let index = 0; index < id.length; index++) {
    hash ^= id.charCodeAt(index)
    hash = Math.imul(hash, 16_777_619)
  }
  return ((hash >>> 0) % 10_000) / 10_000
}

function stageIndex(order: Order): number {
  const ticket = hashUnit(order.id)
  let index = 0
  while (index + 1 < STAGE_SHARE.length && ticket < STAGE_SHARE[index + 1]) index++
  return index
}

/** Where a deal stands: its stage while it is open, its outcome once it is not. */
export function stageOf(order: Order): Stage {
  if (isWon(order)) return "Closed won"
  if (isLost(order)) return "Closed lost"
  return STAGES[stageIndex(order)]
}

/** The open pipeline by value, counting every deal that has reached each stage. */
export function pipelineByStage(): { label: string; value: number }[] {
  const pipeline = open()
  return STAGES.map((label, index) => ({
    label,
    value: Math.round(
      dollars(
        pipeline.filter((order) => stageIndex(order) >= index).reduce(
          (total, order) => total + order.totalCents,
          0
        )
      )
    ),
  }))
}

/** Closed-won value per rep over the window, largest first. */
export function repLeaderboard(): { label: string; value: number }[] {
  const totals = new Map<string, number>()
  const accounts = accountsById()
  const reps = repsById()
  for (const order of orders()) {
    if (!isWon(order) || !placedIn(order, WINDOW_START, WINDOW_END)) continue
    const owner = accounts.get(order.customerId)?.owner
    if (!owner) continue
    totals.set(owner, (totals.get(owner) ?? 0) + order.totalCents)
  }

  return [...totals]
    .map(([id, cents]) => ({ label: reps.get(id)?.name ?? "Unassigned", value: Math.round(dollars(cents)) }))
    .sort((a, b) => b.value - a.value)
    .slice(0, 6)
}

export type Deal = {
  id: string
  number: string
  account: string
  contact: string
  rep: string
  stage: Stage
  value: number
  placedAt: Date
  country: string
}

/** The deals on the board, newest first — the order book read as a sales list. */
export function deals(): Deal[] {
  const accounts = accountsById()
  const reps = repsById()
  return orders().map((order) => {
    const account = accounts.get(order.customerId)
    return {
      id: order.id,
      number: order.number,
      // A sale the till rang up for nobody in particular has no account.
      account: account?.company ?? (order.customerId || "Walk-in"),
      contact: account?.name ?? "Unknown",
      rep: (account && reps.get(account.owner)?.name) ?? "Unassigned",
      stage: stageOf(order),
      value: dollars(order.totalCents),
      placedAt: order.placedAt,
      country: order.country,
    }
  })
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
    .slice(0, 60)
}

/** 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 }
}

const MONTH_LABEL = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

/** Which month the headline numbers closed on. */
export function lastUpdated(): string {
  return `Closed through ${MONTH_LABEL.format(LAST_MONTH_START)} ${LAST_MONTH_START.getUTCFullYear()}`
}

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 Sales dashboard page