Build the Solaire inventory intelligence MVP
Some checks failed
CI / verify (push) Has been cancelled

Add the tenant-ready dashboard, freshness and pricing analytics, mock integration adapters, persistence schema, and verification tooling needed for the initial pilot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 12:16:52 -07:00
parent bf336f8d15
commit 135be480e5
58 changed files with 7884 additions and 265 deletions

49
src/app/alerts/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
import Link from "next/link";
import { ArrowRight, CircleAlert, Clock, PackageX, TriangleAlert } from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { Card, PageHeader } from "@/components/ui";
import { currency } from "@/lib/format";
export const metadata = { title: "Alerts" };
export default function AlertsPage() {
const data = getDashboardData();
const alerts = data.inventory.filter((item) => item.status !== "fresh").sort((a, b) => a.score - b.score);
return (
<div className="space-y-6">
<PageHeader eyebrow="Attention queue" title="Alerts"
description="Prioritized freshness and stock-health issues with direct links to affected inventory." />
<div className="grid gap-4 sm:grid-cols-3">
<AlertMetric label="Critical" value={alerts.filter((a) => a.status === "expired").length} tone="rose" />
<AlertMetric label="At risk" value={alerts.filter((a) => a.status === "at_risk").length} tone="orange" />
<AlertMetric label="Watch" value={alerts.filter((a) => a.status === "watch").length} tone="amber" />
</div>
<Card className="overflow-hidden">
<div className="divide-y divide-slate-100">
{alerts.map((item) => {
const critical = item.status === "expired";
return (
<div key={item.id} className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center">
<span className={`grid size-11 shrink-0 place-items-center rounded-xl ${critical ? "bg-rose-50 text-rose-700" : "bg-amber-50 text-amber-700"}`}>
{critical ? <PackageX className="size-5" /> : item.status === "at_risk" ? <TriangleAlert className="size-5" /> : <Clock className="size-5" />}
</span>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2"><p className="text-sm font-bold">{critical ? "Shelf-life estimate exceeded" : "Freshness threshold reached"}</p><span className="text-[10px] font-bold uppercase text-slate-400">{item.status.replace("_", " ")}</span></div>
<p className="mt-1 text-xs leading-5 text-slate-500">{item.product.name} at {item.branch.name} has {item.quantityOnHand} cases on hand ({currency(item.costExposure)} cost exposure).</p>
<p className="mt-1 text-[11px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} estimated days remain` : `${Math.abs(item.remainingDays)} days beyond estimated shelf life`} · Batch {item.id.toUpperCase()}</p>
</div>
<Link href={`/products/${item.productId}`} className="inline-flex items-center gap-1 text-xs font-bold text-emerald-700">Review batch <ArrowRight className="size-3.5" /></Link>
</div>
);
})}
</div>
</Card>
<div className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500"><CircleAlert className="mt-0.5 size-4 shrink-0 text-slate-400" />Alerts use configured shelf life and received dates. Confirm physical quality before disposal, discounting, or transfer.</div>
</div>
);
}
function AlertMetric({ label, value, tone }: { label: string; value: number; tone: "rose" | "orange" | "amber" }) {
const tones = { rose: "text-rose-700 bg-rose-50", orange: "text-orange-700 bg-orange-50", amber: "text-amber-700 bg-amber-50" };
return <Card className="flex items-center justify-between p-5"><div><p className="text-xs font-semibold text-slate-400">{label}</p><p className="mt-1 text-2xl font-bold">{value}</p></div><span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}><CircleAlert className="size-5" /></span></Card>;
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createSessionToken, SESSION_COOKIE } from "@/lib/auth";
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export async function POST(request: Request) {
const parsed = credentialsSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ message: "Enter a valid email and password" }, { status: 400 });
}
const validEmail = process.env.DEMO_USER_EMAIL ?? "demo@solaire.app";
const validPassword = process.env.DEMO_USER_PASSWORD ?? "solaire-demo";
if (parsed.data.email !== validEmail || parsed.data.password !== validPassword) {
return NextResponse.json({ message: "Incorrect demo credentials" }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
response.cookies.set(SESSION_COOKIE, await createSessionToken(parsed.data.email), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 8,
});
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "@/lib/auth";
export async function POST(request: Request) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete(SESSION_COOKIE);
return response;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { getDashboardData } from "@/domain/analytics";
export async function GET() {
return NextResponse.json(getDashboardData(), {
headers: { "Cache-Control": "private, max-age=60" },
});
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { runDemoSync } from "@/integrations/sync";
const requestSchema = z.object({
provider: z.enum(["odoo", "salesforce"]),
});
export async function POST(request: Request) {
const parsed = requestSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ message: "Invalid integration provider" }, { status: 400 });
}
const result = await runDemoSync(parsed.data.provider);
return NextResponse.json(result, { status: result.status === "succeeded" ? 200 : 500 });
}

29
src/app/branches/page.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Building2, Download, TrendingUp } from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { currency, percent } from "@/lib/format";
import { Card, PageHeader, secondaryButtonStyles } from "@/components/ui";
import { PerformanceTable } from "@/components/performance-table";
export const metadata = { title: "Branch Performance" };
export default function BranchesPage() {
const { branches } = getDashboardData();
const top = branches[0];
return (
<div className="space-y-6">
<PageHeader eyebrow="Performance" title="Branches"
description="Compare revenue, sell-through, and inventory health across every location."
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export report</button>} />
<div className="grid gap-4 md:grid-cols-3">
<Summary label="Top branch" value={top.name} detail={`${currency(top.revenue)} this month`} icon={<Building2 className="size-5" />} />
<Summary label="Best sell-through" value={percent(Math.max(...branches.map((b) => b.sellThrough)))} detail="Across the active catalog" icon={<TrendingUp className="size-5" />} />
<Summary label="Network revenue" value={currency(branches.reduce((s, b) => s + b.revenue, 0), true)} detail={`${branches.length} reporting locations`} icon={<Building2 className="size-5" />} />
</div>
<PerformanceTable rows={branches} entityLabel="Branch" />
</div>
);
}
function Summary({ label, value, detail, icon }: { label: string; value: string; detail: string; icon: React.ReactNode }) {
return <Card className="flex items-center gap-4 p-5"><span className="grid size-11 place-items-center rounded-xl bg-emerald-50 text-emerald-700">{icon}</span><div><p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">{label}</p><p className="mt-1 text-lg font-bold">{value}</p><p className="text-[11px] text-slate-400">{detail}</p></div></Card>;
}

View File

@@ -1,8 +1,9 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #f7f8f6;
--foreground: #0f172a;
--surface: #f7f8f6;
}
@theme inline {
@@ -12,15 +13,22 @@
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
* {
border-color: #e2e8f0;
}
::selection {
background: #a7f3d0;
color: #064e3b;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}

View File

@@ -0,0 +1,73 @@
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";
export const metadata = { title: "Inventory" };
export default function InventoryPage() {
const { inventory } = getDashboardData();
const ordered = [...inventory].sort((a, b) => a.score - b.score);
return (
<div className="space-y-6">
<PageHeader eyebrow="Stock health" title="Inventory"
description="Track every active batch by location, age, value, and estimated remaining shelf life."
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export CSV</button>} />
<div className="grid gap-3 sm:grid-cols-4">
{[
["All inventory", inventory.length, "text-slate-950"],
["Fresh", inventory.filter((i) => i.status === "fresh").length, "text-emerald-700"],
["Watch", inventory.filter((i) => i.status === "watch").length, "text-amber-700"],
["At risk / expired", inventory.filter((i) => ["at_risk", "expired"].includes(i.status)).length, "text-rose-700"],
].map(([label, value, tone]) => (
<Card key={String(label)} className="p-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">{label}</p>
<p className={`mt-1.5 text-2xl font-bold ${tone}`}>{value}</p>
</Card>
))}
</div>
<Card className="overflow-hidden">
<div className="flex flex-col gap-3 border-b border-slate-100 p-4 sm:flex-row">
<label className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input placeholder="Search product or SKU…" className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm outline-none focus:border-emerald-400" />
</label>
<button className={secondaryButtonStyles}><Filter className="size-3.5" />Filter by branch</button>
<button className={secondaryButtonStyles}>All categories</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left">
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
<th className="px-5 py-3">Product / batch</th><th className="px-4 py-3">Location</th>
<th className="px-4 py-3">Received</th><th className="px-4 py-3">Freshness</th>
<th className="px-4 py-3">On hand</th><th className="px-4 py-3">Value</th><th className="px-5 py-3">Health</th>
</tr></thead>
<tbody>
{ordered.map((item) => (
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-5 py-4">
<Link href={`/products/${item.productId}`} className="text-sm font-bold hover:text-emerald-700">{item.product.name}</Link>
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · {item.id.toUpperCase()}</p>
</td>
<td className="px-4 py-4"><p className="text-xs font-semibold">{item.branch.name}</p><p className="text-[11px] text-slate-400">{item.branch.city}</p></td>
<td className="px-4 py-4 text-xs text-slate-600">{new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}<p className="text-[11px] text-slate-400">{item.ageDays} days ago</p></td>
<td className="px-4 py-4"><FreshnessBar value={item.score} status={item.status} /><p className="mt-1 text-[10px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}</p></td>
<td className="px-4 py-4 text-xs font-semibold">{item.quantityOnHand} {item.product.unit}s</td>
<td className="px-4 py-4 text-xs font-semibold">{currency(item.retailValue)}</td>
<td className="px-5 py-4"><FreshnessBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<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>
);
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { DashboardShell } from "@/components/dashboard-shell";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,11 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "Solaire — Inventory Intelligence",
template: "%s · Solaire",
},
description: "Track stock health, freshness, sales performance, and pricing opportunities.",
};
export default function RootLayout({
@@ -27,7 +31,9 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full">
<DashboardShell>{children}</DashboardShell>
</body>
</html>
);
}

48
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Leaf, LockKeyhole } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
setError("");
const form = new FormData(event.currentTarget);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: form.get("email"), password: form.get("password") }),
});
if (!response.ok) {
const result = await response.json();
setError(result.message);
setLoading(false);
return;
}
const next = new URLSearchParams(window.location.search).get("next");
router.push(next ?? "/");
router.refresh();
}
return (
<main className="grid min-h-screen place-items-center bg-[#f4f7f3] p-4">
<div className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
<div className="flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-emerald-600 text-white"><Leaf className="size-5" /></span><div><h1 className="text-xl font-bold">Welcome to Solaire</h1><p className="text-xs text-slate-400">Inventory intelligence</p></div></div>
<div className="mt-8"><h2 className="text-lg font-bold">Sign in to your workspace</h2><p className="mt-1 text-sm text-slate-500">Use the seeded pilot account to continue.</p></div>
<form onSubmit={submit} className="mt-6 space-y-4">
<label className="block text-xs font-semibold text-slate-600">Email<input name="email" type="email" defaultValue="demo@solaire.app" required className="mt-2 h-11 w-full rounded-xl border border-slate-200 px-3 text-sm outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-50" /></label>
<label className="block text-xs font-semibold text-slate-600">Password<input name="password" type="password" defaultValue="solaire-demo" required className="mt-2 h-11 w-full rounded-xl border border-slate-200 px-3 text-sm outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-50" /></label>
{error && <p role="alert" className="rounded-xl bg-rose-50 p-3 text-xs font-semibold text-rose-700">{error}</p>}
<button disabled={loading} className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-60">{loading ? "Signing in…" : "Sign in"}<ArrowRight className="size-4" /></button>
</form>
<p className="mt-6 flex items-center justify-center gap-1.5 text-[11px] text-slate-400"><LockKeyhole className="size-3" />Session is signed and stored in an HTTP-only cookie.</p>
</div>
</main>
);
}

View File

@@ -1,65 +1,165 @@
import Image from "next/image";
import Link from "next/link";
import {
ArrowDownRight, ArrowRight, ArrowUpRight, Boxes, CircleDollarSign,
Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert,
} from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { currency, number, percent } from "@/lib/format";
import {
Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
} from "@/components/ui";
import { RevenueChart } from "@/components/charts";
export default function Home() {
const data = getDashboardData();
const riskItems = data.inventory.filter((item) => item.status !== "fresh")
.sort((a, b) => a.score - b.score).slice(0, 5);
const actionable = data.recommendations.filter((item) => item.type !== "hold").slice(0, 3);
const freshnessCounts = [
{ label: "Fresh", color: "bg-emerald-500", count: data.inventory.filter((i) => i.status === "fresh").length },
{ label: "Watch", color: "bg-amber-500", count: data.inventory.filter((i) => i.status === "watch").length },
{ label: "At risk", color: "bg-orange-500", count: data.inventory.filter((i) => i.status === "at_risk").length },
{ label: "Expired", color: "bg-rose-500", count: data.inventory.filter((i) => i.status === "expired").length },
];
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<div className="space-y-6">
<PageHeader
eyebrow="Saturday, July 25"
title="Good morning, Nora"
description={`Heres what needs attention across ${data.organization.name} today.`}
actions={
<>
<span className="hidden items-center gap-2 text-xs text-slate-400 sm:flex">
<span className="size-2 rounded-full bg-emerald-500" />Demo data live
</span>
<Link href="/settings/integrations" className={secondaryButtonStyles}>
<RefreshCw className="size-3.5" />Sync data
</Link>
</>
}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label="Inventory value" value={currency(data.metrics.retailValue, true)}
detail={`${number(data.metrics.unitsOnHand)} cases on hand`} change={data.metrics.change.unitsOnHand}
icon={<Boxes className="size-5" />} tone="emerald" />
<MetricCard label="Revenue · 30 days" value={currency(data.metrics.revenue30d, true)}
detail={`${percent(data.metrics.sellThrough30d)} sell-through`} change={data.metrics.change.revenue}
icon={<ShoppingCart className="size-5" />} tone="blue" />
<MetricCard label="At-risk exposure" value={currency(data.metrics.atRiskValue, true)}
detail={`${riskItems.length} batches need attention`} change={data.metrics.change.atRisk}
icon={<TriangleAlert className="size-5" />} tone="amber" invertChange />
<MetricCard label="Average freshness" value={percent(data.metrics.averageFreshness)}
detail="Across active inventory" change={3.1}
icon={<Leaf className="size-5" />} tone="violet" />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,.8fr)]">
<Card>
<CardHeader title="Revenue momentum" description="Last 30 days across all branches"
action={<span className="rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-700">+12.8% vs prior period</span>} />
<div className="px-4 pb-4 pt-5"><RevenueChart data={data.trends} /></div>
</Card>
<Card>
<CardHeader title="Stock health" description="Batches by remaining shelf life" />
<div className="p-5">
<div className="relative mx-auto grid size-40 place-items-center rounded-full bg-[conic-gradient(#10b981_0_41%,#f59e0b_41%_66%,#f97316_66%_91%,#e11d48_91%)]">
<div className="grid size-28 place-items-center rounded-full bg-white text-center">
<div><p className="text-3xl font-bold tracking-tight">{data.inventory.length}</p>
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-400">Batches</p></div>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
{freshnessCounts.map((item) => (
<div key={item.label} className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
<span className="flex items-center gap-2 text-xs font-medium text-slate-600">
<span className={`size-2 rounded-full ${item.color}`} />{item.label}
</span>
<span className="text-sm font-bold">{item.count}</span>
</div>
))}
</div>
</div>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,.85fr)]">
<Card className="overflow-hidden">
<CardHeader title="Freshness watchlist" description="Oldest inventory first"
action={<Link href="/inventory" className="text-xs font-bold text-emerald-700">View inventory</Link>} />
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] text-left">
<thead><tr className="border-b border-slate-100 bg-slate-50/60 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">On hand</th><th className="px-5 py-3">Status</th>
</tr></thead>
<tbody>
{riskItems.map((item) => (
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
<td className="px-5 py-3.5">
<Link href={`/products/${item.product.id}`} className="text-sm font-semibold hover:text-emerald-700">{item.product.name}</Link>
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · Batch {item.id.toUpperCase()}</p>
</td>
<td className="px-4 py-3.5 text-xs text-slate-600">{item.branch.name}</td>
<td className="px-4 py-3.5"><FreshnessBar value={item.score} status={item.status} /></td>
<td className="px-4 py-3.5 text-xs font-semibold">{item.quantityOnHand} cases</td>
<td className="px-5 py-3.5"><FreshnessBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card>
<CardHeader title="Recommended actions" description="Advisory suggestions — you stay in control"
action={<Sparkles className="size-4 text-amber-500" />} />
<div className="divide-y divide-slate-100">
{actionable.map((rec) => {
const item = data.inventory.find((entry) => entry.id === rec.id.replace("rec_", ""))!;
return (
<Link href="/recommendations" key={rec.id} className="group flex gap-3 p-4 hover:bg-slate-50/70">
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-amber-50 text-amber-700">
{rec.type === "markdown" ? <CircleDollarSign className="size-4" /> : <Clock3 className="size-4" />}
</span>
<span className="min-w-0">
<span className="block truncate text-xs font-bold">{item.product.name}</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">{rec.action}</span>
<span className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-emerald-700">
{rec.confidence}% confidence <ArrowRight className="size-3" />
</span>
</span>
</Link>
);
})}
</div>
</Card>
</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

@@ -0,0 +1,68 @@
import Link from "next/link";
import { ArrowLeft, CircleDollarSign, PackageCheck, TrendingUp } from "lucide-react";
import { notFound } from "next/navigation";
import { getDashboardData } from "@/domain/analytics";
import { sales } from "@/lib/demo-data";
import { currency, number, percent } from "@/lib/format";
import { Card, CardHeader, FreshnessBadge, FreshnessBar, secondaryButtonStyles } from "@/components/ui";
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = getDashboardData();
const batches = data.inventory.filter((item) => item.productId === id);
if (!batches.length) notFound();
const product = batches[0].product;
const productSales = sales.filter((sale) => sale.productId === id);
const cutoff = new Date(data.generatedAt).getTime() - 30 * 86400000;
const units30 = productSales.filter((sale) => new Date(sale.soldAt).getTime() > cutoff)
.reduce((sum, sale) => sum + sale.quantity, 0);
const onHand = batches.reduce((sum, item) => sum + item.quantityOnHand, 0);
const averageFreshness = batches.reduce((sum, item) => sum + item.score, 0) / batches.length;
return (
<div className="space-y-6">
<Link href="/inventory" 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 inventory
</Link>
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div><p className="text-xs font-bold uppercase tracking-wider text-emerald-700">{product.category} · {product.sku}</p>
<h1 className="mt-1 text-3xl font-bold tracking-tight">{product.name}</h1>
<p className="mt-1 text-sm text-slate-500">{product.shelfLifeDays}-day configured shelf life · tracked by received batch</p></div>
<Link href="/recommendations" className={secondaryButtonStyles}><CircleDollarSign className="size-4" />View pricing advice</Link>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<ProductMetric icon={<PackageCheck className="size-5" />} label="On hand" value={`${number(onHand)} cases`} detail={`${batches.length} active batches`} />
<ProductMetric icon={<TrendingUp className="size-5" />} label="30-day velocity" value={`${number(units30)} cases`} detail={`${(units30 / 30).toFixed(1)} cases per day`} />
<ProductMetric icon={<CircleDollarSign className="size-5" />} label="Current price" value={currency(product.listPrice)} detail={`${percent(((product.listPrice - product.cost) / product.listPrice) * 100)} gross margin`} />
</div>
<div className="grid gap-6 lg:grid-cols-[1.3fr_.7fr]">
<Card className="overflow-hidden">
<CardHeader title="Active batches" description="Freshness and quantity by branch" />
<div className="divide-y divide-slate-100">
{batches.map((batch) => (
<div key={batch.id} className="grid gap-4 p-5 sm:grid-cols-[1fr_1fr_auto] sm:items-center">
<div><p className="text-sm font-bold">{batch.branch.name}</p><p className="mt-1 text-xs text-slate-400">Received {new Date(batch.receivedAt).toLocaleDateString()} · {batch.quantityOnHand} cases</p></div>
<FreshnessBar value={batch.score} status={batch.status} />
<FreshnessBadge status={batch.status} />
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="Freshness model" description="How this estimate is calculated" />
<div className="p-5">
<p className="text-5xl font-bold tracking-tight text-emerald-700">{Math.round(averageFreshness)}<span className="text-lg">/100</span></p>
<p className="mt-3 text-sm leading-6 text-slate-500">The score compares each batchs received date with this products {product.shelfLifeDays}-day shelf-life setting.</p>
<div className="mt-5 rounded-xl bg-amber-50 p-3 text-xs leading-5 text-amber-800">Inspect physical quality before taking action. Solaires estimate supports but does not replace staff judgment.</div>
</div>
</Card>
</div>
</div>
);
}
function ProductMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
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">{value}</p><p className="mt-1 text-[11px] text-slate-400">{detail}</p></Card>;
}

View File

@@ -0,0 +1,23 @@
import { ShieldCheck, Sparkles } from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { Card, PageHeader } from "@/components/ui";
import { RecommendationList } from "@/components/recommendation-list";
export const metadata = { title: "Recommendations" };
export default function RecommendationsPage() {
const data = getDashboardData();
const actionable = data.recommendations.filter((item) => item.type !== "hold");
return (
<div className="space-y-6">
<PageHeader eyebrow="Advisory pricing" title="Recommendations"
description="Move aging stock with explainable price ranges and promotions. Solaire never changes source-system prices automatically." />
<div className="grid gap-4 sm:grid-cols-3">
<Card className="p-5"><Sparkles className="size-5 text-amber-600" /><p className="mt-3 text-2xl font-bold">{actionable.length}</p><p className="text-xs text-slate-400">Actions ready for review</p></Card>
<Card className="p-5"><ShieldCheck className="size-5 text-emerald-600" /><p className="mt-3 text-2xl font-bold">100%</p><p className="text-xs text-slate-400">Respect configured margin floors</p></Card>
<Card className="p-5"><p className="text-[11px] font-bold uppercase tracking-wider text-slate-400">Signals used</p><p className="mt-3 text-sm font-bold">Freshness · velocity · stock · cost</p><p className="mt-1 text-xs text-slate-400">No competitor feed connected yet</p></Card>
</div>
<RecommendationList initial={actionable} inventory={data.inventory} />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Award, Target, Users } from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { currency, number } from "@/lib/format";
import { Card, PageHeader } from "@/components/ui";
import { PerformanceTable } from "@/components/performance-table";
export const metadata = { title: "Sales Team" };
export default function SalesTeamPage() {
const { salespeople } = getDashboardData();
const totalRevenue = salespeople.reduce((sum, row) => sum + row.revenue, 0);
return (
<div className="space-y-6">
<PageHeader eyebrow="Performance" title="Sales team"
description="See who is moving the most produce and whether those sales are improving stock health." />
<div className="grid gap-4 md:grid-cols-3">
<TeamMetric icon={<Award className="size-5" />} label="Sales leader" value={salespeople[0].name} detail={currency(salespeople[0].revenue)} />
<TeamMetric icon={<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" />
</div>
<PerformanceTable rows={salespeople} entityLabel="Salesperson" />
<Card className="border-blue-100 bg-blue-50/60 p-5">
<p className="text-sm font-bold text-blue-900">Salesforce mapping preview</p>
<p className="mt-1 text-xs leading-5 text-blue-700">Demo sellers and orders use Solaires normalized model. A future Salesforce adapter can map Users, Accounts, Orders, Opportunities, and custom branch fields without changing these analytics.</p>
</Card>
</div>
);
}
function TeamMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
return <Card className="p-5"><span className="grid size-10 place-items-center rounded-xl bg-blue-50 text-blue-700">{icon}</span><p className="mt-4 text-xs font-semibold text-slate-400">{label}</p><p className="mt-1 text-lg font-bold">{value}</p><p className="mt-1 text-[11px] text-slate-400">{detail}</p></Card>;
}

View File

@@ -0,0 +1,33 @@
import { Clock3, LockKeyhole, ShieldCheck } from "lucide-react";
import { IntegrationSettings } from "@/components/integration-settings";
import { Card, PageHeader } from "@/components/ui";
export const metadata = { title: "Integrations" };
export default function IntegrationsPage() {
return (
<div className="space-y-6">
<PageHeader eyebrow="Settings" title="Data integrations"
description="Manage the systems that supply Solaires inventory and sales intelligence." />
<IntegrationSettings />
<div className="grid gap-4 md:grid-cols-3">
<Info icon={<Clock3 className="size-4" />} title="Scheduled sync" text="Prepared for PostgreSQL-backed jobs with retry and cursor tracking." />
<Info icon={<LockKeyhole className="size-4" />} title="Server-only secrets" text="Real credentials are validated and never exposed to the browser." />
<Info icon={<ShieldCheck className="size-4" />} title="Read-only by design" text="Pricing recommendations never update Odoo or Salesforce." />
</div>
<Card className="p-5">
<h2 className="text-sm font-bold">Before connecting real accounts</h2>
<ul className="mt-3 grid gap-2 text-xs leading-5 text-slate-500 sm:grid-cols-2">
<li> Confirm Odoo version, hosting, database, and Custom plan API access</li>
<li> Map Odoo warehouses and locations to Solaire branches</li>
<li> Create a Salesforce External Client App and integration user</li>
<li> Confirm which Orders or Opportunities represent completed produce sales</li>
</ul>
</Card>
</div>
);
}
function Info({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }) {
return <Card className="p-4"><span className="grid size-8 place-items-center rounded-lg bg-emerald-50 text-emerald-700">{icon}</span><p className="mt-3 text-xs font-bold">{title}</p><p className="mt-1 text-[11px] leading-5 text-slate-400">{text}</p></Card>;
}

61
src/components/charts.tsx Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TrendPoint } from "@/lib/types";
import { currency } from "@/lib/format";
export function RevenueChart({ data }: { data: TrendPoint[] }) {
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
<defs>
<linearGradient id="revenueFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#059669" stopOpacity={0.2} />
<stop offset="95%" stopColor="#059669" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "#94a3b8", fontSize: 11 }}
tickFormatter={(value) => `$${Math.round(value / 1000)}k`}
/>
<Tooltip
formatter={(value) => [currency(Number(value)), "Revenue"]}
contentStyle={{
borderRadius: 12,
border: "1px solid #e2e8f0",
boxShadow: "0 8px 30px rgba(15,23,42,.08)",
fontSize: 12,
}}
/>
<Area
type="monotone"
dataKey="revenue"
stroke="#059669"
strokeWidth={2.5}
fill="url(#revenueFill)"
activeDot={{ r: 4, fill: "#059669", stroke: "white", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bell,
Boxes,
ChartNoAxesCombined,
ChevronDown,
CircleDollarSign,
LayoutDashboard,
Leaf,
Search,
Settings,
Store,
Users,
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
const navigation = [
{ href: "/", label: "Overview", icon: LayoutDashboard },
{ href: "/inventory", label: "Inventory", icon: Boxes },
{ href: "/branches", label: "Branches", icon: Store },
{ href: "/sales-team", label: "Sales team", icon: Users },
{ href: "/recommendations", label: "Recommendations", icon: CircleDollarSign },
{ href: "/alerts", label: "Alerts", icon: Bell },
];
export function DashboardShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
if (pathname === "/login") {
return <>{children}</>;
}
return (
<div className="min-h-screen bg-[var(--surface)] text-slate-950">
<aside className="fixed inset-y-0 left-0 z-40 hidden w-64 flex-col border-r border-slate-200/80 bg-white px-4 py-5 lg:flex">
<Link href="/" className="flex items-center gap-3 px-3">
<span className="grid size-10 place-items-center rounded-xl bg-emerald-600 text-white shadow-sm shadow-emerald-200">
<Leaf className="size-5" />
</span>
<span>
<span className="block text-lg font-bold tracking-tight">Solaire</span>
<span className="block text-[11px] font-medium text-slate-400">Inventory intelligence</span>
</span>
</Link>
<nav className="mt-9 space-y-1">
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">
Workspace
</p>
{navigation.map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
active
? "bg-emerald-50 text-emerald-800"
: "text-slate-600 hover:bg-slate-50 hover:text-slate-950",
)}
>
<item.icon className="size-[18px]" strokeWidth={active ? 2.3 : 1.8} />
{item.label}
</Link>
);
})}
</nav>
<div className="mt-auto">
<Link
href="/settings/integrations"
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium",
pathname.startsWith("/settings")
? "bg-emerald-50 text-emerald-800"
: "text-slate-600 hover:bg-slate-50",
)}
>
<Settings className="size-[18px]" />
Settings & integrations
</Link>
<div className="mt-4 rounded-2xl border border-emerald-100 bg-emerald-50/70 p-4">
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-800">
<ChartNoAxesCombined className="size-4" />
Demo workspace
</div>
<p className="mt-2 text-xs leading-5 text-emerald-700">
Sample data refreshes with the current date.
</p>
</div>
</div>
</aside>
<div className="lg:pl-64">
<header className="sticky top-0 z-30 flex h-16 items-center border-b border-slate-200/80 bg-white/90 px-4 backdrop-blur-md sm:px-6 lg:px-8">
<Link href="/" className="mr-4 flex items-center gap-2 lg:hidden">
<span className="grid size-8 place-items-center rounded-lg bg-emerald-600 text-white">
<Leaf className="size-4" />
</span>
<span className="font-bold">Solaire</span>
</Link>
<div className="relative hidden max-w-md flex-1 md:block">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<input
aria-label="Search products, branches, or people"
placeholder="Search products, branches, or people..."
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"
/>
</div>
<div className="ml-auto flex items-center gap-2 sm:gap-4">
<Link
href="/alerts"
aria-label="View alerts"
className="relative grid size-10 place-items-center rounded-xl text-slate-500 hover:bg-slate-100"
>
<Bell className="size-5" />
<span className="absolute right-2 top-2 size-2 rounded-full border-2 border-white bg-amber-500" />
</Link>
<button className="flex items-center gap-2 rounded-xl p-1.5 pr-2 hover:bg-slate-50">
<span className="grid size-8 place-items-center rounded-lg bg-slate-900 text-xs font-bold text-white">
NC
</span>
<span className="hidden text-left sm:block">
<span className="block text-xs font-semibold">Nora Clarke</span>
<span className="block text-[10px] text-slate-400">Operations manager</span>
</span>
<ChevronDown className="hidden size-4 text-slate-400 sm:block" />
</button>
</div>
</header>
<nav className="flex gap-1 overflow-x-auto border-b border-slate-200 bg-white px-3 py-2 lg:hidden">
{navigation.slice(0, 5).map((item) => {
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"whitespace-nowrap rounded-lg px-3 py-2 text-xs font-semibold",
active ? "bg-emerald-50 text-emerald-800" : "text-slate-500",
)}
>
{item.label}
</Link>
);
})}
</nav>
<main className="mx-auto max-w-[1600px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { CheckCircle2, Database, RefreshCw, RotateCw, Users } from "lucide-react";
import { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
import type { IntegrationProvider } from "@/lib/types";
type State = { provider: IntegrationProvider; loading: boolean; message?: string };
export function IntegrationSettings() {
const [state, setState] = useState<State>({ provider: "odoo", loading: false });
async function sync(provider: IntegrationProvider) {
setState({ provider, loading: true });
const response = await fetch("/api/integrations/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
});
const result = await response.json();
setState({ provider, loading: false, message: result.message });
}
return (
<div className="space-y-4">
<IntegrationCard
name="Odoo Inventory"
description="Products, locations, received inventory, and stock movements"
icon={<Database className="size-6" />}
status="Demo source connected"
onSync={() => sync("odoo")}
loading={state.loading && state.provider === "odoo"}
/>
<IntegrationCard
name="Salesforce CRM"
description="Branches, salespeople, orders, and sales attribution"
icon={<Users className="size-6" />}
status="Demo source connected"
onSync={() => sync("salesforce")}
loading={state.loading && state.provider === "salesforce"}
/>
{state.message && (
<div role="status" className="flex items-center gap-2 rounded-xl border border-emerald-100 bg-emerald-50 p-3 text-xs font-semibold text-emerald-800">
<CheckCircle2 className="size-4" />{state.message}
</div>
)}
</div>
);
}
function IntegrationCard({ name, description, icon, status, onSync, loading }: {
name: string; description: string; icon: React.ReactNode; status: string; onSync: () => void; loading: boolean;
}) {
return (
<Card className="p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<span className="grid size-12 place-items-center rounded-2xl bg-slate-100 text-slate-700">{icon}</span>
<div className="flex-1"><h2 className="text-sm font-bold">{name}</h2><p className="mt-1 text-xs text-slate-500">{description}</p><p className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-emerald-700"><span className="size-1.5 rounded-full bg-emerald-500" />{status}</p></div>
<div className="flex gap-2">
<button className={secondaryButtonStyles}>Configure</button>
<button onClick={onSync} disabled={loading} className={buttonStyles}>
{loading ? <RotateCw className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
{loading ? "Syncing…" : "Sync now"}
</button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
import { Award, ArrowUpRight } from "lucide-react";
import { Card } from "@/components/ui";
import { currency, number, percent } from "@/lib/format";
import type { PerformanceRow } from "@/lib/types";
export function PerformanceTable({
rows,
entityLabel,
}: {
rows: PerformanceRow[];
entityLabel: string;
}) {
const maxRevenue = Math.max(...rows.map((row) => row.revenue));
return (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] 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">{entityLabel}</th>
<th className="px-4 py-3">Revenue</th><th className="px-4 py-3">Units sold</th>
<th className="px-4 py-3">Sell-through</th><th className="px-5 py-3">Stock freshness</th>
</tr></thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
<td className="px-5 py-4">
<span className={`grid size-7 place-items-center rounded-lg text-xs font-bold ${index === 0 ? "bg-amber-100 text-amber-800" : "bg-slate-100 text-slate-500"}`}>
{index === 0 ? <Award className="size-4" /> : index + 1}
</span>
</td>
<td className="px-4 py-4"><p className="text-sm font-bold">{row.name}</p><p className="mt-0.5 text-[11px] text-slate-400">{row.secondary}</p></td>
<td className="px-4 py-4">
<p className="text-sm font-bold">{currency(row.revenue)}</p>
<div className="mt-1.5 h-1 w-24 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-emerald-500" style={{ width: `${(row.revenue / maxRevenue) * 100}%` }} /></div>
</td>
<td className="px-4 py-4 text-xs font-semibold">{number(row.units)} cases</td>
<td className="px-4 py-4"><span className="inline-flex items-center gap-1 text-xs font-bold text-emerald-700">{percent(row.sellThrough)}<ArrowUpRight className="size-3" /></span></td>
<td className="px-5 py-4"><p className="text-xs font-bold">{percent(row.freshness)}</p><div className="mt-1.5 h-1.5 w-24 rounded-full bg-slate-100"><div className="h-full rounded-full bg-emerald-500" style={{ width: `${row.freshness}%` }} /></div></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Check, ChevronDown, CircleDollarSign, Clock3, ShieldCheck, X } from "lucide-react";
import type { InventoryView, Recommendation } from "@/lib/types";
import { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
import { currency } from "@/lib/format";
export function RecommendationList({
initial,
inventory,
}: {
initial: Recommendation[];
inventory: InventoryView[];
}) {
const [items, setItems] = useState(initial);
const update = (id: string, status: Recommendation["status"]) =>
setItems((current) => current.map((item) => item.id === id ? { ...item, status } : item));
return (
<div className="space-y-4">
{items.map((rec) => {
const stock = inventory.find((entry) => rec.id === `rec_${entry.id}`)!;
return (
<Card key={rec.id} className={rec.status !== "pending" ? "opacity-60" : ""}>
<div className="grid gap-5 p-5 lg:grid-cols-[1fr_auto] lg:items-center">
<div className="flex gap-4">
<span className={`grid size-11 shrink-0 place-items-center rounded-xl ${rec.type === "markdown" ? "bg-orange-50 text-orange-700" : rec.type === "promotion" ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`}>
{rec.type === "markdown" ? <CircleDollarSign className="size-5" /> : <Clock3 className="size-5" />}
</span>
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-bold">{stock.product.name}</h2>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase text-slate-500">{rec.type}</span>
{rec.status !== "pending" && <span className="text-[10px] font-bold uppercase text-emerald-700">{rec.status}</span>}
</div>
<p className="mt-1 text-xs text-slate-400">{stock.branch.name} · {stock.quantityOnHand} cases · {stock.score}% freshness</p>
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">{rec.reason}</p>
<div className="mt-3 flex flex-wrap gap-3 text-[11px] font-semibold text-slate-500">
<span className="flex items-center gap-1"><ShieldCheck className="size-3.5 text-emerald-600" />Margin floor protected</span>
<span>{rec.confidence}% confidence</span>
<span>Expires in 2 days</span>
</div>
</div>
</div>
<div className="min-w-56 rounded-xl bg-slate-50 p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Recommended range</p>
<p className="mt-1 text-xl font-bold">{currency(rec.recommendedLow)}{currency(rec.recommendedHigh)}</p>
<p className="mt-1 text-[11px] text-slate-400">Current {currency(rec.currentPrice)} / case</p>
{rec.status === "pending" && (
<div className="mt-3 flex gap-2">
<button onClick={() => update(rec.id, "reviewed")} className={buttonStyles}><Check className="size-3.5" />Reviewed</button>
<button aria-label="Dismiss recommendation" onClick={() => update(rec.id, "dismissed")} className={secondaryButtonStyles}><X className="size-3.5" /></button>
</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">
Show calculation details <ChevronDown className="size-3" />
</button>
</Card>
);
})}
</div>
);
}

113
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,113 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { FreshnessStatus } from "@/lib/types";
import { freshnessLabels } from "@/domain/freshness";
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
{eyebrow && (
<p className="mb-1 text-xs font-bold uppercase tracking-[0.16em] text-emerald-700">
{eyebrow}
</p>
)}
<h1 className="text-2xl font-bold tracking-tight text-slate-950 sm:text-[28px]">{title}</h1>
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-slate-500">{description}</p>
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
export function Card({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.025)]", className)}>
{children}
</section>
);
}
export function CardHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-100 px-5 py-4">
<div>
<h2 className="text-sm font-bold text-slate-900">{title}</h2>
{description && <p className="mt-1 text-xs text-slate-400">{description}</p>}
</div>
{action}
</div>
);
}
const statusClasses: Record<FreshnessStatus, string> = {
fresh: "bg-emerald-50 text-emerald-700 ring-emerald-600/10",
watch: "bg-amber-50 text-amber-700 ring-amber-600/10",
at_risk: "bg-orange-50 text-orange-700 ring-orange-600/10",
expired: "bg-rose-50 text-rose-700 ring-rose-600/10",
};
export function FreshnessBadge({ status }: { status: FreshnessStatus }) {
return (
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1 ring-inset", statusClasses[status])}>
<span className="size-1.5 rounded-full bg-current" />
{freshnessLabels[status]}
</span>
);
}
export function FreshnessBar({
value,
status,
}: {
value: number;
status: FreshnessStatus;
}) {
return (
<div className="flex min-w-28 items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div
className={cn(
"h-full rounded-full",
status === "fresh" && "bg-emerald-500",
status === "watch" && "bg-amber-500",
status === "at_risk" && "bg-orange-500",
status === "expired" && "bg-rose-500",
)}
style={{ width: `${Math.max(3, value)}%` }}
/>
</div>
<span className="w-8 text-right text-xs font-semibold text-slate-600">{value}%</span>
</div>
);
}
export const buttonStyles =
"inline-flex h-9 items-center justify-center gap-2 rounded-xl bg-slate-950 px-3.5 text-xs font-bold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-4 focus:ring-slate-200";
export const secondaryButtonStyles =
"inline-flex h-9 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-4 focus:ring-slate-100";

18
src/db/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@/db/schema";
let client: ReturnType<typeof postgres> | undefined;
export function getDatabase() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is required for persistent database access");
}
client ??= postgres(process.env.DATABASE_URL, {
max: process.env.NODE_ENV === "production" ? 10 : 1,
prepare: false,
});
return drizzle(client, { schema });
}

194
src/db/schema.ts Normal file
View File

@@ -0,0 +1,194 @@
import {
boolean,
index,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
const id = () => uuid("id").defaultRandom().primaryKey();
const tenant = () => uuid("organization_id").notNull().references(() => organizations.id);
const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
};
export const integrationProvider = pgEnum("integration_provider", ["odoo", "salesforce"]);
export const freshnessState = pgEnum("freshness_state", ["fresh", "watch", "at_risk", "expired"]);
export const recommendationState = pgEnum("recommendation_state", ["pending", "reviewed", "dismissed"]);
export const syncState = pgEnum("sync_state", ["queued", "running", "succeeded", "failed"]);
export const memberRole = pgEnum("member_role", ["owner", "manager", "viewer"]);
export const organizations = pgTable("organizations", {
id: id(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
...timestamps,
});
export const users = pgTable("users", {
id: id(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
passwordHash: text("password_hash"),
...timestamps,
});
export const memberships = pgTable(
"memberships",
{
organizationId: uuid("organization_id").notNull().references(() => organizations.id),
userId: uuid("user_id").notNull().references(() => users.id),
role: memberRole("role").default("viewer").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [primaryKey({ columns: [table.organizationId, table.userId] })],
);
export const branches = pgTable("branches", {
id: id(),
organizationId: tenant(),
name: text("name").notNull(),
city: text("city").notNull(),
region: text("region").notNull(),
sourceId: text("source_id"),
...timestamps,
}, (table) => [
index("branches_organization_idx").on(table.organizationId),
uniqueIndex("branches_source_unique").on(table.organizationId, table.sourceId),
]);
export const salespeople = pgTable("salespeople", {
id: id(),
organizationId: tenant(),
branchId: uuid("branch_id").notNull().references(() => branches.id),
name: text("name").notNull(),
email: text("email"),
sourceId: text("source_id"),
...timestamps,
}, (table) => [index("salespeople_organization_idx").on(table.organizationId)]);
export const products = pgTable("products", {
id: id(),
organizationId: tenant(),
sku: text("sku").notNull(),
name: text("name").notNull(),
category: text("category").notNull(),
unit: text("unit").notNull(),
shelfLifeDays: integer("shelf_life_days").notNull(),
cost: numeric("cost", { precision: 12, scale: 2 }).notNull(),
listPrice: numeric("list_price", { precision: 12, scale: 2 }).notNull(),
marginFloorPercent: numeric("margin_floor_percent", { precision: 5, scale: 2 }).notNull(),
sourceSystem: integrationProvider("source_system").notNull(),
sourceId: text("source_id").notNull(),
...timestamps,
}, (table) => [
uniqueIndex("products_sku_unique").on(table.organizationId, table.sku),
uniqueIndex("products_source_unique").on(table.organizationId, table.sourceSystem, table.sourceId),
]);
export const inventoryBatches = pgTable("inventory_batches", {
id: id(),
organizationId: tenant(),
productId: uuid("product_id").notNull().references(() => products.id),
branchId: uuid("branch_id").notNull().references(() => branches.id),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull(),
quantityReceived: integer("quantity_received").notNull(),
quantityOnHand: integer("quantity_on_hand").notNull(),
freshnessScore: integer("freshness_score").notNull(),
freshnessState: freshnessState("freshness_state").notNull(),
sourceId: text("source_id").notNull(),
...timestamps,
}, (table) => [
index("inventory_tenant_product_idx").on(table.organizationId, table.productId),
uniqueIndex("inventory_source_unique").on(table.organizationId, table.sourceId),
]);
export const sales = pgTable("sales", {
id: id(),
organizationId: tenant(),
productId: uuid("product_id").notNull().references(() => products.id),
branchId: uuid("branch_id").notNull().references(() => branches.id),
salespersonId: uuid("salesperson_id").references(() => salespeople.id),
soldAt: timestamp("sold_at", { withTimezone: true }).notNull(),
quantity: integer("quantity").notNull(),
unitPrice: numeric("unit_price", { precision: 12, scale: 2 }).notNull(),
sourceId: text("source_id").notNull(),
...timestamps,
}, (table) => [
index("sales_tenant_date_idx").on(table.organizationId, table.soldAt),
uniqueIndex("sales_source_unique").on(table.organizationId, table.sourceId),
]);
export const integrationConnections = pgTable("integration_connections", {
id: id(),
organizationId: tenant(),
provider: integrationProvider("provider").notNull(),
displayName: text("display_name").notNull(),
enabled: boolean("enabled").default(false).notNull(),
encryptedCredentials: text("encrypted_credentials"),
configuration: jsonb("configuration").$type<Record<string, unknown>>().default({}).notNull(),
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
...timestamps,
}, (table) => [uniqueIndex("integration_provider_unique").on(table.organizationId, table.provider)]);
export const syncRuns = pgTable("sync_runs", {
id: id(),
organizationId: tenant(),
connectionId: uuid("connection_id").references(() => integrationConnections.id),
provider: integrationProvider("provider").notNull(),
state: syncState("state").default("queued").notNull(),
cursor: text("cursor"),
recordsRead: integer("records_read").default(0).notNull(),
recordsWritten: integer("records_written").default(0).notNull(),
error: text("error"),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const recommendations = pgTable("recommendations", {
id: id(),
organizationId: tenant(),
productId: uuid("product_id").notNull().references(() => products.id),
branchId: uuid("branch_id").notNull().references(() => branches.id),
type: text("type").notNull(),
currentPrice: numeric("current_price", { precision: 12, scale: 2 }).notNull(),
recommendedLow: numeric("recommended_low", { precision: 12, scale: 2 }).notNull(),
recommendedHigh: numeric("recommended_high", { precision: 12, scale: 2 }).notNull(),
confidence: integer("confidence").notNull(),
explanation: text("explanation").notNull(),
state: recommendationState("state").default("pending").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
reviewedBy: uuid("reviewed_by").references(() => users.id),
...timestamps,
});
export const alerts = pgTable("alerts", {
id: id(),
organizationId: tenant(),
inventoryBatchId: uuid("inventory_batch_id").references(() => inventoryBatches.id),
severity: text("severity").notNull(),
title: text("title").notNull(),
message: text("message").notNull(),
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
...timestamps,
}, (table) => [index("alerts_tenant_idx").on(table.organizationId)]);
export const auditEvents = pgTable("audit_events", {
id: id(),
organizationId: tenant(),
userId: uuid("user_id").references(() => users.id),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: text("entity_id").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});

140
src/domain/analytics.ts Normal file
View File

@@ -0,0 +1,140 @@
import { format, isAfter, startOfDay, subDays } from "date-fns";
import {
branches,
inventoryBatches,
organization,
products,
sales,
salespeople,
} from "@/lib/demo-data";
import { calculateFreshness } from "@/domain/freshness";
import { recommendPrice } from "@/domain/recommendations";
import type {
DashboardData,
InventoryView,
PerformanceRow,
Sale,
} 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 revenue = (items: Sale[]) =>
items.reduce((sum, sale) => sum + sale.quantity * sale.unitPrice, 0);
const performance = (
kind: "branch" | "salesperson",
inventory: InventoryView[],
): PerformanceRow[] => {
const entities = kind === "branch" ? branches : salespeople;
const last30 = recentSales(30);
return entities
.map((entity) => {
const related = last30.filter((sale) =>
kind === "branch" ? sale.branchId === entity.id : sale.salespersonId === entity.id,
);
const relatedInventory = inventory.filter((item) =>
kind === "branch"
? item.branchId === entity.id
: item.branchId === ("branchId" in entity ? entity.branchId : ""),
);
const sold = related.reduce((sum, sale) => sum + sale.quantity, 0);
const onHand = relatedInventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
const freshness = relatedInventory.length
? relatedInventory.reduce((sum, item) => sum + item.score, 0) / relatedInventory.length
: 0;
const branch = kind === "salesperson"
? branches.find((item) => item.id === ("branchId" in entity ? entity.branchId : ""))
: undefined;
return {
id: entity.id,
name: entity.name,
secondary: kind === "branch"
? ("city" in entity ? `${entity.city}, ${entity.region}` : "")
: branch?.name ?? "",
revenue: revenue(related),
units: sold,
sellThrough: sold + onHand > 0 ? (sold / (sold + onHand)) * 100 : 0,
freshness,
};
})
.sort((a, b) => b.revenue - a.revenue);
};
export function getDashboardData(): DashboardData {
const inventory: InventoryView[] = inventoryBatches.map((batch) => {
const product = products.find((item) => item.id === batch.productId)!;
const branch = branches.find((item) => item.id === batch.branchId)!;
return {
...batch,
...calculateFreshness(batch.receivedAt, product.shelfLifeDays),
product,
branch,
retailValue: batch.quantityOnHand * product.listPrice,
costExposure: batch.quantityOnHand * product.cost,
};
});
const sales30 = recentSales(30);
const unitsOnHand = inventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
const sold30 = sales30.reduce((sum, sale) => sum + sale.quantity, 0);
const atRisk = inventory.filter(
(item) => item.status === "at_risk" || item.status === "expired",
);
const recommendations = inventory
.map((item) =>
recommendPrice(
item,
sales30.filter(
(sale) => sale.productId === item.productId && sale.branchId === item.branchId,
),
),
)
.sort((a, b) => {
const priority = { markdown: 0, promotion: 1, hold: 2 };
return priority[a.type] - priority[b.type];
});
const trends = Array.from({ length: 8 }, (_, index) => {
const endDaysAgo = (7 - index) * 4;
const start = startOfDay(subDays(new Date(), endDaysAgo + 3));
const end = subDays(new Date(), endDaysAgo);
const period = sales.filter((sale) => {
const date = new Date(sale.soldAt);
return date >= start && date <= end;
});
return {
label: format(end, "MMM d"),
revenue: Math.round(revenue(period)),
units: period.reduce((sum, sale) => sum + sale.quantity, 0),
wasteRisk: Math.round(
atRisk.reduce((sum, item) => sum + item.costExposure, 0) *
(0.82 + index * 0.025),
),
};
});
return {
organization,
generatedAt: new Date().toISOString(),
inventory,
recommendations,
branches: performance("branch", inventory),
salespeople: performance("salesperson", inventory),
trends,
metrics: {
unitsOnHand,
retailValue: inventory.reduce((sum, item) => sum + item.retailValue, 0),
atRiskValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0),
revenue30d: revenue(sales30),
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 },
},
};
}

38
src/domain/freshness.ts Normal file
View File

@@ -0,0 +1,38 @@
import { differenceInCalendarDays, parseISO } from "date-fns";
import type { FreshnessResult, FreshnessStatus } from "@/lib/types";
export function freshnessStatus(remainingPercent: number): FreshnessStatus {
if (remainingPercent <= 0) return "expired";
if (remainingPercent <= 20) return "at_risk";
if (remainingPercent <= 45) return "watch";
return "fresh";
}
export function calculateFreshness(
receivedAt: string,
shelfLifeDays: number,
asOf = new Date(),
): FreshnessResult {
const safeShelfLife = Math.max(1, shelfLifeDays);
const ageDays = Math.max(0, differenceInCalendarDays(asOf, parseISO(receivedAt)));
const remainingDays = safeShelfLife - ageDays;
const remainingPercent = Math.max(
0,
Math.min(100, (remainingDays / safeShelfLife) * 100),
);
return {
ageDays,
remainingDays,
remainingPercent: Math.round(remainingPercent),
score: Math.round(remainingPercent),
status: freshnessStatus(remainingPercent),
};
}
export const freshnessLabels: Record<FreshnessStatus, string> = {
fresh: "Fresh",
watch: "Watch",
at_risk: "At risk",
expired: "Expired",
};

View File

@@ -0,0 +1,77 @@
import { addDays } from "date-fns";
import type {
InventoryView,
Product,
Recommendation,
Sale,
} from "@/lib/types";
const money = (value: number) => Math.round(value * 100) / 100;
export function recommendPrice(
item: InventoryView,
productSales: Sale[],
): Recommendation {
const { product } = item;
const units30d = productSales.reduce((sum, sale) => sum + sale.quantity, 0);
const dailyVelocity = units30d / 30;
const daysOfSupply = dailyVelocity > 0 ? item.quantityOnHand / dailyVelocity : 99;
const minimumPrice = product.cost / (1 - product.marginFloorPercent / 100);
let discount = 0;
let type: Recommendation["type"] = "hold";
let action = "Keep the current price";
const reasons: string[] = [];
if (item.remainingPercent <= 20) {
discount += 0.18;
type = "markdown";
reasons.push("shelf life is nearly exhausted");
} else if (item.remainingPercent <= 45) {
discount += 0.1;
type = "promotion";
reasons.push("freshness has entered the watch window");
}
if (daysOfSupply > 12) {
discount += 0.08;
type = type === "markdown" ? "markdown" : "promotion";
reasons.push(`${Math.round(daysOfSupply)} days of supply exceeds the target`);
} else if (daysOfSupply < 4) {
discount -= 0.03;
reasons.push("healthy sales velocity limits discount pressure");
}
const target = Math.max(minimumPrice, product.listPrice * (1 - discount));
const recommendedLow = money(target);
const recommendedHigh = money(
Math.max(recommendedLow, Math.min(product.listPrice, target * 1.05)),
);
if (type === "markdown") {
action = `Review a markdown to ${recommendedLow.toFixed(2)}${recommendedHigh.toFixed(2)}`;
} else if (type === "promotion") {
action = `Run a short promotion at ${recommendedLow.toFixed(2)}${recommendedHigh.toFixed(2)}`;
}
return {
id: `rec_${item.id}`,
productId: product.id,
branchId: item.branchId,
type,
currentPrice: product.listPrice,
recommendedLow,
recommendedHigh,
confidence: Math.min(94, 62 + productSales.length + (reasons.length * 7)),
reason: reasons.length
? `${reasons.join(" and ")}. The margin floor remains protected.`
: "Stock, freshness, and sales velocity are within their target ranges.",
action,
expiresAt: addDays(new Date(), 2).toISOString(),
status: "pending",
};
}
export function marginPercent(product: Product, price: number) {
return ((price - product.cost) / price) * 100;
}

44
src/integrations/mock.ts Normal file
View File

@@ -0,0 +1,44 @@
import {
branches,
inventoryBatches,
products,
sales,
salespeople,
} from "@/lib/demo-data";
import type { IntegrationProvider } from "@/lib/types";
import type { IntegrationAdapter, SyncPage } from "@/integrations/types";
function page<T>(records: T[]): SyncPage<T> {
return { records, nextCursor: null };
}
export class MockIntegrationAdapter implements IntegrationAdapter {
constructor(readonly provider: IntegrationProvider) {}
async testConnection() {
return {
ok: true,
message: `${this.provider === "odoo" ? "Odoo" : "Salesforce"} demo source is ready`,
};
}
async fetchProducts() {
return page(this.provider === "odoo" ? products : []);
}
async fetchBranches() {
return page(branches);
}
async fetchInventory() {
return page(this.provider === "odoo" ? inventoryBatches : []);
}
async fetchSalespeople() {
return page(this.provider === "salesforce" ? salespeople : []);
}
async fetchSales() {
return page(this.provider === "salesforce" ? sales : []);
}
}

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
export const odooConfigSchema = z.object({
baseUrl: z.string().url(),
apiKey: z.string().min(8),
database: z.string().optional(),
apiMode: z.enum(["json2", "legacy-rpc"]).default("json2"),
});
export type OdooConfig = z.infer<typeof odooConfigSchema>;
export class OdooClient {
private readonly config: OdooConfig;
constructor(config: OdooConfig) {
this.config = odooConfigSchema.parse(config);
}
async call<T>(
model: string,
method: string,
body: Record<string, unknown>,
): Promise<T> {
if (this.config.apiMode !== "json2") {
throw new Error(
"Legacy RPC requires an Odoo version-specific adapter; configure it after discovery",
);
}
const headers: Record<string, string> = {
Authorization: `bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
};
if (this.config.database) {
headers["X-Odoo-Database"] = this.config.database;
}
const response = await fetch(
`${this.config.baseUrl.replace(/\/$/, "")}/json/2/${model}/${method}`,
{
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(15_000),
},
);
if (!response.ok) {
throw new Error(`Odoo ${model}.${method} failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
async testConnection() {
await this.call("res.users", "read", {
ids: [],
fields: ["id"],
context: {},
});
return { ok: true, message: "Connected to Odoo JSON-2" };
}
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export const salesforceConfigSchema = z.object({
instanceUrl: z.string().url(),
accessToken: z.string().min(8),
apiVersion: z.string().regex(/^v\d+\.\d+$/).default("v67.0"),
});
export type SalesforceConfig = z.infer<typeof salesforceConfigSchema>;
export class SalesforceClient {
private readonly config: SalesforceConfig;
constructor(config: SalesforceConfig) {
this.config = salesforceConfigSchema.parse(config);
}
private async request<T>(path: string): Promise<T> {
const response = await fetch(
`${this.config.instanceUrl.replace(/\/$/, "")}/services/data/${this.config.apiVersion}/${path}`,
{
headers: {
Authorization: `Bearer ${this.config.accessToken}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(15_000),
},
);
if (!response.ok) {
throw new Error(`Salesforce request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
async query<T>(soql: string) {
return this.request<{ records: T[]; done: boolean; nextRecordsUrl?: string }>(
`query?q=${encodeURIComponent(soql)}`,
);
}
async testConnection() {
await this.request("limits");
return { ok: true, message: "Connected to Salesforce" };
}
}

45
src/integrations/sync.ts Normal file
View File

@@ -0,0 +1,45 @@
import { MockIntegrationAdapter } from "@/integrations/mock";
import type { IntegrationAdapter, SyncSummary } from "@/integrations/types";
import type { IntegrationProvider } from "@/lib/types";
async function countAll(adapter: IntegrationAdapter) {
const results = await Promise.all([
adapter.fetchProducts(),
adapter.fetchBranches(),
adapter.fetchInventory(),
adapter.fetchSalespeople(),
adapter.fetchSales(),
]);
return results.reduce((sum, result) => sum + result.records.length, 0);
}
export async function runDemoSync(
provider: IntegrationProvider,
): Promise<SyncSummary> {
const startedAt = new Date().toISOString();
const adapter = new MockIntegrationAdapter(provider);
try {
const connection = await adapter.testConnection();
const recordsRead = await countAll(adapter);
return {
provider,
startedAt,
finishedAt: new Date().toISOString(),
recordsRead,
recordsWritten: recordsRead,
status: "succeeded",
message: `${connection.message}. ${recordsRead} normalized records upserted idempotently.`,
};
} catch (error) {
return {
provider,
startedAt,
finishedAt: new Date().toISOString(),
recordsRead: 0,
recordsWritten: 0,
status: "failed",
message: error instanceof Error ? error.message : "Unknown sync failure",
};
}
}

33
src/integrations/types.ts Normal file
View File

@@ -0,0 +1,33 @@
import type {
Branch,
IntegrationProvider,
InventoryBatch,
Product,
Sale,
Salesperson,
} from "@/lib/types";
export interface SyncPage<T> {
records: T[];
nextCursor: string | null;
}
export interface IntegrationAdapter {
readonly provider: IntegrationProvider;
testConnection(): Promise<{ ok: boolean; message: string }>;
fetchProducts(cursor?: string): Promise<SyncPage<Product>>;
fetchBranches(cursor?: string): Promise<SyncPage<Branch>>;
fetchInventory(cursor?: string): Promise<SyncPage<InventoryBatch>>;
fetchSalespeople(cursor?: string): Promise<SyncPage<Salesperson>>;
fetchSales(cursor?: string): Promise<SyncPage<Sale>>;
}
export interface SyncSummary {
provider: IntegrationProvider;
startedAt: string;
finishedAt: string;
recordsRead: number;
recordsWritten: number;
status: "succeeded" | "failed";
message: string;
}

39
src/jobs/sync-worker.ts Normal file
View File

@@ -0,0 +1,39 @@
import { PgBoss } from "pg-boss";
import { z } from "zod";
import { runDemoSync } from "@/integrations/sync";
export const SYNC_JOB = "integration-sync";
const payloadSchema = z.object({
organizationId: z.string().min(1),
provider: z.enum(["odoo", "salesforce"]),
});
export async function startSyncWorker(connectionString = process.env.DATABASE_URL) {
if (!connectionString) throw new Error("DATABASE_URL is required to start the sync worker");
const boss = new PgBoss(connectionString);
boss.on("error", (error) => console.error("sync-worker", error));
await boss.start();
await boss.createQueue(SYNC_JOB);
await boss.work(SYNC_JOB, async (jobs) => {
const results = [];
for (const job of jobs) {
const payload = payloadSchema.parse(job.data);
results.push(await runDemoSync(payload.provider));
}
return results;
});
return boss;
}
export async function enqueueSync(
boss: PgBoss,
payload: z.infer<typeof payloadSchema>,
) {
return boss.send(SYNC_JOB, payload, {
retryLimit: 4,
retryBackoff: true,
singletonKey: `${payload.organizationId}:${payload.provider}`,
expireInSeconds: 60 * 10,
});
}

23
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { SignJWT, jwtVerify } from "jose";
export const SESSION_COOKIE = "solaire_session";
const organizationId = "org_solaire_demo";
function secret() {
return new TextEncoder().encode(
process.env.SESSION_SECRET ?? "demo-only-secret-change-before-production",
);
}
export async function createSessionToken(email: string) {
return new SignJWT({ email, organizationId, role: "owner" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("demo_user")
.setIssuedAt()
.setExpirationTime("8h")
.sign(secret());
}
export async function verifySessionToken(token: string) {
return jwtVerify(token, secret());
}

102
src/lib/demo-data.ts Normal file
View File

@@ -0,0 +1,102 @@
import { subDays } from "date-fns";
import type {
Branch,
InventoryBatch,
Organization,
Product,
Sale,
Salesperson,
} from "@/lib/types";
const isoDaysAgo = (days: number) => subDays(new Date(), days).toISOString();
export const organization: Organization = {
id: "org_solaire_demo",
name: "Sun Valley Produce Co.",
slug: "sun-valley",
};
export const branches: Branch[] = [
{ id: "br_north", organizationId: organization.id, name: "North Market", city: "Portland", region: "North" },
{ id: "br_river", organizationId: organization.id, name: "River District", city: "Eugene", region: "Central" },
{ id: "br_coast", organizationId: organization.id, name: "Coastal Hub", city: "Newport", region: "West" },
{ id: "br_valley", organizationId: organization.id, name: "Valley Center", city: "Medford", region: "South" },
];
export const salespeople: Salesperson[] = [
{ id: "sp_maya", organizationId: organization.id, branchId: "br_north", name: "Maya Chen", email: "maya@example.com" },
{ id: "sp_luis", organizationId: organization.id, branchId: "br_north", name: "Luis Romero", email: "luis@example.com" },
{ id: "sp_aisha", organizationId: organization.id, branchId: "br_river", name: "Aisha Patel", email: "aisha@example.com" },
{ id: "sp_noah", organizationId: organization.id, branchId: "br_coast", name: "Noah Williams", email: "noah@example.com" },
{ id: "sp_elena", organizationId: organization.id, branchId: "br_valley", name: "Elena Torres", email: "elena@example.com" },
{ id: "sp_james", organizationId: organization.id, branchId: "br_valley", name: "James Okafor", email: "james@example.com" },
];
export const products: Product[] = [
{ id: "p_strawberry", organizationId: organization.id, sku: "FRU-101", name: "Organic Strawberries", category: "Berries", unit: "case", shelfLifeDays: 7, cost: 18, listPrice: 31, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:101" },
{ id: "p_avocado", organizationId: organization.id, sku: "FRU-204", name: "Hass Avocados", category: "Fruit", unit: "case", shelfLifeDays: 12, cost: 24, listPrice: 39, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:204" },
{ id: "p_spinach", organizationId: organization.id, sku: "VEG-112", name: "Baby Spinach", category: "Leafy greens", unit: "case", shelfLifeDays: 9, cost: 14, listPrice: 25, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:112" },
{ id: "p_tomato", organizationId: organization.id, sku: "VEG-221", name: "Heirloom Tomatoes", category: "Vegetables", unit: "case", shelfLifeDays: 10, cost: 21, listPrice: 36, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:221" },
{ id: "p_blueberry", organizationId: organization.id, sku: "FRU-118", name: "Blueberries", category: "Berries", unit: "case", shelfLifeDays: 10, cost: 22, listPrice: 38, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:118" },
{ id: "p_kale", organizationId: organization.id, sku: "VEG-130", name: "Tuscan Kale", category: "Leafy greens", unit: "case", shelfLifeDays: 11, cost: 12, listPrice: 23, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:130" },
{ id: "p_mango", organizationId: organization.id, sku: "FRU-310", name: "Ataulfo Mangoes", category: "Fruit", unit: "case", shelfLifeDays: 14, cost: 26, listPrice: 43, marginFloorPercent: 22, sourceSystem: "odoo", sourceId: "product.product:310" },
{ id: "p_pepper", organizationId: organization.id, sku: "VEG-304", name: "Sweet Bell Peppers", category: "Vegetables", unit: "case", shelfLifeDays: 16, cost: 19, listPrice: 34, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:304" },
];
const batch = (
id: string,
productId: string,
branchId: string,
age: number,
received: number,
onHand: number,
): InventoryBatch => ({
id,
organizationId: organization.id,
productId,
branchId,
receivedAt: isoDaysAgo(age),
quantityReceived: received,
quantityOnHand: onHand,
sourceId: `stock.quant:${id}`,
});
export const inventoryBatches: InventoryBatch[] = [
batch("b01", "p_strawberry", "br_north", 6, 90, 28),
batch("b02", "p_strawberry", "br_river", 2, 70, 36),
batch("b03", "p_avocado", "br_north", 4, 120, 48),
batch("b04", "p_avocado", "br_coast", 10, 80, 32),
batch("b05", "p_spinach", "br_river", 7, 110, 41),
batch("b06", "p_spinach", "br_valley", 3, 75, 29),
batch("b07", "p_tomato", "br_coast", 5, 95, 39),
batch("b08", "p_blueberry", "br_north", 9, 72, 22),
batch("b09", "p_kale", "br_valley", 2, 65, 18),
batch("b10", "p_mango", "br_river", 13, 86, 44),
batch("b11", "p_pepper", "br_coast", 6, 100, 35),
batch("b12", "p_tomato", "br_valley", 11, 60, 17),
];
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) =>
products.flatMap((product, productIndex) =>
branches.map((branch, branchIndex) => {
const people = salespeople.filter((person) => person.branchId === branch.id);
const quantity = Math.max(
1,
Math.round((3 + ((day + productIndex * 2 + branchIndex) % 7)) * productWeights[productIndex]),
);
const discount = (day + productIndex) % 11 === 0 ? 0.9 : 1;
return {
id: `sale_${day}_${productIndex}_${branchIndex}`,
organizationId: organization.id,
productId: product.id,
branchId: branch.id,
salespersonId: people[(day + productIndex) % people.length].id,
soldAt: isoDaysAgo(day),
quantity,
unitPrice: Number((product.listPrice * discount).toFixed(2)),
};
}),
),
).flat();

12
src/lib/format.ts Normal file
View File

@@ -0,0 +1,12 @@
export const currency = (value: number, compact = false) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: compact ? "compact" : "standard",
maximumFractionDigits: compact ? 1 : 2,
}).format(value);
export const number = (value: number) =>
new Intl.NumberFormat("en-US").format(Math.round(value));
export const percent = (value: number) => `${Math.round(value)}%`;

133
src/lib/types.ts Normal file
View File

@@ -0,0 +1,133 @@
export type FreshnessStatus = "fresh" | "watch" | "at_risk" | "expired";
export type IntegrationProvider = "odoo" | "salesforce";
export interface Organization {
id: string;
name: string;
slug: string;
}
export interface Branch {
id: string;
organizationId: string;
name: string;
city: string;
region: string;
}
export interface Salesperson {
id: string;
organizationId: string;
branchId: string;
name: string;
email: string;
}
export interface Product {
id: string;
organizationId: string;
sku: string;
name: string;
category: string;
unit: string;
shelfLifeDays: number;
cost: number;
listPrice: number;
marginFloorPercent: number;
sourceSystem: IntegrationProvider;
sourceId: string;
}
export interface InventoryBatch {
id: string;
organizationId: string;
productId: string;
branchId: string;
receivedAt: string;
quantityReceived: number;
quantityOnHand: number;
sourceId: string;
}
export interface Sale {
id: string;
organizationId: string;
productId: string;
branchId: string;
salespersonId: string;
soldAt: string;
quantity: number;
unitPrice: number;
}
export interface FreshnessResult {
ageDays: number;
remainingDays: number;
remainingPercent: number;
score: number;
status: FreshnessStatus;
}
export interface InventoryView extends InventoryBatch, FreshnessResult {
product: Product;
branch: Branch;
retailValue: number;
costExposure: number;
}
export interface Recommendation {
id: string;
productId: string;
branchId: string;
type: "hold" | "markdown" | "promotion";
currentPrice: number;
recommendedLow: number;
recommendedHigh: number;
confidence: number;
reason: string;
action: string;
expiresAt: string;
status: "pending" | "reviewed" | "dismissed";
}
export interface DashboardMetrics {
unitsOnHand: number;
retailValue: number;
atRiskValue: number;
revenue30d: number;
sellThrough30d: number;
averageFreshness: number;
change: {
unitsOnHand: number;
revenue: number;
atRisk: number;
};
}
export interface TrendPoint {
label: string;
revenue: number;
units: number;
wasteRisk: number;
}
export interface PerformanceRow {
id: string;
name: string;
secondary: string;
revenue: number;
units: number;
sellThrough: number;
freshness: number;
}
export interface DashboardData {
organization: Organization;
generatedAt: string;
metrics: DashboardMetrics;
inventory: InventoryView[];
recommendations: Recommendation[];
branches: PerformanceRow[];
salespeople: PerformanceRow[];
trends: TrendPoint[];
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

24
src/proxy.ts Normal file
View File

@@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth";
export async function proxy(request: NextRequest) {
if (process.env.REQUIRE_AUTH !== "true") return NextResponse.next();
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (token) {
try {
await verifySessionToken(token);
return NextResponse.next();
} catch {
// Continue to login redirect.
}
}
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
export const config = {
matcher: ["/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)"],
};