Add interactive analytics drill-downs
Some checks are pending
CI / verify (push) Waiting to run

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:
2026-07-25 13:14:22 -07:00
parent 135be480e5
commit 49f2c28a46
22 changed files with 1090 additions and 104 deletions

View File

@@ -1,25 +1,51 @@
import { Building2, Download, TrendingUp } from "lucide-react"; import { Building2, TrendingUp } from "lucide-react";
import { getDashboardData } from "@/domain/analytics"; import { getDashboardData } from "@/domain/analytics";
import { currency, percent } from "@/lib/format"; 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 { PerformanceTable } from "@/components/performance-table";
import { CsvExportButton } from "@/components/csv-export-button";
export const metadata = { title: "Branch Performance" }; export const metadata = { title: "Branch Performance" };
export default function BranchesPage() { export default async function BranchesPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { branches } = getDashboardData(); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader eyebrow="Performance" title="Branches" <PageHeader eyebrow="Performance" title="Branches"
description="Compare revenue, sell-through, and inventory health across every location." 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"> <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="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(Math.max(...branches.map((b) => b.sellThrough)))} detail="Across the active catalog" icon={<TrendingUp 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(branches.reduce((s, b) => s + b.revenue, 0), true)} detail={`${branches.length} reporting locations`} icon={<Building2 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> </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> </div>
); );
} }

View File

@@ -1,22 +1,16 @@
import Link from "next/link";
import { Download, Filter, Search } from "lucide-react";
import { getDashboardData } from "@/domain/analytics"; import { getDashboardData } from "@/domain/analytics";
import { currency } from "@/lib/format"; import { Card, PageHeader } from "@/components/ui";
import { import { InventoryControls } from "@/components/inventory-controls";
Card, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
} from "@/components/ui";
export const metadata = { title: "Inventory" }; export const metadata = { title: "Inventory" };
export default function InventoryPage() { export default function InventoryPage() {
const { inventory } = getDashboardData(); const { inventory } = getDashboardData();
const ordered = [...inventory].sort((a, b) => a.score - b.score);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader eyebrow="Stock health" title="Inventory" <PageHeader eyebrow="Stock health" title="Inventory"
description="Track every active batch by location, age, value, and estimated remaining shelf life." 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>} />
<div className="grid gap-3 sm:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-4">
{[ {[
@@ -32,41 +26,7 @@ export default function InventoryPage() {
))} ))}
</div> </div>
<Card className="overflow-hidden"> <InventoryControls inventory={inventory} />
<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>
<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> <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> </div>
); );

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { DashboardShell } from "@/components/dashboard-shell"; import { DashboardShell } from "@/components/dashboard-shell";
import { branches, products, salespeople } from "@/lib/demo-data";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -26,13 +27,43 @@ export default function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; 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 ( return (
<html <html
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full"> <body className="min-h-full">
<DashboardShell>{children}</DashboardShell> <DashboardShell searchItems={searchItems}>{children}</DashboardShell>
</body> </body>
</html> </html>
); );

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowDownRight, ArrowRight, ArrowUpRight, Boxes, CircleDollarSign, ArrowRight, Boxes, CircleDollarSign,
Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert, Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { getDashboardData } from "@/domain/analytics"; import { getDashboardData } from "@/domain/analytics";
@@ -9,6 +9,7 @@ import {
Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles, Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
} from "@/components/ui"; } from "@/components/ui";
import { RevenueChart } from "@/components/charts"; import { RevenueChart } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
export default function Home() { export default function Home() {
const data = getDashboardData(); const data = getDashboardData();
@@ -41,16 +42,16 @@ export default function Home() {
/> />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <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} detail={`${number(data.metrics.unitsOnHand)} cases on hand`} change={data.metrics.change.unitsOnHand}
icon={<Boxes className="size-5" />} tone="emerald" /> 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} detail={`${percent(data.metrics.sellThrough30d)} sell-through`} change={data.metrics.change.revenue}
icon={<ShoppingCart className="size-5" />} tone="blue" /> 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} detail={`${riskItems.length} batches need attention`} change={data.metrics.change.atRisk}
icon={<TriangleAlert className="size-5" />} tone="amber" invertChange /> 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} detail="Across active inventory" change={3.1}
icon={<Leaf className="size-5" />} tone="violet" /> icon={<Leaf className="size-5" />} tone="violet" />
</div> </div>
@@ -139,27 +140,3 @@ export default function Home() {
</div> </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>
);
}

View File

@@ -6,19 +6,30 @@ import { PerformanceTable } from "@/components/performance-table";
export const metadata = { title: "Sales Team" }; export const metadata = { title: "Sales Team" };
export default function SalesTeamPage() { export default async function SalesTeamPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { salespeople } = getDashboardData(); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader eyebrow="Performance" title="Sales team" <PageHeader eyebrow="Performance" title="Sales team"
description="See who is moving the most produce and whether those sales are improving stock health." /> 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"> <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={<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> </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"> <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="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 Solaires normalized model. A future Salesforce adapter can map Users, Accounts, Orders, Opportunities, and custom branch fields without changing these analytics.</p> <p className="mt-1 text-xs leading-5 text-blue-700">Demo sellers and orders use Solaires normalized model. A future Salesforce adapter can map Users, Accounts, Orders, Opportunities, and custom branch fields without changing these analytics.</p>

View File

@@ -3,6 +3,9 @@
import { import {
Area, Area,
AreaChart, AreaChart,
Bar,
BarChart,
Cell,
CartesianGrid, CartesianGrid,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
@@ -59,3 +62,38 @@ export function RevenueChart({ data }: { data: TrendPoint[] }) {
</div> </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>
);
}

View 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>
);
}

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
Bell, Bell,
Boxes, Boxes,
@@ -15,7 +15,7 @@ import {
Store, Store,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const navigation = [ const navigation = [
@@ -27,8 +27,31 @@ const navigation = [
{ href: "/alerts", label: "Alerts", icon: Bell }, { 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 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") { if (pathname === "/login") {
return <>{children}</>; return <>{children}</>;
@@ -109,8 +132,43 @@ export function DashboardShell({ children }: { children: ReactNode }) {
<input <input
aria-label="Search products, branches, or people" aria-label="Search products, branches, or people"
placeholder="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" 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>
<div className="ml-auto flex items-center gap-2 sm:gap-4"> <div className="ml-auto flex items-center gap-2 sm:gap-4">
<Link <Link
@@ -121,7 +179,13 @@ export function DashboardShell({ children }: { children: ReactNode }) {
<Bell className="size-5" /> <Bell className="size-5" />
<span className="absolute right-2 top-2 size-2 rounded-full border-2 border-white bg-amber-500" /> <span className="absolute right-2 top-2 size-2 rounded-full border-2 border-white bg-amber-500" />
</Link> </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"> <span className="grid size-8 place-items-center rounded-lg bg-slate-900 text-xs font-bold text-white">
NC NC
</span> </span>
@@ -131,6 +195,20 @@ export function DashboardShell({ children }: { children: ReactNode }) {
</span> </span>
<ChevronDown className="hidden size-4 text-slate-400 sm:block" /> <ChevronDown className="hidden size-4 text-slate-400 sm:block" />
</button> </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> </div>
</header> </header>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; 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 { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
import type { IntegrationProvider } from "@/lib/types"; import type { IntegrationProvider } from "@/lib/types";
@@ -9,6 +9,7 @@ type State = { provider: IntegrationProvider; loading: boolean; message?: string
export function IntegrationSettings() { export function IntegrationSettings() {
const [state, setState] = useState<State>({ provider: "odoo", loading: false }); const [state, setState] = useState<State>({ provider: "odoo", loading: false });
const [configuring, setConfiguring] = useState<IntegrationProvider | null>(null);
async function sync(provider: IntegrationProvider) { async function sync(provider: IntegrationProvider) {
setState({ provider, loading: true }); setState({ provider, loading: true });
@@ -28,6 +29,7 @@ export function IntegrationSettings() {
description="Products, locations, received inventory, and stock movements" description="Products, locations, received inventory, and stock movements"
icon={<Database className="size-6" />} icon={<Database className="size-6" />}
status="Demo source connected" status="Demo source connected"
onConfigure={() => setConfiguring("odoo")}
onSync={() => sync("odoo")} onSync={() => sync("odoo")}
loading={state.loading && state.provider === "odoo"} loading={state.loading && state.provider === "odoo"}
/> />
@@ -36,6 +38,7 @@ export function IntegrationSettings() {
description="Branches, salespeople, orders, and sales attribution" description="Branches, salespeople, orders, and sales attribution"
icon={<Users className="size-6" />} icon={<Users className="size-6" />}
status="Demo source connected" status="Demo source connected"
onConfigure={() => setConfiguring("salesforce")}
onSync={() => sync("salesforce")} onSync={() => sync("salesforce")}
loading={state.loading && state.provider === "salesforce"} loading={state.loading && state.provider === "salesforce"}
/> />
@@ -44,12 +47,37 @@ export function IntegrationSettings() {
<CheckCircle2 className="size-4" />{state.message} <CheckCircle2 className="size-4" />{state.message}
</div> </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> </div>
); );
} }
function IntegrationCard({ name, description, icon, status, onSync, loading }: { function IntegrationCard({ name, description, icon, status, onConfigure, onSync, loading }: {
name: string; description: string; icon: React.ReactNode; status: string; onSync: () => void; loading: boolean; name: string; description: string; icon: React.ReactNode; status: string; onConfigure: () => void; onSync: () => void; loading: boolean;
}) { }) {
return ( return (
<Card className="p-5"> <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> <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-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"> <div className="flex gap-2">
<button className={secondaryButtonStyles}>Configure</button> <button onClick={onConfigure} className={secondaryButtonStyles}>Configure</button>
<button onClick={onSync} disabled={loading} className={buttonStyles}> <button onClick={onSync} disabled={loading} className={buttonStyles}>
{loading ? <RotateCw className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />} {loading ? <RotateCw className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
{loading ? "Syncing…" : "Sync now"} {loading ? "Syncing…" : "Sync now"}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -14,6 +14,7 @@ export function RecommendationList({
inventory: InventoryView[]; inventory: InventoryView[];
}) { }) {
const [items, setItems] = useState(initial); const [items, setItems] = useState(initial);
const [expandedId, setExpandedId] = useState<string | null>(null);
const update = (id: string, status: Recommendation["status"]) => const update = (id: string, status: Recommendation["status"]) =>
setItems((current) => current.map((item) => item.id === id ? { ...item, status } : item)); setItems((current) => current.map((item) => item.id === id ? { ...item, status } : item));
@@ -55,8 +56,21 @@ export function RecommendationList({
)} )}
</div> </div>
</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"> {expandedId === rec.id && (
Show calculation details <ChevronDown className="size-3" /> <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> </button>
</Card> </Card>
); );

View File

@@ -1,4 +1,4 @@
import { format, isAfter, startOfDay, subDays } from "date-fns"; import { format, startOfDay, subDays } from "date-fns";
import { import {
branches, branches,
inventoryBatches, inventoryBatches,
@@ -11,19 +11,39 @@ import { calculateFreshness } from "@/domain/freshness";
import { recommendPrice } from "@/domain/recommendations"; import { recommendPrice } from "@/domain/recommendations";
import type { import type {
DashboardData, DashboardData,
FreshnessBreakdownRow,
FreshnessStatus,
InventoryView, InventoryView,
MetricDetails,
PerformanceRow, PerformanceRow,
RevenueBreakdownRow,
RiskBreakdownRow,
Sale, Sale,
ValueBreakdownRow,
} from "@/lib/types"; } from "@/lib/types";
const recentSales = (days: number) => { const recentSales = (days: number) => {
const cutoff = startOfDay(subDays(new Date(), days)); const now = new Date();
return sales.filter((sale) => isAfter(new Date(sale.soldAt), cutoff)); const cutoff = subDays(now, days);
return sales.filter((sale) => {
const date = new Date(sale.soldAt);
return date > cutoff && date <= now;
});
}; };
const revenue = (items: Sale[]) => const revenue = (items: Sale[]) =>
items.reduce((sum, sale) => sum + sale.quantity * sale.unitPrice, 0); 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 = ( const performance = (
kind: "branch" | "salesperson", kind: "branch" | "salesperson",
inventory: InventoryView[], inventory: InventoryView[],
@@ -80,6 +100,9 @@ export function getDashboardData(): DashboardData {
}); });
const sales30 = recentSales(30); 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 unitsOnHand = inventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
const sold30 = sales30.reduce((sum, sale) => sum + sale.quantity, 0); const sold30 = sales30.reduce((sum, sale) => sum + sale.quantity, 0);
const atRisk = inventory.filter( const atRisk = inventory.filter(
@@ -130,11 +153,193 @@ export function getDashboardData(): DashboardData {
unitsOnHand, unitsOnHand,
retailValue: inventory.reduce((sum, item) => sum + item.retailValue, 0), retailValue: inventory.reduce((sum, item) => sum + item.retailValue, 0),
atRiskValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0), atRiskValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0),
revenue30d: revenue(sales30), revenue30d: currentRevenue,
sellThrough30d: (sold30 / (sold30 + unitsOnHand)) * 100, sellThrough30d: (sold30 / (sold30 + unitsOnHand)) * 100,
averageFreshness: averageFreshness:
inventory.reduce((sum, item) => sum + item.score, 0) / inventory.length, 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,
})),
}, },
}; };
} }

View File

@@ -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]; 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) => products.flatMap((product, productIndex) =>
branches.map((branch, branchIndex) => { branches.map((branch, branchIndex) => {
const people = salespeople.filter((person) => person.branchId === branch.id); const people = salespeople.filter((person) => person.branchId === branch.id);

View File

@@ -121,6 +121,83 @@ export interface PerformanceRow {
freshness: number; 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 { export interface DashboardData {
organization: Organization; organization: Organization;
generatedAt: string; generatedAt: string;

43
tests/analytics.test.ts Normal file
View 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);
});
});

View File

@@ -7,6 +7,22 @@ test("shows inventory intelligence overview", async ({ page }) => {
await expect(page.getByText("Freshness watchlist")).toBeVisible(); 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 }) => { test("moves from inventory to a product", async ({ page }) => {
await page.goto("/inventory"); await page.goto("/inventory");
await expect(page.getByRole("heading", { name: "Inventory" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Inventory" })).toBeVisible();