Skip to contentVibraUI

Revenue by month

Booked against fulfilled value over the complete months on the book — the gap between them is the warehouse; reads revenueByMonth().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-sales-revenue-by-month

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

Source

app/ecommerce/sales/components/revenue-by-month.tsx
"use client"

import { formatCurrency } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"

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

// Fixed to UTC so the axis reads the same wherever the page is rendered.
const MONTH_LABEL = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })

const SERIES = [
  { key: "booked", label: "Booked", color: "chart-1" as const },
  { key: "fulfilled", label: "Fulfilled", color: "chart-2" as const },
]

const money = (value: number) =>
  formatCurrency(value, "USD", { compact: true, maximumFractionDigits: 0 })

/** Booked against fulfilled value: the gap between them is the warehouse. */
export function RevenueByMonth({ rows }: { rows: MonthPoint[] }) {
  return (
    <ChartCard
      data-widget="widget-ecommerce-sales-revenue-by-month"
      title="Revenue by month"
      description={`Booked and fulfilled value, the ${rows.length} complete months on the book`}
      height={264}
      className="h-full"
    >
      <AreaChart
        data={rows}
        index="month"
        series={SERIES}
        height={264}
        showYAxis
        valueFormatter={money}
        indexFormatter={(value) => MONTH_LABEL.format(new Date(`${value}-01T00:00:00Z`))}
      />
    </ChartCard>
  )
}
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