Wire the console to the platform and make it deployable

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 <base href>, 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) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-24 15:20:13 +05:30
parent f859fbba6d
commit 9210681488
24 changed files with 842 additions and 56 deletions

3
.env.example Normal file
View File

@ -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

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

105
README.md
View File

@ -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
`<base href>` 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 |

View File

@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/webp" href="/brand/zurich_logo.webp" />
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zurich Kotak | Sign in</title>
<title>Zurich Kotak | Lead Desk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>

90
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 <Login />
const { isAuthed } = useZino()
if (!isAuthed) {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Login />} />
</Routes>
)
}
return (
<Routes>
<Route path="/login" element={<Navigate to="/" replace />} />
<Route element={<Shell />}>
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
<Route path="/stage/:stageUid" element={<Pipeline />} />
<Route path="/lead/:instanceId" element={<Lead />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
)
}

159
src/api/client.js Normal file
View File

@ -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))}`)
}
}

45
src/api/config.js Normal file
View File

@ -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'

51
src/api/provider.jsx Normal file
View File

@ -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 <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>
}
export function useZino() {
const ctx = useContext(ZinoContext)
if (!ctx) throw new Error('useZino must be used inside <ZinoProvider>')
return ctx
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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;

51
src/layout/Shell.css Normal file
View File

@ -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; }

71
src/layout/Shell.jsx Normal file
View File

@ -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 (
<div className="shell">
<header className="shell__top">
<div className="shell__brand">
<img src={logo} alt="Zurich Kotak General Insurance" />
<div>
<strong>Lead Desk</strong>
<span>Lead to Policy</span>
</div>
</div>
<div className="shell__who">
<div className="shell__user">
<strong>{user?.name || user?.email || 'Signed in'}</strong>
<span>{user?.email}</span>
</div>
<button type="button" className="shell__signout" onClick={signOut}>
Sign out
</button>
</div>
</header>
<div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline">
<p className="shell__navlabel">Pipeline</p>
{work.map((s) => (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
className={({ isActive }) =>
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
}
>
<span className="shell__stagename">{s.name}</span>
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
</NavLink>
))}
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
{closed.map((s) => (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
>
<span className="shell__stagename">{s.name}</span>
</NavLink>
))}
</nav>
<main className="shell__main">
<Outlet />
</main>
</div>
</div>
)
}

View File

@ -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(
<StrictMode>
{/* basename comes from the <base href> the server wrote, so one build
serves any mount path without a rebuild. */}
<BrowserRouter basename={basePath()}>
<ZinoProvider baseUrl={baseUrl}>
<App />
</ZinoProvider>
</BrowserRouter>
</StrictMode>,
)

View File

@ -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)
}

41
src/runtimeConfig.js Normal file
View File

@ -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 `<base href>` 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
}

23
src/screens/Lead.jsx Normal file
View File

@ -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 (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Lead {instanceId}</h1>
<p className="page__sub">Evidence, attribution and the referral file.</p>
</div>
<Link className="grid__link" to="/">Back to the queue</Link>
</header>
<div className="notice">
<strong>Not built yet.</strong>
<p>The lead file reads the <code>zk-dv-lead</code> detail view and the live
activity forms. Both arrive with the next app-config pass.</p>
</div>
</section>
)
}

108
src/screens/Pipeline.jsx Normal file
View File

@ -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 <p className="empty">Unknown stage.</p>
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">{stage.name}</h1>
<p className="page__sub">
{stage.kind === 'human'
? 'The only queue in the machine that waits for a person.'
: 'Handled by the agents; shown here for visibility.'}
</p>
</div>
{state.status === 'ready' ? (
<span className="page__count">{state.rows.length}</span>
) : null}
</header>
{state.status === 'loading' ? <p className="empty">Loading</p> : null}
{state.status === 'error' ? (
<div className="notice">
<strong>This queue has no record view yet.</strong>
<p>
The console reads every queue from the <code>{RV_LEADS}</code> record view.
It is not seeded yet, so there is nothing to list. Everything else
sign-in, routing and the pipeline itself is live.
</p>
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
</div>
) : null}
{state.status === 'ready' && state.rows.length === 0 ? (
<p className="empty">No leads at this stage.</p>
) : null}
{state.status === 'ready' && state.rows.length > 0 ? (
<table className="grid">
<thead>
<tr>
<th>Lead</th><th>Customer</th><th>Channel</th>
<th>Product</th><th className="num">Premium</th><th>Attribution</th>
</tr>
</thead>
<tbody>
{state.rows.map((r) => {
const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
return (
<tr key={id}>
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
<td><span className="chip">{ch.label}</span></td>
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<td>
{r.attribution_status === 'contested'
? <span className="chip chip--warn">Contested</span>
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
</td>
</tr>
)
})}
</tbody>
</table>
) : null}
</section>
)
}

50
src/screens/screens.css Normal file
View File

@ -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; }

View File

@ -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 <base href> 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 },
})