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>;
|
||||
}
|
||||
61
src/components/charts.tsx
Normal file
61
src/components/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/dashboard-shell.tsx
Normal file
160
src/components/dashboard-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/integration-settings.tsx
Normal file
69
src/components/integration-settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/performance-table.tsx
Normal file
46
src/components/performance-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/recommendation-list.tsx
Normal file
66
src/components/recommendation-list.tsx
Normal 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
113
src/components/ui.tsx
Normal 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
18
src/db/index.ts
Normal 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
194
src/db/schema.ts
Normal 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
140
src/domain/analytics.ts
Normal 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
38
src/domain/freshness.ts
Normal 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",
|
||||
};
|
||||
77
src/domain/recommendations.ts
Normal file
77
src/domain/recommendations.ts
Normal 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
44
src/integrations/mock.ts
Normal 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 : []);
|
||||
}
|
||||
}
|
||||
63
src/integrations/odoo/client.ts
Normal file
63
src/integrations/odoo/client.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
46
src/integrations/salesforce/client.ts
Normal file
46
src/integrations/salesforce/client.ts
Normal 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
45
src/integrations/sync.ts
Normal 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
33
src/integrations/types.ts
Normal 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
39
src/jobs/sync-worker.ts
Normal 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
23
src/lib/auth.ts
Normal 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
102
src/lib/demo-data.ts
Normal 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
12
src/lib/format.ts
Normal 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
133
src/lib/types.ts
Normal 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
6
src/lib/utils.ts
Normal 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
24
src/proxy.ts
Normal 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).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user