Skip to contentVibraUI

Inventory turnover

How many times a year each category's shelf turns over, from the units sold against the units on hand; reads turnoverByCategory().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-inventory-turnover-chart

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

Source

app/ecommerce/inventory/components/turnover-chart.tsx
"use client"

import { ChartCard } from "@/components/ui/chart-card"
import { BarChart } from "@/components/ui/bar-chart"

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

const SERIES = [{ key: "turns", label: "Turns a year", color: "chart-1" as const }]

export type TurnoverChartProps = {
  /** Every category, fastest first. */
  points: TurnoverPoint[]
  /** The days of sales the rate is read from. */
  days: number
}

/** How many times a year each category's shelf turns over; the page hands the rates in. */
export function TurnoverChart({ points, days }: TurnoverChartProps) {
  const fastest = points[0]
  const slowest = points[points.length - 1]

  return (
    <ChartCard
      data-widget="widget-ecommerce-inventory-turnover-chart"
      title="Inventory turnover"
      description={`Units sold in the last ${days} days over units on hand, annualised`}
      height={300}
      className="h-full"
      footer={
        fastest.turns < 1
          ? `Nothing on these shelves turns over even once a year: ${fastest.category} leads at ${fastest.turns.toFixed(2)}, ${slowest.category} trails at ${slowest.turns.toFixed(2)}.`
          : `${fastest.category} turns ${fastest.turns.toFixed(1)} times a year; ${slowest.category} turns ${slowest.turns.toFixed(1)}.`
      }
    >
      <BarChart
        data={points}
        index="category"
        series={SERIES}
        height={300}
        showYAxis
        showLegend={false}
        valueFormatter={(value) => `${value.toFixed(1)}x`}
      />
    </ChartCard>
  )
}
app/ecommerce/inventory/data.ts
/**
 * What this page reads. Every number comes from two db entities and nothing
 * is generated: `db.products` carries the catalog, the units on hand, the
 * reorder point and the supplier, and `db.orders` carries what actually
 * shipped. Turnover is the second read against the first — the units sold in
 * the last quarter over the units on hand — so a page that says a category
 * turns four times a year is saying what the orders say. "Now" is
 * `REFERENCE_DATE`; nothing here reads a clock.
 */
import { formatCurrency, formatNumber, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Member, type Product } from "@/lib/sample-data"

export type { Product }

const DAY_MS = 86_400_000

/** The window turnover is measured over, in days. */
export const TURNOVER_DAYS = 90

// An archived product is not stocked, so it is not inventory. Read per call,
// never held at module scope: a sale on the till takes units off the shelf,
// and the next count has to see it.
const stocked = (): Product[] => db.products.all().filter((product) => product.status === "active")

const valueCentsOf = (product: Product): number => product.stock * product.priceCents

/** Where a product sits against its reorder point. */
export type StockState = "out" | "low" | "in stock"

/** A product's stock state: out at zero, low at or below its reorder point. */
export function stockState(product: Product): StockState {
  if (product.stock === 0) return "out"
  return product.stock <= product.reorderPoint ? "low" : "in stock"
}

const below = (): Product[] => stocked().filter((product) => stockState(product) !== "in stock")

/** How many stocked SKUs are at or below their reorder point. */
export function belowReorderCount(): number {
  return below().length
}

/** How many SKUs have run out altogether. */
export function outOfStockCount(): number {
  return stocked().filter((product) => product.stock === 0).length
}

export type InventoryStat = { key: string; label: string; value: string; description: string }

/** The four headline numbers: what is stocked, how much of it, and what it is worth. */
export function inventoryStats(): InventoryStat[] {
  const shelf = stocked()
  const units = shelf.reduce((total, product) => total + product.stock, 0)
  const valueCents = shelf.reduce((total, product) => total + valueCentsOf(product), 0)
  const low = shelf.filter((product) => stockState(product) !== "in stock").length
  const out = shelf.filter((product) => product.stock === 0).length
  return [
    {
      key: "skus",
      label: "SKUs stocked",
      value: formatNumber(shelf.length, { maximumFractionDigits: 0 }),
      description: "active lines in the catalog",
    },
    {
      key: "units",
      label: "Units on hand",
      value: formatNumber(units, { maximumFractionDigits: 0 }),
      description: "across every warehouse",
    },
    {
      key: "value",
      label: "Inventory value",
      value: formatCurrency(valueCents / 100, "USD", { compact: true }),
      description: "at list price",
    },
    {
      key: "below",
      label: "Below reorder point",
      value: formatNumber(low, { maximumFractionDigits: 0 }),
      description: out > 0 ? `${out} of them out of stock` : "none out of stock yet",
    },
  ]
}

export type StockRow = Product & { valueCents: number }

/** The largest positions on the shelf, by what they are worth. */
export function stockLevels(limit = 8): StockRow[] {
  return stocked()
    .map((product) => ({ ...product, valueCents: valueCentsOf(product) }))
    .sort((a, b) => b.valueCents - a.valueCents)
    .slice(0, limit)
}

export type ReorderRow = Product & { suggested: number }

/**
 * Everything at or below its reorder point, emptiest first. The suggested
 * order brings the shelf back to twice the reorder point, rounded up to a
 * whole case of ten.
 */
export function reorderAlerts(limit = 8): ReorderRow[] {
  return below()
    .map((product) => ({
      ...product,
      suggested: Math.ceil((product.reorderPoint * 2 - product.stock) / 10) * 10,
    }))
    .sort((a, b) => a.stock - b.stock || b.reorderPoint - a.reorderPoint)
    .slice(0, limit)
}

// What shipped in the window: only an order that was paid for or fulfilled
// counts, and only the lines inside it that name a stocked product.
const WINDOW_START = REFERENCE_DATE.getTime() - TURNOVER_DAYS * DAY_MS

function soldById(shelf: Product[]): Map<string, number> {
  const onShelf = new Set(shelf.map((product) => product.id))
  const sold = new Map<string, number>()
  for (const order of db.orders.all()) {
    if (order.status !== "paid" && order.status !== "fulfilled") continue
    if (order.placedAt.getTime() < WINDOW_START) continue
    for (const item of order.items) {
      if (!onShelf.has(item.productId)) continue
      sold.set(item.productId, (sold.get(item.productId) ?? 0) + item.qty)
    }
  }
  return sold
}

export type TurnoverPoint = { category: string; turns: number }

// A quarter's sales, annualised: four windows to the year.
const WINDOWS_A_YEAR = 365 / TURNOVER_DAYS

/**
 * How many times a category's shelf turns over in a year: the units it sold
 * in the window, over the units it holds, annualised.
 */
export function turnoverByCategory(): TurnoverPoint[] {
  const sold = new Map<string, number>()
  const held = new Map<string, number>()
  const shelf = stocked()
  const soldOf = soldById(shelf)

  for (const product of shelf) {
    sold.set(product.category, (sold.get(product.category) ?? 0) + (soldOf.get(product.id) ?? 0))
    held.set(product.category, (held.get(product.category) ?? 0) + product.stock)
  }

  return [...held.keys()]
    .map((category) => ({
      category,
      turns:
        (held.get(category) ?? 0) === 0
          ? 0
          : Math.round(((sold.get(category) ?? 0) / (held.get(category) ?? 1)) * WINDOWS_A_YEAR * 100) /
            100,
    }))
    .sort((a, b) => b.turns - a.turns)
}

export type SupplierRow = { name: string; skus: number; units: number; valueCents: number; low: number }

/** Who supplies what, by the value of the stock they have on our shelves. */
export function suppliers(limit = 6): SupplierRow[] {
  const rows = new Map<string, SupplierRow>()

  for (const product of stocked()) {
    const row = rows.get(product.supplier) ?? {
      name: product.supplier,
      skus: 0,
      units: 0,
      valueCents: 0,
      low: 0,
    }
    row.skus += 1
    row.units += product.stock
    row.valueCents += valueCentsOf(product)
    if (stockState(product) !== "in stock") row.low += 1
    rows.set(product.supplier, row)
  }

  return [...rows.values()].sort((a, b) => b.valueCents - a.valueCents).slice(0, limit)
}

/** How many suppliers the stocked catalog is spread across. */
export function supplierCount(): number {
  return new Set(stocked().map((product) => product.supplier)).size
}

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

// Fixed to UTC so the line reads the same wherever the page is rendered.
const COUNTED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** When the shelves were last counted. */
export function lastCounted(): string {
  return `Counted ${COUNTED_AT.format(REFERENCE_DATE)} UTC`
}

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