Suppliers
The suppliers holding the most stock by value, each with its SKUs and how many are below reorder; reads suppliers().
Preview
import { formatCurrency, formatNumber } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"
import { supplierCount, suppliers } from "../data"
export function SuppliersList() {
const rows = suppliers()
return (
<Widget
data-widget="widget-ecommerce-inventory-suppliers-list"
title="Suppliers"
description="Who is holding the most of our stock, by value"
className="h-full"
footer={`${formatNumber(supplierCount(), { maximumFractionDigits: 0 })} suppliers cover the stocked catalog.`}
>
<RankList
items={rows.map((row) => ({
label: (
<span className="flex min-w-0 flex-col">
<span className="truncate">{row.name}</span>
<span className="text-xs text-muted-foreground">
{row.skus} SKUs · {row.low} below reorder
</span>
</span>
),
value: row.valueCents / 100,
}))}
color="chart-2"
format={(value) => formatCurrency(value, "USD", { compact: true })}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-inventory-suppliers-listNeeds the @vibra registry in your components.json — set it up once.
Source
import { formatCurrency, formatNumber } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"
import { supplierCount, suppliers } from "../data"
export function SuppliersList() {
const rows = suppliers()
return (
<Widget
data-widget="widget-ecommerce-inventory-suppliers-list"
title="Suppliers"
description="Who is holding the most of our stock, by value"
className="h-full"
footer={`${formatNumber(supplierCount(), { maximumFractionDigits: 0 })} suppliers cover the stocked catalog.`}
>
<RankList
items={rows.map((row) => ({
label: (
<span className="flex min-w-0 flex-col">
<span className="truncate">{row.name}</span>
<span className="text-xs text-muted-foreground">
{row.skus} SKUs · {row.low} below reorder
</span>
</span>
),
value: row.valueCents / 100,
}))}
color="chart-2"
format={(value) => formatCurrency(value, "USD", { compact: true })}
/>
</Widget>
)
}/**
* 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