diff --git a/src/app/branches/page.tsx b/src/app/branches/page.tsx
index b043c9c..7f049ad 100644
--- a/src/app/branches/page.tsx
+++ b/src/app/branches/page.tsx
@@ -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 (
Export report} />
+ actions={ [
+ branch.name,
+ branch.secondary,
+ branch.revenue.toFixed(2),
+ branch.units,
+ branch.sellThrough.toFixed(1),
+ branch.freshness.toFixed(1),
+ ]),
+ ]}
+ />} />
- } />
- b.sellThrough)))} detail="Across the active catalog" icon={ } />
- s + b.revenue, 0), true)} detail={`${branches.length} reporting locations`} icon={ } />
+ } />
+ b.sellThrough)) : 0)} detail="Across the active catalog" icon={ } />
+ s + b.revenue, 0), true)} detail={`${visibleBranches.length} reporting locations`} icon={ } />
-
+ {query && Showing branch results for “{query}”.
}
+
);
}
diff --git a/src/app/inventory/page.tsx b/src/app/inventory/page.tsx
index 3371bbb..b89672c 100644
--- a/src/app/inventory/page.tsx
+++ b/src/app/inventory/page.tsx
@@ -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 (
Export CSV} />
+ description="Track every active batch by location, age, value, and estimated remaining shelf life." />
{[
@@ -32,41 +26,7 @@ export default function InventoryPage() {
))}
-
-
-
-
-
-
- Filter by branch
- All categories
-
-
-
-
- Product / batch Location
- Received Freshness
- On hand Value Health
-
-
- {ordered.map((item) => (
-
-
- {item.product.name}
- {item.product.sku} · {item.id.toUpperCase()}
-
- {item.branch.name}
{item.branch.city}
- {new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}{item.ageDays} days ago
- {item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}
- {item.quantityOnHand} {item.product.unit}s
- {currency(item.retailValue)}
-
-
- ))}
-
-
-
-
+
Freshness is estimated from received date and configured shelf life; it is not a sensor reading.
);
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3876e09..8e26240 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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 (
- {children}
+ {children}
);
diff --git a/src/app/metrics/at-risk/page.tsx b/src/app/metrics/at-risk/page.tsx
new file mode 100644
index 0000000..2f80ae8
--- /dev/null
+++ b/src/app/metrics/at-risk/page.tsx
@@ -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 (
+
+
+
[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)]),
+ ]} />} />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
({ label: freshnessLabels[row.status], value: row.costValue }))} valueLabel="Cost exposure" />
+
+
+
+
+
+ Product Branch Freshness Cost exposure Status
+
+ {risk.affected.map((item) => (
+
+ {item.product.name}{item.product.sku} · {item.id.toUpperCase()}
+ {item.branch.name}
+
+ {currency(item.costExposure)}
+
+
+ ))}
+
+
+
+
+ Exposure is advisory and based on received-date freshness estimates. Confirm physical quality before acting.
+
+ );
+}
diff --git a/src/app/metrics/freshness/page.tsx b/src/app/metrics/freshness/page.tsx
new file mode 100644
index 0000000..e96c4e5
--- /dev/null
+++ b/src/app/metrics/freshness/page.tsx
@@ -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 (
+
+
+
[row.label, row.secondary, row.batches, row.units, row.averageScore.toFixed(1), row.weightedScore.toFixed(1)]),
+ ]} />} />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
({ label: freshnessLabels[row.status], value: row.units }))} valueLabel="Cases" format="number" />
+
({ ...row, value: row.weightedScore }))} metricLabel="Freshness" formatValue={percent} />
+
+
+ ({ ...row, value: row.weightedScore }))} metricLabel="Freshness" formatValue={percent} />
+ Freshness is estimated from received date and configured shelf life; it is not a sensor or inspection reading.
+
+ );
+}
diff --git a/src/app/metrics/inventory-value/page.tsx b/src/app/metrics/inventory-value/page.tsx
new file mode 100644
index 0000000..8bed92f
--- /dev/null
+++ b/src/app/metrics/inventory-value/page.tsx
@@ -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 (
+
+
+
[row.label, row.secondary, row.units, row.retailValue.toFixed(2), row.costValue.toFixed(2), row.potentialMargin.toFixed(2)]),
+ ]} />} />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
({ label: row.label, value: row.retailValue }))} valueLabel="Retail value" />
+
({ ...row, value: row.retailValue }))} metricLabel="Retail value" formatValue={currency} />
+
+
+ ({ ...row, value: row.retailValue }))} metricLabel="Retail value" formatValue={currency} />
+
+ );
+}
diff --git a/src/app/metrics/revenue/page.tsx b/src/app/metrics/revenue/page.tsx
new file mode 100644
index 0000000..caa8f76
--- /dev/null
+++ b/src/app/metrics/revenue/page.tsx
@@ -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 (
+
+
+
[row.label, row.secondary, row.revenue.toFixed(2), row.units, row.averagePrice.toFixed(2)]),
+ ]} />} />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+ ({ ...row, value: row.revenue }))} metricLabel="Revenue" formatValue={currency} />
+ ({ ...row, value: row.revenue }))} metricLabel="Revenue" formatValue={currency} />
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 138145c..898aadc 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -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() {
/>
- } tone="emerald" />
- } tone="blue" />
- } tone="amber" invertChange />
- } tone="violet" />
@@ -139,27 +140,3 @@ export default function Home() {
);
}
-
-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 (
-
-
-
{icon}
-
- {change > 0 ? : }{Math.abs(change)}%
-
-
- {label}
- {value}
- {detail}
-
- );
-}
diff --git a/src/app/sales-team/page.tsx b/src/app/sales-team/page.tsx
index 45580cd..64a139d 100644
--- a/src/app/sales-team/page.tsx
+++ b/src/app/sales-team/page.tsx
@@ -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 (
- } label="Sales leader" value={salespeople[0].name} detail={currency(salespeople[0].revenue)} />
+ } label="Sales leader" value={visiblePeople[0]?.name ?? "No match"} detail={visiblePeople[0] ? currency(visiblePeople[0].revenue) : "Try another search"} />
} label="Team revenue" value={currency(totalRevenue, true)} detail="Last 30 days" />
- } label="Active sellers" value={number(salespeople.length)} detail="Across four branches" />
+ } label="Active sellers" value={number(visiblePeople.length)} detail="Across four branches" />
-
+ {query &&
Showing salesperson results for “{query}”.
}
+
Salesforce mapping preview
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.
diff --git a/src/components/charts.tsx b/src/components/charts.tsx
index 17759fe..a0cad2c 100644
--- a/src/components/charts.tsx
+++ b/src/components/charts.tsx
@@ -3,6 +3,9 @@
import {
Area,
AreaChart,
+ Bar,
+ BarChart,
+ Cell,
CartesianGrid,
ResponsiveContainer,
Tooltip,
@@ -59,3 +62,38 @@ export function RevenueChart({ data }: { data: TrendPoint[] }) {
);
}
+
+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 (
+
+
+
+
+
+
+ [formatValue(Number(value)), valueLabel]}
+ contentStyle={{ borderRadius: 12, border: "1px solid #e2e8f0", fontSize: 12 }}
+ />
+
+ {data.map((item, index) => | )}
+
+
+
+
+ );
+}
diff --git a/src/components/csv-export-button.tsx b/src/components/csv-export-button.tsx
new file mode 100644
index 0000000..c274f88
--- /dev/null
+++ b/src/components/csv-export-button.tsx
@@ -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>;
+ 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 (
+
+ {label}
+
+ );
+}
diff --git a/src/components/dashboard-shell.tsx b/src/components/dashboard-shell.tsx
index 7d9faa4..f20c9ca 100644
--- a/src/components/dashboard-shell.tsx
+++ b/src/components/dashboard-shell.tsx
@@ -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 }) {
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() && (
+
+ {matches.length ? (
+
+ {matches.map((item) => (
+ {
+ 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"
+ >
+
+ {item.label}
+ {item.detail}
+
+
+ {item.type}
+
+
+ ))}
+
+ ) : (
+
+ No products, branches, or people match “{query}”.
+
+ )}
+
+ )}
-
+
+
setProfileOpen((open) => !open)}
+ className="flex items-center gap-2 rounded-xl p-1.5 pr-2 hover:bg-slate-50"
+ >
NC
@@ -131,6 +195,20 @@ export function DashboardShell({ children }: { children: ReactNode }) {
+ {profileOpen && (
+
+
Demo account
+
setProfileOpen(false)} className="block rounded-lg px-3 py-2 text-xs font-semibold hover:bg-slate-50">
+ Workspace settings
+
+
+
+ )}
+
diff --git a/src/components/integration-settings.tsx b/src/components/integration-settings.tsx
index 3f8d176..dc45987 100644
--- a/src/components/integration-settings.tsx
+++ b/src/components/integration-settings.tsx
@@ -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({ provider: "odoo", loading: false });
+ const [configuring, setConfiguring] = useState(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={ }
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={ }
status="Demo source connected"
+ onConfigure={() => setConfiguring("salesforce")}
onSync={() => sync("salesforce")}
loading={state.loading && state.provider === "salesforce"}
/>
@@ -44,12 +47,37 @@ export function IntegrationSettings() {
{state.message}
)}
+ {configuring && (
+
+
+
+
Integration settings
{configuring === "odoo" ? "Odoo Inventory" : "Salesforce CRM"}
+
setConfiguring(null)} className="grid size-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-100">
+
+
Data source
+
+ Deterministic demo data
+ Real account — credentials required
+
+
+
+ Sync schedule
+ Tenant mapping
+
+
This pilot uses normalized fake records. Real credentials remain server-only when connected later.
+
+ setConfiguring(null)} className={secondaryButtonStyles}>Cancel
+ { setState({ provider: configuring, loading: false, message: `${configuring === "odoo" ? "Odoo" : "Salesforce"} demo configuration saved.` }); setConfiguring(null); }} className={buttonStyles}>Save configuration
+
+
+
+ )}
);
}
-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 (
@@ -57,7 +85,7 @@ function IntegrationCard({ name, description, icon, status, onSync, loading }: {
{icon}
{name} {description}
{status}
-
Configure
+
Configure
{loading ? : }
{loading ? "Syncing…" : "Sync now"}
diff --git a/src/components/inventory-controls.tsx b/src/components/inventory-controls.tsx
new file mode 100644
index 0000000..48bdf77
--- /dev/null
+++ b/src/components/inventory-controls.tsx
@@ -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 (
+
+
+
+
+ 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"
+ />
+
+ 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"
+ >
+ All branches
+ {branches.map((item) => {item.name} · {item.city} )}
+
+ 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"
+ >
+ All categories
+ {categories.map((item) => {item} )}
+
+ {hasFilters && (
+ { setQuery(""); setBranch("all"); setCategory("all"); }}
+ className={secondaryButtonStyles}
+ >
+ Clear
+
+ )}
+
+ Export {filtered.length} rows
+
+
+
+ Showing {filtered.length} of {inventory.length} batches
+
+
+
+
+ Product / batch Location
+ Received Freshness
+ On hand Value Health
+
+
+ {filtered.map((item) => (
+
+
+ {item.product.name}
+ {item.product.sku} · {item.id.toUpperCase()}
+
+ {item.branch.name}
{item.branch.city}
+ {new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}{item.ageDays} days ago
+ {item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}
+ {item.quantityOnHand} {item.product.unit}s
+ {currency(item.retailValue)}
+
+
+ ))}
+
+
+ {!filtered.length && (
+
No inventory matches these filters
Try another product, branch, city, or category.
+ )}
+
+
+ );
+}
diff --git a/src/components/metric-card.tsx b/src/components/metric-card.tsx
new file mode 100644
index 0000000..643e7b6
--- /dev/null
+++ b/src/components/metric-card.tsx
@@ -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 (
+
+
+
+
{icon}
+
+ {change > 0 ? : }
+ {Math.abs(change)}%
+
+
+ {label}
+ {value}
+
+
{detail}
+
+ View details
+
+
+
+
+ );
+}
diff --git a/src/components/metric-detail.tsx b/src/components/metric-detail.tsx
new file mode 100644
index 0000000..e1e39dd
--- /dev/null
+++ b/src/components/metric-detail.tsx
@@ -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 (
+
+ Back to overview
+
+ );
+}
+
+export function DetailStat({
+ label,
+ value,
+ detail,
+ icon,
+}: {
+ label: string;
+ value: string;
+ detail: string;
+ icon: ReactNode;
+}) {
+ return (
+
+ {icon}
+ {label}
+ {value}
+ {detail}
+
+ );
+}
+
+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 (
+
+
+
+ Rank Name
+ {metricLabel} Units
+
+
+ {rows.map((row, index) => (
+
+ {index + 1}
+ {row.label}
{row.secondary}
+ {formatValue(row.value)}
+ {Math.round(row.units).toLocaleString()}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/recommendation-list.tsx b/src/components/recommendation-list.tsx
index d6d6c9d..d3691db 100644
--- a/src/components/recommendation-list.tsx
+++ b/src/components/recommendation-list.tsx
@@ -14,6 +14,7 @@ export function RecommendationList({
inventory: InventoryView[];
}) {
const [items, setItems] = useState(initial);
+ const [expandedId, setExpandedId] = useState(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({
)}
-
- Show calculation details
+ {expandedId === rec.id && (
+
+
Freshness pressure
{stock.score}% remaining estimate
+
Stock pressure
{stock.quantityOnHand} cases available
+
Cost guardrail
{currency(stock.product.cost)} cost · {stock.product.marginFloorPercent}% floor
+
+
+ )}
+ 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"}
+
);
diff --git a/src/domain/analytics.ts b/src/domain/analytics.ts
index 350ead1..aadd153 100644
--- a/src/domain/analytics.ts
+++ b/src/domain/analytics.ts
@@ -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();
+ 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();
+ 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();
+ 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,
+ })),
},
};
}
diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts
index a7b37aa..c8b7dcd 100644
--- a/src/lib/demo-data.ts
+++ b/src/lib/demo-data.ts
@@ -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);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 771c3b2..ddf4102 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -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;
diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts
new file mode 100644
index 0000000..f631a94
--- /dev/null
+++ b/tests/analytics.test.ts
@@ -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);
+ });
+});
diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts
index e125d03..d25bcce 100644
--- a/tests/e2e/dashboard.spec.ts
+++ b/tests/e2e/dashboard.spec.ts
@@ -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();