Skip to contentVibraUI

Reorder alerts

The products at or below their reorder point, emptiest first, with the quantity that brings each shelf back; reads reorderAlerts().

Preview

Install

npx shadcn@latest add @vibra/widget-ecommerce-inventory-reorder-alerts

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

Source

app/ecommerce/inventory/components/reorder-alerts.tsx
import { formatNumber } from "@/lib/format"
import { Callout } from "@/components/ui/callout"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { Widget } from "@/components/ui/widget"

import { belowReorderCount, reorderAlerts, stockState, type ReorderRow } from "../data"

const units = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

const COLUMNS: SimpleTableColumn<ReorderRow>[] = [
  {
    key: "name",
    header: "Product",
    cell: (row) => (
      <div className="flex min-w-0 flex-col">
        <span className="truncate font-medium">{row.name}</span>
        <span className="font-mono text-xs text-muted-foreground">{row.sku}</span>
      </div>
    ),
  },
  { key: "category", header: "Category" },
  { key: "supplier", header: "Supplier" },
  { key: "stock", header: "On hand", align: "right", cell: (row) => units(row.stock) },
  {
    key: "reorderPoint",
    header: "Reorder at",
    align: "right",
    cell: (row) => units(row.reorderPoint),
  },
  {
    key: "suggested",
    header: "Order",
    align: "right",
    cell: (row) => <span className="font-medium">{units(row.suggested)}</span>,
  },
  {
    key: "state",
    header: "State",
    cell: (row) => (
      <StatusBadge status={stockState(row)} map={{ out: "danger", low: "warning" }} />
    ),
  },
]

export function ReorderAlerts() {
  const rows = reorderAlerts()
  const below = belowReorderCount()

  return (
    <Widget
      data-widget="widget-ecommerce-inventory-reorder-alerts"
      title="Reorder alerts"
      description="Emptiest first, with the quantity that brings the shelf back"
      className="h-full"
      contentClassName="px-0"
      footer="An order brings the shelf back to twice its reorder point, rounded up to a whole case of ten."
    >
      <Callout variant="warning" title="Purchasing has not been notified" className="mx-4 mb-3">
        {below} SKUs are at or below their reorder point
        {rows.length < below ? `; the ${rows.length} shortest are listed here` : ""}. Raising a
        purchase order is still a manual step.
      </Callout>

      <SimpleTable
        columns={COLUMNS}
        rows={rows}
        rowKey="id"
        size="sm"
        caption="Products at or below their reorder point"
      />
    </Widget>
  )
}
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