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
"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>
)
}Install
$
npx shadcn@latest add @vibra/widget-ecommerce-products-products-statsNeeds the @vibra registry in your components.json — set it up once.
Source
"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>
)
}/**
* 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