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

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

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

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
.git
.env*
coverage
test-results
playwright-report
npm-debug.log*

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# The demo UI works without PostgreSQL. Set this for migrations and persistence.
DATABASE_URL=postgres://solaire:solaire@localhost:5432/solaire
# Set to true to require the seeded pilot login.
REQUIRE_AUTH=false
SESSION_SECRET=replace-with-at-least-32-random-characters
DEMO_USER_EMAIL=demo@solaire.app
DEMO_USER_PASSWORD=solaire-demo
# Future Odoo connection (API access requires an eligible Odoo plan).
ODOO_BASE_URL=
ODOO_DATABASE=
ODOO_API_KEY=
ODOO_API_MODE=json2
# Future Salesforce External Client App connection.
SALESFORCE_INSTANCE_URL=
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=

19
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check
- run: npm run build

4
.gitignore vendored
View File

@@ -12,6 +12,8 @@
# testing # testing
/coverage /coverage
/test-results
/playwright-report
# next.js # next.js
/.next/ /.next/
@@ -23,6 +25,7 @@
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
/.cursor/
# debug # debug
npm-debug.log* npm-debug.log*
@@ -32,6 +35,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,36 +1,85 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Solaire Inventory Intelligence
## Getting Started Solaire is a single-customer pilot for understanding produce inventory health, freshness risk, branch and salesperson performance, and advisory pricing opportunities. It currently runs on deterministic demo data and is structured to connect to Odoo Inventory and Salesforce later.
First, run the development server: ## What is included
- Friendly, responsive overview with inventory value, revenue, sell-through, and freshness risk
- Batch-level stock health estimated from received date and configurable product shelf life
- Product, branch, and salesperson performance views
- Explainable pricing and promotion recommendations with protected margin floors
- Odoo and Salesforce adapter boundaries plus working mock syncs
- Tenant-scoped PostgreSQL schema, signed pilot sessions, CI, Docker, and automated tests
Pricing is advisory only. Solaire does not write price changes to external systems.
## Run locally
Requirements: Node.js 22+ and npm.
```bash ```bash
npm install
cp .env.example .env.local
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open http://localhost:3000. Demo mode does not require a database.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. To exercise authentication, set `REQUIRE_AUTH=true`. The default pilot login is:
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - Email: `demo@solaire.app`
- Password: `solaire-demo`
## Learn More Change all demo secrets before deployment.
To learn more about Next.js, take a look at the following resources: ## PostgreSQL and migrations
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ```bash
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. docker compose up -d postgres
npm run db:generate
npm run db:migrate
```
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! The UI intentionally uses the in-memory demo source until real integration credentials are available. The Drizzle schema is the persistence contract for the production sync service.
## Deploy on Vercel ## Quality checks
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ```bash
npm run check
npm run build
npx playwright install chromium
npm run test:e2e
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## Architecture
- `src/app` — Next.js routes, APIs, and dashboard pages
- `src/components` — reusable dashboard and interactive UI
- `src/domain` — freshness, analytics, and pricing recommendation logic
- `src/integrations` — normalized adapters, mock sources, and sync orchestration
- `src/db` — tenant-aware Drizzle schema and PostgreSQL client
- `src/lib/demo-data.ts` — repeatable pilot scenarios
Every business entity carries an `organizationId`. The seeded pilot has one organization, but authorization boundaries, indexes, and source-system uniqueness constraints are ready for multiple tenants.
## Real integration handoff
### Odoo
Confirm the customers Odoo version, hosting model, database name, and external API entitlement. Odoo 19+ can use JSON-2 with a bearer API key. Older supported versions require a version-specific XML-RPC/JSON-RPC adapter. Map products, warehouses/locations, received stock, quantities, and completed stock moves into Solaires normalized types.
### Salesforce
Create a Salesforce External Client App and dedicated integration user, then confirm which standard or custom objects represent branches, salespeople, completed orders, and line items. Mapping belongs in the adapter configuration; analytics must not depend on Salesforce object names.
### Freshness assumptions
The pilot does not claim sensor, harvest, or inspection data. Freshness is:
`remaining shelf life = configured shelf-life days - days since received`
Staff should always confirm physical quality before disposal, transfer, markdown, or promotion.
## Deployment notes
The Dockerfile uses Next.js standalone output. For production, provide PostgreSQL, a strong `SESSION_SECRET`, encrypted integration credentials, HTTPS, and a scheduled worker process for PostgreSQL-backed sync jobs. Add audit retention and secret rotation policies before onboarding additional customers.

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: solaire
POSTGRES_USER: solaire
POSTGRES_PASSWORD: solaire
ports:
- "5432:5432"
volumes:
- solaire_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U solaire"]
interval: 5s
timeout: 3s
retries: 10
volumes:
solaire_postgres:

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://solaire:solaire@localhost:5432/solaire",
},
strict: true,
verbose: true,
});

View File

@@ -0,0 +1,204 @@
CREATE TYPE "public"."freshness_state" AS ENUM('fresh', 'watch', 'at_risk', 'expired');--> statement-breakpoint
CREATE TYPE "public"."integration_provider" AS ENUM('odoo', 'salesforce');--> statement-breakpoint
CREATE TYPE "public"."member_role" AS ENUM('owner', 'manager', 'viewer');--> statement-breakpoint
CREATE TYPE "public"."recommendation_state" AS ENUM('pending', 'reviewed', 'dismissed');--> statement-breakpoint
CREATE TYPE "public"."sync_state" AS ENUM('queued', 'running', 'succeeded', 'failed');--> statement-breakpoint
CREATE TABLE "alerts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"inventory_batch_id" uuid,
"severity" text NOT NULL,
"title" text NOT NULL,
"message" text NOT NULL,
"acknowledged_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"user_id" uuid,
"action" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "branches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"city" text NOT NULL,
"region" text NOT NULL,
"source_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "integration_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"provider" "integration_provider" NOT NULL,
"display_name" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"encrypted_credentials" text,
"configuration" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_synced_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "inventory_batches" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"received_at" timestamp with time zone NOT NULL,
"quantity_received" integer NOT NULL,
"quantity_on_hand" integer NOT NULL,
"freshness_score" integer NOT NULL,
"freshness_state" "freshness_state" NOT NULL,
"source_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberships" (
"organization_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "member_role" DEFAULT 'viewer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "memberships_organization_id_user_id_pk" PRIMARY KEY("organization_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "organizations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"slug" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "organizations_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"sku" text NOT NULL,
"name" text NOT NULL,
"category" text NOT NULL,
"unit" text NOT NULL,
"shelf_life_days" integer NOT NULL,
"cost" numeric(12, 2) NOT NULL,
"list_price" numeric(12, 2) NOT NULL,
"margin_floor_percent" numeric(5, 2) NOT NULL,
"source_system" "integration_provider" NOT NULL,
"source_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "recommendations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"type" text NOT NULL,
"current_price" numeric(12, 2) NOT NULL,
"recommended_low" numeric(12, 2) NOT NULL,
"recommended_high" numeric(12, 2) NOT NULL,
"confidence" integer NOT NULL,
"explanation" text NOT NULL,
"state" "recommendation_state" DEFAULT 'pending' NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"reviewed_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"salesperson_id" uuid,
"sold_at" timestamp with time zone NOT NULL,
"quantity" integer NOT NULL,
"unit_price" numeric(12, 2) NOT NULL,
"source_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "salespeople" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"name" text NOT NULL,
"email" text,
"source_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sync_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"connection_id" uuid,
"provider" "integration_provider" NOT NULL,
"state" "sync_state" DEFAULT 'queued' NOT NULL,
"cursor" text,
"records_read" integer DEFAULT 0 NOT NULL,
"records_written" integer DEFAULT 0 NOT NULL,
"error" text,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"password_hash" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "alerts" ADD CONSTRAINT "alerts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "alerts" ADD CONSTRAINT "alerts_inventory_batch_id_inventory_batches_id_fk" FOREIGN KEY ("inventory_batch_id") REFERENCES "public"."inventory_batches"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "branches" ADD CONSTRAINT "branches_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integration_connections" ADD CONSTRAINT "integration_connections_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inventory_batches" ADD CONSTRAINT "inventory_batches_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inventory_batches" ADD CONSTRAINT "inventory_batches_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "inventory_batches" ADD CONSTRAINT "inventory_batches_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales" ADD CONSTRAINT "sales_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales" ADD CONSTRAINT "sales_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales" ADD CONSTRAINT "sales_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales" ADD CONSTRAINT "sales_salesperson_id_salespeople_id_fk" FOREIGN KEY ("salesperson_id") REFERENCES "public"."salespeople"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "salespeople" ADD CONSTRAINT "salespeople_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "salespeople" ADD CONSTRAINT "salespeople_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_runs" ADD CONSTRAINT "sync_runs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sync_runs" ADD CONSTRAINT "sync_runs_connection_id_integration_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."integration_connections"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "alerts_tenant_idx" ON "alerts" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "branches_organization_idx" ON "branches" USING btree ("organization_id");--> statement-breakpoint
CREATE UNIQUE INDEX "branches_source_unique" ON "branches" USING btree ("organization_id","source_id");--> statement-breakpoint
CREATE UNIQUE INDEX "integration_provider_unique" ON "integration_connections" USING btree ("organization_id","provider");--> statement-breakpoint
CREATE INDEX "inventory_tenant_product_idx" ON "inventory_batches" USING btree ("organization_id","product_id");--> statement-breakpoint
CREATE UNIQUE INDEX "inventory_source_unique" ON "inventory_batches" USING btree ("organization_id","source_id");--> statement-breakpoint
CREATE UNIQUE INDEX "products_sku_unique" ON "products" USING btree ("organization_id","sku");--> statement-breakpoint
CREATE UNIQUE INDEX "products_source_unique" ON "products" USING btree ("organization_id","source_system","source_id");--> statement-breakpoint
CREATE INDEX "sales_tenant_date_idx" ON "sales" USING btree ("organization_id","sold_at");--> statement-breakpoint
CREATE UNIQUE INDEX "sales_source_unique" ON "sales" USING btree ("organization_id","source_id");--> statement-breakpoint
CREATE INDEX "salespeople_organization_idx" ON "salespeople" USING btree ("organization_id");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1785004957850,
"tag": "0000_nostalgic_christian_walker",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
allowedDevOrigins: ["127.0.0.1"],
}; };
export default nextConfig; export default nextConfig;

3748
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,52 @@
{ {
"name": "solaire-app", "name": "solaire",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"check": "npm run lint && npm run typecheck && npm run test"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"jose": "^6.2.4",
"lucide-react": "^1.26.0",
"next": "16.2.11", "next": "16.2.11",
"pg-boss": "^12.26.3",
"postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.10",
"drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.11", "eslint-config-next": "16.2.11",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.10"
},
"overrides": {
"postcss": "^8.5.23",
"sharp": "^0.35.3"
} }
} }

19
playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
use: {
baseURL: "http://127.0.0.1:3000",
trace: "on-first-retry",
},
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile", use: { ...devices["Pixel 7"] } },
],
});

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,65 +1,165 @@
import Image from "next/image"; import Link from "next/link";
import {
ArrowDownRight, ArrowRight, ArrowUpRight, Boxes, CircleDollarSign,
Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert,
} from "lucide-react";
import { getDashboardData } from "@/domain/analytics";
import { currency, number, percent } from "@/lib/format";
import {
Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
} from "@/components/ui";
import { RevenueChart } from "@/components/charts";
export default function Home() { 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 ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="space-y-6">
<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"> <PageHeader
<Image eyebrow="Saturday, July 25"
className="dark:invert" title="Good morning, Nora"
src="/next.svg" description={`Heres what needs attention across ${data.organization.name} today.`}
alt="Next.js logo" actions={
width={100} <>
height={20} <span className="hidden items-center gap-2 text-xs text-slate-400 sm:flex">
priority <span className="size-2 rounded-full bg-emerald-500" />Demo data live
/> </span>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <Link href="/settings/integrations" className={secondaryButtonStyles}>
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <RefreshCw className="size-3.5" />Sync data
To get started, edit the page.tsx file. </Link>
</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" <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
className="font-medium text-zinc-950 dark:text-zinc-50" <MetricCard label="Inventory value" value={currency(data.metrics.retailValue, true)}
> detail={`${number(data.metrics.unitsOnHand)} cases on hand`} change={data.metrics.change.unitsOnHand}
Templates icon={<Boxes className="size-5" />} tone="emerald" />
</a>{" "} <MetricCard label="Revenue · 30 days" value={currency(data.metrics.revenue30d, true)}
or the{" "} detail={`${percent(data.metrics.sellThrough30d)} sell-through`} change={data.metrics.change.revenue}
<a icon={<ShoppingCart className="size-5" />} tone="blue" />
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <MetricCard label="At-risk exposure" value={currency(data.metrics.atRiskValue, true)}
className="font-medium text-zinc-950 dark:text-zinc-50" detail={`${riskItems.length} batches need attention`} change={data.metrics.change.atRisk}
> icon={<TriangleAlert className="size-5" />} tone="amber" invertChange />
Learning <MetricCard label="Average freshness" value={percent(data.metrics.averageFreshness)}
</a>{" "} detail="Across active inventory" change={3.1}
center. icon={<Leaf className="size-5" />} tone="violet" />
</p> </div>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,.8fr)]">
<a <Card>
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]" <CardHeader title="Revenue momentum" description="Last 30 days across all branches"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 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>} />
target="_blank" <div className="px-4 pb-4 pt-5"><RevenueChart data={data.trends} /></div>
rel="noopener noreferrer" </Card>
> <Card>
<Image <CardHeader title="Stock health" description="Batches by remaining shelf life" />
className="dark:invert" <div className="p-5">
src="/vercel.svg" <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%)]">
alt="Vercel logomark" <div className="grid size-28 place-items-center rounded-full bg-white text-center">
width={16} <div><p className="text-3xl font-bold tracking-tight">{data.inventory.length}</p>
height={16} <p className="text-[10px] font-semibold uppercase tracking-widest text-slate-400">Batches</p></div>
/> </div>
Deploy Now </div>
</a> <div className="mt-6 grid grid-cols-2 gap-3">
<a {freshnessCounts.map((item) => (
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]" <div key={item.label} className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <span className="flex items-center gap-2 text-xs font-medium text-slate-600">
target="_blank" <span className={`size-2 rounded-full ${item.color}`} />{item.label}
rel="noopener noreferrer" </span>
> <span className="text-sm font-bold">{item.count}</span>
Documentation </div>
</a> ))}
</div> </div>
</main> </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> </div>
); );
} }
function MetricCard({ label, value, detail, change, icon, tone, invertChange = false }: {
label: string; value: string; detail: string; change: number; icon: React.ReactNode;
tone: "emerald" | "blue" | "amber" | "violet"; invertChange?: boolean;
}) {
const positive = invertChange ? change < 0 : change > 0;
const tones = {
emerald: "bg-emerald-50 text-emerald-700", blue: "bg-blue-50 text-blue-700",
amber: "bg-amber-50 text-amber-700", violet: "bg-violet-50 text-violet-700",
};
return (
<Card className="p-5">
<div className="flex items-start justify-between">
<span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}>{icon}</span>
<span className={`flex items-center text-[11px] font-bold ${positive ? "text-emerald-600" : "text-rose-600"}`}>
{change > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}{Math.abs(change)}%
</span>
</div>
<p className="mt-5 text-xs font-semibold text-slate-500">{label}</p>
<p className="mt-1 text-2xl font-bold tracking-tight">{value}</p>
<p className="mt-1 text-[11px] text-slate-400">{detail}</p>
</Card>
);
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

24
src/proxy.ts Normal file
View File

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

View File

@@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test";
test("shows inventory intelligence overview", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Good morning, Nora" })).toBeVisible();
await expect(page.getByText("Inventory value")).toBeVisible();
await expect(page.getByText("Freshness watchlist")).toBeVisible();
});
test("moves from inventory to a product", async ({ page }) => {
await page.goto("/inventory");
await expect(page.getByRole("heading", { name: "Inventory" })).toBeVisible();
await page.getByRole("link", { name: "Organic Strawberries" }).first().click();
await expect(page.getByRole("heading", { name: "Organic Strawberries" })).toBeVisible();
});
test("reviews a recommendation", async ({ page }) => {
await page.goto("/recommendations");
await expect(page.getByRole("heading", { name: "Recommendations" })).toBeVisible();
await page.getByRole("button", { name: "Reviewed" }).first().click();
await expect(page.getByText("reviewed").first()).toBeVisible();
});

25
tests/freshness.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { calculateFreshness, freshnessStatus } from "@/domain/freshness";
describe("freshness scoring", () => {
const asOf = new Date("2026-07-25T12:00:00Z");
it("returns a fresh score for recently received stock", () => {
expect(calculateFreshness("2026-07-23T12:00:00Z", 10, asOf)).toMatchObject({
ageDays: 2,
remainingDays: 8,
score: 80,
status: "fresh",
});
});
it("marks the threshold states consistently", () => {
expect(freshnessStatus(45)).toBe("watch");
expect(freshnessStatus(20)).toBe("at_risk");
expect(freshnessStatus(0)).toBe("expired");
});
it("never emits a negative freshness score", () => {
expect(calculateFreshness("2026-07-01T12:00:00Z", 7, asOf).score).toBe(0);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { getDashboardData } from "@/domain/analytics";
import { runDemoSync } from "@/integrations/sync";
describe("mock integration sync", () => {
it("normalizes Odoo and Salesforce fixtures", async () => {
const [odoo, salesforce] = await Promise.all([
runDemoSync("odoo"),
runDemoSync("salesforce"),
]);
expect(odoo.status).toBe("succeeded");
expect(salesforce.status).toBe("succeeded");
expect(odoo.recordsWritten).toBeGreaterThan(0);
expect(salesforce.recordsWritten).toBeGreaterThan(odoo.recordsWritten);
});
it("keeps all demo data inside the pilot tenant", () => {
const data = getDashboardData();
expect(data.inventory.every((item) => item.organizationId === data.organization.id)).toBe(true);
});
it("is idempotent for repeated fixture reads", async () => {
const first = await runDemoSync("odoo");
const second = await runDemoSync("odoo");
expect(second.recordsWritten).toBe(first.recordsWritten);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getDashboardData } from "@/domain/analytics";
import { marginPercent } from "@/domain/recommendations";
describe("pricing recommendations", () => {
const data = getDashboardData();
it("protects every product margin floor", () => {
for (const recommendation of data.recommendations) {
const product = data.inventory.find((item) => item.productId === recommendation.productId)!.product;
expect(marginPercent(product, recommendation.recommendedLow))
.toBeGreaterThanOrEqual(product.marginFloorPercent - 0.01);
}
});
it("provides explanations and bounded ranges", () => {
for (const recommendation of data.recommendations) {
expect(recommendation.reason.length).toBeGreaterThan(20);
expect(recommendation.recommendedHigh).toBeGreaterThanOrEqual(recommendation.recommendedLow);
expect(recommendation.recommendedHigh).toBeLessThanOrEqual(recommendation.currentPrice);
}
});
it("contains actionable aging-stock recommendations", () => {
expect(data.recommendations.some((item) => item.type === "markdown")).toBe(true);
expect(data.recommendations.some((item) => item.type === "promotion")).toBe(true);
});
});

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "node",
include: ["tests/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/domain/**/*.ts", "src/integrations/**/*.ts"],
},
},
});