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() { ))}
- -
- - - -
-
- - - - - - - - {ordered.map((item) => ( - - - - - - - - - - ))} - -
Product / batchLocationReceivedFreshnessOn handValueHealth
- {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" />
+ + +
+ + + + + {risk.affected.map((item) => ( + + + + + + + + ))} +
ProductBranchFreshnessCost exposureStatus
{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 ( + + ); +} 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) => ( + + ))} +
+ ) : ( +

+ No products, branches, or people match “{query}”. +

+ )} +
+ )}
- + {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"}

+ +
+ +
+ + +
+

This pilot uses normalized fake records. Real credentials remain server-only when connected later.

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

- + + )} + +
+
+ Showing {filtered.length} of {inventory.length} batches +
+
+ + + + + + + + {filtered.map((item) => ( + + + + + + + + + + ))} + +
Product / batchLocationReceivedFreshnessOn handValueHealth
+ {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 ( +
+ + + + + + + {rows.map((row, index) => ( + + + + + + + ))} + +
RankName{metricLabel}Units
{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({ )} - ); 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();