Build the Solaire inventory intelligence MVP
Some checks failed
CI / verify (push) Has been cancelled
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:
49
src/app/alerts/page.tsx
Normal file
49
src/app/alerts/page.tsx
Normal 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>;
|
||||
}
|
||||
31
src/app/api/auth/login/route.ts
Normal file
31
src/app/api/auth/login/route.ts
Normal 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;
|
||||
}
|
||||
8
src/app/api/auth/logout/route.ts
Normal file
8
src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
8
src/app/api/dashboard/route.ts
Normal file
8
src/app/api/dashboard/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
16
src/app/api/integrations/sync/route.ts
Normal file
16
src/app/api/integrations/sync/route.ts
Normal 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
29
src/app/branches/page.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
73
src/app/inventory/page.tsx
Normal file
73
src/app/inventory/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
48
src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/app/page.tsx
218
src/app/page.tsx
@@ -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={`Here’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/app/products/[id]/page.tsx
Normal file
68
src/app/products/[id]/page.tsx
Normal 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 batch’s received date with this product’s {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. Solaire’s 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>;
|
||||
}
|
||||
23
src/app/recommendations/page.tsx
Normal file
23
src/app/recommendations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/sales-team/page.tsx
Normal file
32
src/app/sales-team/page.tsx
Normal 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 Solaire’s 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>;
|
||||
}
|
||||
33
src/app/settings/integrations/page.tsx
Normal file
33
src/app/settings/integrations/page.tsx
Normal 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 Solaire’s 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user