/ecommerce/inventoryInventory dashboard
A stockroom page: what is on the shelf and what it is worth, a low-stock banner, the SKUs at or below their reorder point with the quantity that brings them back, inventory turnover by category, and the suppliers holding the most stock.
The page is a server component inside AppShell; only the turnover chart crosses into the client, because a chart formats its own axis labels. Nothing here is generated: db.products carries the catalog, the units on hand, the reorder point and the supplier, and db.orders carries what actually shipped, so turnover is the second read against the first — units sold in the last 90 days over units on hand, annualised. An archived product is not stocked and never counts as inventory. Composes AppShell, PageHeader, Banner, StatCardGroup, StatCard, DashboardGrid, Widget, SimpleTable, StatusBadge, Callout, RankList, ChartCard and BarChart.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { InventoryStats } from "./components/inventory-stats"
import { LowStockBanner } from "./components/low-stock-banner"
import { ReorderAlerts } from "./components/reorder-alerts"
import { StockLevels } from "./components/stock-levels"
import { SuppliersList } from "./components/suppliers-list"
import { TurnoverChart } from "./components/turnover-chart"
import { currentUser, lastCounted, shellNotifications, turnoverByCategory, TURNOVER_DAYS } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The inventory page. It is a server component: the catalog and what shipped
* against it are read from db on the server, and only the turnover chart
* crosses into the client, because a chart formats its own axis labels — with
* its rates as props, so the catalog itself never does.
*/
export default function InventoryPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.inventory}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Inventory"
description="What is on the shelf, what is running out, and how fast it moves."
meta={lastCounted()}
/>
<LowStockBanner />
<InventoryStats />
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<StockLevels />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<ReorderAlerts />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<TurnoverChart points={turnoverByCategory()} days={TURNOVER_DAYS} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<SuppliersList />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/ecommerce-inventoryNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { InventoryStats } from "./components/inventory-stats"
import { LowStockBanner } from "./components/low-stock-banner"
import { ReorderAlerts } from "./components/reorder-alerts"
import { StockLevels } from "./components/stock-levels"
import { SuppliersList } from "./components/suppliers-list"
import { TurnoverChart } from "./components/turnover-chart"
import { currentUser, lastCounted, shellNotifications, turnoverByCategory, TURNOVER_DAYS } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ecommerce/nav"
/**
* The inventory page. It is a server component: the catalog and what shipped
* against it are read from db on the server, and only the turnover chart
* crosses into the client, because a chart formats its own axis labels — with
* its rates as props, so the catalog itself never does.
*/
export default function InventoryPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.inventory}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Inventory"
description="What is on the shelf, what is running out, and how fast it moves."
meta={lastCounted()}
/>
<LowStockBanner />
<InventoryStats />
<DashboardGrid>
<DashboardGridItem colSpan={12}>
<StockLevels />
</DashboardGridItem>
<DashboardGridItem colSpan={12}>
<ReorderAlerts />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
<TurnoverChart points={turnoverByCategory()} days={TURNOVER_DAYS} />
</DashboardGridItem>
<DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
<SuppliersList />
</DashboardGridItem>
</DashboardGrid>
</AppShell>
)
}/**
* 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`
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. A server action so the page can stay a
* server component and still hand the shell something to call, and a `Result`
* so the caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { inventoryStats } from "../data"
export function InventoryStats() {
return (
<StatCardGroup data-widget="widget-ecommerce-inventory-inventory-stats" columns={4}>
{inventoryStats().map((stat) => (
<StatCard
key={stat.key}
label={stat.label}
value={stat.value}
description={stat.description}
/>
))}
</StatCardGroup>
)
}import { Banner } from "@/components/ui/banner"
import { belowReorderCount, outOfStockCount } from "../data"
export function LowStockBanner() {
const below = belowReorderCount()
const out = outOfStockCount()
return (
<Banner
variant="warning"
title={`${below} SKUs are at or below their reorder point`}
dismissible
>
{out > 0
? `${out} of them have run out altogether.`
: "None have run out altogether yet."}{" "}
Purchasing sees the same list under Reorder alerts.
</Banner>
)
}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>
)
}import { formatCurrency, formatNumber } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { Widget } from "@/components/ui/widget"
import { stockLevels, type StockRow } from "../data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const units = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
const COLUMNS: SimpleTableColumn<StockRow>[] = [
{
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: "value", header: "Value", align: "right", cell: (row) => money(row.valueCents) },
{
key: "cover",
header: "Cover",
align: "right",
// How many reorder points deep the shelf is: 1x means the next sale
// triggers a purchase order.
cell: (row) => `${(row.stock / Math.max(1, row.reorderPoint)).toFixed(1)}x`,
},
]
export function StockLevels() {
return (
<Widget
data-widget="widget-ecommerce-inventory-stock-levels"
title="Stock levels"
description="The eight largest positions on the shelf, by what they are worth"
className="h-full"
contentClassName="px-0"
footer="Value is units on hand at list price; cover is how many reorder points deep the shelf is."
>
<SimpleTable
columns={COLUMNS}
rows={stockLevels()}
rowKey="id"
size="sm"
caption="Stocked products by inventory value"
/>
</Widget>
)
}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>
)
}"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>
)
}