Make demo search, filters, exports, and controls functional while exposing dedicated KPI detail pages with tested breakdowns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,25 +1,51 @@
|
||||
import { Building2, Download, TrendingUp } from "lucide-react";
|
||||
import { Building2, TrendingUp } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency, percent } from "@/lib/format";
|
||||
import { Card, PageHeader, secondaryButtonStyles } from "@/components/ui";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
import { PerformanceTable } from "@/components/performance-table";
|
||||
import { CsvExportButton } from "@/components/csv-export-button";
|
||||
|
||||
export const metadata = { title: "Branch Performance" };
|
||||
|
||||
export default function BranchesPage() {
|
||||
export default async function BranchesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}) {
|
||||
const { branches } = getDashboardData();
|
||||
const top = branches[0];
|
||||
const { query = "" } = await searchParams;
|
||||
const visibleBranches = query
|
||||
? branches.filter((branch) =>
|
||||
`${branch.name} ${branch.secondary}`.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
: branches;
|
||||
const top = visibleBranches[0];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Performance" title="Branches"
|
||||
description="Compare revenue, sell-through, and inventory health across every location."
|
||||
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export report</button>} />
|
||||
actions={<CsvExportButton
|
||||
filename="solaire-branch-performance.csv"
|
||||
label="Export report"
|
||||
rows={[
|
||||
["Branch", "Location", "Revenue", "Units", "Sell-through", "Freshness"],
|
||||
...visibleBranches.map((branch) => [
|
||||
branch.name,
|
||||
branch.secondary,
|
||||
branch.revenue.toFixed(2),
|
||||
branch.units,
|
||||
branch.sellThrough.toFixed(1),
|
||||
branch.freshness.toFixed(1),
|
||||
]),
|
||||
]}
|
||||
/>} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Summary label="Top branch" value={top.name} detail={`${currency(top.revenue)} this month`} icon={<Building2 className="size-5" />} />
|
||||
<Summary label="Best sell-through" value={percent(Math.max(...branches.map((b) => b.sellThrough)))} detail="Across the active catalog" icon={<TrendingUp className="size-5" />} />
|
||||
<Summary label="Network revenue" value={currency(branches.reduce((s, b) => s + b.revenue, 0), true)} detail={`${branches.length} reporting locations`} icon={<Building2 className="size-5" />} />
|
||||
<Summary label="Top branch" value={top?.name ?? "No match"} detail={top ? `${currency(top.revenue)} this month` : "Try another search"} icon={<Building2 className="size-5" />} />
|
||||
<Summary label="Best sell-through" value={percent(visibleBranches.length ? Math.max(...visibleBranches.map((b) => b.sellThrough)) : 0)} detail="Across the active catalog" icon={<TrendingUp className="size-5" />} />
|
||||
<Summary label="Network revenue" value={currency(visibleBranches.reduce((s, b) => s + b.revenue, 0), true)} detail={`${visibleBranches.length} reporting locations`} icon={<Building2 className="size-5" />} />
|
||||
</div>
|
||||
<PerformanceTable rows={branches} entityLabel="Branch" />
|
||||
{query && <p className="text-xs font-semibold text-slate-500">Showing branch results for “{query}”.</p>}
|
||||
<PerformanceTable rows={visibleBranches} entityLabel="Branch" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { Download, Filter, Search } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency } from "@/lib/format";
|
||||
import {
|
||||
Card, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
|
||||
} from "@/components/ui";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
import { InventoryControls } from "@/components/inventory-controls";
|
||||
|
||||
export const metadata = { title: "Inventory" };
|
||||
|
||||
export default function InventoryPage() {
|
||||
const { inventory } = getDashboardData();
|
||||
const ordered = [...inventory].sort((a, b) => a.score - b.score);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Stock health" title="Inventory"
|
||||
description="Track every active batch by location, age, value, and estimated remaining shelf life."
|
||||
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export CSV</button>} />
|
||||
description="Track every active batch by location, age, value, and estimated remaining shelf life." />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
@@ -32,41 +26,7 @@ export default function InventoryPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-slate-100 p-4 sm:flex-row">
|
||||
<label className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input placeholder="Search product or SKU…" className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm outline-none focus:border-emerald-400" />
|
||||
</label>
|
||||
<button className={secondaryButtonStyles}><Filter className="size-3.5" />Filter by branch</button>
|
||||
<button className={secondaryButtonStyles}>All categories</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Product / batch</th><th className="px-4 py-3">Location</th>
|
||||
<th className="px-4 py-3">Received</th><th className="px-4 py-3">Freshness</th>
|
||||
<th className="px-4 py-3">On hand</th><th className="px-4 py-3">Value</th><th className="px-5 py-3">Health</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{ordered.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/products/${item.productId}`} className="text-sm font-bold hover:text-emerald-700">{item.product.name}</Link>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · {item.id.toUpperCase()}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4"><p className="text-xs font-semibold">{item.branch.name}</p><p className="text-[11px] text-slate-400">{item.branch.city}</p></td>
|
||||
<td className="px-4 py-4 text-xs text-slate-600">{new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}<p className="text-[11px] text-slate-400">{item.ageDays} days ago</p></td>
|
||||
<td className="px-4 py-4"><FreshnessBar value={item.score} status={item.status} /><p className="mt-1 text-[10px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}</p></td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{item.quantityOnHand} {item.product.unit}s</td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{currency(item.retailValue)}</td>
|
||||
<td className="px-5 py-4"><FreshnessBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<InventoryControls inventory={inventory} />
|
||||
<p className="text-center text-[11px] text-slate-400">Freshness is estimated from received date and configured shelf life; it is not a sensor reading.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { DashboardShell } from "@/components/dashboard-shell";
|
||||
import { branches, products, salespeople } from "@/lib/demo-data";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -26,13 +27,43 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const searchItems = [
|
||||
...products.map((product) => ({
|
||||
id: product.id,
|
||||
label: product.name,
|
||||
detail: `${product.sku} · ${product.category}`,
|
||||
href: `/products/${product.id}`,
|
||||
keywords: `${product.name} ${product.sku} ${product.category}`,
|
||||
type: "Product",
|
||||
})),
|
||||
...branches.map((branch) => ({
|
||||
id: branch.id,
|
||||
label: branch.name,
|
||||
detail: `${branch.city}, ${branch.region}`,
|
||||
href: `/branches?query=${encodeURIComponent(branch.city)}`,
|
||||
keywords: `${branch.name} ${branch.city} ${branch.region}`,
|
||||
type: "Branch",
|
||||
})),
|
||||
...salespeople.map((person) => {
|
||||
const branch = branches.find((item) => item.id === person.branchId);
|
||||
return {
|
||||
id: person.id,
|
||||
label: person.name,
|
||||
detail: branch?.name ?? "Sales team",
|
||||
href: `/sales-team?query=${encodeURIComponent(person.name)}`,
|
||||
keywords: `${person.name} ${person.email} ${branch?.name ?? ""}`,
|
||||
type: "Salesperson",
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full">
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
<DashboardShell searchItems={searchItems}>{children}</DashboardShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
59
src/app/metrics/at-risk/page.tsx
Normal file
59
src/app/metrics/at-risk/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { Boxes, CircleDollarSign, PackageX, TriangleAlert } from "lucide-react";
|
||||
import { getMetricDetails } from "@/domain/analytics";
|
||||
import { currency, number } from "@/lib/format";
|
||||
import { BackToOverview, DetailStat } from "@/components/metric-detail";
|
||||
import { BreakdownBarChart } from "@/components/charts";
|
||||
import { Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader } from "@/components/ui";
|
||||
import { CsvExportButton } from "@/components/csv-export-button";
|
||||
import { freshnessLabels } from "@/domain/freshness";
|
||||
|
||||
export const metadata = { title: "At-Risk Exposure" };
|
||||
|
||||
export default function AtRiskPage() {
|
||||
const { risk } = getMetricDetails();
|
||||
const attention = risk.byStatus.filter((row) => row.status !== "fresh");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackToOverview />
|
||||
<PageHeader eyebrow="Metric drill-down" title="At-risk exposure"
|
||||
description="See the cost and retail value tied to stock that has reached at-risk or expired status."
|
||||
actions={<CsvExportButton filename="solaire-at-risk-inventory.csv" rows={[
|
||||
["Product", "SKU", "Batch", "Branch", "Status", "Freshness", "Units", "Cost exposure", "Retail exposure"],
|
||||
...risk.affected.map((item) => [item.product.name, item.product.sku, item.id, item.branch.name, item.status, item.score, item.quantityOnHand, item.costExposure.toFixed(2), item.retailValue.toFixed(2)]),
|
||||
]} />} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailStat label="Cost exposure" value={currency(risk.costValue)} detail="Capital at risk" icon={<TriangleAlert className="size-5" />} />
|
||||
<DetailStat label="Retail exposure" value={currency(risk.retailValue)} detail="Potential revenue affected" icon={<CircleDollarSign className="size-5" />} />
|
||||
<DetailStat label="Affected units" value={number(risk.units)} detail="At-risk and expired cases" icon={<Boxes className="size-5" />} />
|
||||
<DetailStat label="Affected batches" value={number(risk.batches)} detail="Prioritized below" icon={<PackageX className="size-5" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[.8fr_1.2fr]">
|
||||
<Card><CardHeader title="Exposure by status" description="Watch stock is shown for early intervention" /><div className="p-4"><BreakdownBarChart data={attention.map((row) => ({ label: freshnessLabels[row.status], value: row.costValue }))} valueLabel="Cost exposure" /></div></Card>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader title="Affected inventory" description="Lowest freshness score first" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Product</th><th className="px-4 py-3">Branch</th><th className="px-4 py-3">Freshness</th><th className="px-4 py-3">Cost exposure</th><th className="px-5 py-3">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>{risk.affected.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-4"><Link href={`/products/${item.productId}`} className="text-sm font-bold hover:text-emerald-700">{item.product.name}</Link><p className="text-[11px] text-slate-400">{item.product.sku} · {item.id.toUpperCase()}</p></td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{item.branch.name}</td>
|
||||
<td className="px-4 py-4"><FreshnessBar value={item.score} status={item.status} /></td>
|
||||
<td className="px-4 py-4 text-xs font-bold">{currency(item.costExposure)}</td>
|
||||
<td className="px-5 py-4"><FreshnessBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-slate-400">Exposure is advisory and based on received-date freshness estimates. Confirm physical quality before acting.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/app/metrics/freshness/page.tsx
Normal file
42
src/app/metrics/freshness/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Boxes, Layers3, Leaf, Scale } from "lucide-react";
|
||||
import { getMetricDetails } from "@/domain/analytics";
|
||||
import { number, percent } from "@/lib/format";
|
||||
import { BackToOverview, DetailStat, RankedMetricTable } from "@/components/metric-detail";
|
||||
import { BreakdownBarChart } from "@/components/charts";
|
||||
import { Card, CardHeader, PageHeader } from "@/components/ui";
|
||||
import { CsvExportButton } from "@/components/csv-export-button";
|
||||
import { freshnessLabels } from "@/domain/freshness";
|
||||
|
||||
export const metadata = { title: "Freshness" };
|
||||
|
||||
export default function FreshnessPage() {
|
||||
const { freshness } = getMetricDetails();
|
||||
const totalUnits = freshness.byStatus.reduce((sum, row) => sum + row.units, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackToOverview />
|
||||
<PageHeader eyebrow="Metric drill-down" title="Average freshness"
|
||||
description="Compare remaining shelf-life estimates across branches, categories, products, and stock-health states."
|
||||
actions={<CsvExportButton filename="solaire-freshness.csv" rows={[
|
||||
["Product", "SKU", "Batches", "Units", "Average score", "Weighted score"],
|
||||
...freshness.byProduct.map((row) => [row.label, row.secondary, row.batches, row.units, row.averageScore.toFixed(1), row.weightedScore.toFixed(1)]),
|
||||
]} />} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailStat label="Batch average" value={percent(freshness.averageScore)} detail="Every batch weighted equally" icon={<Leaf className="size-5" />} />
|
||||
<DetailStat label="Unit-weighted average" value={percent(freshness.weightedScore)} detail="Larger batches carry more weight" icon={<Scale className="size-5" />} />
|
||||
<DetailStat label="Fresh units" value={percent(freshness.freshUnitsPercent)} detail="Units above the watch threshold" icon={<Boxes className="size-5" />} />
|
||||
<DetailStat label="Tracked units" value={number(totalUnits)} detail="Cases across active batches" icon={<Layers3 className="size-5" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[.8fr_1.2fr]">
|
||||
<Card><CardHeader title="Units by freshness state" description="Current stock-health distribution" /><div className="p-4"><BreakdownBarChart data={freshness.byStatus.map((row) => ({ label: freshnessLabels[row.status], value: row.units }))} valueLabel="Cases" format="number" /></div></Card>
|
||||
<Card className="overflow-hidden"><CardHeader title="Branch freshness" description="Lowest unit-weighted score first" /><RankedMetricTable rows={freshness.byBranch.map((row) => ({ ...row, value: row.weightedScore }))} metricLabel="Freshness" formatValue={percent} /></Card>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden"><CardHeader title="Product freshness" description="Use low scores to prioritize inspection and movement" /><RankedMetricTable rows={freshness.byProduct.map((row) => ({ ...row, value: row.weightedScore }))} metricLabel="Freshness" formatValue={percent} /></Card>
|
||||
<p className="text-center text-[11px] text-slate-400">Freshness is estimated from received date and configured shelf life; it is not a sensor or inspection reading.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/app/metrics/inventory-value/page.tsx
Normal file
42
src/app/metrics/inventory-value/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Boxes, CircleDollarSign, PackageCheck, TrendingUp } from "lucide-react";
|
||||
import { getMetricDetails } from "@/domain/analytics";
|
||||
import { currency, number, percent } from "@/lib/format";
|
||||
import { BackToOverview, DetailStat, RankedMetricTable } from "@/components/metric-detail";
|
||||
import { BreakdownBarChart } from "@/components/charts";
|
||||
import { Card, CardHeader, PageHeader } from "@/components/ui";
|
||||
import { CsvExportButton } from "@/components/csv-export-button";
|
||||
|
||||
export const metadata = { title: "Inventory Value" };
|
||||
|
||||
export default function InventoryValuePage() {
|
||||
const { inventoryValue } = getMetricDetails();
|
||||
const marginPercent = inventoryValue.retailValue
|
||||
? (inventoryValue.potentialMargin / inventoryValue.retailValue) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackToOverview />
|
||||
<PageHeader eyebrow="Metric drill-down" title="Inventory value"
|
||||
description="Understand where current retail value, cost basis, and potential margin are concentrated."
|
||||
actions={<CsvExportButton filename="solaire-inventory-value.csv" rows={[
|
||||
["Product", "SKU", "Units", "Retail value", "Cost basis", "Potential margin"],
|
||||
...inventoryValue.byProduct.map((row) => [row.label, row.secondary, row.units, row.retailValue.toFixed(2), row.costValue.toFixed(2), row.potentialMargin.toFixed(2)]),
|
||||
]} />} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailStat label="Retail value" value={currency(inventoryValue.retailValue)} detail="At current list prices" icon={<CircleDollarSign className="size-5" />} />
|
||||
<DetailStat label="Cost basis" value={currency(inventoryValue.costValue)} detail="Capital currently in stock" icon={<Boxes className="size-5" />} />
|
||||
<DetailStat label="Potential margin" value={currency(inventoryValue.potentialMargin)} detail={`${percent(marginPercent)} at list price`} icon={<TrendingUp className="size-5" />} />
|
||||
<DetailStat label="Units on hand" value={number(inventoryValue.units)} detail="Cases across active batches" icon={<PackageCheck className="size-5" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1.15fr]">
|
||||
<Card><CardHeader title="Value by category" description="Retail value contribution" /><div className="p-4"><BreakdownBarChart data={inventoryValue.byCategory.map((row) => ({ label: row.label, value: row.retailValue }))} valueLabel="Retail value" /></div></Card>
|
||||
<Card className="overflow-hidden"><CardHeader title="Branch concentration" description="Value and units by location" /><RankedMetricTable rows={inventoryValue.byBranch.map((row) => ({ ...row, value: row.retailValue }))} metricLabel="Retail value" formatValue={currency} /></Card>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden"><CardHeader title="Product value" description="Highest-value products first" /><RankedMetricTable rows={inventoryValue.byProduct.map((row) => ({ ...row, value: row.retailValue }))} metricLabel="Retail value" formatValue={currency} /></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/metrics/revenue/page.tsx
Normal file
40
src/app/metrics/revenue/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { CalendarDays, CircleDollarSign, ReceiptText, ShoppingCart } from "lucide-react";
|
||||
import { getDashboardData, getMetricDetails } from "@/domain/analytics";
|
||||
import { currency, number, percent } from "@/lib/format";
|
||||
import { BackToOverview, DetailStat, RankedMetricTable } from "@/components/metric-detail";
|
||||
import { RevenueChart } from "@/components/charts";
|
||||
import { Card, CardHeader, PageHeader } from "@/components/ui";
|
||||
import { CsvExportButton } from "@/components/csv-export-button";
|
||||
|
||||
export const metadata = { title: "Revenue" };
|
||||
|
||||
export default function RevenuePage() {
|
||||
const data = getDashboardData();
|
||||
const { revenue } = getMetricDetails();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackToOverview />
|
||||
<PageHeader eyebrow="Metric drill-down" title="Revenue · 30 days"
|
||||
description="Trace revenue momentum to the branches, categories, and products creating it."
|
||||
actions={<CsvExportButton filename="solaire-revenue-30d.csv" rows={[
|
||||
["Product", "SKU", "Revenue", "Units", "Average price"],
|
||||
...revenue.byProduct.map((row) => [row.label, row.secondary, row.revenue.toFixed(2), row.units, row.averagePrice.toFixed(2)]),
|
||||
]} />} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailStat label="30-day revenue" value={currency(revenue.current30d)} detail={`${percent(revenue.changePercent)} vs prior 30 days`} icon={<CircleDollarSign className="size-5" />} />
|
||||
<DetailStat label="Prior 30 days" value={currency(revenue.prior30d)} detail="Complete comparison period" icon={<CalendarDays className="size-5" />} />
|
||||
<DetailStat label="Units sold" value={number(revenue.units)} detail="Cases sold in current period" icon={<ShoppingCart className="size-5" />} />
|
||||
<DetailStat label="Average daily revenue" value={currency(revenue.averageDailyRevenue)} detail={`${currency(revenue.averageOrderPrice)} per sales record`} icon={<ReceiptText className="size-5" />} />
|
||||
</div>
|
||||
|
||||
<Card><CardHeader title="Revenue momentum" description="Four-day revenue buckets across the current period" /><div className="px-4 py-5"><RevenueChart data={data.trends} /></div></Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card className="overflow-hidden"><CardHeader title="Revenue by branch" description="Highest contribution first" /><RankedMetricTable rows={revenue.byBranch.map((row) => ({ ...row, value: row.revenue }))} metricLabel="Revenue" formatValue={currency} /></Card>
|
||||
<Card className="overflow-hidden"><CardHeader title="Revenue by product" description="Product-level performance" /><RankedMetricTable rows={revenue.byProduct.map((row) => ({ ...row, value: row.revenue }))} metricLabel="Revenue" formatValue={currency} /></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowDownRight, ArrowRight, ArrowUpRight, Boxes, CircleDollarSign,
|
||||
ArrowRight, Boxes, CircleDollarSign,
|
||||
Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
|
||||
} from "@/components/ui";
|
||||
import { RevenueChart } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export default function Home() {
|
||||
const data = getDashboardData();
|
||||
@@ -41,16 +42,16 @@ export default function Home() {
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard label="Inventory value" value={currency(data.metrics.retailValue, true)}
|
||||
<MetricCard href="/metrics/inventory-value" label="Inventory value" value={currency(data.metrics.retailValue, true)}
|
||||
detail={`${number(data.metrics.unitsOnHand)} cases on hand`} change={data.metrics.change.unitsOnHand}
|
||||
icon={<Boxes className="size-5" />} tone="emerald" />
|
||||
<MetricCard label="Revenue · 30 days" value={currency(data.metrics.revenue30d, true)}
|
||||
<MetricCard href="/metrics/revenue" label="Revenue · 30 days" value={currency(data.metrics.revenue30d, true)}
|
||||
detail={`${percent(data.metrics.sellThrough30d)} sell-through`} change={data.metrics.change.revenue}
|
||||
icon={<ShoppingCart className="size-5" />} tone="blue" />
|
||||
<MetricCard label="At-risk exposure" value={currency(data.metrics.atRiskValue, true)}
|
||||
<MetricCard href="/metrics/at-risk" label="At-risk exposure" value={currency(data.metrics.atRiskValue, true)}
|
||||
detail={`${riskItems.length} batches need attention`} change={data.metrics.change.atRisk}
|
||||
icon={<TriangleAlert className="size-5" />} tone="amber" invertChange />
|
||||
<MetricCard label="Average freshness" value={percent(data.metrics.averageFreshness)}
|
||||
<MetricCard href="/metrics/freshness" label="Average freshness" value={percent(data.metrics.averageFreshness)}
|
||||
detail="Across active inventory" change={3.1}
|
||||
icon={<Leaf className="size-5" />} tone="violet" />
|
||||
</div>
|
||||
@@ -139,27 +140,3 @@ export default function Home() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail, change, icon, tone, invertChange = false }: {
|
||||
label: string; value: string; detail: string; change: number; icon: React.ReactNode;
|
||||
tone: "emerald" | "blue" | "amber" | "violet"; invertChange?: boolean;
|
||||
}) {
|
||||
const positive = invertChange ? change < 0 : change > 0;
|
||||
const tones = {
|
||||
emerald: "bg-emerald-50 text-emerald-700", blue: "bg-blue-50 text-blue-700",
|
||||
amber: "bg-amber-50 text-amber-700", violet: "bg-violet-50 text-violet-700",
|
||||
};
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}>{icon}</span>
|
||||
<span className={`flex items-center text-[11px] font-bold ${positive ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{change > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}{Math.abs(change)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold text-slate-500">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight">{value}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{detail}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,19 +6,30 @@ import { PerformanceTable } from "@/components/performance-table";
|
||||
|
||||
export const metadata = { title: "Sales Team" };
|
||||
|
||||
export default function SalesTeamPage() {
|
||||
export default async function SalesTeamPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
}) {
|
||||
const { salespeople } = getDashboardData();
|
||||
const totalRevenue = salespeople.reduce((sum, row) => sum + row.revenue, 0);
|
||||
const { query = "" } = await searchParams;
|
||||
const visiblePeople = query
|
||||
? salespeople.filter((person) =>
|
||||
`${person.name} ${person.secondary}`.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
: salespeople;
|
||||
const totalRevenue = visiblePeople.reduce((sum, row) => sum + row.revenue, 0);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Performance" title="Sales team"
|
||||
description="See who is moving the most produce and whether those sales are improving stock health." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TeamMetric icon={<Award className="size-5" />} label="Sales leader" value={salespeople[0].name} detail={currency(salespeople[0].revenue)} />
|
||||
<TeamMetric icon={<Award className="size-5" />} label="Sales leader" value={visiblePeople[0]?.name ?? "No match"} detail={visiblePeople[0] ? currency(visiblePeople[0].revenue) : "Try another search"} />
|
||||
<TeamMetric icon={<Target className="size-5" />} label="Team revenue" value={currency(totalRevenue, true)} detail="Last 30 days" />
|
||||
<TeamMetric icon={<Users className="size-5" />} label="Active sellers" value={number(salespeople.length)} detail="Across four branches" />
|
||||
<TeamMetric icon={<Users className="size-5" />} label="Active sellers" value={number(visiblePeople.length)} detail="Across four branches" />
|
||||
</div>
|
||||
<PerformanceTable rows={salespeople} entityLabel="Salesperson" />
|
||||
{query && <p className="text-xs font-semibold text-slate-500">Showing salesperson results for “{query}”.</p>}
|
||||
<PerformanceTable rows={visiblePeople} entityLabel="Salesperson" />
|
||||
<Card className="border-blue-100 bg-blue-50/60 p-5">
|
||||
<p className="text-sm font-bold text-blue-900">Salesforce mapping preview</p>
|
||||
<p className="mt-1 text-xs leading-5 text-blue-700">Demo sellers and orders use Solaire’s normalized model. A future Salesforce adapter can map Users, Accounts, Orders, Opportunities, and custom branch fields without changing these analytics.</p>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
@@ -59,3 +62,38 @@ export function RevenueChart({ data }: { data: TrendPoint[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreakdownBarChart({
|
||||
data,
|
||||
valueLabel,
|
||||
format = "currency",
|
||||
}: {
|
||||
data: Array<{ label: string; value: number }>;
|
||||
valueLabel: string;
|
||||
format?: "currency" | "number" | "percent";
|
||||
}) {
|
||||
const colors = ["#059669", "#2563eb", "#d97706", "#7c3aed", "#e11d48", "#0891b2"];
|
||||
const formatValue = (value: number) => {
|
||||
if (format === "percent") return `${Math.round(value)}%`;
|
||||
if (format === "number") return Math.round(value).toLocaleString();
|
||||
return currency(value);
|
||||
};
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis dataKey="label" axisLine={false} tickLine={false} tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: "#94a3b8", fontSize: 11 }} tickFormatter={formatValue} />
|
||||
<Tooltip
|
||||
formatter={(value) => [formatValue(Number(value)), valueLabel]}
|
||||
contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0", fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[7, 7, 0, 0]}>
|
||||
{data.map((item, index) => <Cell key={item.label} fill={colors[index % colors.length]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/components/csv-export-button.tsx
Normal file
32
src/components/csv-export-button.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { secondaryButtonStyles } from "@/components/ui";
|
||||
|
||||
export function CsvExportButton({
|
||||
filename,
|
||||
rows,
|
||||
label = "Export CSV",
|
||||
}: {
|
||||
filename: string;
|
||||
rows: Array<Array<string | number>>;
|
||||
label?: string;
|
||||
}) {
|
||||
function download() {
|
||||
const csv = rows
|
||||
.map((row) => row.map((value) => `"${String(value).replaceAll("\"", "\"\"")}"`).join(","))
|
||||
.join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={download} className={secondaryButtonStyles}>
|
||||
<Download className="size-3.5" />{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Boxes,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navigation = [
|
||||
@@ -27,8 +27,31 @@ const navigation = [
|
||||
{ href: "/alerts", label: "Alerts", icon: Bell },
|
||||
];
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
interface SearchItem {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
href: string;
|
||||
keywords: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function DashboardShell({
|
||||
children,
|
||||
searchItems,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
searchItems: SearchItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const matches = query.trim().length
|
||||
? searchItems
|
||||
.filter((item) => item.keywords.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.slice(0, 7)
|
||||
: [];
|
||||
|
||||
if (pathname === "/login") {
|
||||
return <>{children}</>;
|
||||
@@ -109,8 +132,43 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
<input
|
||||
aria-label="Search products, branches, or people"
|
||||
placeholder="Search products, branches, or people..."
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setQuery("");
|
||||
}}
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50/80 pl-10 pr-3 text-sm outline-none transition focus:border-emerald-400 focus:bg-white focus:ring-4 focus:ring-emerald-50"
|
||||
/>
|
||||
{query.trim() && (
|
||||
<div className="absolute left-0 right-0 top-12 z-50 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/10">
|
||||
{matches.length ? (
|
||||
<div className="p-2">
|
||||
{matches.map((item) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
onClick={() => {
|
||||
router.push(item.href);
|
||||
setQuery("");
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-xl px-3 py-2.5 text-left hover:bg-slate-50"
|
||||
>
|
||||
<span>
|
||||
<span className="block text-xs font-bold text-slate-900">{item.label}</span>
|
||||
<span className="mt-0.5 block text-[11px] text-slate-400">{item.detail}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-1 text-[9px] font-bold uppercase tracking-wider text-emerald-700">
|
||||
{item.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-5 text-center text-xs text-slate-500">
|
||||
No products, branches, or people match “{query}”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 sm:gap-4">
|
||||
<Link
|
||||
@@ -121,7 +179,13 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
<Bell className="size-5" />
|
||||
<span className="absolute right-2 top-2 size-2 rounded-full border-2 border-white bg-amber-500" />
|
||||
</Link>
|
||||
<button className="flex items-center gap-2 rounded-xl p-1.5 pr-2 hover:bg-slate-50">
|
||||
<div className="relative">
|
||||
<button
|
||||
aria-expanded={profileOpen}
|
||||
aria-label="Open account menu"
|
||||
onClick={() => setProfileOpen((open) => !open)}
|
||||
className="flex items-center gap-2 rounded-xl p-1.5 pr-2 hover:bg-slate-50"
|
||||
>
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-slate-900 text-xs font-bold text-white">
|
||||
NC
|
||||
</span>
|
||||
@@ -131,6 +195,20 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
</span>
|
||||
<ChevronDown className="hidden size-4 text-slate-400 sm:block" />
|
||||
</button>
|
||||
{profileOpen && (
|
||||
<div className="absolute right-0 top-12 z-50 w-52 rounded-xl border border-slate-200 bg-white p-2 shadow-xl shadow-slate-900/10">
|
||||
<p className="px-3 py-2 text-[11px] text-slate-400">Demo account</p>
|
||||
<Link href="/settings/integrations" onClick={() => setProfileOpen(false)} className="block rounded-lg px-3 py-2 text-xs font-semibold hover:bg-slate-50">
|
||||
Workspace settings
|
||||
</Link>
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button className="w-full rounded-lg px-3 py-2 text-left text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Database, RefreshCw, RotateCw, Users } from "lucide-react";
|
||||
import { CheckCircle2, Database, RefreshCw, RotateCw, Users, X } from "lucide-react";
|
||||
import { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
|
||||
import type { IntegrationProvider } from "@/lib/types";
|
||||
|
||||
@@ -9,6 +9,7 @@ type State = { provider: IntegrationProvider; loading: boolean; message?: string
|
||||
|
||||
export function IntegrationSettings() {
|
||||
const [state, setState] = useState<State>({ provider: "odoo", loading: false });
|
||||
const [configuring, setConfiguring] = useState<IntegrationProvider | null>(null);
|
||||
|
||||
async function sync(provider: IntegrationProvider) {
|
||||
setState({ provider, loading: true });
|
||||
@@ -28,6 +29,7 @@ export function IntegrationSettings() {
|
||||
description="Products, locations, received inventory, and stock movements"
|
||||
icon={<Database className="size-6" />}
|
||||
status="Demo source connected"
|
||||
onConfigure={() => setConfiguring("odoo")}
|
||||
onSync={() => sync("odoo")}
|
||||
loading={state.loading && state.provider === "odoo"}
|
||||
/>
|
||||
@@ -36,6 +38,7 @@ export function IntegrationSettings() {
|
||||
description="Branches, salespeople, orders, and sales attribution"
|
||||
icon={<Users className="size-6" />}
|
||||
status="Demo source connected"
|
||||
onConfigure={() => setConfiguring("salesforce")}
|
||||
onSync={() => sync("salesforce")}
|
||||
loading={state.loading && state.provider === "salesforce"}
|
||||
/>
|
||||
@@ -44,12 +47,37 @@ export function IntegrationSettings() {
|
||||
<CheckCircle2 className="size-4" />{state.message}
|
||||
</div>
|
||||
)}
|
||||
{configuring && (
|
||||
<div className="fixed inset-0 z-[70] grid place-items-center bg-slate-950/30 p-4 backdrop-blur-sm">
|
||||
<div role="dialog" aria-modal="true" aria-label={`Configure ${configuring}`} className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-wider text-emerald-700">Integration settings</p><h2 className="mt-1 text-lg font-bold">{configuring === "odoo" ? "Odoo Inventory" : "Salesforce CRM"}</h2></div>
|
||||
<button aria-label="Close configuration" onClick={() => setConfiguring(null)} className="grid size-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100"><X className="size-4" /></button>
|
||||
</div>
|
||||
<label className="mt-6 block text-xs font-semibold text-slate-600">Data source
|
||||
<select className="mt-2 h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm">
|
||||
<option>Deterministic demo data</option>
|
||||
<option disabled>Real account — credentials required</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs font-semibold text-slate-600">Sync schedule<input value="Every 4 hours" readOnly className="mt-2 h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-500" /></label>
|
||||
<label className="text-xs font-semibold text-slate-600">Tenant mapping<input value="Sun Valley Produce Co." readOnly className="mt-2 h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-500" /></label>
|
||||
</div>
|
||||
<p className="mt-4 rounded-xl bg-blue-50 p-3 text-xs leading-5 text-blue-700">This pilot uses normalized fake records. Real credentials remain server-only when connected later.</p>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button onClick={() => setConfiguring(null)} className={secondaryButtonStyles}>Cancel</button>
|
||||
<button onClick={() => { setState({ provider: configuring, loading: false, message: `${configuring === "odoo" ? "Odoo" : "Salesforce"} demo configuration saved.` }); setConfiguring(null); }} className={buttonStyles}>Save configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCard({ name, description, icon, status, onSync, loading }: {
|
||||
name: string; description: string; icon: React.ReactNode; status: string; onSync: () => void; loading: boolean;
|
||||
function IntegrationCard({ name, description, icon, status, onConfigure, onSync, loading }: {
|
||||
name: string; description: string; icon: React.ReactNode; status: string; onConfigure: () => void; onSync: () => void; loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
@@ -57,7 +85,7 @@ function IntegrationCard({ name, description, icon, status, onSync, loading }: {
|
||||
<span className="grid size-12 place-items-center rounded-2xl bg-slate-100 text-slate-700">{icon}</span>
|
||||
<div className="flex-1"><h2 className="text-sm font-bold">{name}</h2><p className="mt-1 text-xs text-slate-500">{description}</p><p className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-emerald-700"><span className="size-1.5 rounded-full bg-emerald-500" />{status}</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button className={secondaryButtonStyles}>Configure</button>
|
||||
<button onClick={onConfigure} className={secondaryButtonStyles}>Configure</button>
|
||||
<button onClick={onSync} disabled={loading} className={buttonStyles}>
|
||||
{loading ? <RotateCw className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
{loading ? "Syncing…" : "Sync now"}
|
||||
|
||||
144
src/components/inventory-controls.tsx
Normal file
144
src/components/inventory-controls.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Download, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { InventoryView } from "@/lib/types";
|
||||
import { currency } from "@/lib/format";
|
||||
import {
|
||||
Card,
|
||||
FreshnessBadge,
|
||||
FreshnessBar,
|
||||
secondaryButtonStyles,
|
||||
} from "@/components/ui";
|
||||
|
||||
export function InventoryControls({ inventory }: { inventory: InventoryView[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [branch, setBranch] = useState("all");
|
||||
const [category, setCategory] = useState("all");
|
||||
const branches = [...new Map(inventory.map((item) => [item.branchId, item.branch])).values()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const categories = [...new Set(inventory.map((item) => item.product.category))].sort();
|
||||
|
||||
const filtered = (() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return [...inventory]
|
||||
.filter((item) => {
|
||||
const searchable = [
|
||||
item.product.name,
|
||||
item.product.sku,
|
||||
item.id,
|
||||
item.branch.name,
|
||||
item.branch.city,
|
||||
item.branch.region,
|
||||
].join(" ").toLowerCase();
|
||||
return (!needle || searchable.includes(needle))
|
||||
&& (branch === "all" || item.branchId === branch)
|
||||
&& (category === "all" || item.product.category === category);
|
||||
})
|
||||
.sort((a, b) => a.score - b.score);
|
||||
})();
|
||||
|
||||
function exportCsv() {
|
||||
const header = ["Product", "SKU", "Batch", "Branch", "City", "Received", "Freshness", "Status", "On hand", "Retail value"];
|
||||
const rows = filtered.map((item) => [
|
||||
item.product.name,
|
||||
item.product.sku,
|
||||
item.id.toUpperCase(),
|
||||
item.branch.name,
|
||||
item.branch.city,
|
||||
item.receivedAt,
|
||||
item.score,
|
||||
item.status,
|
||||
item.quantityOnHand,
|
||||
item.retailValue.toFixed(2),
|
||||
]);
|
||||
const csv = [header, ...rows]
|
||||
.map((row) => row.map((value) => `"${String(value).replaceAll("\"", "\"\"")}"`).join(","))
|
||||
.join("\n");
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `solaire-inventory-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const hasFilters = query || branch !== "all" || category !== "all";
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-slate-100 p-4 lg:flex-row">
|
||||
<label className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search product, SKU, branch, or city…"
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm outline-none focus:border-emerald-400"
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
aria-label="Filter by branch"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-xs font-bold text-slate-700 outline-none focus:border-emerald-400"
|
||||
>
|
||||
<option value="all">All branches</option>
|
||||
{branches.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.city}</option>)}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filter by category"
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-xs font-bold text-slate-700 outline-none focus:border-emerald-400"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
{categories.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setQuery(""); setBranch("all"); setCategory("all"); }}
|
||||
className={secondaryButtonStyles}
|
||||
>
|
||||
<X className="size-3.5" />Clear
|
||||
</button>
|
||||
)}
|
||||
<button onClick={exportCsv} className={secondaryButtonStyles}>
|
||||
<Download className="size-3.5" />Export {filtered.length} rows
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b border-slate-100 bg-slate-50/50 px-5 py-2 text-[11px] font-semibold text-slate-500">
|
||||
Showing {filtered.length} of {inventory.length} batches
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Product / batch</th><th className="px-4 py-3">Location</th>
|
||||
<th className="px-4 py-3">Received</th><th className="px-4 py-3">Freshness</th>
|
||||
<th className="px-4 py-3">On hand</th><th className="px-4 py-3">Value</th><th className="px-5 py-3">Health</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/products/${item.productId}`} className="text-sm font-bold hover:text-emerald-700">{item.product.name}</Link>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · {item.id.toUpperCase()}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4"><p className="text-xs font-semibold">{item.branch.name}</p><p className="text-[11px] text-slate-400">{item.branch.city}</p></td>
|
||||
<td className="px-4 py-4 text-xs text-slate-600">{new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}<p className="text-[11px] text-slate-400">{item.ageDays} days ago</p></td>
|
||||
<td className="px-4 py-4"><FreshnessBar value={item.score} status={item.status} /><p className="mt-1 text-[10px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}</p></td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{item.quantityOnHand} {item.product.unit}s</td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{currency(item.retailValue)}</td>
|
||||
<td className="px-5 py-4"><FreshnessBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!filtered.length && (
|
||||
<div className="p-12 text-center"><p className="text-sm font-bold">No inventory matches these filters</p><p className="mt-1 text-xs text-slate-400">Try another product, branch, city, or category.</p></div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
58
src/components/metric-card.tsx
Normal file
58
src/components/metric-card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowDownRight, ArrowRight, ArrowUpRight } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export function MetricCard({
|
||||
href,
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
change,
|
||||
icon,
|
||||
tone,
|
||||
invertChange = false,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
change: number;
|
||||
icon: ReactNode;
|
||||
tone: "emerald" | "blue" | "amber" | "violet";
|
||||
invertChange?: boolean;
|
||||
}) {
|
||||
const positive = invertChange ? change < 0 : change > 0;
|
||||
const tones = {
|
||||
emerald: "bg-emerald-50 text-emerald-700",
|
||||
blue: "bg-blue-50 text-blue-700",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
violet: "bg-violet-50 text-violet-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={`View details for ${label}`}
|
||||
className="group rounded-2xl outline-none focus-visible:ring-4 focus-visible:ring-emerald-200"
|
||||
>
|
||||
<Card className="h-full p-5 transition duration-200 group-hover:-translate-y-0.5 group-hover:border-emerald-200 group-hover:shadow-lg group-hover:shadow-emerald-950/5">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}>{icon}</span>
|
||||
<span className={`flex items-center text-[11px] font-bold ${positive ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{change > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}
|
||||
{Math.abs(change)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold text-slate-500">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight">{value}</p>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-slate-400">{detail}</p>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] font-bold text-emerald-700 opacity-70 transition group-hover:opacity-100">
|
||||
View details <ArrowRight className="size-3 transition group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
65
src/components/metric-detail.tsx
Normal file
65
src/components/metric-detail.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export function BackToOverview() {
|
||||
return (
|
||||
<Link href="/" className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500 hover:text-slate-900">
|
||||
<ArrowLeft className="size-3.5" />Back to overview
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailStat({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<span className="grid size-9 place-items-center rounded-xl bg-emerald-50 text-emerald-700">{icon}</span>
|
||||
<p className="mt-4 text-xs font-semibold text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-xl font-bold tracking-tight">{value}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{detail}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function RankedMetricTable({
|
||||
rows,
|
||||
metricLabel,
|
||||
formatValue,
|
||||
}: {
|
||||
rows: Array<{ id: string; label: string; secondary: string; value: number; units: number }>;
|
||||
metricLabel: string;
|
||||
formatValue: (value: number) => string;
|
||||
}) {
|
||||
const maximum = Math.max(...rows.map((row) => row.value), 1);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Rank</th><th className="px-4 py-3">Name</th>
|
||||
<th className="px-4 py-3">{metricLabel}</th><th className="px-5 py-3">Units</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-5 py-3.5 text-xs font-bold text-slate-400">{index + 1}</td>
|
||||
<td className="px-4 py-3.5"><p className="text-sm font-bold">{row.label}</p><p className="text-[11px] text-slate-400">{row.secondary}</p></td>
|
||||
<td className="px-4 py-3.5"><p className="text-sm font-bold">{formatValue(row.value)}</p><div className="mt-1.5 h-1.5 w-32 rounded-full bg-slate-100"><div className="h-full rounded-full bg-emerald-500" style={{ width: `${(row.value / maximum) * 100}%` }} /></div></td>
|
||||
<td className="px-5 py-3.5 text-xs font-semibold">{Math.round(row.units).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function RecommendationList({
|
||||
inventory: InventoryView[];
|
||||
}) {
|
||||
const [items, setItems] = useState(initial);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const update = (id: string, status: Recommendation["status"]) =>
|
||||
setItems((current) => current.map((item) => item.id === id ? { ...item, status } : item));
|
||||
|
||||
@@ -55,8 +56,21 @@ export function RecommendationList({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex w-full items-center justify-center gap-1 border-t border-slate-100 py-2 text-[11px] font-semibold text-slate-400 hover:bg-slate-50">
|
||||
Show calculation details <ChevronDown className="size-3" />
|
||||
{expandedId === rec.id && (
|
||||
<div className="grid gap-3 border-t border-slate-100 bg-slate-50/60 px-5 py-4 text-xs sm:grid-cols-4">
|
||||
<div><p className="font-bold text-slate-900">Freshness pressure</p><p className="mt-1 text-slate-500">{stock.score}% remaining estimate</p></div>
|
||||
<div><p className="font-bold text-slate-900">Stock pressure</p><p className="mt-1 text-slate-500">{stock.quantityOnHand} cases available</p></div>
|
||||
<div><p className="font-bold text-slate-900">Cost guardrail</p><p className="mt-1 text-slate-500">{currency(stock.product.cost)} cost · {stock.product.marginFloorPercent}% floor</p></div>
|
||||
<div><p className="font-bold text-slate-900">Decision</p><p className="mt-1 text-slate-500">{rec.action}</p></div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-expanded={expandedId === rec.id}
|
||||
onClick={() => setExpandedId((current) => current === rec.id ? null : rec.id)}
|
||||
className="flex w-full items-center justify-center gap-1 border-t border-slate-100 py-2 text-[11px] font-semibold text-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
{expandedId === rec.id ? "Hide calculation details" : "Show calculation details"}
|
||||
<ChevronDown className={`size-3 transition ${expandedId === rec.id ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format, isAfter, startOfDay, subDays } from "date-fns";
|
||||
import { format, startOfDay, subDays } from "date-fns";
|
||||
import {
|
||||
branches,
|
||||
inventoryBatches,
|
||||
@@ -11,19 +11,39 @@ import { calculateFreshness } from "@/domain/freshness";
|
||||
import { recommendPrice } from "@/domain/recommendations";
|
||||
import type {
|
||||
DashboardData,
|
||||
FreshnessBreakdownRow,
|
||||
FreshnessStatus,
|
||||
InventoryView,
|
||||
MetricDetails,
|
||||
PerformanceRow,
|
||||
RevenueBreakdownRow,
|
||||
RiskBreakdownRow,
|
||||
Sale,
|
||||
ValueBreakdownRow,
|
||||
} from "@/lib/types";
|
||||
|
||||
const recentSales = (days: number) => {
|
||||
const cutoff = startOfDay(subDays(new Date(), days));
|
||||
return sales.filter((sale) => isAfter(new Date(sale.soldAt), cutoff));
|
||||
const now = new Date();
|
||||
const cutoff = subDays(now, days);
|
||||
return sales.filter((sale) => {
|
||||
const date = new Date(sale.soldAt);
|
||||
return date > cutoff && date <= now;
|
||||
});
|
||||
};
|
||||
|
||||
const revenue = (items: Sale[]) =>
|
||||
items.reduce((sum, sale) => sum + sale.quantity * sale.unitPrice, 0);
|
||||
|
||||
const salesInWindow = (startDaysAgo: number, endDaysAgo: number) => {
|
||||
const now = new Date();
|
||||
const newest = subDays(now, startDaysAgo);
|
||||
const oldest = subDays(now, endDaysAgo);
|
||||
return sales.filter((sale) => {
|
||||
const date = new Date(sale.soldAt);
|
||||
return date > oldest && date <= newest;
|
||||
});
|
||||
};
|
||||
|
||||
const performance = (
|
||||
kind: "branch" | "salesperson",
|
||||
inventory: InventoryView[],
|
||||
@@ -80,6 +100,9 @@ export function getDashboardData(): DashboardData {
|
||||
});
|
||||
|
||||
const sales30 = recentSales(30);
|
||||
const priorSales30 = salesInWindow(30, 60);
|
||||
const currentRevenue = revenue(sales30);
|
||||
const priorRevenue = revenue(priorSales30);
|
||||
const unitsOnHand = inventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
const sold30 = sales30.reduce((sum, sale) => sum + sale.quantity, 0);
|
||||
const atRisk = inventory.filter(
|
||||
@@ -130,11 +153,193 @@ export function getDashboardData(): DashboardData {
|
||||
unitsOnHand,
|
||||
retailValue: inventory.reduce((sum, item) => sum + item.retailValue, 0),
|
||||
atRiskValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0),
|
||||
revenue30d: revenue(sales30),
|
||||
revenue30d: currentRevenue,
|
||||
sellThrough30d: (sold30 / (sold30 + unitsOnHand)) * 100,
|
||||
averageFreshness:
|
||||
inventory.reduce((sum, item) => sum + item.score, 0) / inventory.length,
|
||||
change: { unitsOnHand: -4.2, revenue: 12.8, atRisk: -8.4 },
|
||||
change: {
|
||||
unitsOnHand: -4.2,
|
||||
revenue: priorRevenue ? ((currentRevenue - priorRevenue) / priorRevenue) * 100 : 0,
|
||||
atRisk: -8.4,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function valueBreakdown(
|
||||
inventory: InventoryView[],
|
||||
group: (item: InventoryView) => { id: string; label: string; secondary: string },
|
||||
): ValueBreakdownRow[] {
|
||||
const rows = new Map<string, ValueBreakdownRow>();
|
||||
for (const item of inventory) {
|
||||
const key = group(item);
|
||||
const row = rows.get(key.id) ?? {
|
||||
...key,
|
||||
units: 0,
|
||||
retailValue: 0,
|
||||
costValue: 0,
|
||||
potentialMargin: 0,
|
||||
};
|
||||
row.units += item.quantityOnHand;
|
||||
row.retailValue += item.retailValue;
|
||||
row.costValue += item.costExposure;
|
||||
row.potentialMargin = row.retailValue - row.costValue;
|
||||
rows.set(key.id, row);
|
||||
}
|
||||
return [...rows.values()].sort((a, b) => b.retailValue - a.retailValue);
|
||||
}
|
||||
|
||||
function revenueBreakdown(
|
||||
items: Sale[],
|
||||
group: (sale: Sale) => { id: string; label: string; secondary: string },
|
||||
): RevenueBreakdownRow[] {
|
||||
const rows = new Map<string, RevenueBreakdownRow>();
|
||||
for (const sale of items) {
|
||||
const key = group(sale);
|
||||
const row = rows.get(key.id) ?? {
|
||||
...key,
|
||||
revenue: 0,
|
||||
units: 0,
|
||||
averagePrice: 0,
|
||||
};
|
||||
row.revenue += sale.quantity * sale.unitPrice;
|
||||
row.units += sale.quantity;
|
||||
row.averagePrice = row.units ? row.revenue / row.units : 0;
|
||||
rows.set(key.id, row);
|
||||
}
|
||||
return [...rows.values()].sort((a, b) => b.revenue - a.revenue);
|
||||
}
|
||||
|
||||
function riskBreakdown(inventory: InventoryView[]): RiskBreakdownRow[] {
|
||||
const statuses: FreshnessStatus[] = ["fresh", "watch", "at_risk", "expired"];
|
||||
return statuses.map((status) => {
|
||||
const items = inventory.filter((item) => item.status === status);
|
||||
return {
|
||||
status,
|
||||
batches: items.length,
|
||||
units: items.reduce((sum, item) => sum + item.quantityOnHand, 0),
|
||||
costValue: items.reduce((sum, item) => sum + item.costExposure, 0),
|
||||
retailValue: items.reduce((sum, item) => sum + item.retailValue, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function freshnessBreakdown(
|
||||
inventory: InventoryView[],
|
||||
group: (item: InventoryView) => { id: string; label: string; secondary: string },
|
||||
): FreshnessBreakdownRow[] {
|
||||
const groups = new Map<string, InventoryView[]>();
|
||||
for (const item of inventory) {
|
||||
const key = group(item);
|
||||
groups.set(key.id, [...(groups.get(key.id) ?? []), item]);
|
||||
}
|
||||
return [...groups.entries()].map(([id, items]) => {
|
||||
const key = group(items[0]);
|
||||
const units = items.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
return {
|
||||
id,
|
||||
label: key.label,
|
||||
secondary: key.secondary,
|
||||
batches: items.length,
|
||||
units,
|
||||
averageScore: items.reduce((sum, item) => sum + item.score, 0) / items.length,
|
||||
weightedScore: units
|
||||
? items.reduce((sum, item) => sum + item.score * item.quantityOnHand, 0) / units
|
||||
: 0,
|
||||
};
|
||||
}).sort((a, b) => a.weightedScore - b.weightedScore);
|
||||
}
|
||||
|
||||
export function getMetricDetails(): MetricDetails {
|
||||
const data = getDashboardData();
|
||||
const currentSales = salesInWindow(0, 30);
|
||||
const priorSales = salesInWindow(30, 60);
|
||||
const currentRevenue = revenue(currentSales);
|
||||
const priorRevenue = revenue(priorSales);
|
||||
const totalUnits = data.inventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
const costValue = data.inventory.reduce((sum, item) => sum + item.costExposure, 0);
|
||||
const attention = data.inventory.filter((item) => item.status !== "fresh");
|
||||
const atRisk = data.inventory.filter(
|
||||
(item) => item.status === "at_risk" || item.status === "expired",
|
||||
);
|
||||
const productForSale = (sale: Sale) => products.find((item) => item.id === sale.productId)!;
|
||||
const branchForSale = (sale: Sale) => branches.find((item) => item.id === sale.branchId)!;
|
||||
|
||||
return {
|
||||
inventoryValue: {
|
||||
retailValue: data.metrics.retailValue,
|
||||
costValue,
|
||||
potentialMargin: data.metrics.retailValue - costValue,
|
||||
units: totalUnits,
|
||||
byBranch: valueBreakdown(data.inventory, (item) => ({
|
||||
id: item.branch.id,
|
||||
label: item.branch.name,
|
||||
secondary: `${item.branch.city}, ${item.branch.region}`,
|
||||
})),
|
||||
byCategory: valueBreakdown(data.inventory, (item) => ({
|
||||
id: item.product.category,
|
||||
label: item.product.category,
|
||||
secondary: "Product category",
|
||||
})),
|
||||
byProduct: valueBreakdown(data.inventory, (item) => ({
|
||||
id: item.product.id,
|
||||
label: item.product.name,
|
||||
secondary: item.product.sku,
|
||||
})),
|
||||
},
|
||||
revenue: {
|
||||
current30d: currentRevenue,
|
||||
prior30d: priorRevenue,
|
||||
changePercent: priorRevenue ? ((currentRevenue - priorRevenue) / priorRevenue) * 100 : 0,
|
||||
units: currentSales.reduce((sum, sale) => sum + sale.quantity, 0),
|
||||
averageDailyRevenue: currentRevenue / 30,
|
||||
averageOrderPrice: currentSales.length ? currentRevenue / currentSales.length : 0,
|
||||
byBranch: revenueBreakdown(currentSales, (sale) => {
|
||||
const branch = branchForSale(sale);
|
||||
return { id: branch.id, label: branch.name, secondary: `${branch.city}, ${branch.region}` };
|
||||
}),
|
||||
byCategory: revenueBreakdown(currentSales, (sale) => {
|
||||
const product = productForSale(sale);
|
||||
return { id: product.category, label: product.category, secondary: "Product category" };
|
||||
}),
|
||||
byProduct: revenueBreakdown(currentSales, (sale) => {
|
||||
const product = productForSale(sale);
|
||||
return { id: product.id, label: product.name, secondary: product.sku };
|
||||
}),
|
||||
},
|
||||
risk: {
|
||||
costValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0),
|
||||
retailValue: atRisk.reduce((sum, item) => sum + item.retailValue, 0),
|
||||
units: atRisk.reduce((sum, item) => sum + item.quantityOnHand, 0),
|
||||
batches: atRisk.length,
|
||||
byStatus: riskBreakdown(attention),
|
||||
affected: atRisk.sort((a, b) => a.score - b.score),
|
||||
},
|
||||
freshness: {
|
||||
averageScore: data.metrics.averageFreshness,
|
||||
weightedScore: totalUnits
|
||||
? data.inventory.reduce((sum, item) => sum + item.score * item.quantityOnHand, 0) / totalUnits
|
||||
: 0,
|
||||
freshUnitsPercent: totalUnits
|
||||
? (data.inventory.filter((item) => item.status === "fresh")
|
||||
.reduce((sum, item) => sum + item.quantityOnHand, 0) / totalUnits) * 100
|
||||
: 0,
|
||||
byStatus: riskBreakdown(data.inventory),
|
||||
byBranch: freshnessBreakdown(data.inventory, (item) => ({
|
||||
id: item.branch.id,
|
||||
label: item.branch.name,
|
||||
secondary: `${item.branch.city}, ${item.branch.region}`,
|
||||
})),
|
||||
byCategory: freshnessBreakdown(data.inventory, (item) => ({
|
||||
id: item.product.category,
|
||||
label: item.product.category,
|
||||
secondary: "Product category",
|
||||
})),
|
||||
byProduct: freshnessBreakdown(data.inventory, (item) => ({
|
||||
id: item.product.id,
|
||||
label: item.product.name,
|
||||
secondary: item.product.sku,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export const inventoryBatches: InventoryBatch[] = [
|
||||
|
||||
const productWeights = [1.35, 1.15, 1.1, 0.9, 1.2, 0.72, 0.62, 0.84];
|
||||
|
||||
export const sales: Sale[] = Array.from({ length: 45 }, (_, day) =>
|
||||
export const sales: Sale[] = Array.from({ length: 65 }, (_, day) =>
|
||||
products.flatMap((product, productIndex) =>
|
||||
branches.map((branch, branchIndex) => {
|
||||
const people = salespeople.filter((person) => person.branchId === branch.id);
|
||||
|
||||
@@ -121,6 +121,83 @@ export interface PerformanceRow {
|
||||
freshness: number;
|
||||
}
|
||||
|
||||
export interface ValueBreakdownRow {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary: string;
|
||||
units: number;
|
||||
retailValue: number;
|
||||
costValue: number;
|
||||
potentialMargin: number;
|
||||
}
|
||||
|
||||
export interface RevenueBreakdownRow {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary: string;
|
||||
revenue: number;
|
||||
units: number;
|
||||
averagePrice: number;
|
||||
}
|
||||
|
||||
export interface RiskBreakdownRow {
|
||||
status: FreshnessStatus;
|
||||
batches: number;
|
||||
units: number;
|
||||
costValue: number;
|
||||
retailValue: number;
|
||||
}
|
||||
|
||||
export interface FreshnessBreakdownRow {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary: string;
|
||||
batches: number;
|
||||
units: number;
|
||||
averageScore: number;
|
||||
weightedScore: number;
|
||||
}
|
||||
|
||||
export interface MetricDetails {
|
||||
inventoryValue: {
|
||||
retailValue: number;
|
||||
costValue: number;
|
||||
potentialMargin: number;
|
||||
units: number;
|
||||
byBranch: ValueBreakdownRow[];
|
||||
byCategory: ValueBreakdownRow[];
|
||||
byProduct: ValueBreakdownRow[];
|
||||
};
|
||||
revenue: {
|
||||
current30d: number;
|
||||
prior30d: number;
|
||||
changePercent: number;
|
||||
units: number;
|
||||
averageDailyRevenue: number;
|
||||
averageOrderPrice: number;
|
||||
byBranch: RevenueBreakdownRow[];
|
||||
byCategory: RevenueBreakdownRow[];
|
||||
byProduct: RevenueBreakdownRow[];
|
||||
};
|
||||
risk: {
|
||||
costValue: number;
|
||||
retailValue: number;
|
||||
units: number;
|
||||
batches: number;
|
||||
byStatus: RiskBreakdownRow[];
|
||||
affected: InventoryView[];
|
||||
};
|
||||
freshness: {
|
||||
averageScore: number;
|
||||
weightedScore: number;
|
||||
freshUnitsPercent: number;
|
||||
byStatus: RiskBreakdownRow[];
|
||||
byBranch: FreshnessBreakdownRow[];
|
||||
byCategory: FreshnessBreakdownRow[];
|
||||
byProduct: FreshnessBreakdownRow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
organization: Organization;
|
||||
generatedAt: string;
|
||||
|
||||
43
tests/analytics.test.ts
Normal file
43
tests/analytics.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getDashboardData, getMetricDetails } from "@/domain/analytics";
|
||||
|
||||
describe("metric drill-down analytics", () => {
|
||||
const dashboard = getDashboardData();
|
||||
const details = getMetricDetails();
|
||||
|
||||
it("reconciles inventory value with batch totals", () => {
|
||||
expect(details.inventoryValue.retailValue).toBe(dashboard.metrics.retailValue);
|
||||
expect(details.inventoryValue.costValue).toBe(
|
||||
dashboard.inventory.reduce((sum, item) => sum + item.costExposure, 0),
|
||||
);
|
||||
expect(details.inventoryValue.potentialMargin).toBe(
|
||||
details.inventoryValue.retailValue - details.inventoryValue.costValue,
|
||||
);
|
||||
expect(details.inventoryValue.byBranch).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("uses complete current and prior revenue periods", () => {
|
||||
expect(details.revenue.current30d).toBe(dashboard.metrics.revenue30d);
|
||||
expect(details.revenue.current30d).toBeGreaterThan(0);
|
||||
expect(details.revenue.prior30d).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(details.revenue.changePercent)).toBe(true);
|
||||
expect(details.revenue.byProduct).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("limits headline risk exposure to at-risk and expired batches", () => {
|
||||
expect(details.risk.affected.every(
|
||||
(item) => item.status === "at_risk" || item.status === "expired",
|
||||
)).toBe(true);
|
||||
expect(details.risk.costValue).toBe(dashboard.metrics.atRiskValue);
|
||||
expect(details.risk.byStatus.some((row) => row.status === "watch")).toBe(true);
|
||||
});
|
||||
|
||||
it("provides bounded freshness rollups", () => {
|
||||
expect(details.freshness.averageScore).toBe(dashboard.metrics.averageFreshness);
|
||||
expect(details.freshness.weightedScore).toBeGreaterThanOrEqual(0);
|
||||
expect(details.freshness.weightedScore).toBeLessThanOrEqual(100);
|
||||
expect(details.freshness.freshUnitsPercent).toBeGreaterThanOrEqual(0);
|
||||
expect(details.freshness.freshUnitsPercent).toBeLessThanOrEqual(100);
|
||||
expect(details.freshness.byProduct).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,22 @@ test("shows inventory intelligence overview", async ({ page }) => {
|
||||
await expect(page.getByText("Freshness watchlist")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens every overview metric drill-down", async ({ page }) => {
|
||||
const metrics = [
|
||||
{ label: "Inventory value", path: "/metrics/inventory-value", heading: "Inventory value" },
|
||||
{ label: "Revenue · 30 days", path: "/metrics/revenue", heading: "Revenue · 30 days" },
|
||||
{ label: "At-risk exposure", path: "/metrics/at-risk", heading: "At-risk exposure" },
|
||||
{ label: "Average freshness", path: "/metrics/freshness", heading: "Average freshness" },
|
||||
];
|
||||
|
||||
for (const metric of metrics) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("link", { name: `View details for ${metric.label}` }).click();
|
||||
await expect(page).toHaveURL(metric.path);
|
||||
await expect(page.getByRole("heading", { name: metric.heading })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("moves from inventory to a product", async ({ page }) => {
|
||||
await page.goto("/inventory");
|
||||
await expect(page.getByRole("heading", { name: "Inventory" })).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user