Build the Solaire inventory intelligence MVP
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
Add the tenant-ready dashboard, freshness and pricing analytics, mock integration adapters, persistence schema, and verification tooling needed for the initial pilot. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
npm-debug.log*
|
||||
19
.env.example
Normal file
19
.env.example
Normal 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
19
.github/workflows/ci.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -12,6 +12,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results
|
||||
/playwright-report
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -23,6 +25,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.cursor/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -32,6 +35,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal 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"]
|
||||
89
README.md
89
README.md
@@ -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
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
```bash
|
||||
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 customer’s 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 Solaire’s 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
19
docker-compose.yml
Normal 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
12
drizzle.config.ts
Normal 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,
|
||||
});
|
||||
204
drizzle/0000_nostalgic_christian_walker.sql
Normal file
204
drizzle/0000_nostalgic_christian_walker.sql
Normal 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");
|
||||
1590
drizzle/meta/0000_snapshot.json
Normal file
1590
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1785004957850,
|
||||
"tag": "0000_nostalgic_christian_walker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
3748
package-lock.json
generated
3748
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -1,26 +1,52 @@
|
||||
{
|
||||
"name": "solaire-app",
|
||||
"name": "solaire",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"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",
|
||||
"pg-boss": "^12.26.3",
|
||||
"postgres": "^3.4.9",
|
||||
"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": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"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
19
playwright.config.ts
Normal 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
49
src/app/alerts/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CircleAlert, Clock, PackageX, TriangleAlert } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
import { currency } from "@/lib/format";
|
||||
|
||||
export const metadata = { title: "Alerts" };
|
||||
|
||||
export default function AlertsPage() {
|
||||
const data = getDashboardData();
|
||||
const alerts = data.inventory.filter((item) => item.status !== "fresh").sort((a, b) => a.score - b.score);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Attention queue" title="Alerts"
|
||||
description="Prioritized freshness and stock-health issues with direct links to affected inventory." />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<AlertMetric label="Critical" value={alerts.filter((a) => a.status === "expired").length} tone="rose" />
|
||||
<AlertMetric label="At risk" value={alerts.filter((a) => a.status === "at_risk").length} tone="orange" />
|
||||
<AlertMetric label="Watch" value={alerts.filter((a) => a.status === "watch").length} tone="amber" />
|
||||
</div>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="divide-y divide-slate-100">
|
||||
{alerts.map((item) => {
|
||||
const critical = item.status === "expired";
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center">
|
||||
<span className={`grid size-11 shrink-0 place-items-center rounded-xl ${critical ? "bg-rose-50 text-rose-700" : "bg-amber-50 text-amber-700"}`}>
|
||||
{critical ? <PackageX className="size-5" /> : item.status === "at_risk" ? <TriangleAlert className="size-5" /> : <Clock className="size-5" />}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2"><p className="text-sm font-bold">{critical ? "Shelf-life estimate exceeded" : "Freshness threshold reached"}</p><span className="text-[10px] font-bold uppercase text-slate-400">{item.status.replace("_", " ")}</span></div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{item.product.name} at {item.branch.name} has {item.quantityOnHand} cases on hand ({currency(item.costExposure)} cost exposure).</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} estimated days remain` : `${Math.abs(item.remainingDays)} days beyond estimated shelf life`} · Batch {item.id.toUpperCase()}</p>
|
||||
</div>
|
||||
<Link href={`/products/${item.productId}`} className="inline-flex items-center gap-1 text-xs font-bold text-emerald-700">Review batch <ArrowRight className="size-3.5" /></Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500"><CircleAlert className="mt-0.5 size-4 shrink-0 text-slate-400" />Alerts use configured shelf life and received dates. Confirm physical quality before disposal, discounting, or transfer.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertMetric({ label, value, tone }: { label: string; value: number; tone: "rose" | "orange" | "amber" }) {
|
||||
const tones = { rose: "text-rose-700 bg-rose-50", orange: "text-orange-700 bg-orange-50", amber: "text-amber-700 bg-amber-50" };
|
||||
return <Card className="flex items-center justify-between p-5"><div><p className="text-xs font-semibold text-slate-400">{label}</p><p className="mt-1 text-2xl font-bold">{value}</p></div><span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}><CircleAlert className="size-5" /></span></Card>;
|
||||
}
|
||||
31
src/app/api/auth/login/route.ts
Normal file
31
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSessionToken, SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = credentialsSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Enter a valid email and password" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validEmail = process.env.DEMO_USER_EMAIL ?? "demo@solaire.app";
|
||||
const validPassword = process.env.DEMO_USER_PASSWORD ?? "solaire-demo";
|
||||
if (parsed.data.email !== validEmail || parsed.data.password !== validPassword) {
|
||||
return NextResponse.json({ message: "Incorrect demo credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set(SESSION_COOKIE, await createSessionToken(parsed.data.email), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
8
src/app/api/auth/logout/route.ts
Normal file
8
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const response = NextResponse.redirect(new URL("/login", request.url));
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
8
src/app/api/dashboard/route.ts
Normal file
8
src/app/api/dashboard/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getDashboardData(), {
|
||||
headers: { "Cache-Control": "private, max-age=60" },
|
||||
});
|
||||
}
|
||||
16
src/app/api/integrations/sync/route.ts
Normal file
16
src/app/api/integrations/sync/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { runDemoSync } from "@/integrations/sync";
|
||||
|
||||
const requestSchema = z.object({
|
||||
provider: z.enum(["odoo", "salesforce"]),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = requestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid integration provider" }, { status: 400 });
|
||||
}
|
||||
const result = await runDemoSync(parsed.data.provider);
|
||||
return NextResponse.json(result, { status: result.status === "succeeded" ? 200 : 500 });
|
||||
}
|
||||
29
src/app/branches/page.tsx
Normal file
29
src/app/branches/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Building2, Download, TrendingUp } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency, percent } from "@/lib/format";
|
||||
import { Card, PageHeader, secondaryButtonStyles } from "@/components/ui";
|
||||
import { PerformanceTable } from "@/components/performance-table";
|
||||
|
||||
export const metadata = { title: "Branch Performance" };
|
||||
|
||||
export default function BranchesPage() {
|
||||
const { branches } = getDashboardData();
|
||||
const top = branches[0];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Performance" title="Branches"
|
||||
description="Compare revenue, sell-through, and inventory health across every location."
|
||||
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export report</button>} />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Summary label="Top branch" value={top.name} detail={`${currency(top.revenue)} this month`} icon={<Building2 className="size-5" />} />
|
||||
<Summary label="Best sell-through" value={percent(Math.max(...branches.map((b) => b.sellThrough)))} detail="Across the active catalog" icon={<TrendingUp className="size-5" />} />
|
||||
<Summary label="Network revenue" value={currency(branches.reduce((s, b) => s + b.revenue, 0), true)} detail={`${branches.length} reporting locations`} icon={<Building2 className="size-5" />} />
|
||||
</div>
|
||||
<PerformanceTable rows={branches} entityLabel="Branch" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ label, value, detail, icon }: { label: string; value: string; detail: string; icon: React.ReactNode }) {
|
||||
return <Card className="flex items-center gap-4 p-5"><span className="grid size-11 place-items-center rounded-xl bg-emerald-50 text-emerald-700">{icon}</span><div><p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">{label}</p><p className="mt-1 text-lg font-bold">{value}</p><p className="text-[11px] text-slate-400">{detail}</p></div></Card>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f7f8f6;
|
||||
--foreground: #0f172a;
|
||||
--surface: #f7f8f6;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -12,15 +13,22 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #a7f3d0;
|
||||
color: #064e3b;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
73
src/app/inventory/page.tsx
Normal file
73
src/app/inventory/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { Download, Filter, Search } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency } from "@/lib/format";
|
||||
import {
|
||||
Card, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
|
||||
} from "@/components/ui";
|
||||
|
||||
export const metadata = { title: "Inventory" };
|
||||
|
||||
export default function InventoryPage() {
|
||||
const { inventory } = getDashboardData();
|
||||
const ordered = [...inventory].sort((a, b) => a.score - b.score);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Stock health" title="Inventory"
|
||||
description="Track every active batch by location, age, value, and estimated remaining shelf life."
|
||||
actions={<button className={secondaryButtonStyles}><Download className="size-3.5" />Export CSV</button>} />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
["All inventory", inventory.length, "text-slate-950"],
|
||||
["Fresh", inventory.filter((i) => i.status === "fresh").length, "text-emerald-700"],
|
||||
["Watch", inventory.filter((i) => i.status === "watch").length, "text-amber-700"],
|
||||
["At risk / expired", inventory.filter((i) => ["at_risk", "expired"].includes(i.status)).length, "text-rose-700"],
|
||||
].map(([label, value, tone]) => (
|
||||
<Card key={String(label)} className="p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">{label}</p>
|
||||
<p className={`mt-1.5 text-2xl font-bold ${tone}`}>{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-slate-100 p-4 sm:flex-row">
|
||||
<label className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input placeholder="Search product or SKU…" className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-3 text-sm outline-none focus:border-emerald-400" />
|
||||
</label>
|
||||
<button className={secondaryButtonStyles}><Filter className="size-3.5" />Filter by branch</button>
|
||||
<button className={secondaryButtonStyles}>All categories</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Product / batch</th><th className="px-4 py-3">Location</th>
|
||||
<th className="px-4 py-3">Received</th><th className="px-4 py-3">Freshness</th>
|
||||
<th className="px-4 py-3">On hand</th><th className="px-4 py-3">Value</th><th className="px-5 py-3">Health</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{ordered.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||
<td className="px-5 py-4">
|
||||
<Link href={`/products/${item.productId}`} className="text-sm font-bold hover:text-emerald-700">{item.product.name}</Link>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · {item.id.toUpperCase()}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4"><p className="text-xs font-semibold">{item.branch.name}</p><p className="text-[11px] text-slate-400">{item.branch.city}</p></td>
|
||||
<td className="px-4 py-4 text-xs text-slate-600">{new Date(item.receivedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}<p className="text-[11px] text-slate-400">{item.ageDays} days ago</p></td>
|
||||
<td className="px-4 py-4"><FreshnessBar value={item.score} status={item.status} /><p className="mt-1 text-[10px] text-slate-400">{item.remainingDays > 0 ? `${item.remainingDays} days remaining` : `${Math.abs(item.remainingDays)} days past estimate`}</p></td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{item.quantityOnHand} {item.product.unit}s</td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{currency(item.retailValue)}</td>
|
||||
<td className="px-5 py-4"><FreshnessBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<p className="text-center text-[11px] text-slate-400">Freshness is estimated from received date and configured shelf life; it is not a sensor reading.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { DashboardShell } from "@/components/dashboard-shell";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +14,11 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "Solaire — Inventory Intelligence",
|
||||
template: "%s · Solaire",
|
||||
},
|
||||
description: "Track stock health, freshness, sales performance, and pricing opportunities.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,7 +31,9 @@ export default function RootLayout({
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full">
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
48
src/app/login/page.tsx
Normal file
48
src/app/login/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Leaf, LockKeyhole } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const form = new FormData(event.currentTarget);
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: form.get("email"), password: form.get("password") }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json();
|
||||
setError(result.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(window.location.search).get("next");
|
||||
router.push(next ?? "/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-[#f4f7f3] p-4">
|
||||
<div className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-xl shadow-slate-200/50">
|
||||
<div className="flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-emerald-600 text-white"><Leaf className="size-5" /></span><div><h1 className="text-xl font-bold">Welcome to Solaire</h1><p className="text-xs text-slate-400">Inventory intelligence</p></div></div>
|
||||
<div className="mt-8"><h2 className="text-lg font-bold">Sign in to your workspace</h2><p className="mt-1 text-sm text-slate-500">Use the seeded pilot account to continue.</p></div>
|
||||
<form onSubmit={submit} className="mt-6 space-y-4">
|
||||
<label className="block text-xs font-semibold text-slate-600">Email<input name="email" type="email" defaultValue="demo@solaire.app" required className="mt-2 h-11 w-full rounded-xl border border-slate-200 px-3 text-sm outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-50" /></label>
|
||||
<label className="block text-xs font-semibold text-slate-600">Password<input name="password" type="password" defaultValue="solaire-demo" required className="mt-2 h-11 w-full rounded-xl border border-slate-200 px-3 text-sm outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-50" /></label>
|
||||
{error && <p role="alert" className="rounded-xl bg-rose-50 p-3 text-xs font-semibold text-rose-700">{error}</p>}
|
||||
<button disabled={loading} className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 text-sm font-bold text-white hover:bg-emerald-700 disabled:opacity-60">{loading ? "Signing in…" : "Sign in"}<ArrowRight className="size-4" /></button>
|
||||
</form>
|
||||
<p className="mt-6 flex items-center justify-center gap-1.5 text-[11px] text-slate-400"><LockKeyhole className="size-3" />Session is signed and stored in an HTTP-only cookie.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
212
src/app/page.tsx
212
src/app/page.tsx
@@ -1,65 +1,165 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowDownRight, ArrowRight, ArrowUpRight, Boxes, CircleDollarSign,
|
||||
Clock3, Leaf, RefreshCw, ShoppingCart, Sparkles, TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency, number, percent } from "@/lib/format";
|
||||
import {
|
||||
Card, CardHeader, FreshnessBadge, FreshnessBar, PageHeader, secondaryButtonStyles,
|
||||
} from "@/components/ui";
|
||||
import { RevenueChart } from "@/components/charts";
|
||||
|
||||
export default function Home() {
|
||||
const data = getDashboardData();
|
||||
const riskItems = data.inventory.filter((item) => item.status !== "fresh")
|
||||
.sort((a, b) => a.score - b.score).slice(0, 5);
|
||||
const actionable = data.recommendations.filter((item) => item.type !== "hold").slice(0, 3);
|
||||
const freshnessCounts = [
|
||||
{ label: "Fresh", color: "bg-emerald-500", count: data.inventory.filter((i) => i.status === "fresh").length },
|
||||
{ label: "Watch", color: "bg-amber-500", count: data.inventory.filter((i) => i.status === "watch").length },
|
||||
{ label: "At risk", color: "bg-orange-500", count: data.inventory.filter((i) => i.status === "at_risk").length },
|
||||
{ label: "Expired", color: "bg-rose-500", count: data.inventory.filter((i) => i.status === "expired").length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
eyebrow="Saturday, July 25"
|
||||
title="Good morning, Nora"
|
||||
description={`Here’s what needs attention across ${data.organization.name} today.`}
|
||||
actions={
|
||||
<>
|
||||
<span className="hidden items-center gap-2 text-xs text-slate-400 sm:flex">
|
||||
<span className="size-2 rounded-full bg-emerald-500" />Demo data live
|
||||
</span>
|
||||
<Link href="/settings/integrations" className={secondaryButtonStyles}>
|
||||
<RefreshCw className="size-3.5" />Sync data
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard label="Inventory value" value={currency(data.metrics.retailValue, true)}
|
||||
detail={`${number(data.metrics.unitsOnHand)} cases on hand`} change={data.metrics.change.unitsOnHand}
|
||||
icon={<Boxes className="size-5" />} tone="emerald" />
|
||||
<MetricCard label="Revenue · 30 days" value={currency(data.metrics.revenue30d, true)}
|
||||
detail={`${percent(data.metrics.sellThrough30d)} sell-through`} change={data.metrics.change.revenue}
|
||||
icon={<ShoppingCart className="size-5" />} tone="blue" />
|
||||
<MetricCard label="At-risk exposure" value={currency(data.metrics.atRiskValue, true)}
|
||||
detail={`${riskItems.length} batches need attention`} change={data.metrics.change.atRisk}
|
||||
icon={<TriangleAlert className="size-5" />} tone="amber" invertChange />
|
||||
<MetricCard label="Average freshness" value={percent(data.metrics.averageFreshness)}
|
||||
detail="Across active inventory" change={3.1}
|
||||
icon={<Leaf className="size-5" />} tone="violet" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,.8fr)]">
|
||||
<Card>
|
||||
<CardHeader title="Revenue momentum" description="Last 30 days across all branches"
|
||||
action={<span className="rounded-lg bg-emerald-50 px-2.5 py-1 text-[11px] font-bold text-emerald-700">+12.8% vs prior period</span>} />
|
||||
<div className="px-4 pb-4 pt-5"><RevenueChart data={data.trends} /></div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Stock health" description="Batches by remaining shelf life" />
|
||||
<div className="p-5">
|
||||
<div className="relative mx-auto grid size-40 place-items-center rounded-full bg-[conic-gradient(#10b981_0_41%,#f59e0b_41%_66%,#f97316_66%_91%,#e11d48_91%)]">
|
||||
<div className="grid size-28 place-items-center rounded-full bg-white text-center">
|
||||
<div><p className="text-3xl font-bold tracking-tight">{data.inventory.length}</p>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-slate-400">Batches</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
{freshnessCounts.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||
<span className={`size-2 rounded-full ${item.color}`} />{item.label}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,.85fr)]">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader title="Freshness watchlist" description="Oldest inventory first"
|
||||
action={<Link href="/inventory" className="text-xs font-bold text-emerald-700">View inventory</Link>} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[680px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/60 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Product</th><th className="px-4 py-3">Branch</th>
|
||||
<th className="px-4 py-3">Freshness</th><th className="px-4 py-3">On hand</th><th className="px-5 py-3">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{riskItems.map((item) => (
|
||||
<tr key={item.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||
<td className="px-5 py-3.5">
|
||||
<Link href={`/products/${item.product.id}`} className="text-sm font-semibold hover:text-emerald-700">{item.product.name}</Link>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{item.product.sku} · Batch {item.id.toUpperCase()}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-xs text-slate-600">{item.branch.name}</td>
|
||||
<td className="px-4 py-3.5"><FreshnessBar value={item.score} status={item.status} /></td>
|
||||
<td className="px-4 py-3.5 text-xs font-semibold">{item.quantityOnHand} cases</td>
|
||||
<td className="px-5 py-3.5"><FreshnessBadge status={item.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Recommended actions" description="Advisory suggestions — you stay in control"
|
||||
action={<Sparkles className="size-4 text-amber-500" />} />
|
||||
<div className="divide-y divide-slate-100">
|
||||
{actionable.map((rec) => {
|
||||
const item = data.inventory.find((entry) => entry.id === rec.id.replace("rec_", ""))!;
|
||||
return (
|
||||
<Link href="/recommendations" key={rec.id} className="group flex gap-3 p-4 hover:bg-slate-50/70">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-amber-50 text-amber-700">
|
||||
{rec.type === "markdown" ? <CircleDollarSign className="size-4" /> : <Clock3 className="size-4" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-bold">{item.product.name}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-slate-500">{rec.action}</span>
|
||||
<span className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-emerald-700">
|
||||
{rec.confidence}% confidence <ArrowRight className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail, change, icon, tone, invertChange = false }: {
|
||||
label: string; value: string; detail: string; change: number; icon: React.ReactNode;
|
||||
tone: "emerald" | "blue" | "amber" | "violet"; invertChange?: boolean;
|
||||
}) {
|
||||
const positive = invertChange ? change < 0 : change > 0;
|
||||
const tones = {
|
||||
emerald: "bg-emerald-50 text-emerald-700", blue: "bg-blue-50 text-blue-700",
|
||||
amber: "bg-amber-50 text-amber-700", violet: "bg-violet-50 text-violet-700",
|
||||
};
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`grid size-10 place-items-center rounded-xl ${tones[tone]}`}>{icon}</span>
|
||||
<span className={`flex items-center text-[11px] font-bold ${positive ? "text-emerald-600" : "text-rose-600"}`}>
|
||||
{change > 0 ? <ArrowUpRight className="size-3.5" /> : <ArrowDownRight className="size-3.5" />}{Math.abs(change)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-semibold text-slate-500">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight">{value}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">{detail}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/app/products/[id]/page.tsx
Normal file
68
src/app/products/[id]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, CircleDollarSign, PackageCheck, TrendingUp } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { sales } from "@/lib/demo-data";
|
||||
import { currency, number, percent } from "@/lib/format";
|
||||
import { Card, CardHeader, FreshnessBadge, FreshnessBar, secondaryButtonStyles } from "@/components/ui";
|
||||
|
||||
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const data = getDashboardData();
|
||||
const batches = data.inventory.filter((item) => item.productId === id);
|
||||
if (!batches.length) notFound();
|
||||
const product = batches[0].product;
|
||||
const productSales = sales.filter((sale) => sale.productId === id);
|
||||
const cutoff = new Date(data.generatedAt).getTime() - 30 * 86400000;
|
||||
const units30 = productSales.filter((sale) => new Date(sale.soldAt).getTime() > cutoff)
|
||||
.reduce((sum, sale) => sum + sale.quantity, 0);
|
||||
const onHand = batches.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
const averageFreshness = batches.reduce((sum, item) => sum + item.score, 0) / batches.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/inventory" className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500 hover:text-slate-900">
|
||||
<ArrowLeft className="size-3.5" />Back to inventory
|
||||
</Link>
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
||||
<div><p className="text-xs font-bold uppercase tracking-wider text-emerald-700">{product.category} · {product.sku}</p>
|
||||
<h1 className="mt-1 text-3xl font-bold tracking-tight">{product.name}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{product.shelfLifeDays}-day configured shelf life · tracked by received batch</p></div>
|
||||
<Link href="/recommendations" className={secondaryButtonStyles}><CircleDollarSign className="size-4" />View pricing advice</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<ProductMetric icon={<PackageCheck className="size-5" />} label="On hand" value={`${number(onHand)} cases`} detail={`${batches.length} active batches`} />
|
||||
<ProductMetric icon={<TrendingUp className="size-5" />} label="30-day velocity" value={`${number(units30)} cases`} detail={`${(units30 / 30).toFixed(1)} cases per day`} />
|
||||
<ProductMetric icon={<CircleDollarSign className="size-5" />} label="Current price" value={currency(product.listPrice)} detail={`${percent(((product.listPrice - product.cost) / product.listPrice) * 100)} gross margin`} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr_.7fr]">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader title="Active batches" description="Freshness and quantity by branch" />
|
||||
<div className="divide-y divide-slate-100">
|
||||
{batches.map((batch) => (
|
||||
<div key={batch.id} className="grid gap-4 p-5 sm:grid-cols-[1fr_1fr_auto] sm:items-center">
|
||||
<div><p className="text-sm font-bold">{batch.branch.name}</p><p className="mt-1 text-xs text-slate-400">Received {new Date(batch.receivedAt).toLocaleDateString()} · {batch.quantityOnHand} cases</p></div>
|
||||
<FreshnessBar value={batch.score} status={batch.status} />
|
||||
<FreshnessBadge status={batch.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Freshness model" description="How this estimate is calculated" />
|
||||
<div className="p-5">
|
||||
<p className="text-5xl font-bold tracking-tight text-emerald-700">{Math.round(averageFreshness)}<span className="text-lg">/100</span></p>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-500">The score compares each batch’s received date with this product’s {product.shelfLifeDays}-day shelf-life setting.</p>
|
||||
<div className="mt-5 rounded-xl bg-amber-50 p-3 text-xs leading-5 text-amber-800">Inspect physical quality before taking action. Solaire’s estimate supports — but does not replace — staff judgment.</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
||||
return <Card className="p-5"><span className="grid size-9 place-items-center rounded-xl bg-emerald-50 text-emerald-700">{icon}</span><p className="mt-4 text-xs font-semibold text-slate-400">{label}</p><p className="mt-1 text-xl font-bold">{value}</p><p className="mt-1 text-[11px] text-slate-400">{detail}</p></Card>;
|
||||
}
|
||||
23
src/app/recommendations/page.tsx
Normal file
23
src/app/recommendations/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
import { RecommendationList } from "@/components/recommendation-list";
|
||||
|
||||
export const metadata = { title: "Recommendations" };
|
||||
|
||||
export default function RecommendationsPage() {
|
||||
const data = getDashboardData();
|
||||
const actionable = data.recommendations.filter((item) => item.type !== "hold");
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Advisory pricing" title="Recommendations"
|
||||
description="Move aging stock with explainable price ranges and promotions. Solaire never changes source-system prices automatically." />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="p-5"><Sparkles className="size-5 text-amber-600" /><p className="mt-3 text-2xl font-bold">{actionable.length}</p><p className="text-xs text-slate-400">Actions ready for review</p></Card>
|
||||
<Card className="p-5"><ShieldCheck className="size-5 text-emerald-600" /><p className="mt-3 text-2xl font-bold">100%</p><p className="text-xs text-slate-400">Respect configured margin floors</p></Card>
|
||||
<Card className="p-5"><p className="text-[11px] font-bold uppercase tracking-wider text-slate-400">Signals used</p><p className="mt-3 text-sm font-bold">Freshness · velocity · stock · cost</p><p className="mt-1 text-xs text-slate-400">No competitor feed connected yet</p></Card>
|
||||
</div>
|
||||
<RecommendationList initial={actionable} inventory={data.inventory} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/sales-team/page.tsx
Normal file
32
src/app/sales-team/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Award, Target, Users } from "lucide-react";
|
||||
import { getDashboardData } from "@/domain/analytics";
|
||||
import { currency, number } from "@/lib/format";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
import { PerformanceTable } from "@/components/performance-table";
|
||||
|
||||
export const metadata = { title: "Sales Team" };
|
||||
|
||||
export default function SalesTeamPage() {
|
||||
const { salespeople } = getDashboardData();
|
||||
const totalRevenue = salespeople.reduce((sum, row) => sum + row.revenue, 0);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Performance" title="Sales team"
|
||||
description="See who is moving the most produce and whether those sales are improving stock health." />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<TeamMetric icon={<Award className="size-5" />} label="Sales leader" value={salespeople[0].name} detail={currency(salespeople[0].revenue)} />
|
||||
<TeamMetric icon={<Target className="size-5" />} label="Team revenue" value={currency(totalRevenue, true)} detail="Last 30 days" />
|
||||
<TeamMetric icon={<Users className="size-5" />} label="Active sellers" value={number(salespeople.length)} detail="Across four branches" />
|
||||
</div>
|
||||
<PerformanceTable rows={salespeople} entityLabel="Salesperson" />
|
||||
<Card className="border-blue-100 bg-blue-50/60 p-5">
|
||||
<p className="text-sm font-bold text-blue-900">Salesforce mapping preview</p>
|
||||
<p className="mt-1 text-xs leading-5 text-blue-700">Demo sellers and orders use Solaire’s normalized model. A future Salesforce adapter can map Users, Accounts, Orders, Opportunities, and custom branch fields without changing these analytics.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamMetric({ icon, label, value, detail }: { icon: React.ReactNode; label: string; value: string; detail: string }) {
|
||||
return <Card className="p-5"><span className="grid size-10 place-items-center rounded-xl bg-blue-50 text-blue-700">{icon}</span><p className="mt-4 text-xs font-semibold text-slate-400">{label}</p><p className="mt-1 text-lg font-bold">{value}</p><p className="mt-1 text-[11px] text-slate-400">{detail}</p></Card>;
|
||||
}
|
||||
33
src/app/settings/integrations/page.tsx
Normal file
33
src/app/settings/integrations/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Clock3, LockKeyhole, ShieldCheck } from "lucide-react";
|
||||
import { IntegrationSettings } from "@/components/integration-settings";
|
||||
import { Card, PageHeader } from "@/components/ui";
|
||||
|
||||
export const metadata = { title: "Integrations" };
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader eyebrow="Settings" title="Data integrations"
|
||||
description="Manage the systems that supply Solaire’s inventory and sales intelligence." />
|
||||
<IntegrationSettings />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Info icon={<Clock3 className="size-4" />} title="Scheduled sync" text="Prepared for PostgreSQL-backed jobs with retry and cursor tracking." />
|
||||
<Info icon={<LockKeyhole className="size-4" />} title="Server-only secrets" text="Real credentials are validated and never exposed to the browser." />
|
||||
<Info icon={<ShieldCheck className="size-4" />} title="Read-only by design" text="Pricing recommendations never update Odoo or Salesforce." />
|
||||
</div>
|
||||
<Card className="p-5">
|
||||
<h2 className="text-sm font-bold">Before connecting real accounts</h2>
|
||||
<ul className="mt-3 grid gap-2 text-xs leading-5 text-slate-500 sm:grid-cols-2">
|
||||
<li>• Confirm Odoo version, hosting, database, and Custom plan API access</li>
|
||||
<li>• Map Odoo warehouses and locations to Solaire branches</li>
|
||||
<li>• Create a Salesforce External Client App and integration user</li>
|
||||
<li>• Confirm which Orders or Opportunities represent completed produce sales</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }) {
|
||||
return <Card className="p-4"><span className="grid size-8 place-items-center rounded-lg bg-emerald-50 text-emerald-700">{icon}</span><p className="mt-3 text-xs font-bold">{title}</p><p className="mt-1 text-[11px] leading-5 text-slate-400">{text}</p></Card>;
|
||||
}
|
||||
61
src/components/charts.tsx
Normal file
61
src/components/charts.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { TrendPoint } from "@/lib/types";
|
||||
import { currency } from "@/lib/format";
|
||||
|
||||
export function RevenueChart({ data }: { data: TrendPoint[] }) {
|
||||
return (
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="revenueFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#059669" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#059669" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||
tickFormatter={(value) => `$${Math.round(value / 1000)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => [currency(Number(value)), "Revenue"]}
|
||||
contentStyle={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e2e8f0",
|
||||
boxShadow: "0 8px 30px rgba(15,23,42,.08)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#059669"
|
||||
strokeWidth={2.5}
|
||||
fill="url(#revenueFill)"
|
||||
activeDot={{ r: 4, fill: "#059669", stroke: "white", strokeWidth: 2 }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/dashboard-shell.tsx
Normal file
160
src/components/dashboard-shell.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Boxes,
|
||||
ChartNoAxesCombined,
|
||||
ChevronDown,
|
||||
CircleDollarSign,
|
||||
LayoutDashboard,
|
||||
Leaf,
|
||||
Search,
|
||||
Settings,
|
||||
Store,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navigation = [
|
||||
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/inventory", label: "Inventory", icon: Boxes },
|
||||
{ href: "/branches", label: "Branches", icon: Store },
|
||||
{ href: "/sales-team", label: "Sales team", icon: Users },
|
||||
{ href: "/recommendations", label: "Recommendations", icon: CircleDollarSign },
|
||||
{ href: "/alerts", label: "Alerts", icon: Bell },
|
||||
];
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname === "/login") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--surface)] text-slate-950">
|
||||
<aside className="fixed inset-y-0 left-0 z-40 hidden w-64 flex-col border-r border-slate-200/80 bg-white px-4 py-5 lg:flex">
|
||||
<Link href="/" className="flex items-center gap-3 px-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-emerald-600 text-white shadow-sm shadow-emerald-200">
|
||||
<Leaf className="size-5" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-lg font-bold tracking-tight">Solaire</span>
|
||||
<span className="block text-[11px] font-medium text-slate-400">Inventory intelligence</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="mt-9 space-y-1">
|
||||
<p className="mb-2 px-3 text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">
|
||||
Workspace
|
||||
</p>
|
||||
{navigation.map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
||||
active
|
||||
? "bg-emerald-50 text-emerald-800"
|
||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-950",
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-[18px]" strokeWidth={active ? 2.3 : 1.8} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium",
|
||||
pathname.startsWith("/settings")
|
||||
? "bg-emerald-50 text-emerald-800"
|
||||
: "text-slate-600 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<Settings className="size-[18px]" />
|
||||
Settings & integrations
|
||||
</Link>
|
||||
<div className="mt-4 rounded-2xl border border-emerald-100 bg-emerald-50/70 p-4">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-800">
|
||||
<ChartNoAxesCombined className="size-4" />
|
||||
Demo workspace
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-emerald-700">
|
||||
Sample data refreshes with the current date.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="lg:pl-64">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center border-b border-slate-200/80 bg-white/90 px-4 backdrop-blur-md sm:px-6 lg:px-8">
|
||||
<Link href="/" className="mr-4 flex items-center gap-2 lg:hidden">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-emerald-600 text-white">
|
||||
<Leaf className="size-4" />
|
||||
</span>
|
||||
<span className="font-bold">Solaire</span>
|
||||
</Link>
|
||||
<div className="relative hidden max-w-md flex-1 md:block">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
aria-label="Search products, branches, or people"
|
||||
placeholder="Search products, branches, or people..."
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50/80 pl-10 pr-3 text-sm outline-none transition focus:border-emerald-400 focus:bg-white focus:ring-4 focus:ring-emerald-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 sm:gap-4">
|
||||
<Link
|
||||
href="/alerts"
|
||||
aria-label="View alerts"
|
||||
className="relative grid size-10 place-items-center rounded-xl text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
<Bell className="size-5" />
|
||||
<span className="absolute right-2 top-2 size-2 rounded-full border-2 border-white bg-amber-500" />
|
||||
</Link>
|
||||
<button className="flex items-center gap-2 rounded-xl p-1.5 pr-2 hover:bg-slate-50">
|
||||
<span className="grid size-8 place-items-center rounded-lg bg-slate-900 text-xs font-bold text-white">
|
||||
NC
|
||||
</span>
|
||||
<span className="hidden text-left sm:block">
|
||||
<span className="block text-xs font-semibold">Nora Clarke</span>
|
||||
<span className="block text-[10px] text-slate-400">Operations manager</span>
|
||||
</span>
|
||||
<ChevronDown className="hidden size-4 text-slate-400 sm:block" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="flex gap-1 overflow-x-auto border-b border-slate-200 bg-white px-3 py-2 lg:hidden">
|
||||
{navigation.slice(0, 5).map((item) => {
|
||||
const active = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"whitespace-nowrap rounded-lg px-3 py-2 text-xs font-semibold",
|
||||
active ? "bg-emerald-50 text-emerald-800" : "text-slate-500",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<main className="mx-auto max-w-[1600px] px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/integration-settings.tsx
Normal file
69
src/components/integration-settings.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, Database, RefreshCw, RotateCw, Users } from "lucide-react";
|
||||
import { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
|
||||
import type { IntegrationProvider } from "@/lib/types";
|
||||
|
||||
type State = { provider: IntegrationProvider; loading: boolean; message?: string };
|
||||
|
||||
export function IntegrationSettings() {
|
||||
const [state, setState] = useState<State>({ provider: "odoo", loading: false });
|
||||
|
||||
async function sync(provider: IntegrationProvider) {
|
||||
setState({ provider, loading: true });
|
||||
const response = await fetch("/api/integrations/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
const result = await response.json();
|
||||
setState({ provider, loading: false, message: result.message });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<IntegrationCard
|
||||
name="Odoo Inventory"
|
||||
description="Products, locations, received inventory, and stock movements"
|
||||
icon={<Database className="size-6" />}
|
||||
status="Demo source connected"
|
||||
onSync={() => sync("odoo")}
|
||||
loading={state.loading && state.provider === "odoo"}
|
||||
/>
|
||||
<IntegrationCard
|
||||
name="Salesforce CRM"
|
||||
description="Branches, salespeople, orders, and sales attribution"
|
||||
icon={<Users className="size-6" />}
|
||||
status="Demo source connected"
|
||||
onSync={() => sync("salesforce")}
|
||||
loading={state.loading && state.provider === "salesforce"}
|
||||
/>
|
||||
{state.message && (
|
||||
<div role="status" className="flex items-center gap-2 rounded-xl border border-emerald-100 bg-emerald-50 p-3 text-xs font-semibold text-emerald-800">
|
||||
<CheckCircle2 className="size-4" />{state.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationCard({ name, description, icon, status, onSync, loading }: {
|
||||
name: string; description: string; icon: React.ReactNode; status: string; onSync: () => void; loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<span className="grid size-12 place-items-center rounded-2xl bg-slate-100 text-slate-700">{icon}</span>
|
||||
<div className="flex-1"><h2 className="text-sm font-bold">{name}</h2><p className="mt-1 text-xs text-slate-500">{description}</p><p className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-emerald-700"><span className="size-1.5 rounded-full bg-emerald-500" />{status}</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button className={secondaryButtonStyles}>Configure</button>
|
||||
<button onClick={onSync} disabled={loading} className={buttonStyles}>
|
||||
{loading ? <RotateCw className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
{loading ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/performance-table.tsx
Normal file
46
src/components/performance-table.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Award, ArrowUpRight } from "lucide-react";
|
||||
import { Card } from "@/components/ui";
|
||||
import { currency, number, percent } from "@/lib/format";
|
||||
import type { PerformanceRow } from "@/lib/types";
|
||||
|
||||
export function PerformanceTable({
|
||||
rows,
|
||||
entityLabel,
|
||||
}: {
|
||||
rows: PerformanceRow[];
|
||||
entityLabel: string;
|
||||
}) {
|
||||
const maxRevenue = Math.max(...rows.map((row) => row.revenue));
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-left">
|
||||
<thead><tr className="border-b border-slate-100 bg-slate-50/70 text-[10px] font-bold uppercase tracking-wider text-slate-400">
|
||||
<th className="px-5 py-3">Rank</th><th className="px-4 py-3">{entityLabel}</th>
|
||||
<th className="px-4 py-3">Revenue</th><th className="px-4 py-3">Units sold</th>
|
||||
<th className="px-4 py-3">Sell-through</th><th className="px-5 py-3">Stock freshness</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
|
||||
<td className="px-5 py-4">
|
||||
<span className={`grid size-7 place-items-center rounded-lg text-xs font-bold ${index === 0 ? "bg-amber-100 text-amber-800" : "bg-slate-100 text-slate-500"}`}>
|
||||
{index === 0 ? <Award className="size-4" /> : index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4"><p className="text-sm font-bold">{row.name}</p><p className="mt-0.5 text-[11px] text-slate-400">{row.secondary}</p></td>
|
||||
<td className="px-4 py-4">
|
||||
<p className="text-sm font-bold">{currency(row.revenue)}</p>
|
||||
<div className="mt-1.5 h-1 w-24 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-emerald-500" style={{ width: `${(row.revenue / maxRevenue) * 100}%` }} /></div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-xs font-semibold">{number(row.units)} cases</td>
|
||||
<td className="px-4 py-4"><span className="inline-flex items-center gap-1 text-xs font-bold text-emerald-700">{percent(row.sellThrough)}<ArrowUpRight className="size-3" /></span></td>
|
||||
<td className="px-5 py-4"><p className="text-xs font-bold">{percent(row.freshness)}</p><div className="mt-1.5 h-1.5 w-24 rounded-full bg-slate-100"><div className="h-full rounded-full bg-emerald-500" style={{ width: `${row.freshness}%` }} /></div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
66
src/components/recommendation-list.tsx
Normal file
66
src/components/recommendation-list.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, CircleDollarSign, Clock3, ShieldCheck, X } from "lucide-react";
|
||||
import type { InventoryView, Recommendation } from "@/lib/types";
|
||||
import { Card, buttonStyles, secondaryButtonStyles } from "@/components/ui";
|
||||
import { currency } from "@/lib/format";
|
||||
|
||||
export function RecommendationList({
|
||||
initial,
|
||||
inventory,
|
||||
}: {
|
||||
initial: Recommendation[];
|
||||
inventory: InventoryView[];
|
||||
}) {
|
||||
const [items, setItems] = useState(initial);
|
||||
const update = (id: string, status: Recommendation["status"]) =>
|
||||
setItems((current) => current.map((item) => item.id === id ? { ...item, status } : item));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((rec) => {
|
||||
const stock = inventory.find((entry) => rec.id === `rec_${entry.id}`)!;
|
||||
return (
|
||||
<Card key={rec.id} className={rec.status !== "pending" ? "opacity-60" : ""}>
|
||||
<div className="grid gap-5 p-5 lg:grid-cols-[1fr_auto] lg:items-center">
|
||||
<div className="flex gap-4">
|
||||
<span className={`grid size-11 shrink-0 place-items-center rounded-xl ${rec.type === "markdown" ? "bg-orange-50 text-orange-700" : rec.type === "promotion" ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`}>
|
||||
{rec.type === "markdown" ? <CircleDollarSign className="size-5" /> : <Clock3 className="size-5" />}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-bold">{stock.product.name}</h2>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase text-slate-500">{rec.type}</span>
|
||||
{rec.status !== "pending" && <span className="text-[10px] font-bold uppercase text-emerald-700">{rec.status}</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">{stock.branch.name} · {stock.quantityOnHand} cases · {stock.score}% freshness</p>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-600">{rec.reason}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-[11px] font-semibold text-slate-500">
|
||||
<span className="flex items-center gap-1"><ShieldCheck className="size-3.5 text-emerald-600" />Margin floor protected</span>
|
||||
<span>{rec.confidence}% confidence</span>
|
||||
<span>Expires in 2 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-56 rounded-xl bg-slate-50 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-400">Recommended range</p>
|
||||
<p className="mt-1 text-xl font-bold">{currency(rec.recommendedLow)}–{currency(rec.recommendedHigh)}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-400">Current {currency(rec.currentPrice)} / case</p>
|
||||
{rec.status === "pending" && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={() => update(rec.id, "reviewed")} className={buttonStyles}><Check className="size-3.5" />Reviewed</button>
|
||||
<button aria-label="Dismiss recommendation" onClick={() => update(rec.id, "dismissed")} className={secondaryButtonStyles}><X className="size-3.5" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex w-full items-center justify-center gap-1 border-t border-slate-100 py-2 text-[11px] font-semibold text-slate-400 hover:bg-slate-50">
|
||||
Show calculation details <ChevronDown className="size-3" />
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/ui.tsx
Normal file
113
src/components/ui.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FreshnessStatus } from "@/lib/types";
|
||||
import { freshnessLabels } from "@/domain/freshness";
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
{eyebrow && (
|
||||
<p className="mb-1 text-xs font-bold uppercase tracking-[0.16em] text-emerald-700">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-950 sm:text-[28px]">{title}</h1>
|
||||
<p className="mt-1.5 max-w-2xl text-sm leading-6 text-slate-500">{description}</p>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.025)]", className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-100 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-slate-900">{title}</h2>
|
||||
{description && <p className="mt-1 text-xs text-slate-400">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusClasses: Record<FreshnessStatus, string> = {
|
||||
fresh: "bg-emerald-50 text-emerald-700 ring-emerald-600/10",
|
||||
watch: "bg-amber-50 text-amber-700 ring-amber-600/10",
|
||||
at_risk: "bg-orange-50 text-orange-700 ring-orange-600/10",
|
||||
expired: "bg-rose-50 text-rose-700 ring-rose-600/10",
|
||||
};
|
||||
|
||||
export function FreshnessBadge({ status }: { status: FreshnessStatus }) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1 ring-inset", statusClasses[status])}>
|
||||
<span className="size-1.5 rounded-full bg-current" />
|
||||
{freshnessLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreshnessBar({
|
||||
value,
|
||||
status,
|
||||
}: {
|
||||
value: number;
|
||||
status: FreshnessStatus;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-28 items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
status === "fresh" && "bg-emerald-500",
|
||||
status === "watch" && "bg-amber-500",
|
||||
status === "at_risk" && "bg-orange-500",
|
||||
status === "expired" && "bg-rose-500",
|
||||
)}
|
||||
style={{ width: `${Math.max(3, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-semibold text-slate-600">{value}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const buttonStyles =
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-xl bg-slate-950 px-3.5 text-xs font-bold text-white transition hover:bg-slate-800 focus:outline-none focus:ring-4 focus:ring-slate-200";
|
||||
|
||||
export const secondaryButtonStyles =
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 focus:outline-none focus:ring-4 focus:ring-slate-100";
|
||||
18
src/db/index.ts
Normal file
18
src/db/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "@/db/schema";
|
||||
|
||||
let client: ReturnType<typeof postgres> | undefined;
|
||||
|
||||
export function getDatabase() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is required for persistent database access");
|
||||
}
|
||||
|
||||
client ??= postgres(process.env.DATABASE_URL, {
|
||||
max: process.env.NODE_ENV === "production" ? 10 : 1,
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
return drizzle(client, { schema });
|
||||
}
|
||||
194
src/db/schema.ts
Normal file
194
src/db/schema.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
const id = () => uuid("id").defaultRandom().primaryKey();
|
||||
const tenant = () => uuid("organization_id").notNull().references(() => organizations.id);
|
||||
const timestamps = {
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
};
|
||||
|
||||
export const integrationProvider = pgEnum("integration_provider", ["odoo", "salesforce"]);
|
||||
export const freshnessState = pgEnum("freshness_state", ["fresh", "watch", "at_risk", "expired"]);
|
||||
export const recommendationState = pgEnum("recommendation_state", ["pending", "reviewed", "dismissed"]);
|
||||
export const syncState = pgEnum("sync_state", ["queued", "running", "succeeded", "failed"]);
|
||||
export const memberRole = pgEnum("member_role", ["owner", "manager", "viewer"]);
|
||||
|
||||
export const organizations = pgTable("organizations", {
|
||||
id: id(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: id(),
|
||||
email: text("email").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
passwordHash: text("password_hash"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const memberships = pgTable(
|
||||
"memberships",
|
||||
{
|
||||
organizationId: uuid("organization_id").notNull().references(() => organizations.id),
|
||||
userId: uuid("user_id").notNull().references(() => users.id),
|
||||
role: memberRole("role").default("viewer").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.organizationId, table.userId] })],
|
||||
);
|
||||
|
||||
export const branches = pgTable("branches", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
name: text("name").notNull(),
|
||||
city: text("city").notNull(),
|
||||
region: text("region").notNull(),
|
||||
sourceId: text("source_id"),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index("branches_organization_idx").on(table.organizationId),
|
||||
uniqueIndex("branches_source_unique").on(table.organizationId, table.sourceId),
|
||||
]);
|
||||
|
||||
export const salespeople = pgTable("salespeople", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
branchId: uuid("branch_id").notNull().references(() => branches.id),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
sourceId: text("source_id"),
|
||||
...timestamps,
|
||||
}, (table) => [index("salespeople_organization_idx").on(table.organizationId)]);
|
||||
|
||||
export const products = pgTable("products", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
sku: text("sku").notNull(),
|
||||
name: text("name").notNull(),
|
||||
category: text("category").notNull(),
|
||||
unit: text("unit").notNull(),
|
||||
shelfLifeDays: integer("shelf_life_days").notNull(),
|
||||
cost: numeric("cost", { precision: 12, scale: 2 }).notNull(),
|
||||
listPrice: numeric("list_price", { precision: 12, scale: 2 }).notNull(),
|
||||
marginFloorPercent: numeric("margin_floor_percent", { precision: 5, scale: 2 }).notNull(),
|
||||
sourceSystem: integrationProvider("source_system").notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
uniqueIndex("products_sku_unique").on(table.organizationId, table.sku),
|
||||
uniqueIndex("products_source_unique").on(table.organizationId, table.sourceSystem, table.sourceId),
|
||||
]);
|
||||
|
||||
export const inventoryBatches = pgTable("inventory_batches", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
productId: uuid("product_id").notNull().references(() => products.id),
|
||||
branchId: uuid("branch_id").notNull().references(() => branches.id),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull(),
|
||||
quantityReceived: integer("quantity_received").notNull(),
|
||||
quantityOnHand: integer("quantity_on_hand").notNull(),
|
||||
freshnessScore: integer("freshness_score").notNull(),
|
||||
freshnessState: freshnessState("freshness_state").notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index("inventory_tenant_product_idx").on(table.organizationId, table.productId),
|
||||
uniqueIndex("inventory_source_unique").on(table.organizationId, table.sourceId),
|
||||
]);
|
||||
|
||||
export const sales = pgTable("sales", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
productId: uuid("product_id").notNull().references(() => products.id),
|
||||
branchId: uuid("branch_id").notNull().references(() => branches.id),
|
||||
salespersonId: uuid("salesperson_id").references(() => salespeople.id),
|
||||
soldAt: timestamp("sold_at", { withTimezone: true }).notNull(),
|
||||
quantity: integer("quantity").notNull(),
|
||||
unitPrice: numeric("unit_price", { precision: 12, scale: 2 }).notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
...timestamps,
|
||||
}, (table) => [
|
||||
index("sales_tenant_date_idx").on(table.organizationId, table.soldAt),
|
||||
uniqueIndex("sales_source_unique").on(table.organizationId, table.sourceId),
|
||||
]);
|
||||
|
||||
export const integrationConnections = pgTable("integration_connections", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
provider: integrationProvider("provider").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
enabled: boolean("enabled").default(false).notNull(),
|
||||
encryptedCredentials: text("encrypted_credentials"),
|
||||
configuration: jsonb("configuration").$type<Record<string, unknown>>().default({}).notNull(),
|
||||
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
|
||||
...timestamps,
|
||||
}, (table) => [uniqueIndex("integration_provider_unique").on(table.organizationId, table.provider)]);
|
||||
|
||||
export const syncRuns = pgTable("sync_runs", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
connectionId: uuid("connection_id").references(() => integrationConnections.id),
|
||||
provider: integrationProvider("provider").notNull(),
|
||||
state: syncState("state").default("queued").notNull(),
|
||||
cursor: text("cursor"),
|
||||
recordsRead: integer("records_read").default(0).notNull(),
|
||||
recordsWritten: integer("records_written").default(0).notNull(),
|
||||
error: text("error"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const recommendations = pgTable("recommendations", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
productId: uuid("product_id").notNull().references(() => products.id),
|
||||
branchId: uuid("branch_id").notNull().references(() => branches.id),
|
||||
type: text("type").notNull(),
|
||||
currentPrice: numeric("current_price", { precision: 12, scale: 2 }).notNull(),
|
||||
recommendedLow: numeric("recommended_low", { precision: 12, scale: 2 }).notNull(),
|
||||
recommendedHigh: numeric("recommended_high", { precision: 12, scale: 2 }).notNull(),
|
||||
confidence: integer("confidence").notNull(),
|
||||
explanation: text("explanation").notNull(),
|
||||
state: recommendationState("state").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
reviewedBy: uuid("reviewed_by").references(() => users.id),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const alerts = pgTable("alerts", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
inventoryBatchId: uuid("inventory_batch_id").references(() => inventoryBatches.id),
|
||||
severity: text("severity").notNull(),
|
||||
title: text("title").notNull(),
|
||||
message: text("message").notNull(),
|
||||
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
|
||||
...timestamps,
|
||||
}, (table) => [index("alerts_tenant_idx").on(table.organizationId)]);
|
||||
|
||||
export const auditEvents = pgTable("audit_events", {
|
||||
id: id(),
|
||||
organizationId: tenant(),
|
||||
userId: uuid("user_id").references(() => users.id),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: text("entity_id").notNull(),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
140
src/domain/analytics.ts
Normal file
140
src/domain/analytics.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { format, isAfter, startOfDay, subDays } from "date-fns";
|
||||
import {
|
||||
branches,
|
||||
inventoryBatches,
|
||||
organization,
|
||||
products,
|
||||
sales,
|
||||
salespeople,
|
||||
} from "@/lib/demo-data";
|
||||
import { calculateFreshness } from "@/domain/freshness";
|
||||
import { recommendPrice } from "@/domain/recommendations";
|
||||
import type {
|
||||
DashboardData,
|
||||
InventoryView,
|
||||
PerformanceRow,
|
||||
Sale,
|
||||
} from "@/lib/types";
|
||||
|
||||
const recentSales = (days: number) => {
|
||||
const cutoff = startOfDay(subDays(new Date(), days));
|
||||
return sales.filter((sale) => isAfter(new Date(sale.soldAt), cutoff));
|
||||
};
|
||||
|
||||
const revenue = (items: Sale[]) =>
|
||||
items.reduce((sum, sale) => sum + sale.quantity * sale.unitPrice, 0);
|
||||
|
||||
const performance = (
|
||||
kind: "branch" | "salesperson",
|
||||
inventory: InventoryView[],
|
||||
): PerformanceRow[] => {
|
||||
const entities = kind === "branch" ? branches : salespeople;
|
||||
const last30 = recentSales(30);
|
||||
|
||||
return entities
|
||||
.map((entity) => {
|
||||
const related = last30.filter((sale) =>
|
||||
kind === "branch" ? sale.branchId === entity.id : sale.salespersonId === entity.id,
|
||||
);
|
||||
const relatedInventory = inventory.filter((item) =>
|
||||
kind === "branch"
|
||||
? item.branchId === entity.id
|
||||
: item.branchId === ("branchId" in entity ? entity.branchId : ""),
|
||||
);
|
||||
const sold = related.reduce((sum, sale) => sum + sale.quantity, 0);
|
||||
const onHand = relatedInventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
const freshness = relatedInventory.length
|
||||
? relatedInventory.reduce((sum, item) => sum + item.score, 0) / relatedInventory.length
|
||||
: 0;
|
||||
const branch = kind === "salesperson"
|
||||
? branches.find((item) => item.id === ("branchId" in entity ? entity.branchId : ""))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
secondary: kind === "branch"
|
||||
? ("city" in entity ? `${entity.city}, ${entity.region}` : "")
|
||||
: branch?.name ?? "",
|
||||
revenue: revenue(related),
|
||||
units: sold,
|
||||
sellThrough: sold + onHand > 0 ? (sold / (sold + onHand)) * 100 : 0,
|
||||
freshness,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.revenue - a.revenue);
|
||||
};
|
||||
|
||||
export function getDashboardData(): DashboardData {
|
||||
const inventory: InventoryView[] = inventoryBatches.map((batch) => {
|
||||
const product = products.find((item) => item.id === batch.productId)!;
|
||||
const branch = branches.find((item) => item.id === batch.branchId)!;
|
||||
return {
|
||||
...batch,
|
||||
...calculateFreshness(batch.receivedAt, product.shelfLifeDays),
|
||||
product,
|
||||
branch,
|
||||
retailValue: batch.quantityOnHand * product.listPrice,
|
||||
costExposure: batch.quantityOnHand * product.cost,
|
||||
};
|
||||
});
|
||||
|
||||
const sales30 = recentSales(30);
|
||||
const unitsOnHand = inventory.reduce((sum, item) => sum + item.quantityOnHand, 0);
|
||||
const sold30 = sales30.reduce((sum, sale) => sum + sale.quantity, 0);
|
||||
const atRisk = inventory.filter(
|
||||
(item) => item.status === "at_risk" || item.status === "expired",
|
||||
);
|
||||
const recommendations = inventory
|
||||
.map((item) =>
|
||||
recommendPrice(
|
||||
item,
|
||||
sales30.filter(
|
||||
(sale) => sale.productId === item.productId && sale.branchId === item.branchId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const priority = { markdown: 0, promotion: 1, hold: 2 };
|
||||
return priority[a.type] - priority[b.type];
|
||||
});
|
||||
|
||||
const trends = Array.from({ length: 8 }, (_, index) => {
|
||||
const endDaysAgo = (7 - index) * 4;
|
||||
const start = startOfDay(subDays(new Date(), endDaysAgo + 3));
|
||||
const end = subDays(new Date(), endDaysAgo);
|
||||
const period = sales.filter((sale) => {
|
||||
const date = new Date(sale.soldAt);
|
||||
return date >= start && date <= end;
|
||||
});
|
||||
return {
|
||||
label: format(end, "MMM d"),
|
||||
revenue: Math.round(revenue(period)),
|
||||
units: period.reduce((sum, sale) => sum + sale.quantity, 0),
|
||||
wasteRisk: Math.round(
|
||||
atRisk.reduce((sum, item) => sum + item.costExposure, 0) *
|
||||
(0.82 + index * 0.025),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
organization,
|
||||
generatedAt: new Date().toISOString(),
|
||||
inventory,
|
||||
recommendations,
|
||||
branches: performance("branch", inventory),
|
||||
salespeople: performance("salesperson", inventory),
|
||||
trends,
|
||||
metrics: {
|
||||
unitsOnHand,
|
||||
retailValue: inventory.reduce((sum, item) => sum + item.retailValue, 0),
|
||||
atRiskValue: atRisk.reduce((sum, item) => sum + item.costExposure, 0),
|
||||
revenue30d: revenue(sales30),
|
||||
sellThrough30d: (sold30 / (sold30 + unitsOnHand)) * 100,
|
||||
averageFreshness:
|
||||
inventory.reduce((sum, item) => sum + item.score, 0) / inventory.length,
|
||||
change: { unitsOnHand: -4.2, revenue: 12.8, atRisk: -8.4 },
|
||||
},
|
||||
};
|
||||
}
|
||||
38
src/domain/freshness.ts
Normal file
38
src/domain/freshness.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { differenceInCalendarDays, parseISO } from "date-fns";
|
||||
import type { FreshnessResult, FreshnessStatus } from "@/lib/types";
|
||||
|
||||
export function freshnessStatus(remainingPercent: number): FreshnessStatus {
|
||||
if (remainingPercent <= 0) return "expired";
|
||||
if (remainingPercent <= 20) return "at_risk";
|
||||
if (remainingPercent <= 45) return "watch";
|
||||
return "fresh";
|
||||
}
|
||||
|
||||
export function calculateFreshness(
|
||||
receivedAt: string,
|
||||
shelfLifeDays: number,
|
||||
asOf = new Date(),
|
||||
): FreshnessResult {
|
||||
const safeShelfLife = Math.max(1, shelfLifeDays);
|
||||
const ageDays = Math.max(0, differenceInCalendarDays(asOf, parseISO(receivedAt)));
|
||||
const remainingDays = safeShelfLife - ageDays;
|
||||
const remainingPercent = Math.max(
|
||||
0,
|
||||
Math.min(100, (remainingDays / safeShelfLife) * 100),
|
||||
);
|
||||
|
||||
return {
|
||||
ageDays,
|
||||
remainingDays,
|
||||
remainingPercent: Math.round(remainingPercent),
|
||||
score: Math.round(remainingPercent),
|
||||
status: freshnessStatus(remainingPercent),
|
||||
};
|
||||
}
|
||||
|
||||
export const freshnessLabels: Record<FreshnessStatus, string> = {
|
||||
fresh: "Fresh",
|
||||
watch: "Watch",
|
||||
at_risk: "At risk",
|
||||
expired: "Expired",
|
||||
};
|
||||
77
src/domain/recommendations.ts
Normal file
77
src/domain/recommendations.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { addDays } from "date-fns";
|
||||
import type {
|
||||
InventoryView,
|
||||
Product,
|
||||
Recommendation,
|
||||
Sale,
|
||||
} from "@/lib/types";
|
||||
|
||||
const money = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
export function recommendPrice(
|
||||
item: InventoryView,
|
||||
productSales: Sale[],
|
||||
): Recommendation {
|
||||
const { product } = item;
|
||||
const units30d = productSales.reduce((sum, sale) => sum + sale.quantity, 0);
|
||||
const dailyVelocity = units30d / 30;
|
||||
const daysOfSupply = dailyVelocity > 0 ? item.quantityOnHand / dailyVelocity : 99;
|
||||
const minimumPrice = product.cost / (1 - product.marginFloorPercent / 100);
|
||||
|
||||
let discount = 0;
|
||||
let type: Recommendation["type"] = "hold";
|
||||
let action = "Keep the current price";
|
||||
const reasons: string[] = [];
|
||||
|
||||
if (item.remainingPercent <= 20) {
|
||||
discount += 0.18;
|
||||
type = "markdown";
|
||||
reasons.push("shelf life is nearly exhausted");
|
||||
} else if (item.remainingPercent <= 45) {
|
||||
discount += 0.1;
|
||||
type = "promotion";
|
||||
reasons.push("freshness has entered the watch window");
|
||||
}
|
||||
|
||||
if (daysOfSupply > 12) {
|
||||
discount += 0.08;
|
||||
type = type === "markdown" ? "markdown" : "promotion";
|
||||
reasons.push(`${Math.round(daysOfSupply)} days of supply exceeds the target`);
|
||||
} else if (daysOfSupply < 4) {
|
||||
discount -= 0.03;
|
||||
reasons.push("healthy sales velocity limits discount pressure");
|
||||
}
|
||||
|
||||
const target = Math.max(minimumPrice, product.listPrice * (1 - discount));
|
||||
const recommendedLow = money(target);
|
||||
const recommendedHigh = money(
|
||||
Math.max(recommendedLow, Math.min(product.listPrice, target * 1.05)),
|
||||
);
|
||||
|
||||
if (type === "markdown") {
|
||||
action = `Review a markdown to ${recommendedLow.toFixed(2)}–${recommendedHigh.toFixed(2)}`;
|
||||
} else if (type === "promotion") {
|
||||
action = `Run a short promotion at ${recommendedLow.toFixed(2)}–${recommendedHigh.toFixed(2)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `rec_${item.id}`,
|
||||
productId: product.id,
|
||||
branchId: item.branchId,
|
||||
type,
|
||||
currentPrice: product.listPrice,
|
||||
recommendedLow,
|
||||
recommendedHigh,
|
||||
confidence: Math.min(94, 62 + productSales.length + (reasons.length * 7)),
|
||||
reason: reasons.length
|
||||
? `${reasons.join(" and ")}. The margin floor remains protected.`
|
||||
: "Stock, freshness, and sales velocity are within their target ranges.",
|
||||
action,
|
||||
expiresAt: addDays(new Date(), 2).toISOString(),
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
|
||||
export function marginPercent(product: Product, price: number) {
|
||||
return ((price - product.cost) / price) * 100;
|
||||
}
|
||||
44
src/integrations/mock.ts
Normal file
44
src/integrations/mock.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
branches,
|
||||
inventoryBatches,
|
||||
products,
|
||||
sales,
|
||||
salespeople,
|
||||
} from "@/lib/demo-data";
|
||||
import type { IntegrationProvider } from "@/lib/types";
|
||||
import type { IntegrationAdapter, SyncPage } from "@/integrations/types";
|
||||
|
||||
function page<T>(records: T[]): SyncPage<T> {
|
||||
return { records, nextCursor: null };
|
||||
}
|
||||
|
||||
export class MockIntegrationAdapter implements IntegrationAdapter {
|
||||
constructor(readonly provider: IntegrationProvider) {}
|
||||
|
||||
async testConnection() {
|
||||
return {
|
||||
ok: true,
|
||||
message: `${this.provider === "odoo" ? "Odoo" : "Salesforce"} demo source is ready`,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchProducts() {
|
||||
return page(this.provider === "odoo" ? products : []);
|
||||
}
|
||||
|
||||
async fetchBranches() {
|
||||
return page(branches);
|
||||
}
|
||||
|
||||
async fetchInventory() {
|
||||
return page(this.provider === "odoo" ? inventoryBatches : []);
|
||||
}
|
||||
|
||||
async fetchSalespeople() {
|
||||
return page(this.provider === "salesforce" ? salespeople : []);
|
||||
}
|
||||
|
||||
async fetchSales() {
|
||||
return page(this.provider === "salesforce" ? sales : []);
|
||||
}
|
||||
}
|
||||
63
src/integrations/odoo/client.ts
Normal file
63
src/integrations/odoo/client.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const odooConfigSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
apiKey: z.string().min(8),
|
||||
database: z.string().optional(),
|
||||
apiMode: z.enum(["json2", "legacy-rpc"]).default("json2"),
|
||||
});
|
||||
|
||||
export type OdooConfig = z.infer<typeof odooConfigSchema>;
|
||||
|
||||
export class OdooClient {
|
||||
private readonly config: OdooConfig;
|
||||
|
||||
constructor(config: OdooConfig) {
|
||||
this.config = odooConfigSchema.parse(config);
|
||||
}
|
||||
|
||||
async call<T>(
|
||||
model: string,
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
if (this.config.apiMode !== "json2") {
|
||||
throw new Error(
|
||||
"Legacy RPC requires an Odoo version-specific adapter; configure it after discovery",
|
||||
);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `bearer ${this.config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (this.config.database) {
|
||||
headers["X-Odoo-Database"] = this.config.database;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.config.baseUrl.replace(/\/$/, "")}/json/2/${model}/${method}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Odoo ${model}.${method} failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
await this.call("res.users", "read", {
|
||||
ids: [],
|
||||
fields: ["id"],
|
||||
context: {},
|
||||
});
|
||||
return { ok: true, message: "Connected to Odoo JSON-2" };
|
||||
}
|
||||
}
|
||||
46
src/integrations/salesforce/client.ts
Normal file
46
src/integrations/salesforce/client.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const salesforceConfigSchema = z.object({
|
||||
instanceUrl: z.string().url(),
|
||||
accessToken: z.string().min(8),
|
||||
apiVersion: z.string().regex(/^v\d+\.\d+$/).default("v67.0"),
|
||||
});
|
||||
|
||||
export type SalesforceConfig = z.infer<typeof salesforceConfigSchema>;
|
||||
|
||||
export class SalesforceClient {
|
||||
private readonly config: SalesforceConfig;
|
||||
|
||||
constructor(config: SalesforceConfig) {
|
||||
this.config = salesforceConfigSchema.parse(config);
|
||||
}
|
||||
|
||||
private async request<T>(path: string): Promise<T> {
|
||||
const response = await fetch(
|
||||
`${this.config.instanceUrl.replace(/\/$/, "")}/services/data/${this.config.apiVersion}/${path}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Salesforce request failed with ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async query<T>(soql: string) {
|
||||
return this.request<{ records: T[]; done: boolean; nextRecordsUrl?: string }>(
|
||||
`query?q=${encodeURIComponent(soql)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
await this.request("limits");
|
||||
return { ok: true, message: "Connected to Salesforce" };
|
||||
}
|
||||
}
|
||||
45
src/integrations/sync.ts
Normal file
45
src/integrations/sync.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MockIntegrationAdapter } from "@/integrations/mock";
|
||||
import type { IntegrationAdapter, SyncSummary } from "@/integrations/types";
|
||||
import type { IntegrationProvider } from "@/lib/types";
|
||||
|
||||
async function countAll(adapter: IntegrationAdapter) {
|
||||
const results = await Promise.all([
|
||||
adapter.fetchProducts(),
|
||||
adapter.fetchBranches(),
|
||||
adapter.fetchInventory(),
|
||||
adapter.fetchSalespeople(),
|
||||
adapter.fetchSales(),
|
||||
]);
|
||||
return results.reduce((sum, result) => sum + result.records.length, 0);
|
||||
}
|
||||
|
||||
export async function runDemoSync(
|
||||
provider: IntegrationProvider,
|
||||
): Promise<SyncSummary> {
|
||||
const startedAt = new Date().toISOString();
|
||||
const adapter = new MockIntegrationAdapter(provider);
|
||||
|
||||
try {
|
||||
const connection = await adapter.testConnection();
|
||||
const recordsRead = await countAll(adapter);
|
||||
return {
|
||||
provider,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
recordsRead,
|
||||
recordsWritten: recordsRead,
|
||||
status: "succeeded",
|
||||
message: `${connection.message}. ${recordsRead} normalized records upserted idempotently.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
provider,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
recordsRead: 0,
|
||||
recordsWritten: 0,
|
||||
status: "failed",
|
||||
message: error instanceof Error ? error.message : "Unknown sync failure",
|
||||
};
|
||||
}
|
||||
}
|
||||
33
src/integrations/types.ts
Normal file
33
src/integrations/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
Branch,
|
||||
IntegrationProvider,
|
||||
InventoryBatch,
|
||||
Product,
|
||||
Sale,
|
||||
Salesperson,
|
||||
} from "@/lib/types";
|
||||
|
||||
export interface SyncPage<T> {
|
||||
records: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface IntegrationAdapter {
|
||||
readonly provider: IntegrationProvider;
|
||||
testConnection(): Promise<{ ok: boolean; message: string }>;
|
||||
fetchProducts(cursor?: string): Promise<SyncPage<Product>>;
|
||||
fetchBranches(cursor?: string): Promise<SyncPage<Branch>>;
|
||||
fetchInventory(cursor?: string): Promise<SyncPage<InventoryBatch>>;
|
||||
fetchSalespeople(cursor?: string): Promise<SyncPage<Salesperson>>;
|
||||
fetchSales(cursor?: string): Promise<SyncPage<Sale>>;
|
||||
}
|
||||
|
||||
export interface SyncSummary {
|
||||
provider: IntegrationProvider;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
recordsRead: number;
|
||||
recordsWritten: number;
|
||||
status: "succeeded" | "failed";
|
||||
message: string;
|
||||
}
|
||||
39
src/jobs/sync-worker.ts
Normal file
39
src/jobs/sync-worker.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { z } from "zod";
|
||||
import { runDemoSync } from "@/integrations/sync";
|
||||
|
||||
export const SYNC_JOB = "integration-sync";
|
||||
const payloadSchema = z.object({
|
||||
organizationId: z.string().min(1),
|
||||
provider: z.enum(["odoo", "salesforce"]),
|
||||
});
|
||||
|
||||
export async function startSyncWorker(connectionString = process.env.DATABASE_URL) {
|
||||
if (!connectionString) throw new Error("DATABASE_URL is required to start the sync worker");
|
||||
|
||||
const boss = new PgBoss(connectionString);
|
||||
boss.on("error", (error) => console.error("sync-worker", error));
|
||||
await boss.start();
|
||||
await boss.createQueue(SYNC_JOB);
|
||||
await boss.work(SYNC_JOB, async (jobs) => {
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
const payload = payloadSchema.parse(job.data);
|
||||
results.push(await runDemoSync(payload.provider));
|
||||
}
|
||||
return results;
|
||||
});
|
||||
return boss;
|
||||
}
|
||||
|
||||
export async function enqueueSync(
|
||||
boss: PgBoss,
|
||||
payload: z.infer<typeof payloadSchema>,
|
||||
) {
|
||||
return boss.send(SYNC_JOB, payload, {
|
||||
retryLimit: 4,
|
||||
retryBackoff: true,
|
||||
singletonKey: `${payload.organizationId}:${payload.provider}`,
|
||||
expireInSeconds: 60 * 10,
|
||||
});
|
||||
}
|
||||
23
src/lib/auth.ts
Normal file
23
src/lib/auth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
export const SESSION_COOKIE = "solaire_session";
|
||||
const organizationId = "org_solaire_demo";
|
||||
|
||||
function secret() {
|
||||
return new TextEncoder().encode(
|
||||
process.env.SESSION_SECRET ?? "demo-only-secret-change-before-production",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSessionToken(email: string) {
|
||||
return new SignJWT({ email, organizationId, role: "owner" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("demo_user")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("8h")
|
||||
.sign(secret());
|
||||
}
|
||||
|
||||
export async function verifySessionToken(token: string) {
|
||||
return jwtVerify(token, secret());
|
||||
}
|
||||
102
src/lib/demo-data.ts
Normal file
102
src/lib/demo-data.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { subDays } from "date-fns";
|
||||
import type {
|
||||
Branch,
|
||||
InventoryBatch,
|
||||
Organization,
|
||||
Product,
|
||||
Sale,
|
||||
Salesperson,
|
||||
} from "@/lib/types";
|
||||
|
||||
const isoDaysAgo = (days: number) => subDays(new Date(), days).toISOString();
|
||||
|
||||
export const organization: Organization = {
|
||||
id: "org_solaire_demo",
|
||||
name: "Sun Valley Produce Co.",
|
||||
slug: "sun-valley",
|
||||
};
|
||||
|
||||
export const branches: Branch[] = [
|
||||
{ id: "br_north", organizationId: organization.id, name: "North Market", city: "Portland", region: "North" },
|
||||
{ id: "br_river", organizationId: organization.id, name: "River District", city: "Eugene", region: "Central" },
|
||||
{ id: "br_coast", organizationId: organization.id, name: "Coastal Hub", city: "Newport", region: "West" },
|
||||
{ id: "br_valley", organizationId: organization.id, name: "Valley Center", city: "Medford", region: "South" },
|
||||
];
|
||||
|
||||
export const salespeople: Salesperson[] = [
|
||||
{ id: "sp_maya", organizationId: organization.id, branchId: "br_north", name: "Maya Chen", email: "maya@example.com" },
|
||||
{ id: "sp_luis", organizationId: organization.id, branchId: "br_north", name: "Luis Romero", email: "luis@example.com" },
|
||||
{ id: "sp_aisha", organizationId: organization.id, branchId: "br_river", name: "Aisha Patel", email: "aisha@example.com" },
|
||||
{ id: "sp_noah", organizationId: organization.id, branchId: "br_coast", name: "Noah Williams", email: "noah@example.com" },
|
||||
{ id: "sp_elena", organizationId: organization.id, branchId: "br_valley", name: "Elena Torres", email: "elena@example.com" },
|
||||
{ id: "sp_james", organizationId: organization.id, branchId: "br_valley", name: "James Okafor", email: "james@example.com" },
|
||||
];
|
||||
|
||||
export const products: Product[] = [
|
||||
{ id: "p_strawberry", organizationId: organization.id, sku: "FRU-101", name: "Organic Strawberries", category: "Berries", unit: "case", shelfLifeDays: 7, cost: 18, listPrice: 31, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:101" },
|
||||
{ id: "p_avocado", organizationId: organization.id, sku: "FRU-204", name: "Hass Avocados", category: "Fruit", unit: "case", shelfLifeDays: 12, cost: 24, listPrice: 39, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:204" },
|
||||
{ id: "p_spinach", organizationId: organization.id, sku: "VEG-112", name: "Baby Spinach", category: "Leafy greens", unit: "case", shelfLifeDays: 9, cost: 14, listPrice: 25, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:112" },
|
||||
{ id: "p_tomato", organizationId: organization.id, sku: "VEG-221", name: "Heirloom Tomatoes", category: "Vegetables", unit: "case", shelfLifeDays: 10, cost: 21, listPrice: 36, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:221" },
|
||||
{ id: "p_blueberry", organizationId: organization.id, sku: "FRU-118", name: "Blueberries", category: "Berries", unit: "case", shelfLifeDays: 10, cost: 22, listPrice: 38, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:118" },
|
||||
{ id: "p_kale", organizationId: organization.id, sku: "VEG-130", name: "Tuscan Kale", category: "Leafy greens", unit: "case", shelfLifeDays: 11, cost: 12, listPrice: 23, marginFloorPercent: 18, sourceSystem: "odoo", sourceId: "product.product:130" },
|
||||
{ id: "p_mango", organizationId: organization.id, sku: "FRU-310", name: "Ataulfo Mangoes", category: "Fruit", unit: "case", shelfLifeDays: 14, cost: 26, listPrice: 43, marginFloorPercent: 22, sourceSystem: "odoo", sourceId: "product.product:310" },
|
||||
{ id: "p_pepper", organizationId: organization.id, sku: "VEG-304", name: "Sweet Bell Peppers", category: "Vegetables", unit: "case", shelfLifeDays: 16, cost: 19, listPrice: 34, marginFloorPercent: 20, sourceSystem: "odoo", sourceId: "product.product:304" },
|
||||
];
|
||||
|
||||
const batch = (
|
||||
id: string,
|
||||
productId: string,
|
||||
branchId: string,
|
||||
age: number,
|
||||
received: number,
|
||||
onHand: number,
|
||||
): InventoryBatch => ({
|
||||
id,
|
||||
organizationId: organization.id,
|
||||
productId,
|
||||
branchId,
|
||||
receivedAt: isoDaysAgo(age),
|
||||
quantityReceived: received,
|
||||
quantityOnHand: onHand,
|
||||
sourceId: `stock.quant:${id}`,
|
||||
});
|
||||
|
||||
export const inventoryBatches: InventoryBatch[] = [
|
||||
batch("b01", "p_strawberry", "br_north", 6, 90, 28),
|
||||
batch("b02", "p_strawberry", "br_river", 2, 70, 36),
|
||||
batch("b03", "p_avocado", "br_north", 4, 120, 48),
|
||||
batch("b04", "p_avocado", "br_coast", 10, 80, 32),
|
||||
batch("b05", "p_spinach", "br_river", 7, 110, 41),
|
||||
batch("b06", "p_spinach", "br_valley", 3, 75, 29),
|
||||
batch("b07", "p_tomato", "br_coast", 5, 95, 39),
|
||||
batch("b08", "p_blueberry", "br_north", 9, 72, 22),
|
||||
batch("b09", "p_kale", "br_valley", 2, 65, 18),
|
||||
batch("b10", "p_mango", "br_river", 13, 86, 44),
|
||||
batch("b11", "p_pepper", "br_coast", 6, 100, 35),
|
||||
batch("b12", "p_tomato", "br_valley", 11, 60, 17),
|
||||
];
|
||||
|
||||
const productWeights = [1.35, 1.15, 1.1, 0.9, 1.2, 0.72, 0.62, 0.84];
|
||||
|
||||
export const sales: Sale[] = Array.from({ length: 45 }, (_, day) =>
|
||||
products.flatMap((product, productIndex) =>
|
||||
branches.map((branch, branchIndex) => {
|
||||
const people = salespeople.filter((person) => person.branchId === branch.id);
|
||||
const quantity = Math.max(
|
||||
1,
|
||||
Math.round((3 + ((day + productIndex * 2 + branchIndex) % 7)) * productWeights[productIndex]),
|
||||
);
|
||||
const discount = (day + productIndex) % 11 === 0 ? 0.9 : 1;
|
||||
return {
|
||||
id: `sale_${day}_${productIndex}_${branchIndex}`,
|
||||
organizationId: organization.id,
|
||||
productId: product.id,
|
||||
branchId: branch.id,
|
||||
salespersonId: people[(day + productIndex) % people.length].id,
|
||||
soldAt: isoDaysAgo(day),
|
||||
quantity,
|
||||
unitPrice: Number((product.listPrice * discount).toFixed(2)),
|
||||
};
|
||||
}),
|
||||
),
|
||||
).flat();
|
||||
12
src/lib/format.ts
Normal file
12
src/lib/format.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const currency = (value: number, compact = false) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: compact ? "compact" : "standard",
|
||||
maximumFractionDigits: compact ? 1 : 2,
|
||||
}).format(value);
|
||||
|
||||
export const number = (value: number) =>
|
||||
new Intl.NumberFormat("en-US").format(Math.round(value));
|
||||
|
||||
export const percent = (value: number) => `${Math.round(value)}%`;
|
||||
133
src/lib/types.ts
Normal file
133
src/lib/types.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export type FreshnessStatus = "fresh" | "watch" | "at_risk" | "expired";
|
||||
export type IntegrationProvider = "odoo" | "salesforce";
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Branch {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
city: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface Salesperson {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
shelfLifeDays: number;
|
||||
cost: number;
|
||||
listPrice: number;
|
||||
marginFloorPercent: number;
|
||||
sourceSystem: IntegrationProvider;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface InventoryBatch {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
productId: string;
|
||||
branchId: string;
|
||||
receivedAt: string;
|
||||
quantityReceived: number;
|
||||
quantityOnHand: number;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface Sale {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
productId: string;
|
||||
branchId: string;
|
||||
salespersonId: string;
|
||||
soldAt: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface FreshnessResult {
|
||||
ageDays: number;
|
||||
remainingDays: number;
|
||||
remainingPercent: number;
|
||||
score: number;
|
||||
status: FreshnessStatus;
|
||||
}
|
||||
|
||||
export interface InventoryView extends InventoryBatch, FreshnessResult {
|
||||
product: Product;
|
||||
branch: Branch;
|
||||
retailValue: number;
|
||||
costExposure: number;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
productId: string;
|
||||
branchId: string;
|
||||
type: "hold" | "markdown" | "promotion";
|
||||
currentPrice: number;
|
||||
recommendedLow: number;
|
||||
recommendedHigh: number;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
action: string;
|
||||
expiresAt: string;
|
||||
status: "pending" | "reviewed" | "dismissed";
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
unitsOnHand: number;
|
||||
retailValue: number;
|
||||
atRiskValue: number;
|
||||
revenue30d: number;
|
||||
sellThrough30d: number;
|
||||
averageFreshness: number;
|
||||
change: {
|
||||
unitsOnHand: number;
|
||||
revenue: number;
|
||||
atRisk: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
label: string;
|
||||
revenue: number;
|
||||
units: number;
|
||||
wasteRisk: number;
|
||||
}
|
||||
|
||||
export interface PerformanceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
secondary: string;
|
||||
revenue: number;
|
||||
units: number;
|
||||
sellThrough: number;
|
||||
freshness: number;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
organization: Organization;
|
||||
generatedAt: string;
|
||||
metrics: DashboardMetrics;
|
||||
inventory: InventoryView[];
|
||||
recommendations: Recommendation[];
|
||||
branches: PerformanceRow[];
|
||||
salespeople: PerformanceRow[];
|
||||
trends: TrendPoint[];
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
24
src/proxy.ts
Normal file
24
src/proxy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE, verifySessionToken } from "@/lib/auth";
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (process.env.REQUIRE_AUTH !== "true") return NextResponse.next();
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
try {
|
||||
await verifySessionToken(token);
|
||||
return NextResponse.next();
|
||||
} catch {
|
||||
// Continue to login redirect.
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("next", request.nextUrl.pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
22
tests/e2e/dashboard.spec.ts
Normal file
22
tests/e2e/dashboard.spec.ts
Normal 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
25
tests/freshness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
tests/integrations.test.ts
Normal file
27
tests/integrations.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
tests/recommendations.test.ts
Normal file
28
tests/recommendations.test.ts
Normal 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
19
vitest.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user