From 92106814882f4ca429e226c53a429c1ba9cdb681 Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 24 Aug 2026 15:20:13 +0530 Subject: [PATCH] Wire the console to the platform and make it deployable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold had the brand right and nothing behind it: the login was a 600ms setTimeout with a TODO, and three pieces of the frontgen deploy contract were missing. - Sign in against POST /usr/login (org_id as a STRING — a number is rejected by the gateway) with the session in a provider; surface the gateway's own message rather than a generic "invalid credentials", because a wrong password and a user without access to this app look identical from here and are not. - Runtime config: the API URL is read from the config.js the server writes at placement, never compiled in, and requireConfigValue throws so a build with no config.js fails loudly instead of calling whichever backend built it. - base: './' plus a router basename taken from , so one build serves any mount path. - Move the fonts and logo from public/ into src/assets/ — Vite rewrites bundled asset URLs to be relative, while a public/ file referenced as "/fonts/..." stays absolute and 404s under the /zurich-kotak/ mount. - API client for recordview / detailview / form-screens / start / activity, and the pipeline shell whose sidebar is the state machine in the order a lead moves. Queue and lead-file data wait on the record and detail views. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 3 + .gitignore | 2 + README.md | 105 +++++++++++- index.html | 6 +- package-lock.json | 90 ++++++---- package.json | 3 +- src/App.jsx | 28 ++- src/api/client.js | 159 ++++++++++++++++++ src/api/config.js | 45 +++++ src/api/provider.jsx | 51 ++++++ src/assets/brand/zurich_logo.webp | Bin 0 -> 12418 bytes .../assets}/fonts/ZurichSans-Light.otf | Bin .../assets}/fonts/ZurichSans-Medium.otf | Bin .../assets}/fonts/ZurichSans-Regular.otf | Bin src/index.css | 6 +- src/layout/Shell.css | 51 ++++++ src/layout/Shell.jsx | 71 ++++++++ src/main.jsx | 25 ++- src/pages/Login.jsx | 20 ++- src/runtimeConfig.js | 41 +++++ src/screens/Lead.jsx | 23 +++ src/screens/Pipeline.jsx | 108 ++++++++++++ src/screens/screens.css | 50 ++++++ vite.config.js | 11 +- 24 files changed, 842 insertions(+), 56 deletions(-) create mode 100644 .env.example create mode 100644 src/api/client.js create mode 100644 src/api/config.js create mode 100644 src/api/provider.jsx create mode 100644 src/assets/brand/zurich_logo.webp rename {public => src/assets}/fonts/ZurichSans-Light.otf (100%) rename {public => src/assets}/fonts/ZurichSans-Medium.otf (100%) rename {public => src/assets}/fonts/ZurichSans-Regular.otf (100%) create mode 100644 src/layout/Shell.css create mode 100644 src/layout/Shell.jsx create mode 100644 src/runtimeConfig.js create mode 100644 src/screens/Lead.jsx create mode 100644 src/screens/Pipeline.jsx create mode 100644 src/screens/screens.css diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ab09c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Local dev only. In a deployed build this is read at runtime from the +# config.js the server writes when it places the build. +VITE_ZINO_API_URL=https://dev.getzino.in diff --git a/.gitignore b/.gitignore index a547bf3..50c8dda 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env diff --git a/README.md b/README.md index a36934d..13f6102 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,103 @@ -# React + Vite +# Zurich Kotak — Lead Desk -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +Operator console for the Zurich Kotak "Lead to Policy" demo. Leads arrive through +three channels — direct, bancassurance and agency — and run one state machine to +policy issuance. **The agents do the work; a person appears at one queue.** -Currently, two official plugins are available: +Workflow config is seeded from `sm2/custom-apps/zurich-kotak/` (org **84**, +app **536**, workflow `zk_wf_lead`). Design doc: +`sm2/app-designs/zurich-kotak/design.html`. Workflow spec PDF: +`sm2/custom-apps/zurich-kotak/zurich-kotak-workflow.pdf`. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## Run it -## React Compiler +```bash +npm install +npm run dev # http://localhost:5175 +npm run build # → dist/ +``` -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +`.env` carries the API host **for local dev only**: -## Expanding the ESLint configuration +``` +VITE_ZINO_API_URL=https://dev.getzino.in +``` -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +Logins (all password `ZurichKotak@2026`, org `84`): + +| Email | Role | Can do | +|---|---|---| +| `sanjay.uw@zurichkotak.example` | SME Underwriter | **Clear / Decline Referral** — the only human decision | +| `meera.rm@zurichkotak.example` | Bancassurance RM | Submit a **Partner Bank Lead** | +| `arjun.posp@zurichkotak.example` | POSP / Broker | Submit a **Partner Agent Lead** | +| `kavya.csr@zurichkotak.example` | Direct / Call centre | Submit a **Self-Serve Lead** | +| `deepa.ops@zurichkotak.example` | Operations | Cross-channel visibility | + +Sign in as Sanjay to land in **Referred to Underwriting**, which is where the +demo happens. + +## How it talks to the platform + +Everything goes through `src/api/client.js`: + +- `POST /usr/login` — auth. **`org_id` must be a string**; a number returns + `cannot unmarshal number into Go struct field LoginRequest.org_id`. +- `POST /app/536/view/recordview` — every queue is one record view + (`zk-rv-leads`) filtered server-side on `current_state_name`. +- `POST /app/536/view/form-screens` then `POST /app/536/activity` — forms are + read from the **live** activity schema and submitted straight back. + +### Forms are not defined in this repo + +Whatever `/view/form-screens` returns is what renders — labels, types, select +options, which fields are mandatory. Add a field to an activity in Studio, +redeploy, and it appears here with no frontend change. That is deliberate: the +workflow is the source of truth, and a hardcoded form would quietly diverge +from it. + +### Permissions are never enforced here + +The console does not hide a button to stop someone using it. Every permission +decision is the workflow's, made server-side on each submission — the platform +refuses and this app reports what it said. Two refusals worth knowing: +`/activity` answers **403**, `/start` answers **400**, and both carry +`permission denied`. + +### The runtime-config contract + +`VITE_ZINO_API_URL` is read at **runtime** from a `config.js` the server writes +when it places the build — never compiled in. One artifact is promoted between +environments unchanged, so a build-time URL would point every environment at +whichever backend happened to build it. `requireConfigValue` throws if it is +missing, so a production build with no `config.js` fails loudly rather than +calling the wrong backend. + +`vite.config.js` uses `base: './'` and the router takes its basename from the +`` the server writes, so **one build serves any mount path**. Do not +reintroduce a build-time base. + +**This is also why the fonts and logo live under `src/assets/` and not +`public/`.** Vite rewrites bundled asset URLs to be relative; a `public/` file +referenced as `/fonts/…` stays absolute and 404s under the `/zurich-kotak/` +mount path. The favicon is the one exception — it stays in `public/brand/` and +is referenced relatively from `index.html`. + +## Deployment + +Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql` +(slug `zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds +and serves at **https://preview-dev.getzino.in/zurich-kotak/**. + +The webhook only fires on a **new** push — a push made before the frontgen +project row existed matched no project and did nothing. + +## State of the build + +| Piece | Status | +|---|---| +| Brand — Zurich Sans, palette, logo | done (kept from the original scaffold) | +| Sign-in against the real gateway | done | +| Runtime config, relative base, router | done | +| Pipeline sidebar (mirrors `tbl_wf_states`) | done | +| Queue lists | waiting on the `zk-rv-leads` record view | +| Lead file, activity forms, attribution panel | waiting on `zk-dv-lead` + form screens | diff --git a/index.html b/index.html index 242773d..b4bb30b 100644 --- a/index.html +++ b/index.html @@ -2,12 +2,12 @@ - + - Zurich Kotak | Sign in + Zurich Kotak | Lead Desk
- + diff --git a/package-lock.json b/package-lock.json index 3d520b6..3016327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -627,9 +628,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -647,9 +645,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -667,9 +662,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -687,9 +679,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -707,9 +696,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +713,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1006,6 +989,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1707,9 +1703,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1731,9 +1724,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1755,9 +1745,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1779,9 +1766,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2082,6 +2066,44 @@ "react": "^19.2.8" } }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", @@ -2132,6 +2154,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index afbe3be..ba68c12 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.jsx b/src/App.jsx index 2a81062..499c548 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,31 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { useZino } from './api/provider.jsx' import Login from './pages/Login.jsx' +import Shell from './layout/Shell.jsx' +import Pipeline from './screens/Pipeline.jsx' +import Lead from './screens/Lead.jsx' export default function App() { - return + const { isAuthed } = useZino() + + if (!isAuthed) { + return ( + + } /> + } /> + + ) + } + + return ( + + } /> + }> + } /> + } /> + } /> + } /> + + + ) } diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..b3de5bb --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,159 @@ +import { APP_ID, WORKFLOW } from './config' + +const TOKEN_KEY = 'zk_lead_desk_token' +const USER_KEY = 'zk_lead_desk_user' + +/** + * HTTP client for the Zino gateway. + * + * Most routes are app-scoped (`/app/536/...`); login is not. The JWT persists + * in localStorage so a refresh does not bounce the operator back to the login + * screen mid-review. + */ +export class ZinoClient { + constructor(baseUrl, onAuthError) { + this.baseUrl = String(baseUrl).replace(/\/+$/, '') + this.token = null + this.onAuthError = onAuthError + if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY) + } + + setAuthErrorHandler(fn) { this.onAuthError = fn } + + setToken(token) { + this.token = token + if (typeof window === 'undefined') return + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) + } + + getToken() { return this.token } + + setStoredUser(user) { + if (typeof window === 'undefined') return + if (user) localStorage.setItem(USER_KEY, JSON.stringify(user)) + else localStorage.removeItem(USER_KEY) + } + + getStoredUser() { + if (typeof window === 'undefined') return null + try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null') } catch { return null } + } + + async request(method, path, body) { + const headers = { '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.setStoredUser(null) + this.onAuthError?.() + throw { status: 401, message: 'Session expired — sign in again' } + } + if (!res.ok) { + let message = res.statusText + try { + const j = await res.json() + message = j.error || j.message || message + } catch { /* non-JSON body */ } + throw { status: res.status, message } + } + if (res.status === 204) return undefined + return res.json() + } + + /** + * org_id must be a STRING. The gateway rejects a number with + * "cannot unmarshal number into Go struct field LoginRequest.org_id". + */ + async login(email, password, orgId) { + const res = await this.request('POST', '/usr/login', { + email, + password, + org_id: String(orgId), + }) + if (res?.token) { + this.setToken(res.token) + this.setStoredUser(res.user || null) + } + return res + } + + logout() { + this.setToken(null) + this.setStoredUser(null) + } + + /** Paginated records for a record view, filtered server-side. */ + recordView(rvUid, params = {}) { + return this.request('POST', `/app/${APP_ID}/view/recordview`, { + rv_id: rvUid, + page: params.page ?? 1, + limit: params.limit ?? 50, + ...(params.search ? { search: params.search } : {}), + ...(params.sort_by ? { sort_by: params.sort_by, sort_dir: params.sort_dir ?? 'desc' } : {}), + ...(params.filters ? { search_query: { filters: params.filters } } : {}), + }) + } + + detailView(dvUid, instanceId) { + return this.request( + 'GET', + `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, + ) + } + + /** + * The LIVE definition of an activity's form: fields, types, select options, + * which are mandatory. Read rather than hardcoded so that adding a field in + * Studio and redeploying surfaces it here with no frontend change — the + * workflow stays the source of truth. + */ + formSchema(activityUid, instanceId) { + return this.request('POST', `/app/${APP_ID}/view/form-screens`, { + activity_id: activityUid, + device_type: 'desktop', + ...(instanceId ? { instance_id: instanceId } : {}), + }) + } + + /** Start a new instance via one of the three INIT activities. */ + start(activityUid, data) { + return this.request('POST', `/app/${APP_ID}/start`, { + workflow_uuid: WORKFLOW, + activity_id: activityUid, + data, + }) + } + + /** + * Perform an activity on an existing instance. The workflow refuses what the + * signed-in user may not do — a permission denial arrives as 403 here and as + * 400 on /start, both carrying "permission denied". + */ + activity(instanceId, activityUid, data) { + return this.request('POST', `/app/${APP_ID}/activity`, { + workflow_uuid: WORKFLOW, + instance_id: instanceId, + activity_id: activityUid, + data, + }) + } + + instance(instanceId) { + return this.request('POST', `/app/${APP_ID}/instance`, { + workflow_uuid: WORKFLOW, + instance_id: instanceId, + }) + } + + audit(instanceId) { + return this.request('GET', `/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`) + } +} diff --git a/src/api/config.js b/src/api/config.js new file mode 100644 index 0000000..6c7e02a --- /dev/null +++ b/src/api/config.js @@ -0,0 +1,45 @@ +/** + * Environment-scoped identifiers for the Zurich Kotak "Lead to Policy" demo. + * + * Workflow config is seeded from sm2/custom-apps/zurich-kotak/ — org 84, + * app 536, workflow zk_wf_lead (110001 v1). + */ +export const ORG_ID = '84' +export const APP_ID = '536' +export const WORKFLOW = 'zk_wf_lead' + +/** + * The pipeline, in the order a lead actually moves. + * + * Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched + * because the sidebar has to render its ORDER, and the API returns states as a + * set with no canonical sequence. Keep in step with 02_workflow.sql. + */ +export const STAGES = [ + { uid: 'zk-state-new', name: 'New Lead', kind: 'work' }, + { uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' }, + { uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' }, + { uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' }, + { uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' }, + { uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' }, + { uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' }, + // The only human queue in the whole machine. + { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' }, + { uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' }, + { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' }, + { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' }, + { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, + { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, + { uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, +] + +/** Channel presentation. source_channel is data on the instance, never a branch. */ +export const CHANNELS = { + direct: { label: 'Direct', short: 'D' }, + bancassurance: { label: 'Bancassurance', short: 'B' }, + agency: { label: 'Agency', short: 'A' }, +} + +/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */ +export const RV_LEADS = 'zk-rv-leads' +export const DV_LEAD = 'zk-dv-lead' diff --git a/src/api/provider.jsx b/src/api/provider.jsx new file mode 100644 index 0000000..9adcfca --- /dev/null +++ b/src/api/provider.jsx @@ -0,0 +1,51 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' +import { ZinoClient } from './client' + +const ZinoContext = createContext(null) + +/** + * Holds the client and the session. + * + * The stored user is restored on boot so a refresh does not flash the login + * screen. It restores an IDENTITY, never a permission set: every permission + * decision is the workflow's, made server-side on each submission. Nothing here + * hides a button to enforce a rule — the platform refuses, and the console + * reports what it said. + */ +export function ZinoProvider({ baseUrl, children }) { + const clientRef = useRef(null) + if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl) + const client = clientRef.current + + const [user, setUser] = useState(() => (client.getToken() ? client.getStoredUser() : null)) + + const signOut = useCallback(() => { + client.logout() + setUser(null) + }, [client]) + + client.setAuthErrorHandler(() => setUser(null)) + + const signIn = useCallback( + async (email, password, orgId) => { + const res = await client.login(email, password, orgId) + const u = res?.user || { email } + setUser(u) + return u + }, + [client], + ) + + const value = useMemo( + () => ({ client, user, signIn, signOut, isAuthed: Boolean(user && client.getToken()) }), + [client, user, signIn, signOut], + ) + + return {children} +} + +export function useZino() { + const ctx = useContext(ZinoContext) + if (!ctx) throw new Error('useZino must be used inside ') + return ctx +} diff --git a/src/assets/brand/zurich_logo.webp b/src/assets/brand/zurich_logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..6f5c27ee2a8861a6e17d4b5b4bebd74348d57965 GIT binary patch literal 12418 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 s.kind !== 'end') + const closed = STAGES.filter((s) => s.kind === 'end') + + return ( +
+
+
+ Zurich Kotak General Insurance +
+ Lead Desk + Lead to Policy +
+
+
+
+ {user?.name || user?.email || 'Signed in'} + {user?.email} +
+ +
+
+ +
+ + +
+ +
+
+
+ ) +} diff --git a/src/main.jsx b/src/main.jsx index b9a1a6d..d31942c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,31 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './index.css' +import { BrowserRouter } from 'react-router-dom' import App from './App.jsx' +import { ZinoProvider } from './api/provider.jsx' +import { basePath, requireConfigValue } from './runtimeConfig.js' +import './index.css' + +/** + * The API base URL is read at RUNTIME, from the config.js the server writes + * when it places the build — never compiled in. One artifact is promoted + * between environments unchanged, so a build-time URL would point every + * environment at whichever backend happened to build it. + * + * requireConfigValue throws rather than falling back, so a production build + * with no config.js fails loudly on the first paint instead of quietly calling + * the wrong backend. + */ +const baseUrl = requireConfigValue('VITE_ZINO_API_URL') createRoot(document.getElementById('root')).render( - + {/* basename comes from the the server wrote, so one build + serves any mount path without a rebuild. */} + + + + + , ) diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index 03b1568..c4148c7 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -1,5 +1,8 @@ import { useState } from 'react' -import logo from '/brand/zurich_logo.webp' +import { useNavigate } from 'react-router-dom' +import { useZino } from '../api/provider.jsx' +import { ORG_ID } from '../api/config.js' +import logo from '../assets/brand/zurich_logo.webp' import './Login.css' const FEATURES = [ @@ -41,6 +44,8 @@ export default function Login() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) + const { signIn } = useZino() + const navigate = useNavigate() async function handleSubmit(event) { event.preventDefault() @@ -53,10 +58,15 @@ export default function Login() { setSubmitting(true) try { - // TODO: wire to the low-code backend auth endpoint - await new Promise((resolve) => setTimeout(resolve, 600)) - } catch { - setError('We could not sign you in. Please try again.') + // org_id must be a STRING — the gateway rejects a number with + // "cannot unmarshal number into Go struct field LoginRequest.org_id". + await signIn(email.trim(), password, ORG_ID) + navigate('/', { replace: true }) + } catch (err) { + // Surface what the gateway actually said. "Invalid credentials" would + // hide the two failures that look identical from here and are not: + // a wrong password, and a user with no access to this app. + setError(err?.message || 'We could not sign you in. Please try again.') } finally { setSubmitting(false) } diff --git a/src/runtimeConfig.js b/src/runtimeConfig.js new file mode 100644 index 0000000..c1b57bd --- /dev/null +++ b/src/runtimeConfig.js @@ -0,0 +1,41 @@ +/** + * 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 exists because an empty string is falsy and could never survive + * the `||` chain, which is exactly what "use the current origin" has to do. + */ +export function resolveConfigValue(key, fallback = '') { + const runtime = (window.__RUNTIME_CONFIG__ || {})[key] + // Gated on DEV: a production build with no config.js must fail loudly rather + // than quietly calling whichever backend happened to build it. + const devFallback = import.meta.env.DEV ? (import.meta.env[key] ?? '') : '' + const value = runtime || devFallback || fallback + return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value +} + +export function requireConfigValue(key) { + 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 this app is served under, read from the `` the + * server writes into index.html. Drives the router basename, so one build + * serves any mount path without a rebuild. + * + * `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it + * resolves to "./". + */ +export function basePath() { + return new URL(document.baseURI).pathname +} diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx new file mode 100644 index 0000000..d0c7857 --- /dev/null +++ b/src/screens/Lead.jsx @@ -0,0 +1,23 @@ +import { useParams, Link } from 'react-router-dom' +import './screens.css' + +/** The lead file. Built once the detail view is seeded. */ +export default function Lead() { + const { instanceId } = useParams() + return ( +
+
+
+

Lead {instanceId}

+

Evidence, attribution and the referral file.

+
+ Back to the queue +
+
+ Not built yet. +

The lead file reads the zk-dv-lead detail view and the live + activity forms. Both arrive with the next app-config pass.

+
+
+ ) +} diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx new file mode 100644 index 0000000..c0e6c24 --- /dev/null +++ b/src/screens/Pipeline.jsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { useZino } from '../api/provider.jsx' +import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js' +import './screens.css' + +/** + * One record view drives every queue; the stage is a server-side filter on + * current_state_name. Filtering server-side rather than fetching everything and + * narrowing in the browser is what keeps a queue honest once there are more + * leads than one page. + */ +export default function Pipeline() { + const { stageUid } = useParams() + const { client } = useZino() + const stage = STAGES.find((s) => s.uid === stageUid) + + const [state, setState] = useState({ status: 'loading', rows: [], error: null }) + + useEffect(() => { + let cancelled = false + setState({ status: 'loading', rows: [], error: null }) + client + .recordView(RV_LEADS, { + limit: 100, + filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }], + }) + .then((res) => { + if (cancelled) return + const rows = res?.data ?? res?.rows ?? res?.records ?? [] + setState({ status: 'ready', rows, error: null }) + }) + .catch((err) => { + if (cancelled) return + setState({ status: 'error', rows: [], error: err }) + }) + return () => { cancelled = true } + }, [client, stageUid, stage?.name]) + + if (!stage) return

Unknown stage.

+ + return ( +
+
+
+

{stage.name}

+

+ {stage.kind === 'human' + ? 'The only queue in the machine that waits for a person.' + : 'Handled by the agents; shown here for visibility.'} +

+
+ {state.status === 'ready' ? ( + {state.rows.length} + ) : null} +
+ + {state.status === 'loading' ?

Loading…

: null} + + {state.status === 'error' ? ( +
+ This queue has no record view yet. +

+ The console reads every queue from the {RV_LEADS} record view. + It is not seeded yet, so there is nothing to list. Everything else — + sign-in, routing and the pipeline itself — is live. +

+

{state.error?.status} {state.error?.message}

+
+ ) : null} + + {state.status === 'ready' && state.rows.length === 0 ? ( +

No leads at this stage.

+ ) : null} + + {state.status === 'ready' && state.rows.length > 0 ? ( + + + + + + + + + {state.rows.map((r) => { + const id = r.instance_id ?? r.id + const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } + return ( + + + + + + + + + ) + })} + +
LeadCustomerChannelProductPremiumAttribution
{r.lead_ref || id}{r.customer_name || '—'}
{r.entity_name || ''}
{ch.label}{r.product_line === 'motor' ? 'Motor' : 'SME Package'}{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'} + {r.attribution_status === 'contested' + ? Contested + : {r.attribution_status || '—'}} +
+ ) : null} +
+ ) +} diff --git a/src/screens/screens.css b/src/screens/screens.css new file mode 100644 index 0000000..5cf25d2 --- /dev/null +++ b/src/screens/screens.css @@ -0,0 +1,50 @@ +.page__head { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 24px; margin-bottom: 20px; +} +.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); } +.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); } +.page__count { + font-size: 1.35rem; font-weight: 500; color: var(--zk-blue); + background: var(--zk-white); border: 1px solid var(--zk-line); + border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums; +} + +.empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; } + +.notice { + background: var(--zk-white); border: 1px solid var(--zk-line); + border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px; + max-width: 60ch; +} +.notice strong { display: block; font-weight: 500; margin-bottom: 6px; } +.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; } +.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; } +.notice code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; + background: var(--zk-tint); border: 1px solid var(--zk-line-soft); + border-radius: 3px; padding: 1px 4px; +} + +.grid { + width: 100%; border-collapse: collapse; background: var(--zk-white); + border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem; +} +.grid th { + text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em; + text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px; + background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap; +} +.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; } +.grid tbody tr:last-child td { border-bottom: none; } +.grid tbody tr:hover { background: var(--zk-tint); } +.grid .num { text-align: right; font-variant-numeric: tabular-nums; } +.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; } +.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; } +.grid__link:hover { text-decoration: underline; } + +.chip { + display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px; + background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); +} +.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; } diff --git a/vite.config.js b/vite.config.js index 9982072..656c962 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,16 @@ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' -// https://vite.dev/config/ +// Relative base: emitted asset refs become "./assets/…", so the the +// server writes at placement decides where they resolve. One build therefore +// serves any mount path. Do NOT reintroduce a build-time base — the artifact is +// promoted between environments unchanged and would stop being placeable. +// +// This is also why the fonts and the logo live under src/ and not public/: +// Vite rewrites bundled asset URLs to be relative, while a public/ file +// referenced as "/fonts/…" stays absolute and 404s under a mount path. export default defineConfig({ plugins: [react()], + base: './', + server: { port: 5175 }, })