Skip to contentVibraUI

Catalogue figures

Active products, units sold against the thirty days before, stock value at list price, and how many are at or below their reorder point; reads productRows().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-products-products-stats

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

Source

app/ecommerce/products/components/products-stats.tsx
"use client"

import { formatCurrency, formatNumber } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

export type ProductsStatsProps = {
  rows: ProductRow[]
  /** Units sold in the thirty days before the current window, for the pill. */
  soldBefore: number
  windowDays: number
}

/**
 * The four figures over the catalogue, counted from the rows the table holds
 * — so archiving a product takes it out of the active count and the stock
 * value in the render that moves its row. Units sold is history: archiving
 * leaves it alone. It is the one figure with an earlier window, so the one
 * with a pill, and none when that window sold nothing. The low-stock tile
 * tints its frame while anything needs reordering; the number stays in ink.
 */
export function ProductsStats({ rows, soldBefore, windowDays }: ProductsStatsProps) {
  const live = rows.filter((row) => row.status === "active")
  const low = live.filter((row) => row.stock <= row.reorderPoint)
  const empty = low.filter((row) => row.stock === 0)
  // In cents, so a total of prices never rounds a half-cent the other way.
  const value = live.reduce((sum, row) => sum + Math.round(row.price * 100) * row.stock, 0) / 100
  const sold = rows.reduce((sum, row) => sum + row.sold, 0)

  return (
    <StatCardGroup data-widget="widget-ecommerce-products-products-stats" columns={4}>
      <StatCard
        label="Active products"
        value={formatNumber(live.length)}
        description={`${rows.length - live.length} archived`}
      />
      <StatCard
        label="Units sold"
        value={formatNumber(sold)}
        delta={soldBefore === 0 ? (sold === 0 ? 0 : undefined) : sold / soldBefore - 1}
        description={`last ${windowDays} days, vs the ${windowDays} before`}
      />
      <StatCard
        label="Stock value"
        value={formatCurrency(value, "USD", { maximumFractionDigits: 0 })}
        description="active stock at list price"
      />
      <StatCard
        label="At or below reorder point"
        value={formatNumber(low.length)}
        description={empty.length === 0 ? "none out of stock" : `${empty.length} out of stock`}
        tone={low.length > 0 ? "warning" : "neutral"}
      />
    </StatCardGroup>
  )
}
app/ecommerce/products/data.ts
/**
 * What the catalogue page reads. Every row is a `db.products` record; what a
 * product sold is counted off the lines of `db.orders`, because the catalogue
 * row records no sales of its own, by the dashboard's own rule for a sale
 * (`salesBetween`): paid and fulfilled orders, so a cancelled cart and a
 * refund are not sales. "Now" is `REFERENCE_DATE`; nothing here reads a clock.
 */
import { salesBetween } from "@/lib/dashboards/ecommerce/vocabulary"
import { formatDate, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Product } from "@/lib/sample-data"

const DAY_MS = 86_400_000

/** The window "units sold" is counted over, and compared against the one before it. */
export const SALES_WINDOW_DAYS = 30

/** One row of the catalogue table: the product, and what it sold in the window. */
export type ProductRow = {
  id: string
  sku: string
  name: string
  category: string
  /** List price in whole dollars. */
  price: number
  stock: number
  reorderPoint: number
  supplier: string
  status: Product["status"]
  /** Units sold in the last `SALES_WINDOW_DAYS` days. */
  sold: number
}

/** Units sold per product in the window ending `offset` windows before "now". */
function unitsSold(offset: number): Map<string, number> {
  const end = REFERENCE_DATE.getTime() - offset * SALES_WINDOW_DAYS * DAY_MS
  const sales = salesBetween(db.orders.all(), end - SALES_WINDOW_DAYS * DAY_MS, end)
  return new Map([...sales].map(([id, sale]) => [id, sale.units]))
}

const total = (units: Map<string, number>) => [...units.values()].reduce((sum, value) => sum + value, 0)

/** Every product in the catalogue, archived ones included — the table's status facet hides them. */
export function productRows(): ProductRow[] {
  const sold = unitsSold(0)
  return db.products
    .all()
    .map((product) => ({
      id: product.id,
      sku: product.sku,
      name: product.name,
      category: product.category,
      price: product.priceCents / 100,
      stock: product.stock,
      reorderPoint: product.reorderPoint,
      supplier: product.supplier,
      status: product.status,
      sold: sold.get(product.id) ?? 0,
    }))
    .sort((a, b) => a.name.localeCompare(b.name))
}

/**
 * Units sold in the thirty days before the current window, for the pill on
 * the units-sold figure. Every other figure over the table is counted from
 * the rows themselves, in the island that holds them.
 */
export function unitsSoldBefore(): number {
  return total(unitsSold(1))
}

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

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 Product catalogue page