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 0000000..6f5c27e Binary files /dev/null and b/src/assets/brand/zurich_logo.webp differ diff --git a/public/fonts/ZurichSans-Light.otf b/src/assets/fonts/ZurichSans-Light.otf similarity index 100% rename from public/fonts/ZurichSans-Light.otf rename to src/assets/fonts/ZurichSans-Light.otf diff --git a/public/fonts/ZurichSans-Medium.otf b/src/assets/fonts/ZurichSans-Medium.otf similarity index 100% rename from public/fonts/ZurichSans-Medium.otf rename to src/assets/fonts/ZurichSans-Medium.otf diff --git a/public/fonts/ZurichSans-Regular.otf b/src/assets/fonts/ZurichSans-Regular.otf similarity index 100% rename from public/fonts/ZurichSans-Regular.otf rename to src/assets/fonts/ZurichSans-Regular.otf diff --git a/src/index.css b/src/index.css index f3b4073..5b27a45 100644 --- a/src/index.css +++ b/src/index.css @@ -1,21 +1,21 @@ /* ── Zurich Kotak brand typeface ───────────────────────────── */ @font-face { font-family: 'Zurich Sans'; - src: url('/fonts/ZurichSans-Light.otf') format('opentype'); + src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype'); font-weight: 300; font-style: normal; font-display: swap; } @font-face { font-family: 'Zurich Sans'; - src: url('/fonts/ZurichSans-Regular.otf') format('opentype'); + src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype'); font-weight: 400; font-style: normal; font-display: swap; } @font-face { font-family: 'Zurich Sans'; - src: url('/fonts/ZurichSans-Medium.otf') format('opentype'); + src: url('./assets/fonts/ZurichSans-Medium.otf') format('opentype'); font-weight: 500; font-style: normal; font-display: swap; diff --git a/src/layout/Shell.css b/src/layout/Shell.css new file mode 100644 index 0000000..ed31bc0 --- /dev/null +++ b/src/layout/Shell.css @@ -0,0 +1,51 @@ +.shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); } + +.shell__top { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white); + border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10; +} +.shell__brand { display: flex; align-items: center; gap: 14px; } +.shell__brand img { height: 30px; width: auto; display: block; } +.shell__brand div { display: flex; flex-direction: column; line-height: 1.25; padding-left: 14px; border-left: 1px solid var(--zk-line); } +.shell__brand strong { font-size: .95rem; font-weight: 500; color: var(--zk-ink); } +.shell__brand span { font-size: .74rem; color: var(--zk-muted); } + +.shell__who { display: flex; align-items: center; gap: 16px; } +.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; } +.shell__user strong { font-size: .85rem; font-weight: 500; } +.shell__user span { font-size: .72rem; color: var(--zk-muted); } +.shell__signout { + font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer; + background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line); +} +.shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); } + +.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; } + +.shell__nav { + width: 244px; flex: none; background: var(--zk-white); + border-right: 1px solid var(--zk-line); padding: 18px 12px 28px; + display: flex; flex-direction: column; gap: 1px; +} +.shell__navlabel { + margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500; + letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); +} +.shell__navlabel--closed { margin-top: 22px; } + +.shell__stage { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 10px; border-radius: 4px; text-decoration: none; + color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent; +} +.shell__stage:hover { background: var(--zk-tint); } +.shell__stage.is-active { background: var(--zk-tint-blue); border-left-color: var(--zk-blue); color: var(--zk-blue-dark); font-weight: 500; } +/* The one queue where the machine stops and a person decides. */ +.shell__stage.is-human .shell__stagename { font-weight: 500; } +.shell__badge { + font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; + padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white); +} + +.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; } diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx new file mode 100644 index 0000000..4ebf191 --- /dev/null +++ b/src/layout/Shell.jsx @@ -0,0 +1,71 @@ +import { NavLink, Outlet } from 'react-router-dom' +import { useZino } from '../api/provider.jsx' +import { STAGES } from '../api/config.js' +import logo from '../assets/brand/zurich_logo.webp' +import './Shell.css' + +/** + * The sidebar IS the pipeline, in the order a lead moves — so the shape of the + * process is legible before any record is opened. The one human queue is marked + * as such, because it is the only place the machine stops. + */ +export default function Shell() { + const { user, signOut } = useZino() + const work = STAGES.filter((s) => 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 }, })