Dealer Scheme Advisor console: dealer portal + supplier console over the Zino runtime
Vite + React + TS + Tailwind v4, runtime config.js and base-href contract (same shape as HDFC-Loan-Desk). Dealer: home, place order with the scheme engine live beside the form (query-kind record views over simulate()), orders with the Advisor's advice and one-click confirm-as-advised, schemes with progress to the next slab, incentives. Supplier: clusters + near-slab sweeps, dealers and dealer 360, advisories, scheme catalogue with draft → Analyst costing → approval, settlement runs with release/hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
134db9a06c
4
.env.example
Normal file
4
.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
# Local development only (`npm run dev`). A deployed build reads config.js,
|
||||
# which frontgen writes when it places the artifact. One build is promoted
|
||||
# across environments unchanged, so never bake an environment in here.
|
||||
VITE_ZINO_API_URL=https://dev.getzino.in
|
||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
.vite
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
npm-debug.log*
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
55
README.md
Normal file
55
README.md
Normal file
@ -0,0 +1,55 @@
|
||||
# Dealer Scheme Advisor — console
|
||||
|
||||
The custom frontend for the **Meru Cement · Dealer Scheme Advisor** demo app on the
|
||||
Zino platform (dev: org 900000123 / app 900000123). Two personas behind one login:
|
||||
|
||||
- **Dealer portal** (`/d`): this month at a glance, place an order with the scheme
|
||||
engine live beside the form, my orders with the Advisor's advice and one-click
|
||||
"take the advice", my schemes with progress to the next slab, incentives and
|
||||
credit notes.
|
||||
- **Supplier console** (`/s`, roles asm / rso_manager / ho_scheme_admin): clusters
|
||||
and the near-slab sweep, dealers and a dealer 360 with "ask the Advisor", advisories,
|
||||
the scheme catalogue with drafts → Analyst costing → RSO approval, settlement runs.
|
||||
|
||||
Vite + React 18 + TypeScript + Tailwind v4, built by frontgen on push. The platform
|
||||
config this talks to is seeded from `sm2/custom-apps/dealer-schemes/`.
|
||||
|
||||
## Runtime contract (do not break)
|
||||
|
||||
- `VITE_ZINO_API_URL` is read at **runtime** from `config.js`, which frontgen writes
|
||||
when it places the build (`frontgen.projects.zino_api_url`). Nothing is compiled in;
|
||||
one build is promoted between environments unchanged. For `npm run dev` the value
|
||||
comes from `.env` (copy `.env.example`).
|
||||
- The router basename comes from the `<base href>` frontgen writes into `index.html`
|
||||
(`<!--BASE_HREF-->`), and `vite.config.ts` keeps `base: './'`.
|
||||
- Every colour is a token in `src/styles.css`; dark mode redefines the tokens. No
|
||||
literal colours in components. Amber (`accent`) is reserved for money at stake;
|
||||
violet (`ai`) for what the AI wrote.
|
||||
|
||||
## How the data flows
|
||||
|
||||
| Need | Call |
|
||||
|---|---|
|
||||
| Sign in | `POST /usr/login` (org_id as a string) → JWT; `GET /usr/me` for roles |
|
||||
| Who am I in the estate | record view `dsa-rv-portal_user` (email → dealer code / territory) |
|
||||
| Lists | `POST /app/900000123/view/recordview` with `rv_template_uid` + `search_query.filters` (`customer_no`, `rso_code`, `instance_id` …) |
|
||||
| **The engine, live** | the same endpoint with `params`: `dsa-rv-simulate` (`customer_no`, `hypo_json`), `dsa-rv-dealer_snapshot`, `dsa-rv-monthly_history`, `dsa-rv-upcoming_schemes` — query-kind RDBMS templates over `scheme_erp.simulate()` and friends |
|
||||
| Act | `POST /app/900000123/start` / `/activity` with the activity's field keys; forms render from `POST /app/900000123/view/form-screens` (`ActivityForm`) |
|
||||
| What the AI did | `/app/900000123/view/audit?instance_id=` + `/monitor/decisions?instance_id=` |
|
||||
|
||||
The AI never runs in the browser: placing an order creates a Draft, the trigger wakes
|
||||
the Scheme Advisor on the platform, and the order screen polls until the advice and
|
||||
the recommendation rows land.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # VITE_ZINO_API_URL=https://dev.getzino.in
|
||||
npm run dev # http://localhost:5175
|
||||
npm run build # tsc -b && vite build → dist/
|
||||
```
|
||||
|
||||
Demo logins (password in the sm2 README): `sharma@dealers.meru.example` (dealer, the
|
||||
hero), `rso.bengaluru@meru.example` (RSO), `asm.hubballi@meru.example` (ASM),
|
||||
`ho.schemes@meru.example` (HO).
|
||||
41
index.html
Normal file
41
index.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--BASE_HREF-->
|
||||
<title>Meru Cement | Scheme Advisor</title>
|
||||
<!-- Inline SVG favicon: the build is promoted between environments and a
|
||||
/favicon.ico would resolve against whatever mount path the server
|
||||
chose. The mark is a stylised bag-and-peak: three stacked bars. -->
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' rx='8' fill='%230f6b5c'/%3E%3Cpath d='M8 30 L20 10 L32 30 Z' fill='%23ffffff'/%3E%3Cpath d='M14 30 L20 20 L26 30 Z' fill='%23d9a441'/%3E%3C/svg%3E"
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<!-- Theme resolved BEFORE first paint, so a dark-mode user never sees a
|
||||
white flash. Keep the storage key in step with THEME_KEY in theme.ts. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('meru-scheme-advisor-theme');
|
||||
if (t !== 'light' && t !== 'dark') {
|
||||
t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (t === 'dark') {
|
||||
var m = document.querySelector('meta[name="theme-color"]');
|
||||
if (m) m.setAttribute('content', '#0f1f1c');
|
||||
}
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="./config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2419
package-lock.json
generated
Normal file
2419
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dealer-scheme-advisor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Meru Cement — Dealer Scheme Advisor. Dealers order cement and see which scheme fits; the supplier runs schemes, sweeps and payouts; the AI reads the engine and nudges.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.1.0",
|
||||
"lucide-react": "^0.454.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
68
src/App.tsx
Normal file
68
src/App.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useZino } from './api/provider';
|
||||
import { Shell } from './layout/Shell';
|
||||
import type { NavItem } from './layout/Shell';
|
||||
import { Login } from './pages/Login';
|
||||
import { Spinner } from './components/core';
|
||||
import { DealerHome } from './screens/dealer/DealerHome';
|
||||
import { NewOrder } from './screens/dealer/NewOrder';
|
||||
import { DealerOrders, OrderDetail } from './screens/dealer/Orders';
|
||||
import { DealerSchemes } from './screens/dealer/Schemes';
|
||||
import { DealerIncentives } from './screens/dealer/Incentives';
|
||||
import { Overview } from './screens/supplier/Overview';
|
||||
import { Dealers, Dealer360 } from './screens/supplier/Dealers';
|
||||
import { Catalogue } from './screens/supplier/Catalogue';
|
||||
import { Runs } from './screens/supplier/Runs';
|
||||
import { Advisories, AdvisoryDetail } from './screens/supplier/Advisories';
|
||||
|
||||
const DEALER_NAV: NavItem[] = [
|
||||
{ to: '/d', label: 'Home', hint: 'This month, at a glance', end: true },
|
||||
{ to: '/d/order/new', label: 'Place order', hint: 'With the Advisor beside you' },
|
||||
{ to: '/d/orders', label: 'My orders', hint: 'Drafts, advice, confirmations' },
|
||||
{ to: '/d/schemes', label: 'My schemes', hint: 'What pays, and what is close' },
|
||||
{ to: '/d/incentives', label: 'Incentives', hint: 'Tracking and credit notes' },
|
||||
];
|
||||
const SUPPLIER_NAV: NavItem[] = [
|
||||
{ to: '/s', label: 'Territory', hint: 'Clusters and the near-slab sweep', end: true },
|
||||
{ to: '/s/dealers', label: 'Dealers', hint: 'The network, dealer 360' },
|
||||
{ to: '/s/advisories', label: 'Advisories', hint: 'Sweeps and dealer advice' },
|
||||
{ to: '/s/catalogue', label: 'Schemes', hint: 'Catalogue, drafts, approvals' },
|
||||
{ to: '/s/runs', label: 'Scheme runs', hint: 'Compute, review, release' },
|
||||
];
|
||||
|
||||
/** Signed-out swaps the whole tree for Login. Persona decides which shell. */
|
||||
export function App() {
|
||||
const { user, portal } = useZino();
|
||||
if (!user) return <Login />;
|
||||
const roles = user.roles ?? [];
|
||||
const isDealer = roles.includes('dealer') || portal?.persona === 'DEALER';
|
||||
const isSupplier = roles.some((r) => ['asm', 'rso_manager', 'ho_scheme_admin'].includes(r)) || (portal != null && portal.persona !== 'DEALER');
|
||||
if (!isDealer && !isSupplier && roles.length === 0) return <Spinner label="Resolving your role…" />;
|
||||
const home = isDealer ? '/d' : '/s';
|
||||
return (
|
||||
<Routes>
|
||||
{isDealer && (
|
||||
<Route element={<Shell items={DEALER_NAV} sub="Dealer portal" />}>
|
||||
<Route path="/d" element={<DealerHome />} />
|
||||
<Route path="/d/order/new" element={<NewOrder />} />
|
||||
<Route path="/d/orders" element={<DealerOrders />} />
|
||||
<Route path="/d/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/d/schemes" element={<DealerSchemes />} />
|
||||
<Route path="/d/incentives" element={<DealerIncentives />} />
|
||||
</Route>
|
||||
)}
|
||||
{(isSupplier || !isDealer) && (
|
||||
<Route element={<Shell items={SUPPLIER_NAV} sub="Trade schemes" />}>
|
||||
<Route path="/s" element={<Overview />} />
|
||||
<Route path="/s/dealers" element={<Dealers />} />
|
||||
<Route path="/s/dealers/:customerNo" element={<Dealer360 />} />
|
||||
<Route path="/s/advisories" element={<Advisories />} />
|
||||
<Route path="/s/advisories/:id" element={<AdvisoryDetail />} />
|
||||
<Route path="/s/catalogue" element={<Catalogue />} />
|
||||
<Route path="/s/runs" element={<Runs />} />
|
||||
</Route>
|
||||
)}
|
||||
<Route path="*" element={<Navigate to={home} replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
107
src/api/client.ts
Normal file
107
src/api/client.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { ActivityResult, AiDecision, ApiError, AuditEntry, FormScreenResponse, LoginResponse, RecordViewParams, RecordViewResponse, Row, User } from './types';
|
||||
import { APP_ID } from './config';
|
||||
|
||||
const TOKEN_KEY = 'meru_scheme_advisor_token';
|
||||
|
||||
/** HTTP client for the Zino gateway. App-scoped routes live under /app/<id>;
|
||||
* login and the AI monitor do not. The JWT persists in localStorage. */
|
||||
export class ZinoClient {
|
||||
readonly baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private onAuthError?: () => void;
|
||||
|
||||
constructor(baseUrl: string, onAuthError?: () => void) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.onAuthError = onAuthError;
|
||||
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
setAuthErrorHandler(fn: () => void): void { this.onAuthError = fn; }
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token); else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
getToken(): string | null { return this.token; }
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const res = await fetch(`${this.baseUrl}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined });
|
||||
if (res.status === 401) {
|
||||
this.setToken(null);
|
||||
this.onAuthError?.();
|
||||
throw { status: 401, message: 'Session expired — sign in again' } as ApiError;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try { const j = (await res.json()) as { error?: string; message?: string }; message = j.error || j.message || message; } catch { /* non-JSON */ }
|
||||
throw { status: res.status, message } as ApiError;
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** org_id must be a STRING or the gateway rejects the body. */
|
||||
async login(email: string, password: string, orgId: string): Promise<LoginResponse> {
|
||||
const res = await this.request<LoginResponse>('POST', '/usr/login', { email, password, org_id: String(orgId) });
|
||||
this.setToken(res.token);
|
||||
return res;
|
||||
}
|
||||
logout(): void { this.setToken(null); }
|
||||
me(): Promise<User> { return this.request<User>('GET', '/usr/me'); }
|
||||
/** Identity from the persisted JWT (no roles claim — the provider follows up with me()). */
|
||||
currentUser(): User | null {
|
||||
if (!this.token) return null;
|
||||
try {
|
||||
const p = JSON.parse(atob(this.token.split('.')[1]));
|
||||
return { id: String(p.user_id ?? p.sub ?? ''), org_id: String(p.org_id ?? ''), name: p.name ?? '', email: p.email ?? '', roles: p.roles ?? [], groups: p.groups ?? [] };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async recordView(rvUid: string, p: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||
rv_template_uid: rvUid,
|
||||
params: p.params ?? {},
|
||||
search_query: {
|
||||
page: p.page ?? 1, limit: p.limit ?? 50, sort_by: p.sortBy ?? '', sort_dir: p.sortDir ?? 'desc', search: p.search ?? '',
|
||||
filters: (p.filters ?? []).map((f) => ({ field_key: f.field_key, value: f.value, value2: '', data_type: f.data_type ?? 'string' })),
|
||||
},
|
||||
});
|
||||
}
|
||||
/** Rows only; workflow views answer `data`, rdbms views `records` or `data`. */
|
||||
async rows(rvUid: string, p: RecordViewParams = {}): Promise<Row[]> {
|
||||
const r = await this.recordView(rvUid, p);
|
||||
return r.data ?? r.records ?? [];
|
||||
}
|
||||
/** Rows plus the total, for paged tables. */
|
||||
async page(rvUid: string, p: RecordViewParams = {}): Promise<{ rows: Row[]; total: number }> {
|
||||
const r = await this.recordView(rvUid, p);
|
||||
return { rows: r.data ?? r.records ?? [], total: r.pagination?.total_count ?? (r.data ?? r.records ?? []).length };
|
||||
}
|
||||
|
||||
async audit(instanceId: number | string): Promise<AuditEntry[]> {
|
||||
const rows = await this.request<AuditEntry[]>('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`);
|
||||
return (rows ?? []).filter((r) => !String(r.execution_state ?? '').startsWith('TRIGGER_'));
|
||||
}
|
||||
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
|
||||
return this.request<{ decisions: AiDecision[] }>('GET', `/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`).then((r) => r.decisions ?? []);
|
||||
}
|
||||
formSchema(activityUid: string, instanceId?: number | string): Promise<FormScreenResponse> {
|
||||
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
|
||||
activity_id: activityUid, device_type: 'desktop', ...(instanceId != null ? { instance_id: numericId(instanceId) } : {}),
|
||||
});
|
||||
}
|
||||
startInstance(workflowUuid: string, activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, { workflow_uuid: workflowUuid, activity_id: activityUid, data });
|
||||
}
|
||||
performActivity(workflowUuid: string, instanceId: number | string, activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, { workflow_uuid: workflowUuid, instance_id: numericId(instanceId), activity_id: activityUid, data });
|
||||
}
|
||||
}
|
||||
|
||||
/** Instance ids go over the wire as JSON NUMBERS; a quoted id fails the decode. */
|
||||
export function numericId(id: number | string): number | string {
|
||||
if (typeof id === 'number') return id;
|
||||
const n = Number(id);
|
||||
return Number.isFinite(n) && id.trim() !== '' ? n : id;
|
||||
}
|
||||
91
src/api/config.ts
Normal file
91
src/api/config.ts
Normal file
@ -0,0 +1,91 @@
|
||||
// Meru Cement — Dealer Scheme Advisor. dev, org 900000123 / app 900000123.
|
||||
// Every id is the readable uid seeded in sm2/custom-apps/dealer-schemes/.
|
||||
|
||||
export const ORG_ID = '900000123';
|
||||
export const APP_ID = '900000123';
|
||||
|
||||
export const WF = {
|
||||
ORDER: 'dsa_wf_order',
|
||||
ADVISORY: 'dsa_wf_advisory',
|
||||
SCHEME: 'dsa_wf_scheme',
|
||||
RUN: 'dsa_wf_run',
|
||||
} as const;
|
||||
|
||||
export const ACT = {
|
||||
PLACE: 'dsa-act-place',
|
||||
CONFIRM: 'dsa-act-confirm',
|
||||
CANCEL: 'dsa-act-cancel',
|
||||
DISPATCH: 'dsa-act-dispatch',
|
||||
INVOICE: 'dsa-act-invoice',
|
||||
REQ_ADVICE: 'dsa-act-req-advice',
|
||||
REQ_SWEEP: 'dsa-act-req-sweep',
|
||||
ACTED: 'dsa-act-acted',
|
||||
DISMISS: 'dsa-act-dismiss',
|
||||
SCH_DRAFT: 'dsa-act-sch-draft',
|
||||
SCH_APPROVE: 'dsa-act-sch-approve',
|
||||
SCH_REJECT: 'dsa-act-sch-reject',
|
||||
SCH_CLOSE: 'dsa-act-sch-close',
|
||||
RUN_REQUEST: 'dsa-act-run-request',
|
||||
RUN_APPROVE: 'dsa-act-run-approve',
|
||||
RUN_HOLD: 'dsa-act-run-hold',
|
||||
} as const;
|
||||
|
||||
/** Record views. `wf-*` are workflow instances; the rest read the estate
|
||||
* through RDBMS templates; the `engine` ones are query templates that take
|
||||
* `params` and run the scheme engine live. */
|
||||
export const RV = {
|
||||
ORDERS: 'dsa-rv-wf-orders',
|
||||
ADVISORIES: 'dsa-rv-wf-advisories',
|
||||
SCHEME_DRAFTS: 'dsa-rv-wf-schemes',
|
||||
RUNS: 'dsa-rv-wf-runs',
|
||||
DEALERS: 'dsa-rv-dealers',
|
||||
CATALOGUE: 'dsa-rv-scheme_catalogue',
|
||||
TRACKING: 'dsa-rv-dealer_tracking',
|
||||
RECOMMENDATIONS: 'dsa-rv-recommendations',
|
||||
CLUSTERS: 'dsa-rv-cluster_summary',
|
||||
ESTATE_ORDERS: 'dsa-rv-orders',
|
||||
PORTAL_USERS: 'dsa-rv-portal_user',
|
||||
SIMULATE: 'dsa-rv-simulate',
|
||||
SNAPSHOT: 'dsa-rv-dealer_snapshot',
|
||||
HISTORY: 'dsa-rv-monthly_history',
|
||||
UPCOMING: 'dsa-rv-upcoming_schemes',
|
||||
} as const;
|
||||
|
||||
export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet' | 'accent';
|
||||
|
||||
export const KIND_LABEL: Record<string, string> = {
|
||||
BEST_FIT: 'Best fit',
|
||||
TOP_UP: 'Top up',
|
||||
AT_RISK: 'At risk',
|
||||
COLLECTION: 'Clear outstanding',
|
||||
SWITCH_TO_PP: 'Switch to premium',
|
||||
BOOK_IN_WINDOW: 'Book in window',
|
||||
REVIVAL: 'Revival',
|
||||
ROUTE_SHIP_TO: 'Route ship-to',
|
||||
NOTHING_TO_DO: 'Nothing to do',
|
||||
};
|
||||
|
||||
export const STATE_TONE: Record<string, Tone> = {
|
||||
Draft: 'slate',
|
||||
Advised: 'violet',
|
||||
Confirmed: 'sky',
|
||||
Dispatched: 'sky',
|
||||
Invoiced: 'emerald',
|
||||
Cancelled: 'rose',
|
||||
Requested: 'slate',
|
||||
Delivered: 'violet',
|
||||
Closed: 'emerald',
|
||||
'Pending Approval': 'amber',
|
||||
Active: 'emerald',
|
||||
Rejected: 'rose',
|
||||
Computed: 'amber',
|
||||
Released: 'emerald',
|
||||
Held: 'rose',
|
||||
};
|
||||
|
||||
export function prettyRole(r: string): string {
|
||||
return (
|
||||
{ dealer: 'Dealer', asm: 'Area Sales Manager', rso_manager: 'RSO Manager', ho_scheme_admin: 'HO Schemes', ai_scheme_advisor: 'AI', ai_scheme_analyst: 'AI' }[r] ??
|
||||
r
|
||||
);
|
||||
}
|
||||
88
src/api/provider.tsx
Normal file
88
src/api/provider.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ZinoClient } from './client';
|
||||
import type { PortalUser, Row, User } from './types';
|
||||
import { RV } from './config';
|
||||
|
||||
interface ZinoContextValue {
|
||||
client: ZinoClient;
|
||||
user: User | null;
|
||||
/** Who this login IS in the estate (dealer code or territory), from advisor.portal_user. */
|
||||
portal: PortalUser | null;
|
||||
login: (email: string, password: string, orgId: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
const ZinoContext = createContext<ZinoContextValue | null>(null);
|
||||
|
||||
export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
|
||||
const clientRef = useRef<ZinoClient>();
|
||||
if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl);
|
||||
const client = clientRef.current;
|
||||
const [user, setUser] = useState<User | null>(() => client.currentUser());
|
||||
const [portal, setPortal] = useState<PortalUser | null>(null);
|
||||
|
||||
useEffect(() => { client.setAuthErrorHandler(() => { setUser(null); setPortal(null); }); }, [client]);
|
||||
|
||||
// The JWT carries no roles: a restored session asks the server for the real user.
|
||||
useEffect(() => {
|
||||
if (!client.getToken()) return;
|
||||
if ((user?.roles?.length ?? 0) > 0) return;
|
||||
let live = true;
|
||||
client.me().then((u) => { if (live) setUser(u); }).catch(() => { /* identity from the token still stands */ });
|
||||
return () => { live = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, user?.id, user?.roles?.length]);
|
||||
|
||||
// Persona: the portal_user row for this email (15 rows; matched client-side).
|
||||
useEffect(() => {
|
||||
if (!user?.email) { setPortal(null); return; }
|
||||
let live = true;
|
||||
client.rows(RV.PORTAL_USERS, { limit: 100 }).then((rows: Row[]) => {
|
||||
if (!live) return;
|
||||
const r = rows.find((x) => String(x.email ?? '').toLowerCase() === user.email.toLowerCase());
|
||||
setPortal(r ? (r as unknown as PortalUser) : null);
|
||||
}).catch(() => { if (live) setPortal(null); });
|
||||
return () => { live = false; };
|
||||
}, [client, user?.email]);
|
||||
|
||||
const value = useMemo<ZinoContextValue>(() => ({
|
||||
client, user, portal,
|
||||
login: async (email, password, orgId) => { const res = await client.login(email, password, orgId); setUser(res.user ?? client.currentUser()); },
|
||||
logout: () => { client.logout(); setUser(null); setPortal(null); },
|
||||
}), [client, user, portal]);
|
||||
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
|
||||
}
|
||||
|
||||
export function useZino(): ZinoContextValue {
|
||||
const ctx = useContext(ZinoContext);
|
||||
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export interface QueryState<T> { data: T | null; loading: boolean; error: string | null; refetch: () => void; }
|
||||
|
||||
/** Fire `fn` when deps change; optional polling (an AI employee moves instances on its own). */
|
||||
export function useQuery<T>(fn: () => Promise<T>, deps: unknown[], enabled = true, refetchMs?: number): QueryState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
const refetch = useCallback(() => setTick((t) => t + 1), []);
|
||||
useEffect(() => {
|
||||
if (!enabled) { setLoading(false); return; }
|
||||
let live = true;
|
||||
setLoading(true); setError(null);
|
||||
fn().then((d) => live && setData(d))
|
||||
.catch((e: unknown) => { if (live) setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed')); })
|
||||
.finally(() => live && setLoading(false));
|
||||
return () => { live = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, tick, enabled]);
|
||||
useEffect(() => {
|
||||
if (!enabled || !refetchMs || refetchMs <= 0) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [...deps, enabled, refetchMs]);
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
47
src/api/types.ts
Normal file
47
src/api/types.ts
Normal file
@ -0,0 +1,47 @@
|
||||
export interface ApiError { status: number; message: string; }
|
||||
export interface User { id: string; org_id: string; name: string; email: string; roles: string[]; groups: string[]; }
|
||||
export interface LoginResponse { token: string; user?: User; }
|
||||
export interface RecordViewField { type: string; field_key: string; field_uid: string; output_label: string; activity_id: string; data_type: string; is_filter: boolean; is_search: boolean; }
|
||||
export interface RecordViewResponse {
|
||||
config: { fields: RecordViewField[] };
|
||||
data?: Row[];
|
||||
records?: Row[];
|
||||
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
|
||||
}
|
||||
export type Row = Record<string, unknown>;
|
||||
export interface RecordViewParams {
|
||||
page?: number; limit?: number; sortBy?: string; sortDir?: 'asc' | 'desc'; search?: string;
|
||||
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
|
||||
/** Declared input params of a query-kind template (the engine calls). */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
export interface AuditEntry {
|
||||
id: number; user_id: string; user_name: string; user_email: string; user_roles: string[] | null; activity_id: string; activity_name?: string;
|
||||
data: Record<string, unknown>; execution_state: string; state_name?: string; created_at?: string; performed_at?: string;
|
||||
fields?: Array<{ field_id: string; label: string; data_type: string; value: string }>;
|
||||
ai_reasoning?: string | null; ai_confidence?: number | null; ai_model?: string | null;
|
||||
}
|
||||
export interface AiDecision { id: number; instance_id: number | string; action?: string; status?: string; reasoning?: string; confidence?: number; cost?: number; llm_calls?: number; model?: string; created_at?: string; }
|
||||
export interface FormField { id: string; uid: string; name: string; type: string; data_type: string; mandatory?: boolean; properties?: { options?: Array<{ label: string; value: string }> } & Record<string, unknown>; mapped_workflow_field?: string; }
|
||||
export interface FormScreenResponse { activity_uid: string; activity_name: string; workflow_uuid: string; fields: FormField[]; grid_config?: Array<Record<string, unknown>>; field_defaults?: Record<string, unknown>; }
|
||||
export interface ActivityResult { success?: boolean; message?: string; instance_id?: number; status_code?: number; data?: Record<string, unknown>; }
|
||||
|
||||
/** One simulate() row — what a scheme component pays now and if the dealer acts. */
|
||||
export interface SimRow {
|
||||
scheme_id: number; scheme_no: string; scheme_name: string; scheme_type: string; component_id: number; component_name: string; basis: string;
|
||||
period_from: string; period_to: string; days_left: number; rate_unit: string; disbursement_mode: string;
|
||||
measured_qty: string | number; basis_value: string | number | null; slab_label: string | null; growth_pct: string | number | null; achievement_pct: string | number | null;
|
||||
gate_passed: boolean; gate_reason: string | null; payout_now: string | number; in_kind_now: string | null; payout_if: string | number; in_kind_if: string | null;
|
||||
incremental: string | number; slab_label_if: string | null; gate_reason_if: string | null; next_slab_label: string | null; mt_to_next_slab: string | number | null;
|
||||
next_slab_rate: string | number | null; next_growth_label: string | null; growth_pts_to_next: string | number | null;
|
||||
}
|
||||
export interface Snapshot {
|
||||
customer_no: string; name: string; type: string; sales_org: string; rso: string; ao: string; city: string; cluster: string; state: string; status: string;
|
||||
appointed_on: string; first_lifting: string | null; is_new_dealer: boolean; is_inactive: boolean; gold_star: boolean; silver_star: boolean; sow_pct: number | null;
|
||||
loyalty_tier: string | null; credit_limit: number; payment_terms_days: number; open_outstanding: number; overdue: number; mtd_mt: number; mtd_pp_mt: number;
|
||||
month_target_mt: number; month_pp_target_mt: number; qtd_mt: number; quarter_target_mt: number; last_12m_mt: number; ly_same_month_mt: number;
|
||||
open_orders: Array<{ so_no: string; created_on: string; requested: string; status: string; qty_mt: number }>;
|
||||
ship_tos: Array<{ code: string; name: string; cluster: string; default: boolean }>;
|
||||
}
|
||||
export interface Standing { cluster: string; cluster_name: string; is_weak_market: boolean; is_border: boolean; peers: number; rank: number; cluster_mtd_mt: number; cluster_target_mt: number; cluster_achievement_pct: number | null; top3: Array<{ name: string; mtd_mt: number }>; }
|
||||
export interface PortalUser { email: string; persona: 'DEALER' | 'ASM' | 'RSO' | 'HO' | 'AI'; customer_no: string | null; rso_code: string | null; ao_code: string | null; display_name: string; }
|
||||
113
src/components/ActivityForm.tsx
Normal file
113
src/components/ActivityForm.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
|
||||
import type { FormField } from '../api/types';
|
||||
|
||||
/**
|
||||
* Renders an activity's form from the LIVE schema (view-service form-screens):
|
||||
* labels, types, options and mandatory flags all come from the deployed
|
||||
* workflow. The submit sends ONLY keys the form has a field for, so a stray
|
||||
* key is never refused as "unknown". The workflow's refusal text is shown
|
||||
* verbatim — that is the state machine explaining itself.
|
||||
*/
|
||||
export function ActivityForm({ open, onClose, workflow, activityUid, instanceId, title, subtitle, submitLabel = 'Submit', danger = false, seed, prefill, onDone, hiddenKeys = [] }: {
|
||||
open: boolean; onClose: () => void; workflow: string; activityUid: string; instanceId?: number | string; title: string; subtitle?: string;
|
||||
submitLabel?: string; danger?: boolean;
|
||||
/** pre-filled AND locked (identifiers) */ seed?: Record<string, unknown>;
|
||||
/** pre-filled, editable */ prefill?: Record<string, unknown>;
|
||||
/** seeded fields to keep off the screen entirely */ hiddenKeys?: string[];
|
||||
onDone?: (result: { instance_id?: number }) => void;
|
||||
}) {
|
||||
const { client } = useZino();
|
||||
const schema = useQuery(() => client.formSchema(activityUid, instanceId), [activityUid, instanceId], open);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const fields = schema.data?.fields ?? [];
|
||||
|
||||
/** Re-key a bag onto the form's own field ids: exact id, else global slug → `<slug>__<suffix>` / `<slug>_<n>`. */
|
||||
const toFieldKeys = (bag: Record<string, unknown> | undefined) => {
|
||||
if (!bag) return {};
|
||||
const byId = new Map(fields.map((f) => [f.id, f]));
|
||||
const bySlug = new Map<string, string>();
|
||||
for (const f of fields) {
|
||||
const slug = f.id.replace(/__[a-z0-9]+$/i, '').replace(/_\d+$/, '');
|
||||
if (slug !== f.id && !byId.has(slug) && !bySlug.has(slug)) bySlug.set(slug, f.id);
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(bag)) { const id = byId.has(k) ? k : bySlug.get(k); if (id) out[id] = v; }
|
||||
return out;
|
||||
};
|
||||
const seeded = useMemo(() => toFieldKeys(seed), [schema.data, JSON.stringify(seed)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const initial = useMemo(() => ({ ...(schema.data?.field_defaults ?? {}), ...toFieldKeys(prefill), ...seeded }), [schema.data, JSON.stringify(prefill), seeded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const current = { ...initial, ...values };
|
||||
const missing = fields.filter((f) => f.mandatory).filter((f) => { const v = current[f.id]; return v === undefined || v === null || v === ''; }).map((f) => f.id);
|
||||
const hidden = new Set(hiddenKeys.map((k) => Object.keys(toFieldKeys({ [k]: 1 }))[0]).filter(Boolean));
|
||||
|
||||
async function submit() {
|
||||
setTouched(true);
|
||||
if (missing.length) return;
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const f of fields) if (Object.prototype.hasOwnProperty.call(current, f.id)) payload[f.id] = current[f.id];
|
||||
const res = instanceId ? await client.performActivity(workflow, instanceId, activityUid, payload) : await client.startInstance(workflow, activityUid, payload);
|
||||
setValues({}); setTouched(false);
|
||||
onDone?.({ instance_id: res.instance_id });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setErr((e as { message?: string })?.message ?? 'Could not submit');
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const visible = fields.filter((f) => !hidden.has(f.id));
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} subtitle={subtitle} wide={visible.length > 8}>
|
||||
{schema.loading && <Spinner label="Loading form…" />}
|
||||
{schema.error && <ErrorNote>{schema.error}</ErrorNote>}
|
||||
{!schema.loading && !schema.error && (
|
||||
<div className="space-y-4">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
{visible.length === 0 && <p className="text-sm text-muted">This step takes no input — submitting records it as done.</p>}
|
||||
<div className={visible.length > 8 ? 'grid grid-cols-1 gap-3 sm:grid-cols-2' : 'space-y-3'}>
|
||||
{visible.map((f) => (
|
||||
<FieldControl key={f.id} field={f} value={current[f.id]} readOnly={Object.prototype.hasOwnProperty.call(seeded, f.id)}
|
||||
error={touched && missing.includes(f.id) ? 'Required' : null} onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line pt-4">
|
||||
<Button kind="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button kind={danger ? 'danger' : 'primary'} onClick={submit} busy={busy}>{submitLabel}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldControl({ field, value, onChange, readOnly, error }: { field: FormField; value: unknown; onChange: (v: unknown) => void; readOnly?: boolean; error?: string | null }) {
|
||||
const opts = field.properties?.options ?? [];
|
||||
const isProse = field.data_type === 'longtext';
|
||||
const control = (() => {
|
||||
switch (field.data_type) {
|
||||
case 'select': return <Select value={String(value ?? '')} options={opts} onChange={onChange} disabled={readOnly} />;
|
||||
case 'boolean': return (
|
||||
<label className="flex items-center gap-2.5 py-1">
|
||||
<input type="checkbox" disabled={readOnly} checked={value === true || value === 'true'} onChange={(e) => onChange(e.target.checked)} className="size-4 rounded border-line bg-panel2 accent-brand" />
|
||||
<span className="text-sm text-ink">Yes</span>
|
||||
</label>);
|
||||
case 'number': return <Input type="number" step="any" disabled={readOnly} value={value === undefined || value === null ? '' : String(value)} onChange={(v) => onChange(v === '' ? '' : Number(v))} />;
|
||||
case 'date': return <Input type="date" disabled={readOnly} value={String(value ?? '').slice(0, 10)} onChange={onChange} />;
|
||||
case 'longtext': return <Textarea value={String(value ?? '')} rows={5} onChange={onChange} />;
|
||||
default: return <Input disabled={readOnly} value={String(value ?? '')} onChange={onChange} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={isProse ? 'sm:col-span-full' : undefined}>
|
||||
<Label required={field.mandatory}>{field.name}</Label>
|
||||
{control}
|
||||
{error && <p className="mt-1 text-xs font-medium text-bad">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/Logo.tsx
Normal file
21
src/components/Logo.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/** Meru Cement is fictional: the mark is ours (three stacked peaks). No brand asset ships here. */
|
||||
export function MeruMark({ size = 28 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 40 40" aria-hidden="true">
|
||||
<rect width="40" height="40" rx="8" className="fill-brand" />
|
||||
<path d="M8 30 L20 10 L32 30 Z" fill="var(--color-brand-fg)" />
|
||||
<path d="M14 30 L20 20 L26 30 Z" className="fill-accent" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function AppLogo({ sub }: { sub?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MeruMark />
|
||||
<div className="leading-tight">
|
||||
<div className="text-[15px] font-bold tracking-tight text-ink">Meru Cement</div>
|
||||
<div className="text-[11px] font-medium text-faint">{sub ?? 'Scheme Advisor'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/ThemeToggle.tsx
Normal file
12
src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme();
|
||||
return (
|
||||
<button onClick={toggle} title={theme === 'dark' ? 'Switch to light' : 'Switch to dark'} aria-label="Toggle theme"
|
||||
className="rounded-md p-2 text-muted transition-colors hover:bg-panel2 hover:text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand">
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
143
src/components/core/index.tsx
Normal file
143
src/components/core/index.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import type { Tone } from '../../api/config';
|
||||
|
||||
/** The UI kit. Every colour is a token from styles.css — never a literal. */
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
slate: 'text-idle', amber: 'text-warn', sky: 'text-info', emerald: 'text-good', rose: 'text-bad', violet: 'text-ai', accent: 'text-accent',
|
||||
};
|
||||
const TONE_CHIP: Record<Tone, string> = {
|
||||
slate: 'bg-idle-soft text-idle ring-idle-edge', amber: 'bg-warn-soft text-warn ring-warn-edge', sky: 'bg-info-soft text-info ring-info-edge',
|
||||
emerald: 'bg-good-soft text-good ring-good-edge', rose: 'bg-bad-soft text-bad ring-bad-edge', violet: 'bg-ai-soft text-ai ring-ai-edge',
|
||||
accent: 'bg-accent-soft text-accent ring-accent-edge',
|
||||
};
|
||||
export function toneText(tone: Tone): string { return TONE_TEXT[tone]; }
|
||||
|
||||
export function Badge({ children, tone = 'slate', dot = false, className = '' }: { children: ReactNode; tone?: Tone; dot?: boolean; className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ${TONE_CHIP[tone]} ${className}`}>
|
||||
{dot && <span className="live-dot h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonKind = 'primary' | 'secondary' | 'danger' | 'ghost' | 'accent';
|
||||
const BUTTON_KIND: Record<ButtonKind, string> = {
|
||||
primary: 'bg-brand text-brand-fg hover:bg-brand-hover',
|
||||
secondary: 'bg-panel2 text-ink ring-1 ring-inset ring-line hover:bg-panel3',
|
||||
danger: 'bg-bad text-white hover:opacity-90',
|
||||
ghost: 'text-muted hover:bg-panel2 hover:text-ink',
|
||||
accent: 'bg-accent-soft text-accent ring-1 ring-inset ring-accent-edge hover:bg-panel3',
|
||||
};
|
||||
export function Button({ children, onClick, kind = 'secondary', disabled = false, busy = false, type = 'button', className = '', title, size = 'md' }:
|
||||
{ children: ReactNode; onClick?: () => void; kind?: ButtonKind; disabled?: boolean; busy?: boolean; type?: 'button' | 'submit'; className?: string; title?: string; size?: 'sm' | 'md' }) {
|
||||
const pad = size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3.5 py-2 text-sm';
|
||||
return (
|
||||
<button type={type} title={title} onClick={onClick} disabled={disabled || busy}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-not-allowed disabled:opacity-50 ${pad} ${BUTTON_KIND[kind]} ${className}`}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', title, subtitle, actions }: { children?: ReactNode; className?: string; title?: ReactNode; subtitle?: ReactNode; actions?: ReactNode }) {
|
||||
return (
|
||||
<section className={`lift rounded-lg border border-line bg-panel ${className}`}>
|
||||
{(title || actions) && (
|
||||
<header className="flex items-start justify-between gap-4 border-b border-line px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
{title && <h2 className="text-sm font-semibold text-ink">{title}</h2>}
|
||||
{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FIELD = 'w-full rounded-md border border-line bg-panel2 px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-brand focus:bg-panel focus:outline-none focus:ring-2 focus:ring-brand/25 disabled:opacity-60';
|
||||
export function Label({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||
return <label className="mb-1.5 block text-xs font-semibold tracking-wide text-muted uppercase">{children}{required && <span className="ml-1 text-bad">*</span>}</label>;
|
||||
}
|
||||
export function Input({ value, onChange, type = 'text', placeholder, disabled, step }: { value: string; onChange: (v: string) => void; type?: string; placeholder?: string; disabled?: boolean; step?: string }) {
|
||||
return <input className={FIELD} type={type} step={step} value={value} placeholder={placeholder} disabled={disabled} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
export function Textarea({ value, onChange, rows = 4, placeholder }: { value: string; onChange: (v: string) => void; rows?: number; placeholder?: string }) {
|
||||
return <textarea className={FIELD} rows={rows} value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
export function Select({ value, onChange, options, placeholder = 'Select…', disabled }: { value: string; onChange: (v: string) => void; options: Array<{ label: string; value: string }>; placeholder?: string; disabled?: boolean }) {
|
||||
return (
|
||||
<select className={FIELD} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
export function Spinner({ label }: { label?: string }) {
|
||||
return <div className="flex items-center justify-center gap-2 py-10 text-sm text-faint"><Loader2 className="h-4 w-4 animate-spin" />{label ?? 'Loading…'}</div>;
|
||||
}
|
||||
export function ErrorNote({ children }: { children: ReactNode }) {
|
||||
return <div className="rounded-md border border-bad-edge bg-bad-soft px-3 py-2 text-sm text-bad">{children}</div>;
|
||||
}
|
||||
export function Empty({ children }: { children: ReactNode }) {
|
||||
return <div className="py-12 text-center text-sm text-faint">{children}</div>;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, subtitle, children, wide = false }: { open: boolean; onClose: () => void; title: ReactNode; subtitle?: ReactNode; children: ReactNode; wide?: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = prev; };
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-scrim p-4 sm:p-8">
|
||||
<div role="dialog" aria-modal="true" className={`lift-lg w-full rounded-lg border border-line bg-panel ${wide ? 'max-w-3xl' : 'max-w-xl'}`}>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div className="min-w-0"><h2 className="text-base font-semibold text-ink">{title}</h2>{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}</div>
|
||||
<button onClick={onClose} aria-label="Close" className="-m-1 rounded p-1 text-faint hover:bg-panel2 hover:text-ink"><X className="h-4 w-4" /></button>
|
||||
</header>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Facts({ rows, cols = 2 }: { rows: Array<{ label: string; value: ReactNode; tone?: Tone; wide?: boolean }>; cols?: 1 | 2 | 3 | 4 }) {
|
||||
const grid = { 1: 'sm:grid-cols-1', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3', 4: 'sm:grid-cols-4' }[cols];
|
||||
return (
|
||||
<dl className={`grid grid-cols-1 gap-x-6 gap-y-3.5 ${grid}`}>
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className={r.wide ? 'sm:col-span-full' : undefined}>
|
||||
<dt className="text-[11px] font-semibold tracking-wide text-faint uppercase">{r.label}</dt>
|
||||
<dd className={`tnum mt-0.5 text-sm ${r.tone ? TONE_TEXT[r.tone] : 'text-ink'}`}>{r.value === '' || r.value == null ? <span className="text-faint">—</span> : r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/** A number that matters, with a caption. */
|
||||
export function Kpi({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: Tone }) {
|
||||
return (
|
||||
<div className="lift rounded-lg border border-line bg-panel px-4 py-3">
|
||||
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</div>
|
||||
<div className={`tnum mt-1 text-xl font-semibold ${tone ? TONE_TEXT[tone] : 'text-ink'}`}>{value}</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-muted">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Progress toward a target or the next slab. */
|
||||
export function Bar({ value, max, className = '' }: { value: number; max: number; className?: string }) {
|
||||
const p = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
|
||||
return <div className={`bar ${className}`}><i style={{ width: `${p}%` }} /></div>;
|
||||
}
|
||||
214
src/components/widgets.tsx
Normal file
214
src/components/widgets.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../api/provider';
|
||||
import { KIND_LABEL, RV, STATE_TONE } from '../api/config';
|
||||
import type { Tone } from '../api/config';
|
||||
import type { AiDecision, AuditEntry, Row, SimRow } from '../api/types';
|
||||
import { Badge, Bar, Card, Empty, ErrorNote, Spinner } from './core';
|
||||
import { date, dateTime, inr, mt, num, pct } from '../format';
|
||||
|
||||
export function StateBadge({ state }: { state: unknown }) {
|
||||
const s = String(state ?? '');
|
||||
return <Badge tone={STATE_TONE[s] ?? 'slate'}>{s || '—'}</Badge>;
|
||||
}
|
||||
|
||||
export function KindBadge({ kind }: { kind: unknown }) {
|
||||
const k = String(kind ?? '');
|
||||
const tone: Tone = k === 'NOTHING_TO_DO' ? 'slate' : k === 'AT_RISK' ? 'amber' : k === 'COLLECTION' ? 'sky' : 'accent';
|
||||
return <Badge tone={tone}>{KIND_LABEL[k] ?? k}</Badge>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable — a record view as a table, with optional paging and row click.
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface Col { key: string; label: string; render?: (row: Row) => ReactNode; className?: string; align?: 'right' | 'left'; }
|
||||
export function DataTable({ rows, cols, onRow, empty = 'Nothing here yet.', loading, error, rowKey }: {
|
||||
rows: Row[] | null; cols: Col[]; onRow?: (r: Row) => void; empty?: string; loading?: boolean; error?: string | null; rowKey?: (r: Row, i: number) => string;
|
||||
}) {
|
||||
if (loading && !rows) return <Spinner />;
|
||||
if (error) return <div className="p-4"><ErrorNote>{error}</ErrorNote></div>;
|
||||
if (!rows || rows.length === 0) return <Empty>{empty}</Empty>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||
{cols.map((c) => <th key={c.key} className={`px-3 py-2 ${c.align === 'right' ? 'text-right' : ''}`}>{c.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={rowKey ? rowKey(r, i) : i} onClick={onRow ? () => onRow(r) : undefined}
|
||||
className={`border-b border-line last:border-0 ${onRow ? 'cursor-pointer hover:bg-panel2' : ''}`}>
|
||||
{cols.map((c) => (
|
||||
<td key={c.key} className={`tnum px-3 py-2 align-top ${c.align === 'right' ? 'text-right' : ''} ${c.className ?? ''}`}>
|
||||
{c.render ? c.render(r) : cell(r[c.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function cell(v: unknown): ReactNode {
|
||||
if (v === null || v === undefined || v === '') return <span className="text-faint">—</span>;
|
||||
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recommendation card — what the Advisor told a dealer.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function RecCard({ rec, compact = false }: { rec: Row; compact?: boolean }) {
|
||||
const inc = num(rec.incremental_payout);
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<KindBadge kind={rec.kind} />
|
||||
{rec.scheme_name ? <span className="text-xs text-faint">{String(rec.scheme_name)}{rec.component_name ? ` · ${String(rec.component_name)}` : ''}</span> : null}
|
||||
<span className="ml-auto text-xs text-faint">{rec.deadline ? `by ${date(rec.deadline)}` : ''}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start justify-between gap-4">
|
||||
<p className="text-sm font-semibold text-ink">{String(rec.headline ?? '')}</p>
|
||||
{inc !== 0 && <div className={`tnum shrink-0 text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</div>}
|
||||
</div>
|
||||
{!compact && rec.rationale_md ? <p className="mt-2 text-sm whitespace-pre-line text-muted">{String(rec.rationale_md)}</p> : null}
|
||||
{!compact && (
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-faint">
|
||||
{rec.delta_mt ? <span>Add <b className="text-ink">{mt(rec.delta_mt)}</b></span> : null}
|
||||
{rec.delta_pp_mt ? <span>Premium <b className="text-ink">{mt(rec.delta_pp_mt)}</b></span> : null}
|
||||
{rec.clear_outstanding_amt ? <span>Clear <b className="text-ink">{inr(rec.clear_outstanding_amt)}</b></span> : null}
|
||||
{rec.payout_now != null ? <span>Now <b className="text-ink">{inr(rec.payout_now)}</b> → if acted <b className="text-ink">{inr(rec.payout_if)}</b></span> : null}
|
||||
{rec.confidence ? <span>Confidence {pct(num(rec.confidence) * 100, 0)}</span> : null}
|
||||
<span className="ml-auto"><Sparkles className="mr-1 inline h-3 w-3 text-ai" />Scheme Advisor</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The engine, live: simulate() for a dealer with an optional what-if.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function useSimulate(customerNo: string | null | undefined, hypo: Record<string, unknown> | null, debounceMs = 500) {
|
||||
const { client } = useZino();
|
||||
const [key, setKey] = useState(JSON.stringify(hypo ?? {}));
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setKey(JSON.stringify(hypo ?? {})), debounceMs);
|
||||
return () => clearTimeout(t);
|
||||
}, [hypo, debounceMs]);
|
||||
return useQuery(
|
||||
() => client.rows(RV.SIMULATE, { limit: 100, params: { customer_no: customerNo, hypo_json: key } }).then((r) => r as unknown as SimRow[]),
|
||||
[customerNo, key], !!customerNo,
|
||||
);
|
||||
}
|
||||
|
||||
export function SimSummary({ rows, hasHypo }: { rows: SimRow[] | null; hasHypo: boolean }) {
|
||||
if (!rows) return null;
|
||||
const now = rows.reduce((s, r) => s + num(r.payout_now), 0);
|
||||
const if_ = rows.reduce((s, r) => s + num(r.payout_if), 0);
|
||||
const inc = if_ - now;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-line bg-panel2 px-3 py-2"><div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Earning now</div><div className="tnum text-lg font-semibold text-ink">{inr(now)}</div></div>
|
||||
<div className="rounded-lg border border-line bg-panel2 px-3 py-2"><div className="text-[11px] font-semibold tracking-wide text-faint uppercase">{hasHypo ? 'With this order' : 'If nothing changes'}</div><div className="tnum text-lg font-semibold text-ink">{inr(if_)}</div></div>
|
||||
<div className={`rounded-lg border px-3 py-2 ${inc > 0 ? 'border-accent-edge bg-accent-soft' : inc < 0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel2'}`}>
|
||||
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Difference</div>
|
||||
<div className={`tnum text-lg font-semibold ${inc > 0 ? 'text-accent' : inc < 0 ? 'text-bad' : 'text-ink'}`}>{inr(inc, { sign: true })}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row per eligible scheme component: slab, progress to the next slab, payout now → if. */
|
||||
export function SimTable({ rows, hasHypo, loading, error }: { rows: SimRow[] | null; hasHypo: boolean; loading: boolean; error: string | null }) {
|
||||
if (loading && !rows) return <Spinner label="Asking the scheme engine…" />;
|
||||
if (error) return <ErrorNote>{error}</ErrorNote>;
|
||||
if (!rows || rows.length === 0) return <Empty>No live scheme applies to this dealer right now.</Empty>;
|
||||
const sorted = [...rows].sort((a, b) => num(b.incremental) - num(a.incremental) || num(b.payout_now) - num(a.payout_now));
|
||||
return (
|
||||
<div className="divide-y divide-line">
|
||||
{sorted.map((r) => {
|
||||
const inc = num(r.incremental);
|
||||
const toNext = r.mt_to_next_slab != null ? num(r.mt_to_next_slab) : null;
|
||||
const basis = num(r.basis_value);
|
||||
const target = toNext != null ? basis + toNext : null;
|
||||
return (
|
||||
<div key={r.component_id} className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{r.scheme_name}</span>
|
||||
<span className="text-xs text-faint">{r.component_name} · {r.days_left} day{r.days_left === 1 ? '' : 's'} left</span>
|
||||
<span className="ml-auto tnum text-sm">
|
||||
<span className="text-muted">{r.in_kind_now ?? inr(r.payout_now)}</span>
|
||||
{hasHypo && <><span className="mx-1 text-faint">→</span><span className={inc > 0 ? 'font-semibold text-accent' : inc < 0 ? 'font-semibold text-bad' : 'text-muted'}>{r.in_kind_if ?? inr(r.payout_if)}</span></>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
{r.slab_label && <Badge tone={r.gate_passed ? 'emerald' : 'slate'}>{hasHypo && r.slab_label_if && r.slab_label_if !== r.slab_label ? `${r.slab_label} → ${r.slab_label_if}` : r.slab_label}</Badge>}
|
||||
{!r.gate_passed && r.gate_reason && <span className="text-warn">{r.gate_reason}</span>}
|
||||
{r.gate_passed && r.gate_reason && <span className="text-faint">{r.gate_reason}</span>}
|
||||
{r.growth_pct != null && <span>growth {pct(r.growth_pct)}</span>}
|
||||
{r.achievement_pct != null && <span>achievement {pct(r.achievement_pct)}</span>}
|
||||
</div>
|
||||
{toNext != null && target != null && toNext > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between text-[11px] text-faint"><span>{mt(basis)} now</span><span><b className="text-ink">{mt(toNext)}</b> more → {r.next_slab_label}{r.next_slab_rate ? ` @ ₹${num(r.next_slab_rate)}` : ''}</span></div>
|
||||
<Bar value={basis} max={target} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{r.next_growth_label && r.growth_pts_to_next != null && num(r.growth_pts_to_next) > 0 && (
|
||||
<div className="mt-1 text-[11px] text-faint">{pct(r.growth_pts_to_next)} points of growth to {r.next_growth_label}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// What happened on an instance: the audit trail + the AI's own reasoning.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function InstanceTrace({ instanceId }: { instanceId: number | string }) {
|
||||
const { client } = useZino();
|
||||
const audit = useQuery(() => client.audit(instanceId), [instanceId], true, 15000);
|
||||
const ai = useQuery(() => client.aiDecisions(instanceId).catch(() => [] as AiDecision[]), [instanceId], true, 15000);
|
||||
return (
|
||||
<Card title="Trail" subtitle="Every step on this record, and what the AI reasoned.">
|
||||
{audit.loading && !audit.data && <Spinner />}
|
||||
{audit.error && <div className="p-4"><ErrorNote>{audit.error}</ErrorNote></div>}
|
||||
<ol className="divide-y divide-line">
|
||||
{(audit.data ?? []).map((a: AuditEntry) => {
|
||||
const dec = (ai.data ?? []).find((d) => d.action === a.activity_id && Math.abs(new Date(d.created_at ?? 0).getTime() - new Date(a.performed_at ?? a.created_at ?? 0).getTime()) < 120000);
|
||||
const isAi = (a.user_roles ?? []).some((r) => r.startsWith('ai_'));
|
||||
return (
|
||||
<li key={a.id} className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="font-semibold text-ink">{a.activity_name ?? a.activity_id}</span>
|
||||
<span className={`text-xs ${isAi ? 'text-ai' : 'text-faint'}`}>{isAi && <Sparkles className="mr-1 inline h-3 w-3" />}{a.user_name || a.user_email}</span>
|
||||
<span className="ml-auto text-xs text-faint">{dateTime(a.performed_at ?? a.created_at)}</span>
|
||||
</div>
|
||||
{a.fields && a.fields.length > 0 && (
|
||||
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
||||
{a.fields.filter((f) => f.value !== '' && f.value != null).slice(0, 12).map((f) => (
|
||||
<div key={f.field_id} className="min-w-0"><dt className="inline text-faint">{f.label}: </dt><dd className="inline break-words text-ink">{String(f.value).slice(0, 300)}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{(a.ai_reasoning || dec?.reasoning) && (
|
||||
<details className="mt-2 rounded-md border border-ai-edge bg-ai-soft px-3 py-2 text-xs text-ink">
|
||||
<summary className="cursor-pointer font-semibold text-ai">Reasoning{dec?.model ? ` · ${dec.model}` : ''}{dec?.cost ? ` · $${num(dec.cost).toFixed(3)}` : ''}</summary>
|
||||
<p className="mt-2 whitespace-pre-line">{String(a.ai_reasoning ?? dec?.reasoning)}</p>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{audit.data && audit.data.length === 0 && <Empty>No steps recorded yet.</Empty>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
src/format.ts
Normal file
45
src/format.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/** Money and volume formatting. Indian grouping (₹12,34,567), tonnes with one decimal. */
|
||||
export function num(v: unknown): number {
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, ''));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
export function inr(v: unknown, opts: { sign?: boolean; compact?: boolean } = {}): string {
|
||||
const n = num(v);
|
||||
const abs = Math.abs(n);
|
||||
let s: string;
|
||||
if (opts.compact && abs >= 10000000) s = (abs / 10000000).toFixed(2) + ' Cr';
|
||||
else if (opts.compact && abs >= 100000) s = (abs / 100000).toFixed(2) + ' L';
|
||||
else s = new Intl.NumberFormat('en-IN', { maximumFractionDigits: 0 }).format(Math.round(abs));
|
||||
const sign = n < 0 ? '−' : opts.sign && n > 0 ? '+' : '';
|
||||
return `${sign}₹${s}`;
|
||||
}
|
||||
export function mt(v: unknown, digits = 1): string {
|
||||
const n = num(v);
|
||||
return `${new Intl.NumberFormat('en-IN', { maximumFractionDigits: digits, minimumFractionDigits: 0 }).format(n)} MT`;
|
||||
}
|
||||
export function pct(v: unknown, digits = 1): string {
|
||||
if (v === null || v === undefined || v === '') return '—';
|
||||
return `${num(v).toFixed(digits)}%`;
|
||||
}
|
||||
export function date(v: unknown): string {
|
||||
if (!v) return '—';
|
||||
const d = new Date(String(v));
|
||||
if (Number.isNaN(d.getTime())) return String(v);
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
export function dateTime(v: unknown): string {
|
||||
if (!v) return '—';
|
||||
const d = new Date(String(v));
|
||||
if (Number.isNaN(d.getTime())) return String(v);
|
||||
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
export function isoDate(v: unknown): string {
|
||||
if (!v) return '';
|
||||
return String(v).slice(0, 10);
|
||||
}
|
||||
export function parseJson<T>(v: unknown, fallback: T): T {
|
||||
if (v === null || v === undefined || v === '') return fallback;
|
||||
if (typeof v === 'object') return v as T;
|
||||
try { return JSON.parse(String(v)) as T; } catch { return fallback; }
|
||||
}
|
||||
48
src/layout/Shell.tsx
Normal file
48
src/layout/Shell.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useZino } from '../api/provider';
|
||||
import { prettyRole } from '../api/config';
|
||||
import { AppLogo } from '../components/Logo';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
|
||||
export interface NavItem { to: string; label: string; hint: string; end?: boolean; }
|
||||
|
||||
/** The chrome. Nothing here is role-gated beyond choosing which nav to show:
|
||||
* the workflow re-checks RBAC on every submission. */
|
||||
export function Shell({ items, sub }: { items: NavItem[]; sub: string }) {
|
||||
const { user, portal, logout } = useZino();
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-30 bg-panel">
|
||||
<div className="flex items-center gap-4 px-4 py-2.5">
|
||||
<AppLogo sub={sub} />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && (
|
||||
<div className="hidden text-right sm:block">
|
||||
<div className="text-[13px] leading-tight font-semibold text-ink">{portal?.display_name || user.name || user.email}</div>
|
||||
<div className="text-[11px] leading-tight text-faint">{(user.roles ?? []).map(prettyRole).join(' · ') || 'No role'}{portal?.customer_no ? ` · ${portal.customer_no}` : portal?.ao_code ? ` · ${portal.ao_code}` : portal?.rso_code ? ` · ${portal.rso_code}` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<button onClick={logout} title="Sign out" aria-label="Sign out" className="rounded-md p-2 text-muted hover:bg-panel2 hover:text-ink"><LogOut className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div aria-hidden="true" className="h-[3px] bg-brand" />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col lg:flex-row">
|
||||
<nav className="shrink-0 border-b border-line bg-panel lg:w-56 lg:border-r lg:border-b-0">
|
||||
<ul className="flex gap-1 overflow-x-auto p-2 lg:flex-col lg:overflow-visible">
|
||||
{items.map((q) => (
|
||||
<li key={q.to} className="shrink-0 lg:shrink">
|
||||
<NavLink to={q.to} end={q.end} className={({ isActive }) => `block rounded-md px-3 py-2 transition-colors ${isActive ? 'bg-brand text-brand-fg' : 'text-muted hover:bg-panel2 hover:text-ink'}`}>
|
||||
{({ isActive }) => (<><span className="block text-[13px] font-semibold whitespace-nowrap">{q.label}</span><span className={`hidden text-[11px] lg:block ${isActive ? 'text-brand-fg/70' : 'text-faint'}`}>{q.hint}</span></>)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<main className="min-w-0 flex-1 p-4 lg:p-6"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/main.tsx
Normal file
23
src/main.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import { ZinoProvider } from './api/provider';
|
||||
import { basePath, requireConfigValue } from './runtimeConfig';
|
||||
import '@fontsource-variable/inter';
|
||||
import './styles.css';
|
||||
|
||||
// The API base URL is read at RUNTIME from config.js (written by frontgen at
|
||||
// placement). requireConfigValue throws so a build with no config.js fails
|
||||
// loudly rather than calling whichever backend built it.
|
||||
const baseUrl = requireConfigValue('VITE_ZINO_API_URL');
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename={basePath()}>
|
||||
<ZinoProvider baseUrl={baseUrl}>
|
||||
<App />
|
||||
</ZinoProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
37
src/pages/Login.tsx
Normal file
37
src/pages/Login.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState } from 'react';
|
||||
import { useZino } from '../api/provider';
|
||||
import { ORG_ID } from '../api/config';
|
||||
import { Button, ErrorNote, Input, Label } from '../components/core';
|
||||
import { AppLogo, MeruMark } from '../components/Logo';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
|
||||
export function Login() {
|
||||
const { login } = useZino();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
async function submit() {
|
||||
if (!email || !password) { setErr('Enter your email and password'); return; }
|
||||
setBusy(true); setErr(null);
|
||||
try { await login(email, password, ORG_ID); } catch (e) { setErr((e as { message?: string })?.message ?? 'Could not sign in'); } finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-bg">
|
||||
<header className="bg-panel"><div className="flex items-center justify-between px-4 py-2.5"><AppLogo /><ThemeToggle /></div><div aria-hidden="true" className="h-[3px] bg-brand" /></header>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); void submit(); }} className="lift w-full max-w-sm rounded-lg border border-line bg-panel p-6">
|
||||
<MeruMark size={36} />
|
||||
<h1 className="mt-5 text-lg font-semibold text-ink">Sign in</h1>
|
||||
<p className="mt-1 text-sm text-muted">Dealers see their schemes and the Advisor's nudges. Sales teams run sweeps, drafts and payouts.</p>
|
||||
<div className="mt-5 space-y-3">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
<div><Label required>Email</Label><Input value={email} onChange={setEmail} type="email" placeholder="you@dealers.meru.example" /></div>
|
||||
<div><Label required>Password</Label><Input value={password} onChange={setPassword} type="password" placeholder="••••••••" /></div>
|
||||
<Button kind="primary" type="submit" busy={busy} className="w-full">Sign in</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/runtimeConfig.ts
Normal file
33
src/runtimeConfig.ts
Normal file
@ -0,0 +1,33 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__RUNTIME_CONFIG__?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime configuration, written by the server as `config.js` when it places a
|
||||
* build — never baked in. One build is promoted between environments unchanged,
|
||||
* so an API URL compiled at build time would be the source environment's URL
|
||||
* everywhere it lands. SAME_ORIGIN means "use the current origin".
|
||||
*/
|
||||
export function resolveConfigValue(key: string, fallback = ''): string {
|
||||
const runtime = (window.__RUNTIME_CONFIG__ || {})[key];
|
||||
const devFallback = import.meta.env.DEV ? ((import.meta.env as Record<string, string>)[key] ?? '') : '';
|
||||
const value = runtime || devFallback || fallback;
|
||||
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value;
|
||||
}
|
||||
|
||||
export function requireConfigValue(key: string): string {
|
||||
const value = resolveConfigValue(key);
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'Missing runtime config "' + key + '". The server writes config.js when it places the build; for local dev set ' + key + ' in .env.',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The mount path, from the <base href> the server writes into index.html. */
|
||||
export function basePath(): string {
|
||||
return new URL(document.baseURI).pathname;
|
||||
}
|
||||
102
src/screens/dealer/DealerHome.tsx
Normal file
102
src/screens/dealer/DealerHome.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { RV } from '../../api/config';
|
||||
import { Badge, Bar, Button, Card, Empty, ErrorNote, Kpi, Spinner } from '../../components/core';
|
||||
import { RecCard, SimTable, useSimulate } from '../../components/widgets';
|
||||
import { date, inr, mt, num, pct } from '../../format';
|
||||
import { useDealer } from './useDealer';
|
||||
|
||||
export function DealerHome() {
|
||||
const { client, portal } = useZino();
|
||||
const cust = portal?.customer_no;
|
||||
const d = useDealer(cust);
|
||||
const sim = useSimulate(cust, null);
|
||||
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 5, sortBy: 'created_at', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(cust), data_type: 'string' }] }), [cust], !!cust, 30000);
|
||||
const upcoming = useQuery(() => client.rows(RV.UPCOMING, { params: { customer_no: cust } }), [cust], !!cust);
|
||||
|
||||
if (!portal) return <Spinner label="Finding your dealer account…" />;
|
||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||
if (d.loading && !d.data) return <Spinner />;
|
||||
if (d.error) return <ErrorNote>{d.error}</ErrorNote>;
|
||||
const s = d.data?.snapshot;
|
||||
if (!s) return <Empty>No snapshot for {cust}.</Empty>;
|
||||
const st = d.data?.standing;
|
||||
const asOf = d.data?.as_of ?? '';
|
||||
const monthEnd = asOf ? new Date(new Date(asOf).getFullYear(), new Date(asOf).getMonth() + 1, 0) : null;
|
||||
const daysLeft = monthEnd && asOf ? Math.round((monthEnd.getTime() - new Date(asOf).getTime()) / 86400000) : null;
|
||||
const earning = (sim.data ?? []).reduce((a, r) => a + num(r.payout_now), 0);
|
||||
const top = recs.data?.[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink">{s.name}</h1>
|
||||
<p className="text-sm text-muted">{s.customer_no} · {st?.cluster_name ?? s.cluster} cluster · {s.ao} · as of {date(asOf)}{daysLeft != null ? ` · ${daysLeft} days left in the month` : ''}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{s.gold_star && <Badge tone="accent">Gold Star</Badge>}
|
||||
{s.silver_star && <Badge tone="sky">Silver Star</Badge>}
|
||||
{s.is_new_dealer && <Badge tone="emerald">New dealer</Badge>}
|
||||
{s.is_inactive && <Badge tone="amber">Revival</Badge>}
|
||||
{st?.is_weak_market && <Badge tone="slate">Weak-market cluster</Badge>}
|
||||
{st?.is_border && <Badge tone="slate">Border cluster</Badge>}
|
||||
{s.loyalty_tier && <Badge tone="slate">{s.loyalty_tier} tier</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/d/order/new"><Button kind="primary">Place an order <ArrowRight className="h-4 w-4" /></Button></Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<div className="lift rounded-lg border border-line bg-panel px-4 py-3">
|
||||
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Month to date</div>
|
||||
<div className="tnum mt-1 text-xl font-semibold text-ink">{mt(s.mtd_mt)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted">target {mt(s.month_target_mt)} · {s.month_target_mt ? pct((s.mtd_mt / s.month_target_mt) * 100, 0) : '—'}</div>
|
||||
<Bar value={s.mtd_mt} max={s.month_target_mt} className="mt-2" />
|
||||
</div>
|
||||
<Kpi label="Quarter to date" value={mt(s.qtd_mt)} sub={`target ${mt(s.quarter_target_mt)}`} />
|
||||
<Kpi label="Earning this period" value={inr(earning)} sub="across every live scheme" tone="accent" />
|
||||
<Kpi label="Open outstanding" value={inr(s.open_outstanding, { compact: true })} sub={s.overdue > 0 ? `${inr(s.overdue, { compact: true })} overdue` : 'nothing overdue'} tone={s.overdue > 0 ? 'rose' : undefined} />
|
||||
<Kpi label="Cluster rank" value={st ? `#${st.rank} of ${st.peers}` : '—'} sub={st ? `${st.cluster_name} at ${pct(st.cluster_achievement_pct, 0)} of target` : ''} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
<div className="space-y-5 xl:col-span-2">
|
||||
<Card title={<span className="inline-flex items-center gap-2"><Sparkles className="h-4 w-4 text-ai" />Advisor says</span>} subtitle="The latest nudges written for you. Numbers come from the scheme engine, not from the model.">
|
||||
<div className="space-y-3 p-4">
|
||||
{recs.loading && !recs.data && <Spinner />}
|
||||
{recs.data && recs.data.length === 0 && <Empty>No advice yet — place an order and the Advisor reads it.</Empty>}
|
||||
{top && <RecCard rec={top} />}
|
||||
{(recs.data ?? []).slice(1, 3).map((r) => <RecCard key={String(r.rec_id)} rec={r} compact />)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Your schemes right now" subtitle="Every live component you are eligible for, what it pays today, and how far the next slab is." actions={<Link to="/d/schemes" className="text-xs font-semibold text-brand">All schemes →</Link>}>
|
||||
<SimTable rows={(sim.data ?? []).filter((r) => num(r.payout_now) > 0 || (r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0)).slice(0, 6)} hasHypo={false} loading={sim.loading} error={sim.error} />
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Card title="Coming up" subtitle="Schemes that open soon — worth timing an order for.">
|
||||
<div className="divide-y divide-line">
|
||||
{(upcoming.data ?? []).length === 0 && <Empty>Nothing opening in the next 45 days.</Empty>}
|
||||
{(upcoming.data ?? []).map((u) => (
|
||||
<div key={String(u.component_id)} className="px-4 py-3">
|
||||
<div className="text-sm font-semibold text-ink">{String(u.scheme_name)}</div>
|
||||
<div className="text-xs text-muted">opens {date(u.validity_from)} · in {String(u.starts_in_days)} days · to {date(u.validity_to)}</div>
|
||||
<p className="mt-1 line-clamp-3 text-xs text-faint whitespace-pre-line">{String(u.circular_text ?? '').split('\n').slice(2).join(' ')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Open orders">
|
||||
<div className="divide-y divide-line">
|
||||
{s.open_orders.length === 0 && <Empty>No open orders in the estate.</Empty>}
|
||||
{s.open_orders.map((o) => (
|
||||
<div key={o.so_no} className="flex items-center justify-between px-4 py-2.5 text-sm"><div><div className="font-semibold text-ink">{o.so_no}</div><div className="text-xs text-faint">for {date(o.requested)}</div></div><div className="text-right"><div className="tnum text-ink">{mt(o.qty_mt)}</div><Badge tone={o.status === 'DRAFT' ? 'slate' : 'sky'}>{o.status}</Badge></div></div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/screens/dealer/Incentives.tsx
Normal file
40
src/screens/dealer/Incentives.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { RV } from '../../api/config';
|
||||
import { Badge, Card, ErrorNote, Kpi } from '../../components/core';
|
||||
import { DataTable } from '../../components/widgets';
|
||||
import { date, inr, mt, num } from '../../format';
|
||||
|
||||
export function DealerIncentives() {
|
||||
const { client, portal } = useZino();
|
||||
const cust = portal?.customer_no;
|
||||
const q = useQuery(() => client.rows(RV.TRACKING, { limit: 200, sortBy: 'period_from', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(cust), data_type: 'string' }] }), [cust], !!cust);
|
||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||
const rows = q.data ?? [];
|
||||
const final = rows.filter((r) => r.status === 'FINAL');
|
||||
const prov = rows.filter((r) => r.status === 'PROVISIONAL');
|
||||
const paid = final.reduce((a, r) => a + num(r.incentive_amount), 0);
|
||||
const accrued = prov.reduce((a, r) => a + num(r.incentive_amount), 0);
|
||||
const cols = [
|
||||
{ key: 'period_from', label: 'Period', render: (r: Record<string, unknown>) => `${date(r.period_from)} – ${date(r.period_to)}` },
|
||||
{ key: 'scheme_name', label: 'Scheme', render: (r: Record<string, unknown>) => <span><span className="text-ink">{String(r.scheme_name)}</span><span className="block text-xs text-faint">{String(r.component_name)}</span></span> },
|
||||
{ key: 'measured_qty', label: 'Volume', align: 'right' as const, render: (r: Record<string, unknown>) => mt(r.measured_qty) },
|
||||
{ key: 'slab_label', label: 'Slab', render: (r: Record<string, unknown>) => (r.gate_passed ? String(r.slab_label ?? '—') : <span className="text-warn">{String(r.gate_reason ?? 'gate')}</span>) },
|
||||
{ key: 'rate', label: 'Rate', align: 'right' as const, render: (r: Record<string, unknown>) => (r.rate != null ? `₹${num(r.rate)} ${String(r.rate_unit) === 'RS_PER_BAG' ? '/bag' : String(r.rate_unit) === 'RS_PER_MT' ? '/MT' : ''}` : '—') },
|
||||
{ key: 'incentive_amount', label: 'Amount', align: 'right' as const, render: (r: Record<string, unknown>) => (r.in_kind_item ? String(r.in_kind_item) : <span className="font-semibold text-ink">{inr(r.incentive_amount)}</span>) },
|
||||
{ key: 'status', label: 'Status', render: (r: Record<string, unknown>) => <Badge tone={r.status === 'FINAL' ? 'emerald' : r.status === 'HELD' ? 'rose' : 'amber'}>{String(r.status)}</Badge> },
|
||||
{ key: 'credit_note_no', label: 'Credit note', render: (r: Record<string, unknown>) => (r.credit_note_no ? <span>{String(r.credit_note_no)}<span className="block text-xs text-faint">{String(r.settlement_status)} · {date(r.posted_on ?? r.scheduled_on)}</span></span> : r.settlement_status ? String(r.settlement_status) : '—') },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-ink">Incentives</h1>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Kpi label="Released (last 12 months)" value={inr(paid, { compact: true })} sub={`${final.filter((r) => num(r.incentive_amount) > 0).length} payouts`} tone="emerald" />
|
||||
<Kpi label="Accruing now" value={inr(accrued, { compact: true })} sub="provisional, this period" tone="accent" />
|
||||
<Kpi label="Components paying" value={String(prov.filter((r) => num(r.incentive_amount) > 0).length)} sub={`of ${prov.length} live`} />
|
||||
<Kpi label="Gated" value={String(prov.filter((r) => !r.gate_passed).length)} sub="something to clear" tone={prov.some((r) => !r.gate_passed) ? 'amber' : undefined} />
|
||||
</div>
|
||||
<Card title="This period" subtitle="Provisional: computed as of the latest data, final at period end."><DataTable rows={prov} loading={q.loading} error={q.error} cols={cols} empty="Nothing accruing." /></Card>
|
||||
<Card title="Settled" subtitle="Final tracking rows and their credit notes."><DataTable rows={final} loading={q.loading} error={q.error} cols={cols} empty="No settled incentives yet." /></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/screens/dealer/NewOrder.tsx
Normal file
102
src/screens/dealer/NewOrder.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useZino } from '../../api/provider';
|
||||
import { ACT, WF } from '../../api/config';
|
||||
import { Button, Card, ErrorNote, Input, Label, Select, Spinner, Textarea } from '../../components/core';
|
||||
import { SimSummary, SimTable, useSimulate } from '../../components/widgets';
|
||||
import { date, mt, num, parseJson } from '../../format';
|
||||
import { useDealer, useDealerRow } from './useDealer';
|
||||
|
||||
type Opt = { code: string; name: string; premium?: boolean; cluster?: string; is_default?: boolean; type?: string };
|
||||
|
||||
/**
|
||||
* Place an order with the engine beside you. Every change to quantity,
|
||||
* material or ship-to re-runs simulate() for this dealer with the order as the
|
||||
* what-if; the panel shows what the order earns and how far the next slab is.
|
||||
* Saving creates a Draft; the Scheme Advisor then reads it and writes advice.
|
||||
*/
|
||||
export function NewOrder() {
|
||||
const { client, portal } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const cust = portal?.customer_no;
|
||||
const d = useDealer(cust);
|
||||
const row = useDealerRow(cust);
|
||||
const materials = useMemo(() => parseJson<Opt[]>(row.data?.materials_json, []), [row.data]);
|
||||
const shipTos = useMemo(() => parseJson<Opt[]>(row.data?.ship_tos_json, []), [row.data]);
|
||||
const plants = useMemo(() => parseJson<Opt[]>(row.data?.plants_json, []), [row.data]);
|
||||
const asOf = d.data?.as_of ?? '';
|
||||
const defaultDate = asOf ? new Date(new Date(asOf).getTime() + 3 * 86400000).toISOString().slice(0, 10) : '';
|
||||
|
||||
const [material, setMaterial] = useState('');
|
||||
const [qty, setQty] = useState('20');
|
||||
const [shipTo, setShipTo] = useState('');
|
||||
const [plant, setPlant] = useState('');
|
||||
const [when, setWhen] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const mat = material || String(row.data?.default_material ?? materials[0]?.code ?? '');
|
||||
const st = shipTo || String(row.data?.default_ship_to ?? shipTos[0]?.code ?? '');
|
||||
const pl = plant || String(row.data?.default_plant ?? plants[0]?.code ?? '');
|
||||
const dt = when || defaultDate;
|
||||
const isPremium = materials.find((m) => m.code === mat)?.premium ?? false;
|
||||
const q = num(qty);
|
||||
|
||||
const hypo = useMemo(() => (q > 0 ? { ...(isPremium ? { extra_pp_mt: q } : { extra_mt: q }), ship_to: st || undefined, plant: pl || undefined, order_on: dt || undefined } : null), [q, isPremium, st, pl, dt]);
|
||||
const sim = useSimulate(cust, hypo);
|
||||
const nextSlab = useMemo(() => {
|
||||
const cands = (sim.data ?? []).filter((r) => r.basis === 'MONTHLY_VOLUME' && r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0);
|
||||
return cands.length ? Math.min(...cands.map((r) => num(r.mt_to_next_slab))) : null;
|
||||
}, [sim.data]);
|
||||
|
||||
async function save() {
|
||||
if (!cust || q <= 0 || !mat) { setErr('Pick a material and a quantity'); return; }
|
||||
setBusy(true); setErr(null);
|
||||
try {
|
||||
const res = await client.startInstance(WF.ORDER, ACT.PLACE, {
|
||||
customer_no__place: cust, dealer_name__place: d.data?.snapshot?.name ?? '', ship_to_code__place: st, plant_code__place: pl,
|
||||
material_code__place: mat, qty_mt__place: q, requested_date__place: dt, as_of__place: asOf, notes__place: notes,
|
||||
});
|
||||
navigate(`/d/orders/${res.instance_id}`);
|
||||
} catch (e) { setErr((e as { message?: string })?.message ?? 'Could not save the order'); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||
if ((row.loading && !row.data) || (d.loading && !d.data)) return <Spinner />;
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-5">
|
||||
<Card className="xl:col-span-2" title="Place an order" subtitle={`${d.data?.snapshot?.name ?? cust} · prices and schemes as of ${date(asOf)}`}>
|
||||
<div className="space-y-3 p-4">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
<div><Label required>Material</Label><Select value={mat} onChange={setMaterial} options={materials.map((m) => ({ value: m.code, label: `${m.name}${m.premium ? ' · premium' : ''}` }))} /></div>
|
||||
<div>
|
||||
<Label required>Quantity (MT)</Label>
|
||||
<Input type="number" step="1" value={qty} onChange={setQty} />
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{[10, 20, 30, 50].map((n) => <Button key={n} size="sm" onClick={() => setQty(String(n))}>{n} MT</Button>)}
|
||||
{nextSlab != null && <Button size="sm" kind="accent" onClick={() => setQty(String(Math.ceil(nextSlab)))}>Next slab · {mt(Math.ceil(nextSlab), 0)}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<div><Label>Ship to</Label><Select value={st} onChange={setShipTo} options={shipTos.map((s) => ({ value: s.code, label: `${s.name} (${s.cluster})` }))} /></div>
|
||||
<div><Label>Plant</Label><Select value={pl} onChange={setPlant} options={plants.map((p) => ({ value: p.code, label: `${p.name} · ${p.type}` }))} /></div>
|
||||
<div><Label>Requested delivery</Label><Input type="date" value={dt} onChange={setWhen} /></div>
|
||||
<div><Label>Notes</Label><Textarea value={notes} onChange={setNotes} rows={2} /></div>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-line pt-3">
|
||||
<Button kind="primary" onClick={save} busy={busy}><Sparkles className="h-4 w-4" />Save draft and get advice</Button>
|
||||
</div>
|
||||
<p className="text-xs text-faint">Saving creates a draft. The Scheme Advisor reads it within a minute or two and writes what to change, if anything. You confirm or cancel from My orders.</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="xl:col-span-3" title="What this order earns" subtitle="The scheme engine, live. Change the quantity and watch the slabs move.">
|
||||
<div className="space-y-4 p-4">
|
||||
<SimSummary rows={sim.data} hasHypo={!!hypo} />
|
||||
<div className="-mx-4 border-t border-line">
|
||||
<SimTable rows={sim.data} hasHypo={!!hypo} loading={sim.loading} error={sim.error} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/screens/dealer/Orders.tsx
Normal file
120
src/screens/dealer/Orders.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import type { Row } from '../../api/types';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Button, Card, Empty, ErrorNote, Facts, Spinner } from '../../components/core';
|
||||
import { DataTable, InstanceTrace, KindBadge, RecCard, StateBadge } from '../../components/widgets';
|
||||
import { date, inr, mt, num, pct } from '../../format';
|
||||
|
||||
function useOrders(customerNo: string | null | undefined, refetchMs?: number) {
|
||||
const { client } = useZino();
|
||||
return useQuery(() => client.rows(RV.ORDERS, { limit: 100, sortBy: 'instance_id', sortDir: 'desc', filters: customerNo ? [{ field_key: 'customer_no', value: customerNo, data_type: 'string' }] : [] }), [customerNo], true, refetchMs);
|
||||
}
|
||||
|
||||
export function DealerOrders() {
|
||||
const { portal } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const q = useOrders(portal?.customer_no, 15000);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between"><h1 className="text-xl font-semibold text-ink">My orders</h1><Link to="/d/order/new"><Button kind="primary">Place order</Button></Link></div>
|
||||
<Card>
|
||||
<DataTable rows={q.data} loading={q.loading} error={q.error} onRow={(r) => navigate(`/d/orders/${r.instance_id}`)} rowKey={(r) => String(r.instance_id)} empty="No orders yet."
|
||||
cols={[
|
||||
{ key: 'instance_id', label: '#' },
|
||||
{ key: 'material_code', label: 'Material' },
|
||||
{ key: 'qty_mt', label: 'Keyed', align: 'right', render: (r) => mt(r.qty_mt) },
|
||||
{ key: 'requested_date', label: 'For', render: (r) => date(r.requested_date) },
|
||||
{ key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> },
|
||||
{ key: 'advice_kind', label: 'Advice', render: (r) => (r.advice_kind ? <KindBadge kind={r.advice_kind} /> : <span className="text-faint">—</span>) },
|
||||
{ key: 'advice_headline', label: 'Headline', className: 'max-w-md', render: (r) => <span className="line-clamp-2 text-xs text-muted">{String(r.advice_headline ?? '')}</span> },
|
||||
{ key: 'incremental_payout', label: 'Worth', align: 'right', render: (r) => (r.incremental_payout != null ? <span className="font-semibold text-accent">{inr(r.incremental_payout, { sign: true })}</span> : '—') },
|
||||
{ key: 'final_qty_mt', label: 'Final', align: 'right', render: (r) => (r.final_qty_mt ? mt(r.final_qty_mt) : '—') },
|
||||
]} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderDetail() {
|
||||
const { id } = useParams();
|
||||
const { client, user } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const supplier = (user?.roles ?? []).some((r) => ['asm', 'rso_manager', 'ho_scheme_admin'].includes(r));
|
||||
const q = useQuery(() => client.rows(RV.ORDERS, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] }).then((rows) => rows[0] ?? null), [id], true, 8000);
|
||||
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 50, sortBy: 'rec_id', sortDir: 'desc', filters: [{ field_key: 'source_instance_id', value: String(id), data_type: 'number' }] }), [id], true, 15000);
|
||||
const [form, setForm] = useState<null | { act: string; title: string; seed?: Record<string, unknown>; prefill?: Record<string, unknown>; danger?: boolean; label?: string }>(null);
|
||||
const o = q.data;
|
||||
const state = String(o?.current_state_name ?? '');
|
||||
const myRecs = useMemo(() => (recs.data ?? []) as Row[], [recs.data]);
|
||||
if (q.loading && !o) return <Spinner />;
|
||||
if (q.error) return <ErrorNote>{q.error}</ErrorNote>;
|
||||
if (!o) return <Empty>Order {id} not found.</Empty>;
|
||||
const suggested = o.suggested_qty_mt != null && num(o.suggested_qty_mt) > 0 ? num(o.suggested_qty_mt) : null;
|
||||
const canDecide = state === 'Draft' || state === 'Advised';
|
||||
const inc = num(o.incremental_payout);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-ink">Order #{String(o.instance_id)}</h1>
|
||||
<StateBadge state={state} />
|
||||
{state === 'Draft' && <span className="text-xs text-ai"><Sparkles className="mr-1 inline h-3 w-3" />the Advisor is reading this order…</span>}
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
{canDecide && suggested != null && suggested !== num(o.qty_mt) && (
|
||||
<Button kind="accent" onClick={() => setForm({ act: ACT.CONFIRM, title: 'Confirm as advised', label: `Confirm ${mt(suggested, 0)}`, seed: { final_material_code: o.material_code, final_ship_to_code: o.suggested_ship_to || o.ship_to_code, advice_accepted: true }, prefill: { final_qty_mt: suggested } })}>Take the advice · {mt(suggested, 0)}</Button>
|
||||
)}
|
||||
{canDecide && <Button kind="primary" onClick={() => setForm({ act: ACT.CONFIRM, title: 'Confirm order', label: 'Confirm', seed: { final_material_code: o.material_code, final_ship_to_code: o.ship_to_code, advice_accepted: false }, prefill: { final_qty_mt: num(o.qty_mt) } })}>Confirm as keyed</Button>}
|
||||
{canDecide && <Button kind="danger" onClick={() => setForm({ act: ACT.CANCEL, title: 'Cancel order', label: 'Cancel order', danger: true })}>Cancel</Button>}
|
||||
{supplier && state === 'Confirmed' && <Button onClick={() => setForm({ act: ACT.DISPATCH, title: 'Mark dispatched', label: 'Dispatched' })}>Mark dispatched</Button>}
|
||||
{supplier && state === 'Dispatched' && <Button onClick={() => setForm({ act: ACT.INVOICE, title: 'Mark invoiced', label: 'Invoiced' })}>Mark invoiced</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
<div className="space-y-5 xl:col-span-2">
|
||||
<Card title={<span className="inline-flex items-center gap-2"><Sparkles className="h-4 w-4 text-ai" />Advisor</span>} subtitle={o.advice_kind ? `Advice recorded ${o.advice_deadline ? `· act by ${date(o.advice_deadline)}` : ''}${o.advice_confidence ? ` · confidence ${pct(num(o.advice_confidence) * 100, 0)}` : ''}` : 'Waiting for the Advisor to read the draft.'}>
|
||||
<div className="space-y-4 p-4">
|
||||
{Boolean(o.advice_kind) && (
|
||||
<div className="rounded-lg border border-ai-edge bg-ai-soft p-4">
|
||||
<div className="flex flex-wrap items-center gap-2"><KindBadge kind={o.advice_kind} />{o.suggested_scheme ? <span className="text-xs text-faint">{String(o.suggested_scheme)}</span> : null}{inc !== 0 && <span className={`tnum ml-auto text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</span>}</div>
|
||||
<p className="mt-2 text-sm font-semibold text-ink">{String(o.advice_headline ?? '')}</p>
|
||||
<p className="mt-2 text-sm whitespace-pre-line text-ink">{String(o.advice_rationale ?? '')}</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:grid-cols-4">
|
||||
<div><div className="text-faint">Keyed</div><div className="tnum text-ink">{mt(o.qty_mt)} · {inr(o.payout_now)}</div></div>
|
||||
<div><div className="text-faint">Suggested</div><div className="tnum text-ink">{suggested != null ? mt(suggested) : '—'}{o.suggested_pp_mt ? ` (${mt(o.suggested_pp_mt)} premium)` : ''} · {inr(o.payout_if)}</div></div>
|
||||
<div><div className="text-faint">Ship to</div><div className="text-ink">{String(o.suggested_ship_to || o.ship_to_code || '—')}</div></div>
|
||||
<div><div className="text-faint">Act by</div><div className="text-ink">{date(o.advice_deadline)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(o.advice_reason) && <ErrorNote>{String(o.advice_reason)}</ErrorNote>}
|
||||
{myRecs.map((r) => <RecCard key={String(r.rec_id)} rec={r} />)}
|
||||
{!o.advice_kind && myRecs.length === 0 && <Spinner label="The Advisor is working — this refreshes on its own." />}
|
||||
</div>
|
||||
</Card>
|
||||
<InstanceTrace instanceId={String(id)} />
|
||||
</div>
|
||||
<Card title="The order">
|
||||
<div className="p-4">
|
||||
<Facts cols={1} rows={[
|
||||
{ label: 'Dealer', value: `${String(o.dealer_name ?? '')} (${String(o.customer_no ?? '')})` },
|
||||
{ label: 'Material', value: String(o.material_code ?? '') },
|
||||
{ label: 'Quantity keyed', value: mt(o.qty_mt) },
|
||||
{ label: 'Ship to', value: String(o.ship_to_code ?? '—') },
|
||||
{ label: 'Plant', value: String(o.plant_code ?? '—') },
|
||||
{ label: 'Requested delivery', value: date(o.requested_date) },
|
||||
{ label: 'Final quantity', value: o.final_qty_mt ? `${mt(o.final_qty_mt)}${o.advice_accepted ? ' · advice taken' : ''}` : '—' },
|
||||
{ label: 'Notes', value: String(o.notes ?? ''), wide: true },
|
||||
]} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{form && (
|
||||
<ActivityForm open onClose={() => setForm(null)} workflow={WF.ORDER} activityUid={form.act} instanceId={String(id)} title={form.title} submitLabel={form.label} danger={form.danger}
|
||||
seed={form.seed} prefill={form.prefill} hiddenKeys={['advice_accepted']} onDone={() => { q.refetch(); if (form.act === ACT.CANCEL) navigate('/d/orders'); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/screens/dealer/Schemes.tsx
Normal file
20
src/screens/dealer/Schemes.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useZino } from '../../api/provider';
|
||||
import { Card, ErrorNote } from '../../components/core';
|
||||
import { SimSummary, SimTable, useSimulate } from '../../components/widgets';
|
||||
|
||||
export function DealerSchemes() {
|
||||
const { portal } = useZino();
|
||||
const cust = portal?.customer_no;
|
||||
const sim = useSimulate(cust, null);
|
||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-ink">My schemes</h1>
|
||||
<p className="text-sm text-muted">Every scheme component you are eligible for in the current period: what it pays as things stand, the gate if nothing is paid, and the distance to the next slab or growth bracket.</p>
|
||||
<Card>
|
||||
<div className="p-4"><SimSummary rows={sim.data} hasHypo={false} /></div>
|
||||
<div className="border-t border-line"><SimTable rows={sim.data} hasHypo={false} loading={sim.loading} error={sim.error} /></div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/screens/dealer/useDealer.ts
Normal file
27
src/screens/dealer/useDealer.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { RV } from '../../api/config';
|
||||
import type { Row, Snapshot, Standing } from '../../api/types';
|
||||
import { parseJson } from '../../format';
|
||||
|
||||
/** The signed-in dealer's snapshot + cluster standing (one engine call). */
|
||||
export function useDealer(customerNo: string | null | undefined, refetchMs?: number) {
|
||||
const { client } = useZino();
|
||||
const q = useQuery(
|
||||
() => client.rows(RV.SNAPSHOT, { params: { customer_no: customerNo } }).then((rows: Row[]) => {
|
||||
const r = rows[0];
|
||||
if (!r) return null;
|
||||
return { snapshot: parseJson<Snapshot | null>(r.snapshot, null), standing: parseJson<Standing | null>(r.standing, null), as_of: String(r.as_of ?? '').slice(0, 10) };
|
||||
}),
|
||||
[customerNo], !!customerNo, refetchMs,
|
||||
);
|
||||
return q;
|
||||
}
|
||||
|
||||
/** The dealer's own estate row (defaults + pick-lists for the order form). */
|
||||
export function useDealerRow(customerNo: string | null | undefined) {
|
||||
const { client } = useZino();
|
||||
return useQuery(
|
||||
() => client.rows(RV.DEALERS, { limit: 1, filters: [{ field_key: 'customer_no', value: String(customerNo), data_type: 'string' }] }).then((r) => r[0] ?? null),
|
||||
[customerNo], !!customerNo,
|
||||
);
|
||||
}
|
||||
98
src/screens/supplier/Advisories.tsx
Normal file
98
src/screens/supplier/Advisories.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Button, Card, Empty, ErrorNote, Facts, Kpi, Spinner } from '../../components/core';
|
||||
import { DataTable, InstanceTrace, KindBadge, RecCard, StateBadge } from '../../components/widgets';
|
||||
import { date, inr, mt, num, pct } from '../../format';
|
||||
import { useScope } from './scope';
|
||||
|
||||
export function Advisories() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const scope = useScope();
|
||||
const q = useQuery(() => client.rows(RV.ADVISORIES, { limit: 100, sortBy: 'instance_id', sortDir: 'desc', filters: scope.rso ? [{ field_key: 'rso_code', value: scope.rso, data_type: 'string' }] : [] }), [scope.label], true, 15000);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-ink">Advisories</h1>
|
||||
<Card>
|
||||
<DataTable rows={q.data} loading={q.loading} error={q.error} onRow={(r) => navigate(`/s/advisories/${String(r.instance_id)}`)} rowKey={(r) => String(r.instance_id)} empty="Nothing asked yet."
|
||||
cols={[
|
||||
{ key: 'instance_id', label: '#' },
|
||||
{ key: 'scope', label: 'Scope', render: (r) => (r.customer_no ? `Dealer ${String(r.customer_no)}` : `${String(r.ao_code || r.rso_code || '')} sweep`) },
|
||||
{ key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> },
|
||||
{ key: 'advice_kind', label: 'Result', render: (r) => (r.advice_kind ? <KindBadge kind={r.advice_kind} /> : r.near_slab_count != null ? `${String(r.near_slab_count)} dealers near a slab` : <span className="text-faint">—</span>) },
|
||||
{ key: 'advice_headline', label: 'Headline', className: 'max-w-md', render: (r) => <span className="line-clamp-2 text-xs text-muted">{String(r.advice_headline ?? '')}</span> },
|
||||
{ key: 'incremental_payout', label: 'Worth', align: 'right', render: (r) => (r.incremental_payout != null ? inr(r.incremental_payout, { sign: true }) : r.projected_cost != null ? inr(r.projected_cost, { compact: true }) : '—') },
|
||||
]} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdvisoryDetail() {
|
||||
const { id } = useParams();
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const q = useQuery(() => client.rows(RV.ADVISORIES, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] }).then((r) => r[0] ?? null), [id], true, 8000);
|
||||
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 50, sortBy: 'rec_id', sortDir: 'desc', filters: [{ field_key: 'source_instance_id', value: String(id), data_type: 'number' }] }), [id], true, 15000);
|
||||
const [form, setForm] = useState<null | { act: string; title: string; danger?: boolean }>(null);
|
||||
const a = q.data;
|
||||
if (q.loading && !a) return <Spinner />;
|
||||
if (q.error) return <ErrorNote>{q.error}</ErrorNote>;
|
||||
if (!a) return <Empty>Advisory {id} not found.</Empty>;
|
||||
const state = String(a.current_state_name ?? '');
|
||||
const isSweep = !a.customer_no;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-ink">{isSweep ? `Sweep · ${String(a.ao_code || a.rso_code || '')}` : `Advice · ${String(a.customer_no)}`}</h1>
|
||||
<StateBadge state={state} />
|
||||
{state === 'Requested' && <span className="text-xs text-ai"><Sparkles className="mr-1 inline h-3 w-3" />the {isSweep ? 'Analyst' : 'Advisor'} is working…</span>}
|
||||
<div className="ml-auto flex gap-2">
|
||||
{!isSweep && a.customer_no ? <Button onClick={() => navigate(`/s/dealers/${String(a.customer_no)}`)}>Dealer 360</Button> : null}
|
||||
{state === 'Delivered' && <Button kind="primary" onClick={() => setForm({ act: ACT.ACTED, title: 'Mark as acted on' })}>Acted</Button>}
|
||||
{state === 'Delivered' && <Button kind="ghost" onClick={() => setForm({ act: ACT.DISMISS, title: 'Dismiss' })}>Dismiss</Button>}
|
||||
</div>
|
||||
</div>
|
||||
{isSweep && a.near_slab_count != null && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Kpi label="Dealers near a slab" value={String(a.near_slab_count)} />
|
||||
<Kpi label="Projected lift" value={mt(a.projected_lift_mt, 0)} />
|
||||
<Kpi label="Incentive at stake" value={inr(a.projected_cost, { compact: true })} tone="accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
<div className="space-y-5 xl:col-span-2">
|
||||
{isSweep ? (
|
||||
<>
|
||||
<Card title="Commentary"><p className="p-4 text-sm whitespace-pre-line text-ink">{String(a.commentary ?? '') || 'Waiting…'}</p></Card>
|
||||
<Card title="Nudge list" subtitle="One line per dealer, ranked by what the nudge is worth. Send as is."><pre className="overflow-x-auto p-4 font-sans text-sm whitespace-pre-wrap text-ink">{String(a.nudge_list ?? '')}</pre></Card>
|
||||
</>
|
||||
) : (
|
||||
<Card title={<span className="inline-flex items-center gap-2"><Sparkles className="h-4 w-4 text-ai" />Advice</span>}>
|
||||
<div className="space-y-4 p-4">
|
||||
{Boolean(a.advice_kind) && (
|
||||
<div className="rounded-lg border border-ai-edge bg-ai-soft p-4">
|
||||
<div className="flex flex-wrap items-center gap-2"><KindBadge kind={a.advice_kind} />{a.suggested_scheme ? <span className="text-xs text-faint">{String(a.suggested_scheme)}</span> : null}{a.incremental_payout != null && <span className="tnum ml-auto text-lg font-semibold text-accent">{inr(a.incremental_payout, { sign: true })}</span>}</div>
|
||||
<p className="mt-2 text-sm font-semibold text-ink">{String(a.advice_headline ?? '')}</p>
|
||||
<p className="mt-2 text-sm whitespace-pre-line text-ink">{String(a.advice_rationale ?? '')}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-6 text-xs text-faint">{a.suggested_qty_mt ? <span>Add <b className="text-ink">{mt(a.suggested_qty_mt)}</b></span> : null}{a.suggested_pp_mt ? <span>Premium <b className="text-ink">{mt(a.suggested_pp_mt)}</b></span> : null}{a.advice_deadline ? <span>By <b className="text-ink">{date(a.advice_deadline)}</b></span> : null}{a.advice_confidence ? <span>Confidence {pct(num(a.advice_confidence) * 100, 0)}</span> : null}</div>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(a.advice_reason) && <ErrorNote>{String(a.advice_reason)}</ErrorNote>}
|
||||
{(recs.data ?? []).map((r) => <RecCard key={String(r.rec_id)} rec={r} />)}
|
||||
{!a.advice_kind && (recs.data ?? []).length === 0 && <Spinner label="Working — refreshes on its own." />}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<InstanceTrace instanceId={String(id)} />
|
||||
</div>
|
||||
<Card title="The ask"><div className="p-4"><Facts cols={1} rows={[{ label: 'Scope', value: isSweep ? `${String(a.rso_code ?? '')} ${a.ao_code ? `· ${String(a.ao_code)}` : ''}` : String(a.customer_no) }, { label: 'As of', value: date(a.as_of) }, { label: 'Asked', value: String(a.ask ?? ''), wide: true }, { label: 'Outcome', value: String(a.outcome_note ?? ''), wide: true }]} /></div></Card>
|
||||
</div>
|
||||
{form && <ActivityForm open onClose={() => setForm(null)} workflow={WF.ADVISORY} activityUid={form.act} instanceId={String(id)} title={form.title} danger={form.danger} onDone={() => q.refetch()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/screens/supplier/Catalogue.tsx
Normal file
78
src/screens/supplier/Catalogue.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { FilePlus2 } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Badge, Button, Card, Empty, Modal, Spinner } from '../../components/core';
|
||||
import { DataTable, StateBadge } from '../../components/widgets';
|
||||
import { date, inr, num } from '../../format';
|
||||
import { useScope } from './scope';
|
||||
|
||||
const EXAMPLE_SLABS = JSON.stringify([{ slab_from: 101, slab_to: 201, rate: 1, label: '101–200 MT' }, { slab_from: 201, slab_to: 301, rate: 2, label: '201–300 MT' }, { slab_from: 301, rate: 3, label: '301+ MT' }]);
|
||||
|
||||
export function Catalogue() {
|
||||
const { client } = useZino();
|
||||
const scope = useScope();
|
||||
const [tab, setTab] = useState<'live' | 'drafts'>('live');
|
||||
const [open, setOpen] = useState<Record<string, unknown> | null>(null);
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [decide, setDecide] = useState<null | { act: string; title: string; id: string; danger?: boolean }>(null);
|
||||
const live = useQuery(() => client.rows(RV.CATALOGUE, { limit: 200, sortBy: 'validity_from', sortDir: 'desc', filters: scope.rso ? [{ field_key: 'rso_code', value: scope.rso, data_type: 'string' }] : [] }), [scope.label]);
|
||||
const drafts = useQuery(() => client.rows(RV.SCHEME_DRAFTS, { limit: 100, sortBy: 'instance_id', sortDir: 'desc', filters: scope.rso ? [{ field_key: 'rso_code', value: scope.rso, data_type: 'string' }] : [] }), [scope.label], true, 15000);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-ink">Schemes</h1>
|
||||
<div className="flex rounded-md bg-panel2 p-0.5 text-xs font-semibold">{(['live', 'drafts'] as const).map((t) => <button key={t} onClick={() => setTab(t)} className={`rounded px-3 py-1.5 ${tab === t ? 'bg-panel text-ink lift' : 'text-muted'}`}>{t === 'live' ? 'Catalogue' : 'Drafts & approvals'}</button>)}</div>
|
||||
<Button kind="primary" className="ml-auto" onClick={() => setDraft(true)}><FilePlus2 className="h-4 w-4" />Draft a scheme</Button>
|
||||
</div>
|
||||
{tab === 'live' ? (
|
||||
<Card subtitle={`${(live.data ?? []).length} schemes in the estate for ${scope.label}`}>
|
||||
<DataTable rows={live.data} loading={live.loading} error={live.error} onRow={(r) => setOpen(r)} rowKey={(r) => String(r.scheme_id)}
|
||||
cols={[
|
||||
{ key: 'name', label: 'Scheme', render: (r) => <span><span className="font-semibold text-ink">{String(r.name)}</span><span className="block text-xs text-faint">{String(r.scheme_no)} · {String(r.component_names ?? '')}</span></span> },
|
||||
{ key: 'scheme_type', label: 'Type', render: (r) => <Badge tone="slate">{String(r.scheme_type).replace(/_/g, ' ')}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (r) => <Badge tone={r.status === 'ACTIVE' ? 'emerald' : r.status === 'CLOSED' ? 'slate' : 'amber'}>{String(r.status)}</Badge> },
|
||||
{ key: 'validity_from', label: 'Validity', render: (r) => `${date(r.validity_from)} – ${date(r.validity_to)}` },
|
||||
{ key: 'ao_code', label: 'Level', render: (r) => String(r.ao_code || `${String(r.rso_code)} (all AOs)`) },
|
||||
{ key: 'max_rate_per_bag', label: '₹/bag', align: 'right', render: (r) => (r.min_rate_per_bag != null ? `${num(r.min_rate_per_bag)} – ${num(r.max_rate_per_bag)}` : String(r.rate_units ?? '—')) },
|
||||
]} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card subtitle="Drafted here: the Analyst writes the draft into the estate and costs it over the last 12 months; the RSO approves.">
|
||||
<DataTable rows={drafts.data} loading={drafts.loading} error={drafts.error} rowKey={(r) => String(r.instance_id)} empty="No drafts yet."
|
||||
cols={[
|
||||
{ key: 'scheme_name', label: 'Draft', render: (r) => <span><span className="font-semibold text-ink">{String(r.scheme_name)}</span><span className="block text-xs text-faint">{String(r.scheme_no)} · {String(r.scheme_type)} · {date(r.validity_from)} – {date(r.validity_to)}</span></span> },
|
||||
{ key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> },
|
||||
{ key: 'est_cost_12m', label: 'Cost / 12 m', align: 'right', render: (r) => (r.est_cost_12m != null ? <span><span className="text-ink">{inr(r.est_cost_12m, { compact: true })}</span><span className="block text-xs text-faint">{String(r.est_dealers_paid ?? '—')} dealers paid</span></span> : <span className="text-faint">costing…</span>) },
|
||||
{ key: 'actions', label: '', render: (r) => {
|
||||
const s = String(r.current_state_name);
|
||||
return (
|
||||
<span className="flex gap-1.5">
|
||||
{s === 'Pending Approval' && scope.canApprove && <Button size="sm" kind="primary" onClick={() => setDecide({ act: ACT.SCH_APPROVE, title: 'Approve scheme', id: String(r.instance_id) })}>Approve</Button>}
|
||||
{s === 'Pending Approval' && scope.canApprove && <Button size="sm" kind="danger" onClick={() => setDecide({ act: ACT.SCH_REJECT, title: 'Reject scheme', id: String(r.instance_id), danger: true })}>Reject</Button>}
|
||||
{s === 'Active' && scope.canApprove && <Button size="sm" onClick={() => setDecide({ act: ACT.SCH_CLOSE, title: 'Close scheme', id: String(r.instance_id) })}>Close</Button>}
|
||||
<Button size="sm" kind="ghost" onClick={() => setOpen(r)}>Assessment</Button>
|
||||
</span>);
|
||||
} },
|
||||
]} />
|
||||
</Card>
|
||||
)}
|
||||
<Modal open={!!open} onClose={() => setOpen(null)} title={String(open?.name ?? open?.scheme_name ?? '')} subtitle={String(open?.scheme_no ?? '')} wide>
|
||||
{open && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{open.assessment ? <div className="rounded-lg border border-ai-edge bg-ai-soft p-3 whitespace-pre-line text-ink"><b className="text-ai">Analyst:</b> {String(open.assessment)}{open.est_top_payouts ? `\n\nTop payouts: ${String(open.est_top_payouts)}` : ''}</div> : null}
|
||||
{open.eligibility ? <p className="text-xs text-faint">Eligibility: {String(open.eligibility)}</p> : null}
|
||||
<pre className="max-h-96 overflow-auto rounded-md bg-panel2 p-3 font-sans text-xs whitespace-pre-wrap text-ink">{String(open.circular_text ?? '(no circular)')}</pre>
|
||||
</div>
|
||||
)}
|
||||
{!open && <Spinner />}
|
||||
</Modal>
|
||||
<ActivityForm open={draft} onClose={() => setDraft(false)} workflow={WF.SCHEME} activityUid={ACT.SCH_DRAFT} title="Draft a scheme" subtitle="Slabs as JSON; the Analyst costs it against last year before the RSO sees it."
|
||||
prefill={{ rso_code: scope.rso ?? 'RSO-BLR', ao_code: scope.ao ?? '', scheme_type: 'QD_GROWTH', basis: 'MONTHLY_VOLUME', rate_unit: 'RS_PER_BAG', calc_frequency: 'MONTHLY', slabs_json: EXAMPLE_SLABS }}
|
||||
onDone={() => { setTab('drafts'); drafts.refetch(); }} />
|
||||
{decide && <ActivityForm open onClose={() => setDecide(null)} workflow={WF.SCHEME} activityUid={decide.act} instanceId={decide.id} title={decide.title} danger={decide.danger} onDone={() => { drafts.refetch(); live.refetch(); }} />}
|
||||
{(live.data ?? []).length === 0 && !live.loading && tab === 'live' && <Empty>No schemes for this scope.</Empty>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/screens/supplier/Dealers.tsx
Normal file
96
src/screens/supplier/Dealers.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Badge, Bar, Button, Card, Empty, ErrorNote, Facts, Input, Kpi, Spinner } from '../../components/core';
|
||||
import { DataTable, RecCard, SimTable, StateBadge, useSimulate } from '../../components/widgets';
|
||||
import { date, inr, mt, num, pct } from '../../format';
|
||||
import { useDealer } from '../dealer/useDealer';
|
||||
import { useScope } from './scope';
|
||||
|
||||
export function Dealers() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const scope = useScope();
|
||||
const [sp] = useSearchParams();
|
||||
const cluster = sp.get('cluster');
|
||||
const [search, setSearch] = useState('');
|
||||
const q = useQuery(() => client.page(RV.DEALERS, { limit: 200, sortBy: 'mtd_mt', sortDir: 'desc', search, filters: [...scope.filters, ...(cluster ? [{ field_key: 'cluster_code', value: cluster, data_type: 'string' }] : []), { field_key: 'customer_type', value: 'DEALER', data_type: 'string' }] }), [scope.label, cluster, search]);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3"><h1 className="text-xl font-semibold text-ink">Dealers{cluster ? ` · ${cluster}` : ''}</h1><div className="ml-auto w-64"><Input value={search} onChange={setSearch} placeholder="Search name or code…" /></div></div>
|
||||
<Card>
|
||||
<DataTable rows={q.data?.rows ?? null} loading={q.loading} error={q.error} onRow={(r) => navigate(`/s/dealers/${String(r.customer_no)}`)} rowKey={(r) => String(r.customer_no)}
|
||||
cols={[
|
||||
{ key: 'name', label: 'Dealer', render: (r) => <span><span className="font-semibold text-ink">{String(r.name)}</span><span className="block text-xs text-faint">{String(r.customer_no)} · {String(r.city)} · {String(r.cluster_name)}</span></span> },
|
||||
{ key: 'status', label: 'Status', render: (r) => <span className="flex flex-wrap gap-1"><Badge tone={r.status === 'ACTIVE' ? 'emerald' : 'amber'}>{String(r.status)}</Badge>{r.gold_star ? <Badge tone="accent">GS</Badge> : null}{r.silver_star ? <Badge tone="sky">SS</Badge> : null}</span> },
|
||||
{ key: 'mtd_mt', label: 'MTD', align: 'right', render: (r) => <span><span className="text-ink">{mt(r.mtd_mt, 0)}</span><span className="block text-xs text-faint">of {mt(r.month_target_mt, 0)}</span><Bar value={num(r.mtd_mt)} max={num(r.month_target_mt)} className="mt-1 w-20" /></span> },
|
||||
{ key: 'last_12m_mt', label: '12 m', align: 'right', render: (r) => mt(r.last_12m_mt, 0) },
|
||||
{ key: 'open_outstanding', label: 'Outstanding', align: 'right', render: (r) => inr(r.open_outstanding, { compact: true }) },
|
||||
{ key: 'sow_pct', label: 'SOW', align: 'right', render: (r) => (r.sow_pct != null ? pct(r.sow_pct, 0) : '—') },
|
||||
]} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dealer360() {
|
||||
const { customerNo } = useParams();
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const d = useDealer(customerNo);
|
||||
const sim = useSimulate(customerNo, null);
|
||||
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 10, sortBy: 'created_at', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(customerNo), data_type: 'string' }] }), [customerNo]);
|
||||
const hist = useQuery(() => client.rows(RV.HISTORY, { params: { customer_no: customerNo } }), [customerNo]);
|
||||
const orders = useQuery(() => client.rows(RV.ORDERS, { limit: 20, sortBy: 'instance_id', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(customerNo), data_type: 'string' }] }), [customerNo]);
|
||||
const [ask, setAsk] = useState(false);
|
||||
const months = useMemo(() => [...(hist.data ?? [])].slice(-12).reverse(), [hist.data]);
|
||||
if (d.loading && !d.data) return <Spinner />;
|
||||
if (d.error) return <ErrorNote>{d.error}</ErrorNote>;
|
||||
const s = d.data?.snapshot; const st = d.data?.standing;
|
||||
if (!s) return <Empty>No dealer {customerNo}.</Empty>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink">{s.name}</h1>
|
||||
<p className="text-sm text-muted">{s.customer_no} · {st?.cluster_name ?? s.cluster} · {s.ao} · {s.sales_org} · appointed {date(s.appointed_on)}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">{s.gold_star && <Badge tone="accent">Gold Star</Badge>}{s.silver_star && <Badge tone="sky">Silver Star</Badge>}{s.is_new_dealer && <Badge tone="emerald">New dealer</Badge>}{s.is_inactive && <Badge tone="amber">Inactive · revival base</Badge>}{s.sow_pct != null && <Badge tone="slate">SOW {pct(s.sow_pct, 0)}</Badge>}{st?.is_weak_market && <Badge tone="slate">Weak-market cluster</Badge>}{st?.is_border && <Badge tone="slate">Border cluster</Badge>}</div>
|
||||
</div>
|
||||
<Button kind="primary" onClick={() => setAsk(true)}><Sparkles className="h-4 w-4" />Ask the Advisor</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
<Kpi label="Month to date" value={mt(s.mtd_mt)} sub={`target ${mt(s.month_target_mt)}`} />
|
||||
<Kpi label="Quarter to date" value={mt(s.qtd_mt)} sub={`target ${mt(s.quarter_target_mt)}`} />
|
||||
<Kpi label="Earning now" value={inr((sim.data ?? []).reduce((a, r) => a + num(r.payout_now), 0))} tone="accent" />
|
||||
<Kpi label="Outstanding" value={inr(s.open_outstanding, { compact: true })} sub={s.overdue ? `${inr(s.overdue, { compact: true })} overdue` : 'none overdue'} tone={s.overdue ? 'rose' : undefined} />
|
||||
<Kpi label="Cluster rank" value={st ? `#${st.rank} of ${st.peers}` : '—'} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||
<div className="space-y-5 xl:col-span-2">
|
||||
<Card title="Schemes right now"><SimTable rows={sim.data} hasHypo={false} loading={sim.loading} error={sim.error} /></Card>
|
||||
<Card title="Advisor history"><div className="space-y-3 p-4">{(recs.data ?? []).length === 0 && <Empty>No advice written for this dealer yet.</Empty>}{(recs.data ?? []).map((r) => <RecCard key={String(r.rec_id)} rec={r} />)}</div></Card>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Card title="Last 12 months">
|
||||
<DataTable rows={months} loading={hist.loading} error={hist.error} cols={[
|
||||
{ key: 'month', label: 'Month', render: (r) => new Date(String(r.month)).toLocaleDateString('en-IN', { month: 'short', year: '2-digit' }) },
|
||||
{ key: 'total_mt', label: 'MT', align: 'right', render: (r) => mt(r.total_mt, 0) },
|
||||
{ key: 'target_mt', label: 'Target', align: 'right', render: (r) => mt(r.target_mt, 0) },
|
||||
{ key: 'pp_mt', label: 'Premium', align: 'right', render: (r) => mt(r.pp_mt, 0) },
|
||||
]} />
|
||||
</Card>
|
||||
<Card title="Orders in this app">
|
||||
<DataTable rows={orders.data} loading={orders.loading} error={orders.error} onRow={(r) => navigate(`/d/orders/${String(r.instance_id)}`)} rowKey={(r) => String(r.instance_id)} empty="No orders placed here."
|
||||
cols={[{ key: 'instance_id', label: '#' }, { key: 'qty_mt', label: 'MT', align: 'right', render: (r) => mt(r.qty_mt, 0) }, { key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> }, { key: 'incremental_payout', label: 'Advice', align: 'right', render: (r) => (r.incremental_payout != null ? inr(r.incremental_payout, { sign: true }) : '—') }]} />
|
||||
</Card>
|
||||
<Card title="Account"><div className="p-4"><Facts cols={1} rows={[{ label: 'Credit limit', value: inr(s.credit_limit, { compact: true }) }, { label: 'Terms', value: `${s.payment_terms_days} days` }, { label: 'Ship-tos', value: s.ship_tos.map((x) => `${x.name} (${x.cluster})`).join(' · '), wide: true }]} /></div></Card>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityForm open={ask} onClose={() => setAsk(false)} workflow={WF.ADVISORY} activityUid={ACT.REQ_ADVICE} title="Ask the Scheme Advisor" subtitle={`Best-fit scheme and what to change, for ${s.name}.`}
|
||||
seed={{ customer_no: s.customer_no }} prefill={{ ask: 'Which scheme fits this dealer best this period, and what should they add?' }} onDone={(r) => { if (r.instance_id) navigate(`/s/advisories/${r.instance_id}`); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/screens/supplier/Overview.tsx
Normal file
66
src/screens/supplier/Overview.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Radar, Sparkles } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Badge, Bar, Button, Card, Empty, Kpi } from '../../components/core';
|
||||
import { DataTable, StateBadge } from '../../components/widgets';
|
||||
import { date, inr, mt, num, pct } from '../../format';
|
||||
import { useScope } from './scope';
|
||||
|
||||
export function Overview() {
|
||||
const { client } = useZino();
|
||||
const navigate = useNavigate();
|
||||
const scope = useScope();
|
||||
const [sweep, setSweep] = useState(false);
|
||||
const clusters = useQuery(() => client.rows(RV.CLUSTERS, { limit: 100, sortBy: 'mtd_mt', sortDir: 'desc', filters: scope.filters }), [scope.label]);
|
||||
// A sweep is an advisory with no dealer on it (the request carries rso/ao, not a customer_no).
|
||||
const sweeps = useQuery(() => client.rows(RV.ADVISORIES, { limit: 40, sortBy: 'instance_id', sortDir: 'desc', filters: scope.rso ? [{ field_key: 'rso_code', value: scope.rso, data_type: 'string' }] : [] }).then((rows) => rows.filter((r) => !r.customer_no)), [scope.label], true, 15000);
|
||||
const rows = clusters.data ?? [];
|
||||
const mtd = rows.reduce((a, r) => a + num(r.mtd_mt), 0);
|
||||
const tgt = rows.reduce((a, r) => a + num(r.month_target_mt), 0);
|
||||
const os = rows.reduce((a, r) => a + num(r.open_outstanding), 0);
|
||||
const latest = sweeps.data?.[0];
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div><h1 className="text-xl font-semibold text-ink">{scope.label}</h1><p className="text-sm text-muted">Price clusters this month, and the near-slab sweep the Analyst runs for the ASMs.</p></div>
|
||||
<Button kind="primary" onClick={() => setSweep(true)}><Radar className="h-4 w-4" />Request cluster sweep</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Kpi label="Month to date" value={mt(mtd, 0)} sub={`target ${mt(tgt, 0)} · ${tgt ? pct((mtd / tgt) * 100, 0) : '—'}`} />
|
||||
<Kpi label="Clusters" value={String(rows.length)} sub={`${rows.filter((r) => r.is_weak_market).length} weak-market · ${rows.filter((r) => r.is_border).length} border`} />
|
||||
<Kpi label="Open outstanding" value={inr(os, { compact: true })} />
|
||||
<Kpi label="Latest sweep" value={latest?.near_slab_count != null ? `${String(latest.near_slab_count)} dealers` : latest ? 'running…' : '—'} sub={latest?.projected_cost != null ? `${inr(latest.projected_cost, { compact: true })} at stake · ${mt(latest.projected_lift_mt, 0)} lift` : 'no sweep yet'} tone="accent" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-5">
|
||||
<Card className="xl:col-span-3" title="Clusters" subtitle="Month-to-date against target; click a cluster to see its dealers.">
|
||||
<DataTable rows={rows} loading={clusters.loading} error={clusters.error} onRow={(r) => navigate(`/s/dealers?cluster=${String(r.cluster_code)}`)} rowKey={(r) => String(r.cluster_code)}
|
||||
cols={[
|
||||
{ key: 'cluster_name', label: 'Cluster', render: (r) => <span><span className="font-semibold text-ink">{String(r.cluster_name)}</span> <span className="text-xs text-faint">{String(r.cluster_code)} · {String(r.ao_code)}</span>{r.is_weak_market ? <Badge tone="slate" className="ml-2">weak market</Badge> : null}{r.is_border ? <Badge tone="slate" className="ml-2">border</Badge> : null}</span> },
|
||||
{ key: 'dealers', label: 'Dealers', align: 'right' },
|
||||
{ key: 'mtd_mt', label: 'MTD', align: 'right', render: (r) => <span><span className="text-ink">{mt(r.mtd_mt, 0)}</span><span className="block text-xs text-faint">of {mt(r.month_target_mt, 0)}</span><Bar value={num(r.mtd_mt)} max={num(r.month_target_mt)} className="mt-1 w-24" /></span> },
|
||||
{ key: 'achievement_pct', label: 'Achv.', align: 'right', render: (r) => <span className={num(r.achievement_pct) >= 100 ? 'text-good' : num(r.achievement_pct) >= 90 ? 'text-warn' : 'text-bad'}>{pct(r.achievement_pct, 0)}</span> },
|
||||
{ key: 'open_outstanding', label: 'Outstanding', align: 'right', render: (r) => inr(r.open_outstanding, { compact: true }) },
|
||||
]} />
|
||||
</Card>
|
||||
<Card className="xl:col-span-2" title={<span className="inline-flex items-center gap-2"><Sparkles className="h-4 w-4 text-ai" />Near-slab sweeps</span>} subtitle="Dealers within 10% of their next quantity-discount slab — the ASM nudge list.">
|
||||
<div className="divide-y divide-line">
|
||||
{(sweeps.data ?? []).length === 0 && <Empty>No sweep yet. Request one — the Analyst answers in about a minute.</Empty>}
|
||||
{(sweeps.data ?? []).slice(0, 6).map((s) => (
|
||||
<button key={String(s.instance_id)} onClick={() => navigate(`/s/advisories/${String(s.instance_id)}`)} className="block w-full px-4 py-3 text-left hover:bg-panel2">
|
||||
<div className="flex items-center gap-2 text-sm"><span className="font-semibold text-ink">{String(s.ao_code || s.rso_code)}</span><StateBadge state={s.current_state_name} /><span className="ml-auto text-xs text-faint">{date(s.as_of)}</span></div>
|
||||
{s.near_slab_count != null && <div className="mt-1 text-xs text-muted"><b className="text-ink">{String(s.near_slab_count)}</b> dealers · <b className="text-ink">{mt(s.projected_lift_mt, 0)}</b> lift · <b className="text-accent">{inr(s.projected_cost, { compact: true })}</b> at stake</div>}
|
||||
{s.advice_headline ? <div className="mt-1 line-clamp-2 text-xs text-faint">{String(s.advice_headline)}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<ActivityForm open={sweep} onClose={() => setSweep(false)} workflow={WF.ADVISORY} activityUid={ACT.REQ_SWEEP} title="Request a cluster sweep" subtitle="The Analyst lists every dealer within 10% of a slab and what nudging them is worth."
|
||||
seed={scope.rso ? { rso_code: scope.rso } : undefined} prefill={{ ao_code: scope.ao ?? '', ask: 'Which dealers can the ASMs push over a slab before month end?' }}
|
||||
onDone={(r) => { if (r.instance_id) navigate(`/s/advisories/${r.instance_id}`); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/screens/supplier/Runs.tsx
Normal file
50
src/screens/supplier/Runs.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { useQuery, useZino } from '../../api/provider';
|
||||
import { ACT, RV, WF } from '../../api/config';
|
||||
import { ActivityForm } from '../../components/ActivityForm';
|
||||
import { Badge, Button, Card, Modal } from '../../components/core';
|
||||
import { DataTable, StateBadge } from '../../components/widgets';
|
||||
import { date, inr, mt } from '../../format';
|
||||
import { useScope } from './scope';
|
||||
|
||||
export function Runs() {
|
||||
const { client } = useZino();
|
||||
const scope = useScope();
|
||||
const [req, setReq] = useState(false);
|
||||
const [decide, setDecide] = useState<null | { act: string; title: string; id: string; danger?: boolean }>(null);
|
||||
const [open, setOpen] = useState<Record<string, unknown> | null>(null);
|
||||
const runs = useQuery(() => client.rows(RV.RUNS, { limit: 50, sortBy: 'instance_id', sortDir: 'desc' }), [], true, 15000);
|
||||
const rowsFor = useQuery(() => (open?.run_id ? client.rows(RV.TRACKING, { limit: 25, sortBy: 'incentive_amount', sortDir: 'desc', filters: [{ field_key: 'run_id', value: String(open.run_id), data_type: 'number' }] }) : Promise.resolve([])), [open?.run_id], !!open?.run_id);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3"><h1 className="text-xl font-semibold text-ink">Scheme runs</h1><Button kind="primary" className="ml-auto" onClick={() => setReq(true)}><Play className="h-4 w-4" />Request a run</Button></div>
|
||||
<Card subtitle="The engine computes provisional tracking for the period; the Analyst comments; the RSO releases the credit notes.">
|
||||
<DataTable rows={runs.data} loading={runs.loading} error={runs.error} rowKey={(r) => String(r.instance_id)} empty="No runs requested here."
|
||||
cols={[
|
||||
{ key: 'period_from', label: 'Period', render: (r) => <span><span className="text-ink">{date(r.period_from)} – {date(r.period_to)}</span><span className="block text-xs text-faint">{String(r.frequency)} · run {String(r.run_id ?? '—')}</span></span> },
|
||||
{ key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> },
|
||||
{ key: 'dealers_evaluated', label: 'Dealers', align: 'right' },
|
||||
{ key: 'total_amount', label: 'Total', align: 'right', render: (r) => (r.total_amount != null ? <span className="font-semibold text-ink">{inr(r.total_amount, { compact: true })}</span> : '—') },
|
||||
{ key: 'run_commentary', label: 'Analyst', className: 'max-w-md', render: (r) => <span className="line-clamp-2 text-xs text-muted">{String(r.run_commentary ?? '')}</span> },
|
||||
{ key: 'actions', label: '', render: (r) => {
|
||||
const s = String(r.current_state_name);
|
||||
return (
|
||||
<span className="flex gap-1.5">
|
||||
{s === 'Computed' && scope.canApprove && <Button size="sm" kind="primary" onClick={() => setDecide({ act: ACT.RUN_APPROVE, title: 'Release this run', id: String(r.instance_id) })}>Release</Button>}
|
||||
{s === 'Computed' && scope.canApprove && <Button size="sm" kind="danger" onClick={() => setDecide({ act: ACT.RUN_HOLD, title: 'Hold this run', id: String(r.instance_id), danger: true })}>Hold</Button>}
|
||||
<Button size="sm" kind="ghost" onClick={() => setOpen(r)}>Rows</Button>
|
||||
</span>);
|
||||
} },
|
||||
]} />
|
||||
</Card>
|
||||
<Modal open={!!open} onClose={() => setOpen(null)} title={`Run ${String(open?.run_id ?? '')}`} subtitle={open?.run_commentary ? String(open.run_commentary) : undefined} wide>
|
||||
<DataTable rows={rowsFor.data} loading={rowsFor.loading} error={rowsFor.error} empty="No tracking rows for this run."
|
||||
cols={[{ key: 'dealer_name', label: 'Dealer' }, { key: 'scheme_name', label: 'Scheme', render: (r) => `${String(r.scheme_name)} · ${String(r.component_name)}` }, { key: 'measured_qty', label: 'MT', align: 'right', render: (r) => mt(r.measured_qty, 0) }, { key: 'slab_label', label: 'Slab', render: (r) => (r.gate_passed ? String(r.slab_label ?? '') : <span className="text-warn">{String(r.gate_reason ?? '')}</span>) }, { key: 'incentive_amount', label: 'Amount', align: 'right', render: (r) => inr(r.incentive_amount) }, { key: 'status', label: 'Status', render: (r) => <Badge tone={r.status === 'FINAL' ? 'emerald' : 'amber'}>{String(r.status)}</Badge> }]} />
|
||||
</Modal>
|
||||
<ActivityForm open={req} onClose={() => setReq(false)} workflow={WF.RUN} activityUid={ACT.RUN_REQUEST} title="Request a settlement run" subtitle="Pick the period; the engine computes it provisionally and the Analyst reports."
|
||||
prefill={{ frequency: 'MONTHLY', period_from: '2026-09-01', period_to: '2026-09-30' }} onDone={() => runs.refetch()} />
|
||||
{decide && <ActivityForm open onClose={() => setDecide(null)} workflow={WF.RUN} activityUid={decide.act} instanceId={decide.id} title={decide.title} danger={decide.danger} onDone={() => runs.refetch()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/screens/supplier/scope.ts
Normal file
14
src/screens/supplier/scope.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useZino } from '../../api/provider';
|
||||
|
||||
/** The territory a supplier login may see: ASM = one AO, RSO = one RSO, HO = everything. */
|
||||
export function useScope() {
|
||||
const { portal, user } = useZino();
|
||||
const roles = user?.roles ?? [];
|
||||
const ho = roles.includes('ho_scheme_admin') || portal?.persona === 'HO';
|
||||
const rso = ho ? null : portal?.rso_code ?? null;
|
||||
const ao = ho || roles.includes('rso_manager') ? null : portal?.ao_code ?? null;
|
||||
const filters: Array<{ field_key: string; value: string; data_type?: string }> = [];
|
||||
if (rso) filters.push({ field_key: 'rso_code', value: rso, data_type: 'string' });
|
||||
if (ao) filters.push({ field_key: 'ao_code', value: ao, data_type: 'string' });
|
||||
return { ho, rso, ao, filters, canApprove: ho || roles.includes('rso_manager'), label: ao ?? rso ?? 'All RSOs' };
|
||||
}
|
||||
115
src/styles.css
Normal file
115
src/styles.css
Normal file
@ -0,0 +1,115 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Meru Cement — Scheme Advisor. Light and dark.
|
||||
`@theme` defines the LIGHT theme and the fallback; `:root[data-theme='dark']`
|
||||
redefines the SAME token names. Tailwind v4 compiles `bg-panel` to
|
||||
`var(--color-panel)`, so overriding the variable swaps the whole console.
|
||||
NEVER put a literal colour in a component.
|
||||
|
||||
Meru is fictional, so the palette is ours: a deep green-teal brand (cement,
|
||||
earth, growth) with an amber accent reserved for MONEY AT STAKE — the one
|
||||
number the whole product exists to show. Violet marks what the AI wrote.
|
||||
Every text colour clears WCAG AA on the surfaces it sits on, both themes. */
|
||||
@theme {
|
||||
--color-ink: #16211f;
|
||||
--color-muted: #4b5b58;
|
||||
--color-faint: #647572;
|
||||
--color-bg: #f3f6f4;
|
||||
--color-panel: #ffffff;
|
||||
--color-panel2: #f2f6f4;
|
||||
--color-panel3: #e3ebe7;
|
||||
--color-line: #d7e1dc;
|
||||
|
||||
--color-brand: #0f6b5c;
|
||||
--color-brand-fg: #ffffff;
|
||||
--color-brand-hover: #0b5448;
|
||||
|
||||
/* money at stake */
|
||||
--color-accent: #9a5a06;
|
||||
--color-accent-soft: #fdf3e2;
|
||||
--color-accent-edge: #efc27a;
|
||||
|
||||
--color-idle: #5c5346;
|
||||
--color-idle-soft: #f2efeb;
|
||||
--color-idle-edge: #ddd5c9;
|
||||
--color-warn: #8a5a00;
|
||||
--color-warn-soft: #fff8e8;
|
||||
--color-warn-edge: #f0c368;
|
||||
--color-info: #0f4c81;
|
||||
--color-info-soft: #e8f1f9;
|
||||
--color-info-edge: #a9c9e6;
|
||||
--color-good: #0b6e3f;
|
||||
--color-good-soft: #e4f6ec;
|
||||
--color-good-edge: #93d3b0;
|
||||
--color-bad: #b31e23;
|
||||
--color-bad-soft: #fdeced;
|
||||
--color-bad-edge: #eda9ac;
|
||||
--color-ai: #5b2ca0;
|
||||
--color-ai-soft: #f5f1fd;
|
||||
--color-ai-edge: #c3adec;
|
||||
--color-scrim: rgb(8 30 26 / 0.5);
|
||||
|
||||
--font-sans: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
||||
}
|
||||
:root {
|
||||
--lift: 0 1px 2px rgb(8 40 34 / 0.07), 0 1px 1px rgb(8 40 34 / 0.04);
|
||||
--lift-lg: 0 12px 32px rgb(8 40 34 / 0.16), 0 2px 8px rgb(8 40 34 / 0.08);
|
||||
--sb-thumb: #c7d4ce;
|
||||
--sb-thumb-hover: #a9bbb3;
|
||||
color-scheme: light;
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
--color-ink: #e9f1ee;
|
||||
--color-muted: #a7bcb6;
|
||||
--color-faint: #93a8a2;
|
||||
--color-bg: #0b1614;
|
||||
--color-panel: #0f1f1c;
|
||||
--color-panel2: #152925;
|
||||
--color-panel3: #1d3530;
|
||||
--color-line: #24403a;
|
||||
--color-brand: #6fd3c0;
|
||||
--color-brand-fg: #0b1614;
|
||||
--color-brand-hover: #8ee0d0;
|
||||
--color-accent: #f2c178;
|
||||
--color-accent-soft: #3a2a0a;
|
||||
--color-accent-edge: #8a6420;
|
||||
--color-idle: #d6cdbf;
|
||||
--color-idle-soft: #2a2724;
|
||||
--color-idle-edge: #4b453b;
|
||||
--color-warn: #f7cd7a;
|
||||
--color-warn-soft: #3a2a06;
|
||||
--color-warn-edge: #8a681f;
|
||||
--color-info: #b8d4ee;
|
||||
--color-info-soft: #0f3763;
|
||||
--color-info-edge: #467cb8;
|
||||
--color-good: #5fd694;
|
||||
--color-good-soft: #052a18;
|
||||
--color-good-edge: #10683b;
|
||||
--color-bad: #f4a3a6;
|
||||
--color-bad-soft: #3c1416;
|
||||
--color-bad-edge: #8c3a3d;
|
||||
--color-ai: #c9b3f2;
|
||||
--color-ai-soft: #221542;
|
||||
--color-ai-edge: #5a3fa2;
|
||||
--color-scrim: rgb(0 4 3 / 0.66);
|
||||
--lift: 0 1px 2px rgb(0 0 0 / 0.4);
|
||||
--lift-lg: 0 16px 40px rgb(0 0 0 / 0.55), 0 2px 8px rgb(0 0 0 / 0.4);
|
||||
--sb-thumb: #27463f;
|
||||
--sb-thumb-hover: #345c53;
|
||||
color-scheme: dark;
|
||||
}
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; background: var(--color-bg); color: var(--color-ink); font-family: var(--font-sans); -webkit-font-smoothing: antialiased; }
|
||||
.tnum { font-variant-numeric: tabular-nums; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--sb-thumb); border-radius: 6px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--sb-thumb-hover); }
|
||||
@keyframes meru-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
.live-dot { animation: meru-pulse 1.6s ease-in-out infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .live-dot { animation: none; } }
|
||||
.lift { box-shadow: var(--lift); }
|
||||
.lift-lg { box-shadow: var(--lift-lg); }
|
||||
/* progress bar toward the next slab */
|
||||
.bar { height: 6px; border-radius: 999px; background: var(--color-panel3); overflow: hidden; }
|
||||
.bar > i { display: block; height: 100%; background: var(--color-brand); border-radius: 999px; }
|
||||
57
src/theme.ts
Normal file
57
src/theme.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/** Light / dark, persisted per browser. Sets `data-theme` on <html>; every
|
||||
* colour is a CSS variable that styles.css redefines under
|
||||
* `:root[data-theme='dark']`, so one attribute swaps the whole console. */
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
/** Also read by the inline script in index.html — rename both together. */
|
||||
export const THEME_KEY = 'meru-scheme-advisor-theme';
|
||||
|
||||
function isTheme(v: unknown): v is Theme {
|
||||
return v === 'light' || v === 'dark';
|
||||
}
|
||||
export function storedTheme(): Theme | null {
|
||||
try {
|
||||
const v = localStorage.getItem(THEME_KEY);
|
||||
return isTheme(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function systemTheme(): Theme {
|
||||
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
export function resolveTheme(): Theme {
|
||||
return storedTheme() ?? systemTheme();
|
||||
}
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'dark' ? '#0f1f1c' : '#ffffff');
|
||||
}
|
||||
export function useTheme(): { theme: Theme; setTheme: (t: Theme) => void; toggle: () => void } {
|
||||
const [theme, setThemeState] = useState<Theme>(resolveTheme);
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
useEffect(() => {
|
||||
if (storedTheme()) return;
|
||||
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!mq) return;
|
||||
const onChange = (e: MediaQueryListEvent) => setThemeState(e.matches ? 'dark' : 'light');
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
} catch {
|
||||
/* unpersisted is still switchable */
|
||||
}
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
const toggle = useCallback(() => {
|
||||
setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
|
||||
}, [setTheme]);
|
||||
return { theme, setTheme, toggle };
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// Relative base: asset refs become "./assets/…", so the <base href> the server
|
||||
// writes at placement decides where they resolve. One build serves any mount
|
||||
// path. Do NOT reintroduce a build-time base — the artifact is promoted between
|
||||
// environments unchanged.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './',
|
||||
server: { port: 5175 },
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user