The Lead Desk, written for a phone

A separate app from the desktop console, talking to the same platform with
the same client. Not a breakpoint on the old one: the two answer different
questions. The console answers "what is the state of the book?" across a
wide grid; a phone answers "what needs me, and what happened to this one?"
in a column you scroll with a thumb.

The old console at 390px showed why. Tables scrolled sideways, nine queues
stacked above the content ate the first screenful, and the action you came
to perform sat below the fold.

So every row of every table is a CARD — customer, stage, when it is due,
who is holding it, and one line about what is happening. Everything else is
one tap away. The queues live in a drawer. The action a lead is waiting on
is pinned to the bottom of the screen, where a thumb already is.

Shared with the console, because a divergence would be two apps disagreeing
about the same lead: the whole api/ layer, ActivityForm, and Timeline —
whose audit-row merging (one submission writes three rows) is hard-won and
must not be reimplemented twice.

Written fresh: the shell, the three screens, and the stylesheet.

The colour rule is unchanged and is the product in four colours: blue the
AI holds it, amber a person, teal the customer, red risk and nothing else.

A PWA, so Add to Home Screen gives a full-screen app; the layout pads for
the notch and the home indicator.

Verified at 402x874: login, overview, drawer, a queue, and a lead, with no
horizontal overflow on any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-10 16:50:48 +05:30
commit 6455579cc2
53 changed files with 9067 additions and 0 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_ZINO_API_URL=https://dev.getzino.in

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.env
*.local

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# Zurich Kotak — Lead Desk (phone)
The renewal desk on a phone. A separate app from the desktop console
(`zurich_kotak`), talking to the same platform with the same API client.
## Why a second app rather than a breakpoint
The two answer different questions. The desktop console answers *"what is the
state of the book?"* across a wide grid — six-column tables, a lead beside its
record, a rail of queues always visible. A phone answers *"what needs me, and
what happened to this one?"* in a column you scroll with a thumb.
Squeezing the first into 390px produced what it always produces: tables that
scroll sideways, a menu that eats the first screenful, and a primary action
somewhere below the fold. So the screens are written for the phone, and only
the parts that must not diverge are shared.
## What is shared, and what is not
**Copied verbatim, because it is the contract with the platform** — a
divergence here would be two apps disagreeing about the same lead:
src/api/client.js every call
src/api/config.js stages, actions, doc slots, the workflow mirror
src/api/permissions.js who may perform what
src/api/portfolio.jsx the one record-view call the queues share
src/api/holder.js who is holding a lead
src/api/lead.js how to read a date, name a field, group the record
src/components/ActivityForm.jsx, FileField.jsx, Timeline.jsx
`Timeline.jsx` in particular is shared on purpose: its audit-row merging (one
submission writes three rows) is hard-won, and reimplementing it here would
mean two different accounts of the same history.
**Written fresh for the phone:** every screen, the shell, and the stylesheet.
## The colour rule
Unchanged from the console, and it is the whole product in four colours:
blue the AI is holding it
amber a person is holding it
teal the customer is holding it
red risk — and nothing else
A lead that is amber on a laptop must be amber on a phone.
## Run it
npm install
npm run dev # http://localhost:5176
`VITE_ZINO_API_URL` in `.env` for local dev only. In a deployed build the API
host is read at RUNTIME from the `config.js` the server writes when it places
the build — never compiled in, so one artifact is promoted between
environments unchanged.
## Installing it
It is a PWA: `manifest.webmanifest`, a standalone display mode, and the navy
theme colour, so Add to Home Screen gives a full-screen app with no browser
chrome. The layout pads for the notch and the home indicator through
`env(safe-area-inset-*)`.

21
eslint.config.js Normal file
View File

@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

21
index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover so the navy header can sit under an iPhone notch and
the action bar can clear the home indicator via env(safe-area-inset-*). -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0B2A5B" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Lead Desk" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="icon" href="./brand/zurich_logo.webp" />
<title>Zurich Kotak — Lead Desk</title>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2505
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "zurich_kotak_pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"eslint": "^10.9.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"globals": "^17.11.0",
"vite": "^8.2.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

3
public/config.js Normal file
View File

@ -0,0 +1,3 @@
// Written by the server when it places a build. This copy is for local dev
// only; the deployed one names the environment's own API host.
window.__RUNTIME_CONFIG__ = { VITE_ZINO_API_URL: 'https://dev.getzino.in' }

View File

@ -0,0 +1,14 @@
{
"name": "Zurich Kotak — Lead Desk",
"short_name": "Lead Desk",
"description": "Motor and SME renewals, in your pocket.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#F2F4F8",
"theme_color": "#0B2A5B",
"icons": [
{ "src": "./brand/zurich_logo.webp", "sizes": "512x512", "type": "image/webp", "purpose": "any" }
]
}

39
src/App.jsx Normal file
View File

@ -0,0 +1,39 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useZino } from './api/provider.jsx'
import { PortfolioProvider } from './api/portfolio.jsx'
import Login from './pages/Login.jsx'
import Shell from './layout/Shell.jsx'
import Overview from './screens/Overview.jsx'
import Leads from './screens/Leads.jsx'
import Lead from './screens/Lead.jsx'
/**
* Three screens, which is the whole app on a phone: what needs me, the list,
* and one lead. Everything the desktop console shows in side panels is either
* folded into the lead page or left to the desktop, on purpose a phone is
* for working a queue on the move, not for reading the whole book.
*/
export default function App() {
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={<PortfolioProvider><Shell /></PortfolioProvider>}>
<Route path="/" element={<Overview />} />
<Route path="/stage/:stageUid" element={<Leads />} />
<Route path="/lead/:instanceId" element={<Lead />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
)
}

108
src/api/agents.js Normal file
View File

@ -0,0 +1,108 @@
/**
* The AI employees, as people the operator can recognise.
*
* Five agents carry this workflow and the console referred to them by their
* role slug or their full title, differently in each place "ai_engage" in one
* view, "Engage" in another, "Engage AI" in a third. Someone watching a lead
* move could not tell that the thing which called the customer and the thing
* which wrote the quote were the same worker.
*
* So: one roster, one short name, one colour, one initial. Used wherever an
* agent is named. `does` is written in the present tense and in the operator's
* words, not the charter's it appears on hover and in the roster strip, and
* it is what makes an unfamiliar name mean something the first time.
*
* `tools` and `knowledge` are a MIRROR of the employee config in
* aiemployee.tbl_ai_employees. The app frontend cannot read the agents schema,
* so this is hand-maintained the way STAGES and ACTIONS are, and carries the
* same hazard: a tool added or removed there and not here is described wrongly
* in the UI, silently.
*
* Worth the trade, because "what can this thing actually do?" is the first
* question anyone watching an agent asks and the honest answer is short and
* reassuring: four of the five hold no tools at all and work from the policy
* wordings. The one tool that exists only reads a partner registry. Nothing
* here can move money, and since 89 nothing here can telephone anybody.
*
* Keyed by the ROLE, because that is what an audit row carries.
*/
export const AGENTS = {
ai_intake: {
icon: 'inspect',
short: 'Intake',
full: 'Intake & Attribution',
initials: 'IA',
tone: 'violet',
does: 'checks the lead is real, scores it, and works out whose it is',
wakes: 'Once, the moment a lead is filed.',
tools: [
{
name: 'lookup_partner',
does: 'Reads the partner registry by partner code and reports whether that POSP, broker or corporate agent is active and empanelled for this product.',
},
],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
ai_engage: {
icon: 'chat',
short: 'Engage',
full: 'Engage',
initials: 'EN',
tone: 'blue',
does: 'talks to the customer, writes up the call, and asks for what is missing',
wakes: 'Most often of the five: after the call ends, after documents land, after the Advisor reports, when the premium falls due, and on every WhatsApp reply.',
tools: [],
knowledge: ['SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
},
ai_advisor: {
icon: 'shield',
short: 'Advisor',
full: 'Advisor',
initials: 'AD',
tone: 'teal',
does: 'reads the captured risk and recommends the cover and add-ons',
wakes: 'Once, as soon as the risk has been captured.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
},
ai_kyc: {
icon: 'id',
short: 'KYC',
full: 'KYC & Evidence',
initials: 'KY',
tone: 'amber',
does: 'verifies identity and screens the risk for underwriting',
wakes: 'Twice: when the customer accepts, and again to run the underwriting screen.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
ai_uw_referral: {
icon: 'scales',
short: 'Referral',
full: 'Underwriting Referral',
initials: 'UW',
tone: 'plum',
does: 'prepares a referred file so a human underwriter can decide',
wakes: 'Once, and only when the screen refers the risk.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
}
/** The agent behind a set of roles, or null when a person did it. */
export function agentFor(roles) {
if (!Array.isArray(roles)) return null
const key = roles.find((r) => AGENTS[r])
return key ? { key, ...AGENTS[key] } : null
}
/** Initials for a person, so a human entry reads as a name too. */
export function initialsOf(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase()
}

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

@ -0,0 +1,267 @@
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()
}
/* The support desk
Not app-scoped: these are the platform's own agent routes, and the token
this console already holds is accepted on them as-is. A conversation is a
session; a question is a message on it. */
/** This operator's conversations with the desk, newest first. */
deskSessions(agentId) {
return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`)
}
/** Open a new conversation. */
deskStartSession(agentId) {
return this.request('POST', '/api/agent-sessions', { agent_id: agentId })
}
/** The transcript of one conversation. */
deskMessages(sessionId) {
return this.request('GET', `/api/agent-sessions/${sessionId}/messages`)
}
/**
* Ask. Answers with the rows the backend persisted for the turn
* { user_message, call_api_message, agent_message } not a bare string.
*
* These take twenty to forty seconds: the desk runs real queries against the
* book before it answers. The caller has to say so, or the panel looks hung.
*/
deskAsk(sessionId, content) {
return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content })
}
/**
* 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.
*
* The POST body is NOT the same shape as the GET query params: the view is
* named by `rv_template_uid` (not `rv_id`, which is the GET spelling), and
* paging/sort/filters all live INSIDE `search_query`. Sending them at the top
* level returns `400 Missing param: rv_template_uid (body)`.
*/
recordView(rvUid, params = {}) {
return this.request('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sort_by ?? '',
sort_dir: params.sort_dir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
})
}
/**
* Rows only. The response key differs by source type workflow views return
* `data`, rdbms views have been seen returning `records`.
*/
async rows(rvUid, params = {}) {
const r = await this.recordView(rvUid, params)
return r?.data ?? r?.records ?? r?.rows ?? []
}
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,
})
}
/**
* Upload a file and get back a reference {uuid, blob_path, original_name,
* mime_type}. The field context matters: the backend resolves the field's
* own config (allowed types, size limit, ocr_config) server-side from it and
* ignores anything the client claims.
*/
async uploadFile(file, ctx) {
const form = new FormData()
form.append('file', file)
// workflow_uuid is REQUIRED and is fixed for this app, so it is taken from
// config rather than from ctx. Passing it optionally is how the first
// version shipped: the upload sent activity_id, field_id and instance_id
// and got back `Missing required params: workflow_uuid`.
form.append('workflow_uuid', WORKFLOW)
if (ctx.activityId) form.append('activity_id', ctx.activityId)
if (ctx.fieldId) form.append('field_id', ctx.fieldId)
if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId))
const headers = {}
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form })
if (!res.ok) {
let message = res.statusText
try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ }
throw { status: res.status, message }
}
return res.json()
}
/**
* Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes
* the document crosses the wire once, survives a reload, and re-extracting
* costs no re-upload. The ocr_config (which fields to pull, where they map)
* is resolved server-side from the deployed workflow; anything the client
* sends is ignored.
*/
ocrExtract(fileRef, ctx) {
return this.request('POST', `/app/${APP_ID}/ocr-extract`, {
workflow_uuid: WORKFLOW,
activity_id: ctx.activityId,
field_id: ctx.fieldId,
instance_id: ctx.instanceId || undefined,
files: [fileRef],
})
}
instance(instanceId) {
return this.request('POST', `/app/${APP_ID}/instance`, {
workflow_uuid: WORKFLOW,
instance_id: instanceId,
})
}
/**
* The instance's audit trail: one entry per activity performed, with WHO
* performed it and the data they wrote.
*
* Must be the APP-SCOPED path. The bare `/view/audit` is not an API route at
* all it falls through to the SPA and returns HTML with a 200, which
* parses as a JSON error rather than an HTTP one.
*/
audit(instanceId) {
return this.request('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`)
}
}

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

@ -0,0 +1,576 @@
/**
* 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 53_v2_state_model.sql.
*
* `kind` answers "who is holding this lead", which is the question an operator
* actually has not "where is it in the process":
*
* needs a person has to act, and nothing moves until they do
* customer waiting on someone outside the business
* auto an AI employee is carrying it; `doing` is what to say meanwhile
* waiting real, but not yet the renewal is too far off to work
* end terminal
*
* `need` names the queue by what it wants done. A queue called "Upload
* documents" answers "is anything waiting on me?"; one called "Document
* Pending" describes where the lead sits and leaves the operator to work it out.
*
* `name` MUST match workflow.tbl_wf_states.name exactly the queue filter, the
* sidebar tally and the action lookup all resolve a lead through this string
* against current_state_name. A workflow rename not mirrored here does NOT
* error: the queue returns nothing, correctly, for a name no lead is in. That
* is the failure mode this table has, and it is silent.
*/
/**
* The support desk this console can ask questions of.
*
* A Support App on the platform: an AI desk bound to app 536 that reads the
* deployed config and the live book through read-only tools and answers with
* evidence. It is org-scoped and lives outside this app, so its id is config
* here rather than something the console can derive.
*
* Access is granted per user on the platform (usersystem.tbl_org_user_agents)
* and the console additionally gates the panel to ops see ASK_DESK_ROLES.
* Both have to agree; the platform's grant is the one that actually enforces.
*/
export const SUPPORT_AGENT_ID = 29648
export const ASK_DESK_ROLES = ['ops_admin']
export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' },
// The principal stall in the workflow, and the one that had no stage until
// 7 September: a lead waited for its three documents inside "Contacted", so
// the queue that most needed watching was the one that did not exist.
{ uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' },
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' },
// The one queue where the machine stops and a person decides.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' },
// `approval` marks the one queue that is a person's SIGN-OFF rather than a
// piece of work: confirming money has arrived. It is locked to ops_admin in
// the workflow — no agent and no other role can perform it — because an
// employee that can mark a premium received can put a customer on risk for a
// policy nobody paid for. It gets its own band on the overview so the role
// that owns it does not have to find it in a list of things mostly handled
// by somebody else.
// WHERE THE AI PUTS DOWN WHAT IT CANNOT HONESTLY FINISH. An AI employee has
// no way to raise a ticket and no way to send a message; the only move it
// has for "a person should take this" is Flag for Review, and it lands here
// with the reason it wrote. Nothing is running on these leads.
{ uid: 'zk-state-review', name: 'Needs a Person', kind: 'needs', need: 'Flagged by the AI', by: 'operations' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' },
// Nurture. There is no scheduler yet, so Resume Outreach is a button and it
// is the only thing that wakes a parked lead — do not present this as a
// stage with nothing to do.
{ uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Nurture', by: 'the renewal calendar' },
{ 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' },
]
/**
* How the sidebar groups those. Fourteen flat stages answer "what is the
* process?" — the question nobody signing in has. These answer "is anything
* waiting on me?".
*/
export const NAV_GROUPS = [
// The sidebar answers "is anything waiting on me?", so a group is WHO holds
// the work, not where it sits in the process. A person's queues and the
// customer's queues both need a human to look, so they share one heading.
{ label: 'Needs you', kinds: ['needs', 'customer'] },
{ label: 'Scheduled', kinds: ['waiting'] },
{ label: 'History', kinds: ['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'
/**
* Which activities are runnable from which state, and who is expected to do it.
*
* Mirrors workflow.tbl_wf_state_allowed_activities by hand the same reason
* STAGES is duplicated: the console needs the ORDER and the labels, and the API
* returns neither.
*
* `by` is presentational ONLY. Nothing here enforces anything: the workflow
* refuses server-side and the console reports what it said. Showing an action
* the signed-in user cannot perform is deliberate the refusal is the demo.
*
* This map is hand-maintained against workflow.tbl_wf_state_allowed_activities.
* An activity added to the workflow but not added here is simply invisible:
* the platform allows it, the console never offers it, and nothing errors.
* Collect Documents shipped in that state for one round.
*/
/**
* What each activity IS at a stage, not merely who may press it.
*
* `do` the expected next step here. Usually one; Referred has two,
* because clear and decline are a decision pair rather than a
* step and an alternative to it.
* `again` a bounded loop a retry, a reminder, a re-quote. Legitimate,
* never the thing to do next.
* `force` an AI employee's own job, offered to a person ONLY so a
* stalled lead can be pushed by hand. Presenting these as
* actions is what made the app read as a control panel: six
* equal buttons where five are recovery levers.
* `exit` Mark Lost. Always reachable, never a step. Four stages used
* to offer a partner agent this and nothing else, which told
* them their only option was to give up on a lead the system
* was actively working.
*/
const STATE_ACTIVITIES = {
'zk-state-new': [
{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' },
],
'zk-state-qualified': [
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' },
],
'zk-state-contacted': [
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
],
// The one stage whose next step is a person's: three documents have to
// be found and uploaded. Everything else here is the AI's own chain.
'zk-state-docs': [
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' },
],
'zk-state-quoted': [
// The customer accepts from their own phone: their WhatsApp reply is
// routed to Customer Reply, Engage reads it, and Engage performs this.
// Kept reachable — role 'force', folded away — because a customer who
// says yes on a CALL still needs somebody to record it, and because a
// reply Engage judged ambiguous has to be actionable by a person.
{ uid: 'zk-act-accept', label: 'Record the acceptance by hand', by: 'you, on consent', role: 'force' },
{ uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' },
{ uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' },
{ uid: 'zk-act-answer-customer', label: 'Reply to the customer', by: 'you, on WhatsApp', role: 'again' },
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' },
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' },
],
'zk-state-referred': [
{ uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' },
{ uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' },
{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' },
],
'zk-state-review': [
{ uid: 'zk-act-review-resume', label: 'Send back to the workflow', by: 'you', role: 'do' },
{ uid: 'zk-act-review-refer', label: 'Send to underwriting', by: 'you', role: 'do' },
],
'zk-state-payment': [
{ uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' },
// Not offered: the chase is a SCHEDULED activity now (86), booked 24h
// out by Request Premium and re-booked at 48h to a cap of three. It
// carries zero roles so the scheduler can perform it at all, which means
// a button here would 403 for everyone. To actually reach the customer,
// use Reply to the customer — that sends something.
{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' },
],
'zk-state-issued': [
{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' },
],
'zk-state-parked': [
{ uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' },
],
}
/**
* Mark Lost is not tied to one state: a lead can be dropped from anywhere before
* the policy issues, and every role that files a lead may do it. Appended rather
* than written into every entry so there is one place to change it, and so a
* stage added above cannot silently lose it.
*
* It carries role 'exit' precisely so it is never rendered as a step. It is the
* way out, not the way on.
*/
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'whoever holds the lead', role: 'exit' }
const DROPPABLE = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked',
]
export const ACTIONS = Object.fromEntries(
[...new Set([...Object.keys(STATE_ACTIVITIES), ...DROPPABLE])].map((uid) => [
uid,
[...(STATE_ACTIVITIES[uid] ?? []), ...(DROPPABLE.includes(uid) ? [DROP] : [])],
]),
)
/**
* Entry points surfaced in the console.
*
* The workflow has THREE INIT activities direct, bancassurance and agency
* and all three still work over the API with their own permissions. Only the
* agency door is shown here, because this demo is about the individual agent
* who sources a lead and gets paid when it onboards.
*
* The other two are hidden, not removed: the bancassurance door is what makes
* the cross-channel duplicate story possible, and deleting it would take the
* skip-ahead behaviour with it.
*/
export const ENTRY = [
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
note: 'POSP or broker sourcing a lead. Everything after this happens without them.' },
]
/** Product lines, as stored on the instance and as they should be read. */
export const PRODUCTS = {
motor: 'Motor',
sme_package: 'SME Package',
}
/**
* Which upload slot belongs to which product line and, for the two slots that
* are conditional, when the condition holds.
*
* The Collect Documents activity serves BOTH lines from one form: it returns all
* eleven slots to every lead, so a motor renewal was being asked for a Udyam
* certificate and a stock statement. Nothing on the platform decides otherwise
* `field_rules` comes back as `[]` for this activity, so there is no server-side
* visibility to honour.
*
* This is therefore a MIRROR, in the same sense as STAGES and ACTIONS above, and
* carries the same hazard: a slot added to the activity but not added here is
* shown to every product, and one renamed here stops matching and reverts to the
* same. The durable home for this is `field_rules` on the activity; when those
* are seeded, ActivityForm should read them and this table should go.
*
* Every slot is optional in the workflow (`mandatory: false` on all eleven), so
* hiding one cannot make a submission fail validation.
*/
export const DOC_SLOTS = {
// Motor.
doc_rc: { line: 'motor' },
doc_prev_policy: { line: 'motor' },
// "Break-in only" per the policy. The flag DOES exist now — 49 derives
// break_in at filing on all three doors — so the slot can honour its own
// caveat instead of being shown to every motor renewal.
doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' },
// SME.
doc_gst_cert: { line: 'sme' },
doc_udyam_cert: { line: 'sme' },
doc_premises_proof: { line: 'sme' },
doc_stock_statement: { line: 'sme' },
doc_premises_photos: { line: 'sme' },
// Audited accounts are only read when business interruption is on the risk.
doc_financials: { line: 'sme',
when: (lead) => (lead.sme_sections || []).includes('business_interruption') },
// Both lines.
doc_pan: { line: 'both' },
// Identity is only re-evidenced when KYC actually referred; asking up front
// collects an Aadhaar the file does not need.
doc_address_proof: { line: 'both', when: (lead) => lead.kyc_outcome === 'refer' },
}
/**
* The fields that belong to one product line but do not say so in their name.
* Everything else is classified by prefix: `motor_*` is motor, `sme_*` is SME.
* A prefix rule rather than a list, so a field added to the workflow tomorrow is
* classified without anyone remembering to come back here.
*/
export const LINE_FIELDS = {
gstin: 'sme',
udyam_no: 'sme',
// entity_name is labelled "Business Name" on the form. A company-owned
// vehicle does have an owner with a name, so this is not strictly an SME
// field — but asking a motor renewal for a business name puts an empty box
// on the shortest form in the app, and the answer is already carried by
// customer_name in every motor lead we have. It is optional, so hiding it
// cannot block a submission, and an SME lead still gets it.
entity_name: 'sme',
}
/**
* Fields the form STAMPS rather than asks.
*
* Each is resolved by the prefill pipeline from the signed-in identity
* Arjun IS Crestline Insurance Advisors, submitting through the agency door
* so all three arrived filled and read-only-in-spirit, and the New lead form
* opened with three of its eleven boxes already answered by the system. That
* is three boxes of noise in front of the four that actually need a person.
*
* HIDDEN FROM THE FORM, STILL SENT. These are not decorative: source_channel
* is mandatory, and partner_code and rm_or_agent_id are what Intake attributes
* the lead on. So they are filtered at RENDER only `fields`, which drives
* both validation and the payload, still holds them, and their prefilled
* values submit exactly as before. Filtering them out of `fields` instead
* would drop the attribution and 400 on the mandatory channel.
*/
export const STAMPED_FIELDS = new Set([
'source_channel',
'partner_code',
'rm_or_agent_id',
])
/** Whether a field is stamped by prefill and so should not be drawn. */
export function fieldIsStamped(fieldId) {
return STAMPED_FIELDS.has(baseFieldId(fieldId))
}
/**
* Fields the workflow computes for itself, which a form must therefore not ask
* for. Matched on the base id, so every per-activity suffix is covered.
*
* `documents_status` is the one that matters. It gates the AND-join that wakes
* Engage after an upload (55), and it was rendered as a dropdown asking the
* person who had just attached three files to tell the system what it could
* see. Left unset, the lead sits in Document Pending with everything attached
* and nothing happening, and it reads as the AI having stalled. It is derived
* in the trigger now (75), from the files themselves.
*
* `documents_notes` goes with it: the same script writes what is still missing,
* and a person overwriting that would be arguing with the file list.
*/
export const DERIVED_FIELDS = new Set([
'documents_status',
'documents_notes',
])
/**
* Form field ids carry a per-form suffix `doc_rc` arrives as `doc_rc_2`, `pan`
* as `pan_3` so a field is matched on the id with that suffix removed. No
* underlying field id ends in a number, which is what makes this safe.
*/
export function baseFieldId(id) {
return String(id).replace(/_\d+$/, '')
}
/** Which product line a field belongs to: 'motor', 'sme', or 'both'. */
export function fieldLine(fieldId) {
const id = baseFieldId(fieldId)
if (DOC_SLOTS[id]) return DOC_SLOTS[id].line
if (LINE_FIELDS[id]) return LINE_FIELDS[id]
if (id.startsWith('motor_')) return 'motor'
if (id.startsWith('sme_')) return 'sme'
return 'both'
}
/**
* The line a lead is on, in the vocabulary fieldLine answers in, or null when it
* cannot be told.
*
* Null matters: treating an unknown line as SME would quietly drop the RC and
* the expiring policy from Collect Documents on a lead whose product has not
* been stamped yet, with nothing on screen to say a slot was hidden. Not knowing
* means filtering nothing.
*/
export function leadLine(lead) {
if (lead?.product_line === 'motor') return 'motor'
if (lead?.product_line === 'sme_package') return 'sme'
return null
}
/**
* Whether an activity field should be offered for this lead its line has to
* match, and a conditional upload slot has to have its condition hold.
*
* Everything is shown when there is no instance yet: an INIT form has no product
* line to filter on.
*/
export function fieldApplies(fieldId, lead) {
// Computed by the workflow — never asked for, on any form, whether or not
// there is a lead behind it.
if (DERIVED_FIELDS.has(baseFieldId(fieldId))) return false
if (!lead) return true
const on = leadLine(lead)
if (!on) return true
const line = fieldLine(fieldId)
if (line !== 'both' && line !== on) return false
const slot = DOC_SLOTS[baseFieldId(fieldId)]
return slot?.when ? slot.when(lead) : true
}
/**
* A STAGE IS NOT ALWAYS ONE SITUATION.
*
* Document Pending covers two that could not look more different to whoever is
* watching. Before the upload a person has to act and nothing moves until they
* do; after it the lead stays in the same state while Engage captures the risk,
* the Advisor recommends cover and the rating engine prices it three AI steps,
* two to five minutes, no one to chase.
*
* Rendered from `current_state_name` alone both read as "waiting on the partner
* agent", so a lead that had just been served showed an Upload documents button
* and sat in the Action-required queue, and the AI work behind it was invisible.
* The state machine is right to hold one state here nothing has been decided
* yet so the distinction belongs in the read model, once, rather than in each
* screen's own guess.
*
* `documents_status` is what separates them. The workflow derives it from the
* files themselves (75), so it says what is actually attached rather than what
* anyone claimed.
*/
export function phaseOf(stage, lead) {
if (!stage || !lead) return stage
// Quote Presented has the same shape as Document Pending: the state holds
// still while the situation underneath it changes completely.
//
// Before the customer answers, the lead is genuinely with them and nothing
// is running. After they accept, KYC and the underwriting screen run inside
// the SAME state — the lead does not move until underwriting clears — so a
// page saying "waiting for the customer to reply" sat directly above a panel
// saying "accepted by the customer, KYC and underwriting are running". Both
// were rendered from the same instant; only one was true.
if (stage.uid === 'zk-state-quoted' && lead.acceptance_ref && !lead.uw_outcome) {
return {
...stage,
kind: 'auto',
doing: lead.kyc_outcome ? 'Screening the risk for underwriting' : 'Verifying KYC',
by: 'KYC & Evidence',
after: 'customer accepted',
}
}
if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') {
return {
...stage,
kind: 'auto',
doing: 'Reading the documents and pricing the cover',
by: 'Engage AI, then the Advisor',
// Kept so a screen can say WHY this is not the queue it looks like.
after: 'documents received',
}
}
return stage
}
/**
* Why an automated stage has stopped, when it has.
*
* The AI chain behind Document Pending is honest about refusing: the rating
* engine writes `quoted_breakup` = "Not priced: no insured value (IDV)
* captured" rather than inventing a premium, and Engage then declines to
* raise a quote it would have to fabricate. Both are the right call.
*
* What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a
* tab, the AI's task dies in a queue no operator can see, and the lead sits in
* Document Pending looking exactly like one whose documents never arrived
* indefinitely, because nothing wakes the chain again. A workflow that stops
* for a good reason and a workflow that is broken must not look the same.
*
* Recovery is always the same shape: put the missing value in and let the
* chain re-run. Collect Documents carries the IDV field and re-performing it
* wakes Engage again, so the action is one the partner agent already holds.
*/
export function blockedOn(lead) {
if (!lead) return null
const breakup = String(lead.quoted_breakup || '')
if (breakup.startsWith('Not priced')) {
return {
what: breakup.replace(/^Not priced:\s*/, ''),
// Named rather than described: the operator has to find this field, and
// it is not where they would look for it.
fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.',
via: 'zk-act-collect-docs',
}
}
return null
}
/**
* WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS?
*
* Agents stall. Not often, but they do, and for reasons nothing in this app
* controls: the model provider slows to ninety seconds a call or returns a 502,
* a thinking budget runs out mid-sentence, a late webhook wakes the wrong
* employee. Watched from the console every one of those looks identical
* a lead that simply stops and the screen kept promising it would "complete
* within two minutes" indefinitely.
*
* There is no retry button in the platform, and none of these agents can be
* woken directly. But every one of them is woken BY AN ACTIVITY, so performing
* that activity again wakes it again. That is what this table holds: for a
* lead sitting in an automated step, the activity a person can perform to make
* the stalled agent run once more.
*
* `by` is who to expect to move afterwards, so the button says what will
* happen rather than just what it does.
*
* The choice within Document Pending depends on HOW FAR the chain got, because
* three agents work that state in sequence and the one to restart is the one
* that did not finish.
*/
export function nudgeFor(stage, lead) {
if (!stage || !lead) return null
switch (stage.uid) {
case 'zk-state-new':
return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' }
case 'zk-state-qualified':
return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' }
case 'zk-state-contacted':
return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' }
case 'zk-state-docs': {
// Engage captures, the Advisor recommends, the rating engine prices —
// in that order, each woken by the one before.
const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor'
if (!lead.motor_idv && !lead.sme_value_at_risk) {
return { uid: line, label: 'Capture the risk again', by: 'Engage' }
}
if (!lead.ai_recommended_cover) {
// The Advisor is the ONE step nobody may perform by hand — it is
// permitted to ai_advisor alone. Re-performing the activity that wakes
// it is the only way back, and it is the reason this table exists.
return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' }
}
if (!lead.quoted_premium) {
return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' }
}
return null
}
case 'zk-state-quoted': {
// Only after an acceptance — before that nothing is running and there is
// nothing to wake; the lead is with the customer.
if (!lead.acceptance_ref) return null
if (!lead.kyc_outcome) return { uid: 'zk-act-kyc', label: 'Run KYC again', by: 'KYC & Evidence' }
if (!lead.uw_outcome) return { uid: 'zk-act-uw-screen', label: 'Run the underwriting screen', by: 'KYC & Evidence' }
return null
}
case 'zk-state-issued':
return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' }
default:
return null
}
}
/** How long an automated step may sit before the console stops reassuring and
* starts offering the way out. Generous: an employee wake plus a slow model
* can legitimately take three or four minutes. */
export const STALL_AFTER_MS = 5 * 60 * 1000

130
src/api/errors.js Normal file
View File

@ -0,0 +1,130 @@
/**
* The gateway's error shapes, turned into something an operator can act on.
*
* Five shapes matter, and they are told apart by status plus a phrase in the
* message there is no error code to switch on. `kind` is for the caller to
* branch on ('stale' and 'gone' need the screen to do something); `title` and
* `detail` are what gets shown.
*
* Anything unrecognised keeps its own message: a wrong guess reads worse than
* the server's own words.
*/
export function describeError(err) {
const status = err?.status
const raw = String(err?.message ?? '')
const has = (...words) => words.every((w) => raw.toLowerCase().includes(w))
if (status === 404 && has('no record found')) {
return {
kind: 'gone',
title: 'This lead is no longer there.',
detail: 'It may have been removed since the queue was loaded. Taking you back to the list.',
}
}
if (status === 403 && has('not allowed in state')) {
return {
kind: 'stale',
// Deliberately not "the lead moved": the gateway returns this both when a
// lead advanced under the screen AND when the console offered an activity
// the state never allowed. Only one of those is a move, and claiming the
// wrong one sends the operator looking for something that did not happen.
title: 'That activity is not available at this stage.',
detail: 'Either the lead moved on while this was open, or it never allowed this. Refreshed to show what it does allow.',
}
}
if (status === 403) {
return {
kind: 'forbidden',
title: 'The workflow refused this.',
detail: 'This is the platform deciding, not the console: the signed-in role does not hold this activity.',
}
}
// Field validation runs BEFORE the permission check, so a disallowed
// submission carrying bad data arrives here as a 400 and not a 403.
if (status === 400 && has('validation failed')) {
// "field(unknown), field_2(required)" is the unsuffixed-key mistake, and it
// is a bug in this console rather than anything the operator did.
if (has('unknown') && has('required')) {
return {
kind: 'bug',
title: 'The console sent a field name the workflow does not know.',
detail: raw,
}
}
if (has('enum')) {
return {
kind: 'invalid',
title: 'One of the choices is not one the workflow accepts.',
detail: raw,
}
}
return { kind: 'invalid', title: 'The workflow rejected this submission.', detail: raw }
}
if (status === 401) {
return { kind: 'auth', title: 'The session has expired.', detail: 'Sign in again to continue.' }
}
return { kind: 'unknown', title: raw || 'Something went wrong.', detail: null }
}
/**
* Turns the workflow's field-validation reply into something a person can act
* on, using the form's own labels.
*
* The server answers with the machine keys and a bare reason:
*
* validation failed for activity zk-act-init-agent: product_line_4(required)
*
* Shown raw, that asks an operator to know what a field_s_id is, that `_4` is a
* disambiguating suffix rather than part of a name, and that "required" is a
* rule and not a value. All three are ours to know, not theirs.
*
* `fields` is the schema array, so the reply is rendered in the same words the
* form used a line above it "Product Line", not product_line_4. A key the
* schema does not carry keeps its raw name: a wrong label is worse than an
* ugly one, because the operator goes looking for a field that is not there.
*
* Returns null when the message is not a field-validation reply, so callers
* can fall through to describeError.
*/
export function describeValidation(rawMessage, fields = []) {
const raw = String(rawMessage ?? '')
if (!/validation failed/i.test(raw)) return null
const label = (key) =>
fields.find((f) => f.id === key)?.name ??
// Same suffix rule the prefill seeding uses: the activity key may carry a
// numeric tail the global name does not.
fields.find((f) => String(f.id).replace(/_\d+$/, '') === key)?.name ??
key
const reasons = {
required: (n) => `${n} is required`,
unknown: (n) => `${n} is not a field this form accepts`,
enum: (n) => `${n} is not one of the available choices`,
invalid: (n) => `${n} is not valid`,
type: (n) => `${n} is the wrong kind of value`,
}
const items = []
const re = /([A-Za-z0-9_.]+)\s*\(([^)]+)\)/g
let m
while ((m = re.exec(raw)) !== null) {
const name = label(m[1])
const why = String(m[2]).toLowerCase().trim()
const key = Object.keys(reasons).find((k) => why.includes(k))
items.push(key ? reasons[key](name) : `${name}: ${why}`)
}
if (!items.length) return null
return {
kind: 'validation',
// One problem reads as a sentence; several read as a list.
title: items.length === 1 ? items[0] : 'Some details are missing or not accepted.',
items,
}
}

36
src/api/holder.js Normal file
View File

@ -0,0 +1,36 @@
import { phaseOf } from './config.js'
/**
* Who is holding a lead right now, said the way the colour rule says it:
* blue = the AI, amber = a person, teal = the customer, grey = nobody (closed).
*
* Derived from phaseOf, so it agrees with the sidebar tallies and the lead
* header. `label` is the short form for a table cell: "AI · Intake",
* "Underwriter", "Customer", "Closed".
*/
const AI_NAMES = [
['intake', 'Intake'], ['engage', 'Engage'], ['advisor', 'Advisor'],
['kyc', 'KYC'], ['voice', 'Meera'], ['rating', 'Rating'],
]
function tidy(by) {
const s = String(by || '').replace(/^(the|an|a)\s+/i, '').trim()
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '—'
}
export function holderOf(stageDef, row) {
if (!stageDef) return { tone: 'grey', label: '—' }
const ph = phaseOf(stageDef, row) || stageDef
switch (ph.kind) {
case 'auto': {
const by = String(ph.by || '').toLowerCase()
const hit = AI_NAMES.find(([k]) => by.includes(k))
return { tone: 'blue', label: 'AI · ' + (hit ? hit[1] : 'Engage') }
}
case 'customer': return { tone: 'teal', label: 'Customer' }
case 'needs': return { tone: 'amber', label: tidy(ph.by) }
case 'waiting': return { tone: 'grey', label: 'Calendar' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return { tone: 'grey', label: tidy(ph.by) }
}
}

163
src/api/lead.js Normal file
View File

@ -0,0 +1,163 @@
import { PRODUCTS } from './config.js'
/**
* The vocabulary of a lead: how to read a renewal date, how to name a field,
* which fields belong together, and what a blocker means in words.
*
* Lifted out of the desktop console's lead screen unchanged, so the phone and
* the desktop describe the same lead the same way. A divergence here would be
* two products wearing one brand.
*/
/** The countdown, not just the date. On a renewal book this is the number
* that decides whether anyone should act today. */
export function expiryOf(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000)
return {
days,
tone: days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later',
label: days < 0 ? Math.abs(days) + ' days overdue' : days === 0 ? 'expires today' : 'in ' + days + ' days',
on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }),
}
}
export function inrShort(v) {
const n = Number(v)
if (!Number.isFinite(n) || !n) return null
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
export const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount','sme_stock_si'])
/** Field groups, in the order the lead was actually worked. */
/* What the AI said stopped it, turned into a sentence an operator can act on.
Keyed by the stored value so an unknown one degrades to the generic line. */
export const BLOCKER = {
missing_evidence: 'It says the evidence it needed was not there.',
contradictory_data: 'It says the evidence it had contradicts itself.',
tool_failed: 'It says a lookup or a tool it depends on failed.',
outside_my_remit: 'It says the judgement is a person\u2019s to make, not its own.',
other: 'It could not complete its step.',
}
export const GROUPS = [
['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
// First, because when a lead is flagged this is the only thing anyone opens
// it to read. Empty on every lead that was never flagged, and an empty group
// does not render.
['Flagged for review', ['review_blocker','review_reason','review_return','review_note']],
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
// The five OCR slots led this list and were absent from it, which is how a
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
// — the status and the notes. They were also absent from the view itself
// until 76; adding them here without that would have changed nothing.
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']],
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']],
['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
['Conversation', ['customer_reply','customer_reply_from','customer_answer','answer_count']],
['Proposal', ['acceptance_ref','accepted_at']],
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
['Outcome', ['lost_reason','welcome_sent','renewal_due']],
]
/** Past this, a value is prose and gets folded rather than printed in full. */
export const LONG = 150
/**
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
*
* Three things are always true of a lead who the customer is, what is being
* insured, and what has been attached so those are always here. The fourth
* section depends on where the lead has got to: a lead in Quoted needs the
* cover and the quote's expiry; the same lead in Onboarded needs the policy
* number and when it renews. Everything else is in the full file.
*
* THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
* carries the same hazard: a stage not listed here simply gets no fourth
* section, and a field renamed in the workflow quietly stops appearing. That is
* the safe direction to fail the full file is generated from the data and
* still holds everything but it is a mirror, not a source.
*
* The metrics strip at the top of the page already carries premium, commission,
* product, renewal date, lead age and AI confidence. Nothing is repeated here.
*/
export const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'],
'zk-state-qualified': ['lead_score', 'eligibility_outcome', 'outreach_window'],
'zk-state-contacted': ['contact_outcome', 'outreach_window'],
'zk-state-docs': ['documents_status', 'motor_prev_expiry'],
'zk-state-quoted': ['ai_recommended_cover', 'quote_valid_till', 'acceptance_ref'],
'zk-state-payment': ['payment_ref', 'quote_valid_till'],
'zk-state-issued': ['policy_no', 'policy_issued_at', 'payment_ref'],
'zk-state-onboarded': ['policy_no', 'policy_issued_at', 'renewal_due', 'payout_status'],
'zk-state-referred': ['uw_outcome', 'uw_referral_reason'],
'zk-state-parked': ['contact_outcome', 'outreach_window'],
'zk-state-lost': ['lost_reason', 'contact_outcome'],
}
/** The risk, in whichever line's vocabulary this lead is written. */
export const GLANCE_RISK = {
motor: ['motor_reg_no', 'motor_make_model', 'motor_mfg_year', 'motor_idv', 'motor_ncb_pct'],
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
}
/**
* Field ids are snake_case and several carry acronyms, which sentence-casing
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
* other word passes through.
*
* This is the FALLBACK. The detail view ships an output_label for every field
* and that is preferred this covers a field the config does not describe.
*/
export const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE',
amc: 'AMC', km: 'km', posp: 'POSP',
}
export function label(k) {
const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ')
return words.charAt(0).toUpperCase() + words.slice(1)
}
/** The uploaded files on a field value, if that is what it holds. */
export function filesOf(v) {
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
}
export function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(v)) {
// A file field holds an array of upload references — {uuid, original_name,
// blob_path, …} — so joining it raw prints [object Object].
return v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
.filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null
}
// No field carries a bare object today, but one arriving as JSON should not
// print as [object Object].
if (typeof v === 'object') return JSON.stringify(v)
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
return String(v)
}

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

@ -0,0 +1,159 @@
import { ACTIONS, STAGES } from './config.js'
/**
* Who may perform what.
*
* A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same
* sense as STAGES and ACTIONS: the console needs it to decide what to draw,
* and the API returns a refusal rather than a capability list.
*
* This is presentation, never enforcement
* The workflow refuses server-side on every submission, and that refusal is
* the real control. Nothing here can grant anything; a role added here that
* the platform does not recognise gets a 403 the moment it submits. What this
* buys is that an operator is not shown four buttons they will be refused for
* pressing which on a demo reads as a broken app rather than as a fence.
*
* Keep in step with the permissions table. A role REMOVED there but left here
* shows a button that 403s: recoverable and visible. A role ADDED there but
* missing here hides a button that would have worked: invisible, and the
* failure mode worth watching for.
*/
export const ACTIVITY_ROLES = {
'zk-act-init-agent': ['ops_admin', 'partner_agent'],
'zk-act-init-bank': ['ops_admin', 'bank_rm'],
'zk-act-init-direct': ['ops_admin', 'csr_direct'],
'zk-act-qualify': ['ops_admin', 'ai_intake'],
'zk-act-contact': ['ops_admin', 'ai_engage'],
'zk-act-request-docs': ['ops_admin', 'ai_engage'],
'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
'zk-act-capture-motor':['ops_admin', 'ai_engage'],
'zk-act-capture-sme': ['ops_admin', 'ai_engage'],
'zk-act-advise': ['ai_advisor'],
'zk-act-quote': ['ops_admin', 'ai_engage'],
'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// Ops only. The customer's questions are normally Engage's, and this is
// the way a person answers one Engage declined — through the same WhatsApp
// thread, so the reply lands on the lead file instead of somewhere nobody
// can find it later.
'zk-act-answer-customer': ['ops_admin'],
'zk-act-kyc': ['ops_admin', 'ai_kyc'],
'zk-act-uw-screen': ['ops_admin', 'ai_kyc'],
'zk-act-uw-prepare': ['ai_uw_referral'],
// The only exclusive permission in the application. Ops runs everything
// else and cannot touch these two — a referral is cleared by an underwriter
// or it is not cleared.
'zk-act-uw-clear': ['sme_underwriter'],
'zk-act-uw-decline': ['sme_underwriter'],
'zk-act-payment': ['ops_admin', 'ai_engage'],
// Zero roles in the workflow since 86 — the scheduler performs it, and a
// scheduled activity with any role list fails silently. Left here as an
// empty list so the mirror still records that the activity exists.
'zk-act-nudge': [],
'zk-act-realise': ['ops_admin'],
'zk-act-onboard': ['ops_admin', 'ai_engage'],
'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// The AI's one way of saying "a person should take this". Every employee
// holds it, and so does ops — a person can park a lead for the same reasons.
'zk-act-flag-review': ['ops_admin', 'ai_intake', 'ai_engage', 'ai_kyc', 'ai_advisor', 'ai_uw_referral'],
// And the two ways back out of Needs a Person. The underwriter is here too:
// a flag they are sent is theirs to route, not only ops'.
'zk-act-review-resume':['ops_admin', 'sme_underwriter'],
'zk-act-review-refer': ['ops_admin', 'sme_underwriter'],
'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// ── The one place this mirror deliberately DISAGREES with the workflow ──
//
// Both of these carry NO roles in tbl_wf_activity_permissions, which the
// platform reads as "open to anyone". That is not generosity: the scheduler
// performs them as the synthetic `system` actor, and the trigger's own
// perform step ignores allow_system_perform, so zero roles is the only
// configuration under which a scheduled activity can run at all. See
// 63_scheduled_activities_rbac.sql.
//
// Open to the SYSTEM is not the same as open to everyone signing in. Left
// literal, an underwriter is offered "Send Document Reminder" on a queue
// they have no business in. So the UI narrows them to the roles that would
// plausibly chase by hand. The workflow still accepts either from anyone —
// this hides a button, it does not close a door.
'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
}
/** Roles that belong to an AI employee. They never sign in. */
export const AI_ROLES = new Set([
'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral',
])
export function rolesOf(user) {
const r = user?.roles
if (Array.isArray(r)) return r.filter(Boolean).map(String)
if (Array.isArray(user?.role_assignments)) {
return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String)
}
return []
}
/**
* An empty role list means the activity is open to any signed-in user that
* is what the platform's RBAC does with no roles attached, so mirroring it
* here keeps the two from disagreeing.
*/
export function canPerform(roles, activityUid) {
const allowed = ACTIVITY_ROLES[activityUid]
if (!allowed) return true // unknown activity: show it, let the API decide
if (allowed.length === 0) return true
return roles.some((r) => allowed.includes(r))
}
/** The activities this user may actually run from a given stage. */
export function actionsFor(roles, stageUid) {
return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid))
}
/**
* Activities that are available almost everywhere, or that no person runs.
*
* They must not count towards "does this role have business in this queue".
* Mark Lost is offered in eight stages to every agent role, and the two
* scheduled activities carry no roles at all which the platform reads as
* open. Left in the test, between them they make every queue look relevant
* to everybody, which is the whole thing this is trying to avoid.
*/
const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call'])
/**
* Which queues a role has any business WORKING IN.
*
* Terminal stages stay visible to everyone closed business is reporting,
* not work, and hiding it would leave an underwriter unable to see what
* became of a file they declined. Everything else appears only where the
* role can do something substantive.
*
* This gates the SIDEBAR, not the data. The overview still counts the whole
* book for every role, because an agent tracking leads they filed is a
* reasonable thing to want and a queue they cannot act in is not.
*/
export function visibleStages(roles) {
// Operations runs the desk. They cannot clear a referral — that stays
// exclusive to the underwriter — but the underwriting queue is theirs to
// watch, and an ops console that hides a queue because it holds one
// activity they may not press is hiding the wrong thing.
if (roles.includes('ops_admin')) return STAGES
return STAGES.filter((s) => {
if (s.kind === 'end') return true
return (ACTIONS[s.uid] || [])
.filter((a) => !AMBIENT.has(a.uid))
.some((a) => canPerform(roles, a.uid))
})
}
/** Whether this user can file a lead at all, and through which doors. */
export function entryDoorsFor(roles, entries) {
return entries.filter((e) => canPerform(roles, e.uid))
}

109
src/api/portfolio.jsx Normal file
View File

@ -0,0 +1,109 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useZino } from './provider.jsx'
import { RV_LEADS, STAGES, phaseOf } from './config.js'
/**
* The whole book, fetched ONCE and shared.
*
* The sidebar used to carry no counts, on the reasoning that a tally could only
* come from a second full list call that would then disagree with the queue's
* own total. That reasoning was right about the cost and wrong about the
* conclusion: a sidebar with no numbers means the only way to learn whether
* anything is waiting on you is to click all nine queues, which is the one
* question a console's navigation exists to answer.
*
* So there is no second call. The overview already fetched the whole book on a
* timer; that fetch moves here and both surfaces read it, which is one call
* fewer than before and makes the two agree by construction.
*
* Bounded at 200 rows, as the overview always was. Beyond that the honest
* answer is an aggregate endpoint rather than a bigger limit, and the counts
* would need to say they are partial worth knowing before this app meets a
* real book.
*/
const PortfolioContext = createContext(null)
const EMPTY = []
export function PortfolioProvider({ children }) {
const { client, user } = useZino()
// Identity, not just presence: switching user must not show the previous
// one's book while the new fetch is in flight.
const who = user?.id ?? user?.email ?? null
// Seeded from `who` rather than set inside the effect. Nothing here writes
// state synchronously during a render pass a signed-out shell is 'ready'
// and empty from the first frame, and the first fetch only ever moves it
// forwards, so there is no loading flash and no cascading render.
const [state, setState] = useState(() => ({
status: who ? 'loading' : 'ready', rows: EMPTY, at: null, error: null,
}))
const cancelled = useRef(false)
const load = useCallback(() => {
if (!who) return Promise.resolve()
return client.recordView(RV_LEADS, { limit: 200 })
.then((res) => {
if (cancelled.current) return
setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? EMPTY, at: new Date(), error: null })
})
.catch((err) => {
if (cancelled.current) return
// A failed poll leaves a good book on screen. Only a first load is an error.
setState((p) => (p.status === 'ready' ? p : { status: 'error', rows: EMPTY, at: null, error: err }))
})
}, [client, who])
useEffect(() => {
cancelled.current = false
load()
const id = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000)
return () => { cancelled.current = true; clearInterval(id) }
}, [load])
const value = useMemo(() => ({ ...state, reload: load }), [state, load])
return <PortfolioContext.Provider value={value}>{children}</PortfolioContext.Provider>
}
export function usePortfolio() {
const ctx = useContext(PortfolioContext)
if (!ctx) throw new Error('usePortfolio must be used inside PortfolioProvider')
return ctx
}
const DAY = 86400000
const daysToExpiry = (v) => {
if (!v) return null
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
return isNaN(d) ? null : Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
}
/**
* Per-stage tallies for the sidebar.
*
* `waiting` is the number that goes on the badge: leads whose PHASE still needs
* a person. `working` is the rest in the state, carried by an agent, not
* anyone's task. Badging the state total told an operator four leads needed
* them when two did, which is how a queue stops being believed.
*
* `urgent` marks a queue holding a renewal that has lapsed or expires within a
* week. It is the only reason to look at one queue before another, and it was
* invisible until you opened each one.
*/
export function useStageCounts() {
const { rows, status } = usePortfolio()
return useMemo(() => {
const out = {}
for (const s of STAGES) out[s.uid] = { total: 0, waiting: 0, working: 0, urgent: 0 }
for (const r of rows) {
const s = STAGES.find((x) => x.name === r.current_state_name)
if (!s) continue
const t = out[s.uid]
t.total += 1
if (s.kind !== 'end' && phaseOf(s, r).kind === 'auto') t.working += 1
else if (s.kind !== 'end') t.waiting += 1
const d = daysToExpiry(r.renewal_due_date)
if (s.kind !== 'end' && d !== null && d <= 7) t.urgent += 1
}
return { counts: out, ready: status === 'ready' }
}, [rows, status])
}

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
}

216
src/api/thread.js Normal file
View File

@ -0,0 +1,216 @@
import { AGENTS } from './agents.js'
import { baseFieldId } from './config.js'
/**
* THE WHATSAPP THREAD, BUILT ONCE.
*
* The conversation was being reconstructed twice the timeline read it per
* audit row, the dialog read it per field and the two disagreed. The trail
* printed one entry per turn, so a four-message exchange took more of the page
* than the entire underwriting chain, and a message the workflow recorded from
* inside a trigger (the payment request, the issuance note) appeared in the
* dialog and nowhere in the trail. One builder, two readers, one answer.
*
* WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the
* conversation is a thing that persists ACROSS them. Three facts make it awkward
* and they are all handled here rather than in each screen's own guess:
*
* 1. The customer's turns and ours are separate activities `customer-reply`
* is performed by the channel, `answer-customer` by Engage so a turn is
* a FIELD on a row, not a row.
* 2. Two of our sends are not chat activities at all. Request Premium and the
* issuance step compose their message inside a trigger and write it to
* `customer_answer` alongside their own work (see 98/99/100 in the seed
* SQL). They belong in the thread and stay on their own step in the trail.
* 3. One submission is recorded up to three times the trigger commit, its
* repeat, and the settle so the same sentence arrives three times.
*
* `episodes` is what makes this renderable as a timeline entry. The thread is
* one conversation, but it happens in bursts: the customer argues about the
* quote, then KYC and underwriting run for twenty minutes, then the premium
* request reopens the same thread. An episode is one burst. Collapsing the
* whole thread into a single entry would date the payment exchange to the
* moment of the quote objection and destroy the chronology the trail exists
* for; leaving every turn as its own entry is what it did before. A burst is
* the unit that is both honest and short.
*/
/** The two activities that ARE the thread — one turn each, either direction. */
export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
const INBOUND = 'customer_reply'
const OUTBOUND = 'customer_answer'
/**
* The fields the thread OWNS. A step that writes one is not asked to print it
* as well the message belongs to the conversation, and quoting it on the step
* too is how the payment request came to say the same sentence twice.
*/
export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND])
/** Bookkeeping rows. They interrupt nothing: the platform writing a field is
* not a step that broke off a conversation. */
const SYSTEM_ACT = 'DATA_UPDATE'
/**
* Last resort for a step whose activity_name did not come back without it the
* trail prints a raw slug like "zk-act-doc-reminder" in the middle of an
* otherwise readable story. A guess, but one that reads as English.
*/
export function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/** What to call a step. The server's own name first; the slug, tidied, after. */
export function stepName(row) {
return row?.activity_name || prettyUid(row?.activity_id)
}
/**
* `fields[]` is the platform's typed rendering of a submission; `data` is the
* raw fallback for a row the workflow could not resolve.
*/
function fieldsOf(row) {
return Array.isArray(row.fields) && row.fields.length
? row.fields
: Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
}
/** Who sent an outbound turn. Usually Engage; sometimes a person answering by
* hand, which must not be attributed to the machine. */
function senderOf(row) {
const key = (row.user_roles || []).find((r) => AGENTS[r]) || null
return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') }
}
/** The turns carried by one audit row, in field order. */
function turnsInRow(row) {
const out = []
for (const f of fieldsOf(row)) {
const base = baseFieldId(f.field_id)
if (!THREAD_FIELDS.has(base)) continue
const text = typeof f.value === 'string' ? f.value.trim() : ''
if (!text) continue
out.push({ side: base === INBOUND ? 'them' : 'us', text })
}
return out
}
/**
* The thread, its bursts, and which audit row carries what.
*
* Returns:
* turns every message, oldest first
* episodes contiguous bursts, each with the steps that preceded it
* byRow row id -> { episodeId, sent, received }, so a step that sent a
* message can link into the thread instead of quoting it
*/
export function buildThread(rows) {
const empty = { turns: [], episodes: [], byRow: new Map() }
if (!Array.isArray(rows) || !rows.length) return empty
// Sorted on (timestamp, id). One submission writes three rows in the same
// second, so a timestamp alone leaves their order to the sort's stability and
// puts a reply before the message it answers whenever the two land together.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
const turns = []
const episodes = []
const byRow = new Map()
let current = null
// The steps seen since the last message, and whether any of them broke off
// the conversation. `pending` accumulates even before the first message so
// that the first episode can drop it: there is nothing to bridge yet.
let pending = []
let broke = false
for (const row of ordered) {
const mine = turnsInRow(row)
if (!mine.length) {
// A chat activity that recorded no text is not a step that interrupted
// anything — it is an empty row. Bookkeeping is not a step either.
if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue
if (current) broke = true
const name = stepName(row)
if (name && !pending.includes(name)) pending.push(name)
continue
}
const who = senderOf(row)
for (const t of mine) {
const prev = turns[turns.length - 1]
// De-duped against the PREVIOUS turn only. A customer who asks the same
// thing twice because the first went unanswered has said it twice, and a
// thread that showed it once would hide exactly the impatience an
// operator needs to see. Only an immediate repeat is the platform
// recording one submission three times.
if (prev && prev.side === t.side && prev.text === t.text) continue
if (!current || broke) {
current = {
id: `ep${episodes.length + 1}`,
turns: [],
// What ran between this burst and the one before it. Empty on the
// first, which begins the conversation rather than resuming it.
after: episodes.length ? pending : [],
anchorRowId: row.id,
anchorIsChat: CHAT_ACTS.has(row.activity_id),
}
episodes.push(current)
broke = false
}
pending = []
const turn = {
key: `${row.id}-${t.side}-${turns.length}`,
side: t.side,
text: t.text,
at: row.created_at,
rowId: row.id,
episodeId: current.id,
agentKey: t.side === 'us' ? who.agentKey : null,
by: t.side === 'us' ? who.by : 'the customer',
}
current.turns.push(turn)
turns.push(turn)
const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 }
if (t.side === 'us') tally.sent += 1
else tally.received += 1
byRow.set(row.id, tally)
}
}
for (const ep of episodes) {
ep.count = ep.turns.length
ep.from = ep.turns[0].at
ep.to = ep.turns[ep.turns.length - 1].at
// The last exchange, which is where the conversation GOT to — what an
// operator scanning the trail is actually looking for.
ep.preview = ep.turns.slice(-2)
}
return { turns, episodes, byRow }
}
/**
* "Verify KYC and Underwriting Screen", "A, B +2 more" a bridge, not a list.
* Used for the steps that interrupted a conversation and for the voices in it.
*/
export function listOf(names, max = 2) {
if (!names || !names.length) return ''
if (names.length <= max) {
return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
return `${names.slice(0, max).join(', ')} +${names.length - max} more`
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,398 @@
.af {
display: flex;
flex-direction: column;
gap: 20px;
}
.af__loading {
color: var(--zk-muted);
font-size: var(--fs-xs);
}
.af__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
gap: 16px 20px;
}
.af__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.af__field--wide {
grid-column: 1 / -1;
}
.af__field > span {
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0.03em;
color: var(--zk-muted);
display: flex;
align-items: center;
gap: 8px;
}
.af__req {
font-style: normal;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-blue);
background: var(--zk-tint-blue);
padding: 2px 7px;
border-radius: var(--r-pill);
}
.af input,
.af select,
.af textarea {
font: inherit;
font-size: var(--fs-xs);
padding: 10px 12px;
border-radius: var(--r-md);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-ink);
width: 100%;
transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast);
}
.af input:hover,
.af select:hover,
.af textarea:hover {
border-color: var(--zk-blue-light);
}
.af input:focus,
.af select:focus,
.af textarea:focus {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.af select {
appearance: none;
padding-right: 34px;
/* The caret is drawn rather than loaded: an inline data URI keeps the select
consistent across platforms without another asset to serve. */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235d6162' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
}
.af textarea {
resize: vertical;
min-height: 84px;
line-height: 1.55;
}
.af__multi {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.af__chip {
font: inherit;
font-size: var(--fs-2xs);
padding: 6px 13px;
border-radius: var(--r-pill);
cursor: pointer;
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
.af__chip:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
color: var(--zk-ink);
}
.af__chip.is-on {
background: var(--grad-blue);
border-color: var(--zk-blue);
color: var(--zk-white);
box-shadow: var(--sh-xs);
}
.af__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding-top: 4px;
}
.af__submit {
font: inherit;
font-size: var(--fs-xs);
font-weight: 500;
padding: 10px 26px;
border-radius: var(--r-md);
cursor: pointer;
border: 0;
background: var(--grad-blue);
color: var(--zk-white);
box-shadow: var(--sh-sm);
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
}
.af__submit:hover:not(:disabled) {
background: var(--grad-blue-hover);
box-shadow: var(--sh-md);
transform: translateY(-1px);
}
.af__submit:active:not(:disabled) {
transform: none;
box-shadow: var(--sh-xs);
}
.af__submit:disabled {
opacity: 0.5;
cursor: default;
box-shadow: none;
}
.af__ghost {
font: inherit;
font-size: var(--fs-xs);
padding: 10px 20px;
border-radius: var(--r-md);
cursor: pointer;
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.af__ghost:hover {
border-color: var(--zk-grey);
background: var(--zk-tint);
color: var(--zk-ink);
}
.af__err {
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
border-left: 3px solid var(--zk-danger);
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
padding: 12px 16px;
font-size: var(--fs-xs);
color: var(--zk-ink);
animation: zk-rise 0.2s var(--ease) both;
}
.af__err strong {
font-family: var(--font-mono);
font-size: 0.85em;
margin-right: 8px;
padding: 1px 7px;
border-radius: var(--r-xs);
background: rgba(211, 47, 47, 0.1);
color: var(--zk-danger-ink);
}
.af__err p {
margin: 7px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-muted);
line-height: 1.55;
}
.af__auto {
font-size: var(--fs-xs);
padding: 10px 12px;
border-radius: var(--r-md);
min-height: 41px;
border: 1px dashed var(--zk-line);
background: var(--zk-tint);
color: var(--zk-muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.af__auto span {
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--zk-grey);
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
padding: 2px 8px;
background: var(--zk-white);
}
/* ---- file / ocr ---- */
.ff {
display: flex;
flex-direction: column;
gap: 8px;
}
.ff__input {
display: none;
}
.ff__drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
font: inherit;
padding: 16px 12px;
border-radius: var(--r-md);
cursor: pointer;
border: 1px dashed var(--zk-blue-light);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
text-align: center;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.ff__drop svg { width: 20px; height: 20px; }
.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; }
.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); }
.ff__drop:hover:not(:disabled) {
background: var(--zk-white);
border-style: solid;
border-color: var(--zk-blue);
}
.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; }
/* Uploaded: the file as a fact, not a call to action. */
.ff__file {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 9px 9px 11px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
}
.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); }
.ff__name {
flex: 1;
min-width: 0;
font-size: var(--fs-2xs);
color: var(--zk-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ff__replace {
flex: none;
font: inherit;
font-size: var(--fs-3xs);
font-weight: 500;
padding: 4px 10px;
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
background: var(--zk-white);
color: var(--zk-blue-dark);
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__read {
border: 1px solid var(--zk-blue-light);
border-left-width: 3px;
background: var(--zk-tint);
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
animation: zk-rise 0.22s var(--ease) both;
}
.ff__readlabel {
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-blue-dark);
margin-bottom: 3px;
}
.ff__read div {
font-size: var(--fs-2xs);
color: var(--zk-ink);
}
.ff__read em {
font-style: normal;
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 6px;
font-size: var(--fs-2xs);
}
.ff__err {
font-size: var(--fs-2xs);
color: var(--zk-danger-ink);
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
border-radius: var(--r-md);
padding: 8px 11px;
}
/* A field the form is waiting on. Marked on the label rather than the input so
the name is highlighted too an operator scanning a wide form is looking for
the LABEL they missed, not for a red box. */
.af__field.is-missing > span { color: var(--zk-danger-ink); }
.af__field.is-missing input,
.af__field.is-missing select,
.af__field.is-missing textarea {
border-color: var(--zk-danger-line);
background: var(--zk-danger-tint);
}
.af__field.is-missing input:focus,
.af__field.is-missing select:focus,
.af__field.is-missing textarea:focus {
border-color: var(--zk-danger);
outline-color: var(--zk-danger);
}
/* The form talking about itself, or relaying a field-level refusal. Distinct
from af__err, which is for a failure the operator cannot fix by typing. */
.af__err--soft {
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
color: var(--zk-danger-ink);
}
.af__err--soft ul { margin: 6px 0 0; padding-left: 18px; }
.af__err--soft li { margin: 2px 0; }
/* ---- form sections ---- */
.af__sec + .af__sec { margin-top: 22px; }
.af__sech {
margin: 0 0 10px;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* Uploads size to their content and never stretch to the tallest card in the
row, so a document that has been read does not pull its neighbours down. */
.af__grid--docs { align-items: start; }

View File

@ -0,0 +1,402 @@
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx'
import './ActivityForm.css'
/**
* The HTML input each workflow data_type maps to. `phone` and `email` were both
* falling through to plain text, which costs the keyboard on a phone and the
* browser's own validation everywhere.
*/
const INPUT_TYPES = {
number: 'number',
date: 'date',
phone: 'tel',
email: 'email',
}
/**
* Renders whatever /view/form-screens returns for an activity labels, types,
* select options and which fields are mandatory and submits it straight back.
*
* Nothing about this form is defined in the frontend. Add a field to the
* activity in Studio, redeploy, and it appears here with no code change. That
* is the point: the workflow is the source of truth, and a hardcoded form would
* quietly drift from it.
*
* The ONE thing filtered here is which upload slots apply to the lead's product
* line. Collect Documents serves motor and SME from a single form and the
* activity carries no `field_rules`, so without this a motor renewal is asked
* for a Udyam certificate. See DOC_SLOTS in api/config.js including why that
* table should stop existing once the rules are seeded on the activity.
*/
export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) {
const { client } = useZino()
const [schema, setSchema] = useState(null)
const [values, setValues] = useState({})
const [error, setError] = useState(null)
// Which required fields were empty on the last attempt. Kept separate from
// `error`, because this is the form talking about itself rather than the
// server refusing something.
const [missing, setMissing] = useState([])
// Field ids filled from the lead record rather than typed. OCR may overwrite
// these the document is more authoritative than a copy of the record but
// must never overwrite something a person typed.
const seededRef = useRef(new Set())
const [busy, setBusy] = useState(false)
useEffect(() => {
let dead = false
setSchema(null); setError(null); setValues({})
client.formSchema(activityUid, instanceId)
.then((s) => {
if (dead) return
setSchema(s)
// The server resolved a prefill pipeline for this activity it stamps
// the channel from the door and the agent from the signed-in user, so
// the form never asks for either. Seed the inputs with what it sent.
//
// The two sides key differently, and matching only on f.id is why this
// silently did nothing. A form field is the ACTIVITY key, which carries
// a numeric suffix because a workflow version may use one global on
// several activities partner_code_3, rm_or_agent_id_3,
// source_channel_4. The pipeline's fieldMapping names the GLOBAL
// partner_code, rm_or_agent_id, source_channel. Neither is wrong; they
// are different names for the same field, and nothing between them
// reconciles it.
//
// So: exact id first, then the field's uid, then the global name with
// the suffix stripped. Accepting all three means this keeps working
// whichever convention a pipeline is authored in, rather than breaking
// again the next time one is written the other way.
const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
const seed = {}
if (pre && typeof pre === 'object') {
for (const f of s.fields) {
const base = baseFieldId(f.id ?? '')
const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
if (v !== undefined && v !== null && v !== '') seed[f.id] = v
}
}
// THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors
// fourteen fields the record carries registration, make and model,
// previous insurer, expiry, policy number, PAN so the OCR can write
// into them. Rendered blank, they read as fourteen more things to type.
// Seed each from the lead by its base key, so an agent uploading for
// KA01MF6618 sees KA01MF6618 already there.
//
// Server prefill wins, then anything already typed. Files and generated
// ids are never seeded a file reference is not a value to copy, and an
// id_gen is the platform's to issue. Only on an existing lead: an INIT
// form has no record behind it.
const seededFromLead = new Set()
if (lead && instanceId) {
for (const f of s.fields) {
if (seed[f.id] !== undefined) continue
if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue
const v = lead[baseFieldId(f.id ?? '')]
if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') {
seed[f.id] = v
seededFromLead.add(f.id)
}
}
}
seededRef.current = seededFromLead
if (Object.keys(seed).length) setValues(seed)
})
.catch((e) => { if (!dead) setError(e) })
return () => { dead = true }
// `lead` is READ here but deliberately not a dependency. It is the parent's
// polled record object, so its identity changes on every refresh; listing it
// would re-run this effect, re-fetch the schema and call setValues(seed)
// discarding whatever the operator had typed, every few seconds, mid-form.
// The seed is a one-time starting point, not a subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, activityUid, instanceId])
function set(id, v) {
// Typed by hand: from here on it outranks any document.
seededRef.current.delete(id)
setValues((p) => ({ ...p, [id]: v }))
// Clear the complaint as soon as the field is filled. Leaving a red field
// marked after it has been corrected teaches people to ignore the marking.
setMissing((p) => (p.includes(id) ? p.filter((x) => x !== id) : p))
}
// Computed before the early returns below use it, and before submit: a field
// that was never offered must never be sent.
//
// If the lead's line would hide EVERY field, the filter is not removing noise
// any more it is removing the activity. Capture Motor Risk is twelve motor
// fields and is runnable from Contacted whatever the product is, so on an SME
// lead this would otherwise render a form with nothing in it and a live Submit
// button. Show the activity whole and let the operator see what it is asking.
//
// THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no
// lead yet, so `fieldApplies` had nothing to filter on and every entry form
// asked a motor renewal for a business name. But the product line IS on that
// form, three boxes up the person has already said "Motor" by the time the
// question is drawn. So visibility reads the live answer first and the saved
// lead second, and the form narrows as it is filled.
const effLead = (() => {
const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line')
const chosen = live ? values[live.id] : undefined
if (!chosen) return lead
return { ...(lead || {}), product_line: chosen }
})()
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : []
// Filtering must never hide a field the workflow requires: `submit` sends only
// what is rendered, so a hidden mandatory field becomes a 400 naming something
// that is not on screen and cannot be filled. Hiding everything is the same
// failure in the large it removes the activity rather than its noise, and
// Capture Motor Risk is twelve motor fields that stay runnable on an SME lead.
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
/**
* Turn an /ocr-extract response into form values.
*
* THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the
* ocr_config's `extraction_fields[].key` `reg_no`, `engine_cc`, `fuel`
* because that is what the vision prompt was asked to produce. The form's
* fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight
* in, as they were, every extracted value landed on a key no field renders:
* the read succeeded, the panel said so, and nothing filled.
*
* The bridge is already on the field. `ocr_config.field_mappings` maps
* extraction_key -> target_field (the GLOBAL name), and the form field is that
* global plus a per-form suffix. So: key -> target -> the field whose base id
* matches. Same suffix rule as prefill and the same trap, in a third place.
*/
function applyExtraction(ocrField, extracted) {
const maps = ocrField?.properties?.ocr_config?.field_mappings
?? ocrField?.ocr_config?.field_mappings ?? []
const all = schema?.fields ?? []
// extraction_key -> form field id
const target = {}
for (const m of maps) {
if (!m?.extraction_key || !m?.target_field) continue
const hit = all.find((f) => f.id === m.target_field)
?? all.find((f) => baseFieldId(f.id) === m.target_field)
if (hit) target[m.extraction_key] = hit.id
}
setValues((prev) => {
const next = { ...prev }
for (const [key, val] of Object.entries(extracted)) {
if (val === null || val === undefined || String(val) === '') continue
// An unmapped key may still name a field directly on forms whose
// extraction keys ARE the field names.
const id = target[key]
?? all.find((f) => f.id === key)?.id
?? all.find((f) => baseFieldId(f.id) === key)?.id
if (!id) continue
// Never overwrite something a person typed. A value we copied off the
// lead is fair game the document is the better source for it.
const isBlank = next[id] === undefined || next[id] === ''
if (isBlank || seededRef.current.has(id)) {
next[id] = val
seededRef.current.delete(id)
}
}
return next
})
}
function isEmpty(v) {
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
}
async function submit(e) {
e.preventDefault()
// Check here rather than letting the workflow do it. The server's answer is
// correct and unreadable "product_line_4(required)" and it costs a round
// trip to be told something this form already knew. id_gen is issued
// server-side and is never the operator's to fill.
const gaps = fields.filter((f) => f.mandatory && f.data_type !== 'id_gen' && isEmpty(values[f.id]))
if (gaps.length) {
setMissing(gaps.map((f) => f.id))
setError(null)
// Put the first offender on screen. On a form this wide the empty field
// is often above the fold and the message below it. Scrolling to the
// LABEL rather than focusing an input works for every field type,
// including the file and OCR widgets that render no input at all.
requestAnimationFrame(() => {
document
.querySelector('.af__field.is-missing')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return
}
setMissing([])
setBusy(true); setError(null)
// Send only fields the activity defines. A submission is schema-validated
// and an unknown field is fatal, so empties are dropped rather than sent.
const payload = {}
for (const f of fields) {
// id_gen is issued server-side and stripped from the submission. Sending
// it would be forging a reference the platform owns.
if (f.data_type === 'id_gen') continue
const v = values[f.id]
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
payload[f.id] = f.data_type === 'number' ? Number(v) : v
}
try {
const res = instanceId
? await client.activity(instanceId, activityUid, payload)
: await client.start(activityUid, payload)
onDone?.(res)
} catch (err) {
setError(err)
// The lead moved under the form. Nothing the operator can fix by reading
// tell the page to re-fetch so the actions on offer are the real ones.
if (describeError(err).kind === 'stale') onStale?.()
} finally {
setBusy(false)
}
}
if (error && !schema) return <div className="af__err">Could not load the form {describeError(error).title}</div>
if (!schema) return <p className="af__loading">Loading the form</p>
// Stamped fields are in `fields` they validate and they submit but they
// are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is
// drawn anyway: source_channel is mandatory, so a prefill that did not
// resolve would otherwise fail validation against a box that is not on the
// screen and cannot be filled.
const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id]))
/* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat
beside plain inputs, and an upload that has read a document is several
times taller than a text box so a row carried three tall OCR cards and
three short fields, and the form went ragged with the inputs stranded up
beside the buttons. Split in two, each grid is internally one height. */
const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr'
const docFields = visible.filter(isDoc)
const dataFields = visible.filter((f) => !isDoc(f))
const renderField = (f) => {
const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (
<label
key={f.uid}
className={'af__field'
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
+ (missing.includes(f.id) ? ' is-missing' : '')}
>
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
{f.data_type === 'file' || f.data_type === 'ocr' ? (
<FileField
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
onChange={(refs) => set(f.id, refs)}
onExtract={(fields) => applyExtraction(f, fields)}
/>
) : f.data_type === 'id_gen' ? (
<div className="af__auto">
{v || 'Issued on submit'}
<span>auto</span>
</div>
) : f.data_type === 'select' ? (
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
<option value=""></option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.data_type === 'multiselect' ? (
<div className="af__multi">
{opts.map((o) => (
<button
key={o.value} type="button"
className={'af__chip' + (v.includes(o.value) ? ' is-on' : '')}
onClick={() => set(f.id, v.includes(o.value) ? v.filter((x) => x !== o.value) : [...v, o.value])}
>{o.label}</button>
))}
</div>
) : f.data_type === 'longtext' ? (
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
) : (
<input
type={INPUT_TYPES[f.data_type] ?? 'text'}
value={v} onChange={(e) => set(f.id, e.target.value)}
/>
)}
</label>
)
}
return (
<form className="af" onSubmit={submit}>
{docFields.length ? (
<section className="af__sec">
<h4 className="af__sech">Documents</h4>
<div className="af__grid af__grid--docs">{docFields.map(renderField)}</div>
</section>
) : null}
{dataFields.length ? (
<section className="af__sec">
{docFields.length ? <h4 className="af__sech">Details</h4> : null}
<div className="af__grid">{dataFields.map(renderField)}</div>
</section>
) : null}
{/* The form's own complaint, before anything is sent. */}
{missing.length ? (
<div className="af__err af__err--soft" role="alert">
<strong>
{missing.length === 1
? `${fields.find((f) => f.id === missing[0])?.name ?? 'A field'} is required`
: 'Some required details are missing'}
</strong>
{missing.length > 1 ? (
<ul>
{missing.map((id) => (
<li key={id}>{fields.find((f) => f.id === id)?.name ?? id}</li>
))}
</ul>
) : null}
</div>
) : null}
{error ? (() => {
// A field-validation reply is rendered in the form's own words. The
// status code is dropped with it: "400" tells an operator nothing they
// can act on, and it is the first thing they read.
const bad = describeValidation(error?.message, fields)
if (bad) {
return (
<div className="af__err af__err--soft" role="alert">
<strong>{bad.title}</strong>
{bad.items.length > 1 ? (
<ul>{bad.items.map((t) => <li key={t}>{t}</li>)}</ul>
) : null}
</div>
)
}
const said = describeError(error)
return (
<div className="af__err" role="alert">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
</div>
)
})() : null}
<div className="af__actions">
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
<button type="submit" className="af__submit" disabled={busy}>
{busy ? 'Submitting…' : 'Submit'}
</button>
</div>
</form>
)
}

View File

@ -0,0 +1,44 @@
.who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
/* A soft disc in the worker's colour: blue for the AI at large, amber for KYC,
teal for the Advisor, grey initials for a person. */
.who__disc {
position: relative; display: grid; place-items: center; flex: none;
width: 22px; height: 22px; border-radius: 50%;
font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
background: var(--zk-tint-blue); color: var(--zk-blue);
}
/* xs is the label on a chat bubble, where the disc sits beside 12.5px type. */
.who--xs { gap: 5px; }
.who--xs .who__disc { width: 16px; height: 16px; font-size: 8.5px; }
.who--xs .who__name { font-size: var(--fs-3xs); }
.who--xs .who__tag { font-size: 10px; padding: 0 4px; }
.who--md .who__disc { width: 36px; height: 36px; font-size: 13px; }
.who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }
.who__disc--blue, .who__disc--violet, .who__disc--plum { background: var(--zk-tint-blue); color: var(--zk-blue); }
.who__disc--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
.who__disc--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.who__disc--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
.who__glyph { width: 52%; height: 52%; }
.who__init { font-weight: 700; }
.who__name { font-size: var(--fs-sm); font-weight: 600; color: var(--zk-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.who__tag {
font-size: 12px; font-weight: 700; letter-spacing: .04em;
color: var(--zk-blue); background: var(--zk-tint-blue);
padding: 1px 6px; border-radius: 4px;
}
.who--btn {
font: inherit; cursor: pointer; padding: 2px 8px 2px 2px;
border: 1px solid transparent; border-radius: var(--r-pill); background: transparent;
transition: background var(--t-fast), border-color var(--t-fast);
}
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
.who--btn:hover .who__i { opacity: 1; }
.who--btn:focus-visible { outline: none; box-shadow: var(--ring); }
.who__i { width: 12px; height: 12px; flex: none; color: var(--zk-grey); opacity: 0; transition: opacity .12s; }
.who__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

View File

@ -0,0 +1,84 @@
import { AGENTS, initialsOf } from '../api/agents.js'
import './AgentChip.css'
/**
* TWO SYMBOLS, ONE CONTRAST: a sparkle for the machines, a person for the
* people.
*
* The sparkle is the settled 2026 signal for AI the mark Claude, Gemini and
* Copilot all use so an agent's disc reads as "made by AI" without a legend,
* in the agent's own colour. A person's disc carries a plain head-and-shoulders
* glyph, so a human step reads as human at the same glance. The contrast is the
* whole message: which of these did the machine do, and which did a person.
* Who exactly is said by the name beside the disc and by the colour.
*/
function SparkleGlyph() {
return (
<svg className="who__glyph" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 .8c.25 2.4.9 3.9 1.9 4.9 1 1 2.5 1.65 4.9 1.9v.8c-2.4.25-3.9.9-4.9 1.9-1 1-1.65 2.5-1.9 4.9h-.8c-.25-2.4-.9-3.9-1.9-4.9-1-1-2.5-1.65-4.9-1.9v-.8c2.4-.25 3.9-.9 4.9-1.9 1-1 1.65-2.5 1.9-4.9z" />
</svg>
)
}
/**
* Who did this as a face, not a slug.
*
* A disc with initials, coloured per agent and consistent everywhere. The
* point is recognition at a glance down a timeline: the operator should see
* that one worker made four consecutive entries without reading four names.
*
* People get a disc too, in grey. A trail where the agents are decorated and
* the humans are plain text reads as though the machines are the important
* ones, which is the wrong way round on a screen whose job is oversight.
*/
export default function AgentChip({ agentKey, name, size = 'sm', showName = true, onOpen }) {
const a = agentKey ? AGENTS[agentKey] : null
const label = a ? a.short : (name || 'System')
const tone = a ? a.tone : 'grey'
const body = (
<>
{/* THE DISC SAYS WHICH WORKER; THE SPARKLE SAYS IT IS NOT A PERSON.
Initials alone do not carry that "EN" reads as a colleague's
badge, and on a screen whose entire claim is that the work was done
autonomously, the one fact every entry must state is which entries
were. The sparkle is the settled convention for machine-generated
work across current interfaces, so it needs no legend.
Only agents get it. A trail where a human's disc also carried one
would say nothing at all. */}
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">
{/* A machine wears the sparkle; a person wears their initials. */}
{a ? <SparkleGlyph /> : <span className="who__init">{initialsOf(name)}</span>}
</span>
{showName ? <span className="who__name">{label}</span> : null}
{showName && a ? <small className="who__tag">AI</small> : null}
{a ? <span className="who__sr"> (AI)</span> : null}
</>
)
// Only an AGENT opens a card. A person's disc is a label there is nothing
// to explain about a human being that this app is entitled to show.
if (a && onOpen) {
return (
<button
type="button"
className={`who who--${size} who--btn`}
onClick={() => onOpen(agentKey)}
title={`${a.full} — what it does and what it can reach`}
>
{body}
<svg className="who__i" viewBox="0 0 14 14" aria-hidden="true">
<circle cx="7" cy="7" r="5.6" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M7 6.2v3.4M7 4.3v.9" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
</button>
)
}
return (
<span className={`who who--${size}`} title={a ? `${a.full}${a.does}` : name || 'System'}>
{body}
</span>
)
}

View File

@ -0,0 +1,32 @@
.cot { margin-top: 8px; }
.cot__finding { margin: 0 0 8px; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); max-width: 64ch; }
/* The handle is a link, in the row of actions under the entry. */
.cot__toggle {
display: inline-flex; align-items: center; gap: 6px; padding: 0;
border: 0; background: none; cursor: pointer;
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
}
.cot__toggle:hover { text-decoration: underline; }
.cot__toggle:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 4px; }
.cot__brain { display: none; }
.cot__ct { font-weight: 600; }
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); opacity: .7; }
.cot__caret.is-open { transform: rotate(90deg); }
/* The reasoning, as a flat card with a stepped rail. */
.cot__steps {
list-style: none; margin: 10px 0 0; padding: 14px 18px 10px;
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
}
.cot__step { position: relative; display: grid; grid-template-columns: 84px 1fr; gap: 12px; padding: 8px 0 8px 16px; }
.cot__step::before {
content: ''; position: absolute; left: 2px; top: 11px; width: 9px; height: 9px;
border-radius: 50%; background: #fff; border: 2px solid var(--zk-blue); z-index: 1;
}
.cot__step:not(:last-child)::after { content: ''; position: absolute; left: 5.5px; top: 20px; bottom: -6px; width: 1.5px; background: var(--zk-blue-light); }
.cot__mark { font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-blue); padding-top: 2px; }
.cot__b { font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b b { color: var(--zk-ink); font-weight: 600; }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cot__conf { font-size: 12px; font-weight: 700; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 10px; border-radius: var(--r-pill); }

View File

@ -0,0 +1,87 @@
import { useState } from 'react'
import './ChainOfThought.css'
/**
* How an AI employee reached a decision, step by step.
*
* This is the trust surface. A person signing off on or overriding a
* machine's decision needs the answer to one question first: WHY did it do
* that. Before this, the reasoning existed (the employees write it as workflow
* fields, so it is already in the audit trail) but the console showed only the
* conclusion. An operator either trusted it blind or went digging. Neither is
* oversight.
*
* The chain reads the way the employee's own record reads: what it REASONED and
* what it DECIDED. It is assembled from real fields already on the step the
* employee's own words for the reasoning, the activity it committed and the
* state it moved the lead to for the decision never narrated after the fact.
* A step may carry more than one reasoning field (attribution AND eligibility,
* say); each is its own rung.
*
* The finding the first sentence, since the employees write the conclusion
* first is lifted out and stays visible, so the "why" can be read without
* opening the chain.
*/
function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
export default function ChainOfThought({ reasoning, decided, confidence }) {
const [open, setOpen] = useState(false)
const primary = reasoning.length ? reasoning[0][1] : ''
const finding = findingOf(primary)
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return (
<div className="cot">
{finding ? <p className="cot__finding">{finding}</p> : null}
<button
type="button"
className="cot__toggle"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
See reasoning
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<ol className="cot__steps">
{reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
<div className="cot__b">{text}</div>
</li>
))}
<li className="cot__step">
<span className="cot__mark">Decided</span>
<div className="cot__b">
<b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{confidence != null ? (
<div className="cot__foot">
<span className="cot__conf">
{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
</span>
</div>
) : null}
</div>
</li>
</ol>
) : null}
</div>
)
}

View File

@ -0,0 +1,168 @@
.clamp {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.clamp__text {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.6;
color: var(--zk-ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--clamp-lines, 3);
line-clamp: var(--clamp-lines, 3);
overflow: hidden;
}
.clamp__text--short {
display: block;
overflow: visible;
}
.clamp__more {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 0;
border: 0;
background: none;
color: var(--zk-blue);
cursor: pointer;
transition: color var(--t-fast);
}
.clamp__more:hover {
color: var(--zk-blue-dark);
}
.clamp__more svg {
width: 11px;
height: 11px;
transition: transform var(--t);
}
/* The headline lifted out of an AI paragraph. Slightly heavier than the body
and never clamped it is one sentence by construction, and the point of it
is that the finding can be read without opening anything. */
.clamp__lead {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
color: var(--zk-ink);
font-weight: 500;
}
.clamp__lead + .clamp__text { margin-top: 8px; }
/* The finding, lifted out of an AI paragraph. Heavier than the body and never
clamped it is one sentence by construction, and the whole point is that it
can be read without opening anything. */
.clamp__lead {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
font-weight: 500;
color: var(--zk-ink);
}
.clamp__lead + .clamp__text { margin-top: 8px; }
/* The full text, over the page
Expanding inline was the wrong shape for the audit rail: 320px wide, one
entry per step, and a 1,500-character rationale pushed a lead's whole
history off the screen to read one sentence of it. */
.prose__scrim {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(23, 31, 40, 0.42);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
animation: zk-fade 0.14s var(--ease) both;
}
.prose__dlg {
width: min(680px, 100%);
max-height: min(76vh, 720px);
display: flex;
flex-direction: column;
background: var(--zk-white);
border-radius: var(--r-lg);
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
outline: none;
animation: zk-rise 0.18s var(--ease) both;
}
.prose__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 14px 24px;
border-bottom: 1px solid var(--zk-line-soft);
}
.prose__head h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 500;
line-height: 1.35;
color: var(--zk-ink);
}
.prose__head button {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
cursor: pointer;
border: none;
border-radius: var(--r-sm, 6px);
background: transparent;
color: var(--zk-muted);
}
.prose__head button svg { width: 14px; height: 14px; }
.prose__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
.prose__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
.prose__body {
overflow-y: auto;
padding: 18px 24px 26px;
}
.prose__body p {
margin: 0 0 12px;
font-size: var(--fs-xs);
line-height: 1.62;
color: var(--zk-muted);
white-space: pre-wrap;
}
.prose__body p:last-child { margin-bottom: 0; }
/* The finding, kept at the top and kept emphasised: a reader who opened this
to check one thing should not have to find it again. */
.prose__lead {
font-size: var(--fs-sm) !important;
font-weight: 500;
color: var(--zk-ink) !important;
padding-bottom: 12px;
border-bottom: 1px solid var(--zk-line-soft);
margin-bottom: 16px !important;
}
@keyframes zk-fade { from { opacity: 0 } to { opacity: 1 } }
@media (prefers-reduced-motion: reduce) {
.prose__scrim, .prose__dlg { animation: none; }
}

View File

@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import './ClampText.css'
/**
* Splits a block of AI prose into a finding and its working.
*
* The employees write the conclusion first and the evidence after it
* "POSP-77341 is active and empanelled for motor." then four sentences of
* registry detail. So the first sentence already IS the summary, and lifting it
* out gives one without inventing text or asking the model for a second field
* it would have to be trusted to keep in step with the first.
*
* Returns null when the split would be useless: no sentence terminator, a first
* sentence so long it is the paragraph again, or one so short it is a fragment
* rather than a finding. Those fall back to plain folding.
*/
function splitLead(value) {
const m = value.match(/^(.{40,220}?[.!?])(\s+)(\S[\s\S]*)$/)
if (!m) return null
return { lead: m[1].trim(), rest: m[3].trim() }
}
/**
* The full text, in a dialog.
*
* The reasoning used to expand INLINE, and in the audit rail 320px wide, one
* entry per step a 1,500-character rationale pushed the rest of the lead's
* history off the screen to read one sentence of it. Nobody reads a paragraph
* in a sidebar. It opens over the page instead: the finding stays on the line,
* the working is one click away and one Escape back.
*/
function ProseDialog({ title, lead, body, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
// The page behind must not scroll under the dialog.
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
// Focus goes back to the button that opened this, not to the top of the
// document otherwise a keyboard reader loses their place in the trail.
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg"
role="dialog"
aria-modal="true"
aria-label={title || 'Full text'}
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>{title || 'In full'}</h3>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="prose__body">
{/* The finding stays at the top and stays emphasised a reader who
opened this to check one thing should not have to find it again. */}
{lead ? <p className="prose__lead">{lead}</p> : null}
{body.split(/\n{2,}/).map((para, i) => <p key={i}>{para}</p>)}
</div>
</div>
</div>,
document.body,
)
}
/**
* Prose, reduced to its point.
*
* The AI employees write at length a recommendation rationale runs past 1,500
* characters and a screen that prints all of it is a document. Short text is
* shown as it is; anything longer shows its finding and puts the working behind
* one click.
*
* Whether to fold is decided on length, not by measuring the rendered box: a
* ref-and-measure pass would reflow on every resize to answer a question the
* string itself already answers.
*/
export default function ClampText({ text, title, lines = 3, threshold = 150 }) {
const [open, setOpen] = useState(false)
const value = String(text)
if (value.length <= threshold) return <p className="clamp__text clamp__text--short">{value}</p>
const split = splitLead(value)
return (
<div className="clamp">
{split
? <p className="clamp__lead">{split.lead}</p>
: <p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>}
<button type="button" className="clamp__more" onClick={() => setOpen(true)}>
{split ? 'Read the reasoning' : 'Read in full'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<ProseDialog
title={title}
lead={split?.lead}
body={split ? split.rest : value}
onClose={() => setOpen(false)}
/>
) : null}
</div>
)
}

View File

@ -0,0 +1,113 @@
import { useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
/**
* A file or OCR field.
*
* Uploads ON PICK rather than on submit: the backend's extract endpoint takes
* a REFERENCE to a stored file, so the document crosses the wire once, survives
* a reload, and re-extracting costs no second upload.
*
* For an `ocr` field it then calls /ocr-extract, which resolves the field's own
* extraction config server-side and returns the values keyed by extraction key.
* Those are handed up so the form can fill the fields the document answers
* which is the entire point: nobody should be typing an engine capacity that is
* printed on the RC.
*/
export default function FileField({ field, value, instanceId, activityUid, onChange, onExtract }) {
const { client } = useZino()
const inputRef = useRef(null)
const [busy, setBusy] = useState('')
const [err, setErr] = useState(null)
const [extracted, setExtracted] = useState(null)
const isOcr = field.data_type === 'ocr'
const files = Array.isArray(value) ? value : []
async function pick(e) {
const file = e.target.files?.[0]
if (!file) return
setErr(null); setExtracted(null)
const ctx = { activityId: activityUid, fieldId: field.id, instanceId }
try {
setBusy('Uploading…')
const ref = await client.uploadFile(file, ctx)
onChange([ref])
if (isOcr) {
setBusy('Reading the document…')
// Extraction failing must NOT lose the upload. The file is already
// stored and referenced; a failed read just means the fields are not
// pre-filled, which is recoverable by typing. Swallowing the upload
// because the OCR errored would not be.
try {
const out = await client.ocrExtract(ref, ctx)
// The endpoint answers { extracted: {...}, raw: "..." }.
const fields = out?.extracted ?? out?.fields ?? out?.data ?? null
if (fields && typeof fields === 'object' && Object.keys(fields).length) {
setExtracted(fields)
onExtract?.(fields)
} else {
setErr({ status: '', message: 'Uploaded, but nothing could be read from this document.' })
}
} catch (ox) {
setErr({ status: ox.status ?? '', message: 'Uploaded, but reading it failed — ' + (ox.message || 'unknown error') })
}
}
} catch (ex) {
setErr(ex)
} finally {
setBusy('')
}
}
const name = files.length ? (files[0].original_name || files[0].uuid) : ''
const open = () => inputRef.current?.click()
return (
<div className="ff">
<input
ref={inputRef} type="file" className="ff__input"
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
/>
{/* Two states, not one button that changes its words. Nothing uploaded is
an invitation a dashed target that says what it takes. Something
uploaded is a fact the file, named, with a quiet way to swap it. The
old single pink slab stayed the same size and weight either way, which
made a finished upload shout as loudly as an empty one. */}
{files.length && !busy ? (
<div className="ff__file">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.4Zm0 0V5.4h3.6"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
<span className="ff__name" title={name}>{name}</span>
<button type="button" className="ff__replace" onClick={open}>Replace</button>
</div>
) : (
<button type="button" className="ff__drop" disabled={Boolean(busy)} onClick={open}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 13.5V4.2m0 0L6.6 7.6M10 4.2l3.4 3.4M3.5 13v2a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5v-2"
fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<strong>{busy || (isOcr ? 'Upload — we read it for you' : 'Upload document')}</strong>
{busy ? null : <span>PDF, PNG or JPG</span>}
</button>
)}
{extracted ? (
<div className="ff__read">
<span className="ff__readlabel">Read from the document</span>
{Object.entries(extracted)
.filter(([, v]) => v !== null && v !== undefined && String(v) !== '')
.map(([k, v]) => (
<div key={k}><em>{k.replace(/_/g, ' ')}</em> {String(v)}</div>
))}
</div>
) : null}
{err ? <div className="ff__err">{err.status} {err.message}</div> : null}
</div>
)
}

105
src/components/LeadCard.jsx Normal file
View File

@ -0,0 +1,105 @@
import { STAGES, phaseOf } from '../api/config.js'
import { holderOf } from '../api/holder.js'
/**
* One lead, as a card.
*
* The desktop shows six columns; a phone cannot, and a table squeezed into
* 390px is either a horizontal scroll nobody finds or six columns nobody can
* read. So the row becomes a card and the columns become a hierarchy: who it
* is, what stage, when it is due, who is holding it. Everything else premium,
* channel, partner is one tap away on the lead itself.
*/
const DAY = 86400000
/** Days to expiry, and how alarmed to be. A renewal book runs on this number. */
export function expiry(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
return {
days,
tone: days < 0 ? 'red' : days <= 7 ? 'amber' : '',
label: days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd',
on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }),
}
}
/**
* Is an automated stage actually moving? Generous on purpose: an employee wake
* takes a minute or two and a scheduled retry can be hours out, so `stalled`
* means "longer than any normal step", not "longer than average".
*/
export function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null
const t = Date.parse(row.updated_at)
if (isNaN(t)) return null
const mins = (Date.now() - t) / 60000
if (mins < 30) return { state: 'working', label: stage.doing }
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
}
const STAGE_TONE = { auto: 'blue', customer: 'teal', needs: 'amber', waiting: 'grey', end: 'grey' }
function Clock() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="5.6" stroke="currentColor" strokeWidth="1.4" />
<path d="M8 5v3.3l2.1 1.3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
)
}
export default function LeadCard({ row, onOpen }) {
const id = row.instance_id ?? row.id
const def = STAGES.find((s) => s.name === row.current_state_name)
const ph = phaseOf(def, row)
const prog = progress(row, ph)
const h = holderOf(def, row)
const exp = expiry(row.renewal_due_date)
const stalled = prog?.state === 'stalled'
const done = def?.kind === 'end'
return (
<button
type="button"
className={'lead' + (stalled ? ' lead--stalled' : '') + (done ? ' lead--done' : '')}
onClick={() => onOpen(id)}
aria-label={`Open ${row.customer_name || row.lead_ref || id}`}
>
<div className="lead__top">
<span className="lead__id">
<span className="lead__name">{row.customer_name || row.lead_ref || `#${id}`}</span>
<span className="lead__ref">
{row.lead_ref || `#${id}`}
{row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{row.vehicle_reg ? ` · ${row.vehicle_reg}` : row.entity_name ? ` · ${row.entity_name}` : ''}
</span>
</span>
<span className={'pill pill--' + (STAGE_TONE[def?.kind] || 'grey')}>
<span className={'dot dot--' + (STAGE_TONE[def?.kind] || 'grey')} />
{row.current_state_name || '—'}
</span>
</div>
{/* One line about what is happening, but only when it says something the
stage pill does not. */}
{stalled ? (
<div className="lead__alarm"><span className="dot dot--red" />{prog.label}</div>
) : prog?.label ? (
<p className="lead__doing">{prog.label}</p>
) : null}
<div className="lead__foot">
{exp ? (
<span className={'due' + (exp.tone ? ' due--' + exp.tone : '')}>
<Clock />{exp.label} <small>· {exp.on}</small>
</span>
) : <span className="due">No renewal date</span>}
<span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span>
</div>
</button>
)
}

134
src/components/Timeline.css Normal file
View File

@ -0,0 +1,134 @@
.tl { list-style: none; margin: 0; padding: 0; }
.tl__loading, .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
.tl__err { color: var(--zk-danger-ink); }
/* One event: a 36px avatar gutter, then the body. A thin connector runs down
the gutter between avatars. */
.tl__ev { display: grid; grid-template-columns: 36px 1fr; gap: 14px; position: relative; padding-bottom: 22px; }
.tl__ev::before { content: ''; position: absolute; left: 17px; top: 36px; bottom: 0; width: 2px; background: var(--zk-line-soft); }
.tl__ev:last-child::before { display: none; }
.tl__ev:last-child { padding-bottom: 6px; }
.tl__ev--sys { opacity: .75; }
.tl__gutter { width: 36px; height: 36px; display: grid; place-items: center; position: relative; z-index: 1; }
.tl__gutter .who--btn { padding: 0; border: 0; }
.tl__disc {
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-weight: 700; font-size: 13px;
background: var(--zk-line-soft); color: var(--zk-muted);
}
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
.tl__body { min-width: 0; display: flex; flex-direction: column; }
.tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
.tl__what { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
.tl__what--wa { display: inline-flex; align-items: center; gap: 6px; color: var(--zk-teal); }
.tl__what--wa svg { width: 15px; height: 15px; }
.tl__ev--ai.is-live .tl__what { color: var(--zk-blue); }
.tl__to { font-size: var(--fs-xs); color: var(--zk-muted); padding: 2px 10px; border-radius: var(--r-pill); background: var(--zk-line-soft); }
.tl__to::before { content: '→ '; color: var(--zk-grey); }
.tl__n { font-size: var(--fs-2xs); color: var(--zk-muted); padding: 1px 8px; border-radius: var(--r-pill); background: var(--zk-line-soft); font-variant-numeric: tabular-nums; }
.tl__when { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
.tl__by { font-size: 14.5px; color: var(--zk-muted); margin-top: -2px; display: flex; align-items: center; gap: 6px; }
.tl__byname { font-weight: 600; color: var(--zk-ink); }
.tl__tag { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
/* What was said — a quote block. */
.tl__say { margin-top: 8px; padding: 10px 14px; border-left: 2px solid var(--zk-line); border-radius: 0 var(--r-sm) var(--r-sm) 0; background: var(--zk-tint); max-width: 72ch; }
.tl__saylabel { display: block; font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); margin-bottom: 6px; }
.tl__say p { margin: 0; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); }
/* Figures the step wrote — money, as chips. */
.tl__figs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.tl__figs span { display: inline-flex; gap: 8px; align-items: baseline; padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: 14.5px; font-variant-numeric: tabular-nums; }
.tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; }
/* Documents received, as chips. */
.tl__docs { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14.5px; }
.tl__docshead { color: var(--zk-grey); }
.tl__doclist { display: flex; flex-wrap: wrap; gap: 8px; }
.tl__doc {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px;
border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
font-size: 14.5px; color: var(--zk-ink); text-decoration: none;
}
.tl__doc svg { width: 13px; height: 13px; color: var(--zk-blue); }
.tl__doc:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
/* The action row: links, in blue. */
.tl__acts { display: flex; gap: 18px; margin-top: 8px; font-size: var(--fs-xs); align-items: baseline; flex-wrap: wrap; }
.tl__link { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; }
.tl__link:hover { text-decoration: underline; }
.tl__wrote > summary { cursor: pointer; list-style: none; font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); }
.tl__wrote > summary::-webkit-details-marker { display: none; }
.tl__wrote > summary::after { content: ' ▾'; color: var(--zk-grey); }
.tl__wrote[open] > summary::after { content: ' ▴'; }
.tl__wrote dl { margin: 8px 0 0; padding: 10px 12px; border: 1px solid var(--zk-line-soft); border-radius: var(--r-sm); background: var(--zk-tint); display: flex; flex-direction: column; gap: 6px; }
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
.tl__wrote dt { flex: none; width: 42%; font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
.tl__wrote dd { margin: 0; flex: 1; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
/* System updates, folded behind a pill. */
.tl__toggle {
display: inline-block; margin: 6px 0 14px; font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); background: #fff; cursor: pointer;
}
.tl__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); }
/* THE WHATSAPP EXCHANGE, COLLAPSED
One entry for a burst of messages: who spoke, how many there were, the last
two of them, and the whole thread behind a click. The card IS the control
clicking anywhere in it opens the conversation, which is how every timeline
of this shape behaves and what stops the entry needing its own link row. */
.tl__disc--wa { background: var(--zk-teal-tint); color: var(--zk-teal); }
.tl__disc--wa svg { width: 17px; height: 17px; }
/* "The same thread, picked up after Verify KYC and Underwriting Screen." */
.tl__wacont { margin: 4px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.tl__wa {
display: block; width: 100%; max-width: 72ch; margin-top: 10px;
padding: 12px 14px 10px; text-align: left; cursor: pointer;
font: inherit; color: inherit; background: #fff;
border: 1px solid var(--zk-line); border-radius: var(--r-md);
transition: border-color var(--t-fast);
}
.tl__wa:hover { border-color: var(--zk-blue-light); }
.tl__wa:hover .tl__wafoot { text-decoration: underline; }
/* The participants, as chips. A person's disc carries their initials, an
agent's carries the sparkle and an AI tag which is the whole point of the
line: one of these is not a colleague. */
.tl__wavoices { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 1px; }
.tl__wavoices .who--btn { margin-left: -2px; }
.tl__wapre { display: flex; flex-direction: column; gap: 6px; }
.tl__wamsg {
max-width: 88%; padding: 7px 11px 6px;
border-radius: 12px; background: var(--zk-line-soft);
}
.tl__wamsg-who { display: block; margin-bottom: 3px; }
/* Same reason as in the dialog: the disc's tint and the bubble's are one
token, so the disc vanishes into it without this. */
.tl__wamsg .who__disc { background: #fff; }
/* Two lines of each message, no more. A preview that grows with whatever
somebody typed has stopped being a preview. */
.tl__wamsg-txt {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden;
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
}
/* Theirs left, ours right the same arrangement as the thread itself, so the
preview is recognisably a piece of the conversation it opens. */
.tl__wamsg--them { align-self: flex-start; border-bottom-left-radius: 4px; }
.tl__wamsg--us {
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
}
.tl__wafoot {
display: flex; align-items: center; gap: 4px; margin-top: 9px;
font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
}
.tl__wafoot svg { width: 11px; height: 11px; }

675
src/components/Timeline.jsx Normal file
View File

@ -0,0 +1,675 @@
import { useState } from 'react'
import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx'
import { AGENTS, initialsOf } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import { CHAT_ACTS, THREAD_FIELDS, buildThread, listOf, stepName } from '../api/thread.js'
import './Timeline.css'
/* The roster lives in api/agents.js now one short name, one colour and one
set of initials per employee, so the same worker looks the same everywhere
it appears. This file only needs to know which roles are agents. */
/**
* The fields worth surfacing per activity an agent's reasoning, a rule's
* output, a call's notes. Everything else stays in the file below; a timeline
* that shows every field is a table, not a story.
*
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
* field ids, which carry a per-form suffix the call notes arrive as
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
* global ids directly, as this did, meant almost nothing ever matched: the
* timeline showed a bare list of activity names, and the one narrative line
* that did appear was an accident (a DATA_UPDATE row happens to write the
* unsuffixed key).
*/
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
"Reasoned" rung of the chain of thought the employee's own words for why
it did what it did rather than loose quote blocks. The first present one
is the finding shown without opening the chain. */
const REASONING = [
['attribution_reason', 'Attribution'],
['eligibility_reason', 'Eligibility'],
['ai_recommendation_rationale', 'Cover advice'],
['kyc_mismatch_notes', 'KYC mismatch'],
// A clean KYC leaves the mismatch note empty, so the step showed no chain at
// all. kyc_ref is what the KYC employee actually recorded the document
// package it matched the identity against so it stands in as the finding.
['kyc_ref', 'KYC check'],
['referral_analysis', 'Referral analysis'],
// Underwriting writes its reasoning into uw_referral_reason (the screen and
// prepare steps); uw_decision_notes is only the clear/decline note. The map
// knew the second and not the first, so the substantive UW decision the
// one the reader most wants the reasoning for showed as a bare heading.
['uw_referral_reason', 'Underwriting'],
['uw_decision_notes', 'Underwriting decision'],
['contact_notes', 'Call'],
['documents_notes', 'Documents'],
// The payment-nudge employee records why it chose to follow up now (or hold).
['nudge_notes', 'Payment follow-up'],
['quoted_breakup', 'How the premium was reached'],
['lost_reason', 'Why it was dropped'],
['resume_note', 'Why now'],
]
/* WHAT WAS SAID IS NO LONGER QUOTED ON THE STEP.
*
* `customer_reply` and `customer_answer` used to be rendered here, which is
* what made every WhatsApp turn its own entry in the trail: five headings, five
* actors and five quote boxes for one exchange about a premium. They belong to
* the THREAD now (api/thread.js), which owns them for both the collapsed entry
* below and the dialog. Leaving them here as well would print the payment
* request's own message on the Request Premium step and again in the exchange
* it opened. */
const CONVERSATION = [
['dedupe_match_ref', 'Duplicate of'],
]
const NARRATIVE = [...REASONING, ...CONVERSATION]
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
/**
* Fields already shown elsewhere in the entry, so the "what it wrote" list
* does not repeat them: the narrative prose above it, the money figures below
* it, the thread that owns the messages, the document chips, and the platform's
* own bookkeeping.
*/
const SHOWN_ELSEWHERE = new Set([
...NARRATIVE.map(([k]) => k), ...MONEY, ...THREAD_FIELDS, '_system',
])
/** A value as one short line. Long prose is already in the narrative block, so
* anything here is a field value, not a paragraph. */
function short(f) {
const v = f.value
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) {
const named = v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
.filter(Boolean)
return named.length ? named.join(', ') : null
}
if (typeof v === 'object') return null
const t = String(v).trim()
if (!t) return null
// A value long enough to be prose belongs in the narrative block, not in a
// list of what changed truncating it here would show half a sentence and
// teach the reader that this list is unreliable.
return t.length > 90 ? t.slice(0, 88) + '…' : t
}
/** Document slots, in the order they are worth reading. Labels are shorter than
* the workflow's own "Registration Certificate (RC)" is a chip, not a form
* label and a slot missing from here still renders under its server label. */
const DOC_LABELS = {
doc_rc: 'RC',
doc_prev_policy: 'Expiring policy',
doc_pan: 'PAN',
doc_gst_cert: 'GST certificate',
doc_udyam_cert: 'Udyam certificate',
doc_address_proof: 'Address proof',
doc_premises_proof: 'Premises proof',
doc_stock_statement: 'Stock statement',
doc_premises_photos: 'Premises photos',
doc_vehicle_photos: 'Vehicle photos',
doc_financials: 'Financials',
}
const FILE_TYPES = new Set(['file', 'ocr'])
/** The absolute stamp and the relative one, kept apart: a collapsed exchange
* spans two moments and has only one "ago". */
function absAt(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
function agoAt(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000)
return mins < 1 ? 'just now'
: mins < 60 ? `${mins}m ago`
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
: `${Math.round(mins / 1440)}d ago`
}
function when(ts) {
if (!ts) return ''
const abs = absAt(ts)
return abs ? `${abs} · ${agoAt(ts)}` : ''
}
/**
* An exchange happens over a stretch of time rather than at an instant, and the
* stamp has to say so dated only at its first message, an entry reads as
* though the reply visible inside it arrived before it was written.
*
* The "ago" is measured from the LAST message: how stale the conversation is,
* which is the operationally useful half.
*/
function spanAt(from, to) {
if (!from) return ''
if (!to || to === from) return when(from)
const a = new Date(from)
const b = new Date(to)
if (isNaN(a) || isNaN(b)) return when(from)
const hm = (d) => d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })
const day = (d) => d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
const head = a.toDateString() === b.toDateString()
? `${day(a)}, ${hm(a)} ${hm(b)}`
: `${day(a)} ${day(b)}`
return `${head} · ${agoAt(to)}`
}
/** An uploaded file, as the platform stores it. */
function filesIn(value) {
if (!Array.isArray(value)) return []
return value.filter((f) => f && typeof f === 'object' && f.uuid)
}
/** The WhatsApp mark, on the disc and in the heading. */
function WaGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
fill="currentColor" />
</svg>
)
}
/**
* Who spoke in an exchange as CHIPS, not as a sentence.
*
* "Priya Raghavan and Engage" reads as two colleagues. On a screen whose whole
* claim is that the work was done autonomously, the one fact every line has to
* state is which of the two is a machine, and a bare name states the opposite.
*
* Every other entry in the trail already says it the same way the agent's
* disc carries a sparkle, its name carries an AI tag, a person's disc carries
* their initials so this is the existing convention reaching somewhere it was
* missing rather than a new one.
*
* The customer comes first: they are the reason the exchange exists.
*/
function voicesOf(ep, customerName) {
const who = []
if (ep.turns.some((t) => t.side === 'them')) {
who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
}
for (const t of ep.turns) {
if (t.side !== 'us') continue
// Keyed on the agent, or on the name for a person answering by hand: ops
// replying in the thread is a person and must never wear the sparkle.
const key = t.agentKey || `person:${t.by}`
if (!who.some((w) => w.key === key)) who.push({ key, agentKey: t.agentKey, name: t.by })
}
return who
}
/** The label on a preview bubble: the same chip, small, and never a button
* the card it sits in is already one, and a button inside a button is not. */
function TurnWho({ turn, customerName }) {
return turn.side === 'them'
? <AgentChip name={customerName || 'the customer'} size="xs" />
: <AgentChip agentKey={turn.agentKey} name={turn.by} size="xs" />
}
/**
* `rows` is owned by the lead page and refreshed on its poll, so the trail
* keeps up with a lead that five agents are working through in three minutes.
* This component fetched them itself once on mount and never again.
*/
export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat, onOpenCall }) {
const { client } = useZino()
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
// anything. They are the bulk of a busy trail and they are hidden until asked
// for the story is what people and agents did.
const [showSys, setShowSys] = useState(false)
if (!rows) return <p className="tl__loading">Loading the timeline</p>
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
// The conversation, assembled once and shared with the dialog. `episodes` are
// its bursts; `byRow` says which steps put a message on WhatsApp themselves.
const { episodes, byRow } = buildThread(rows)
// Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
// submission separately, told apart by execution_state:
//
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
// zk-act-qualify zk-state-qualified the one that moved the lead
//
// Rendered literally that is "Qualify Lead" three times in a row, which
// reads as the AI having done the same thing three times. Only the row
// carrying a real state means anything to somebody reading the file.
//
// Collapsed on (same activity, within fifteen seconds) rather than on the
// execution_state alone, because a genuine repeat has to survive: Collect
// Documents really is performed twice on a lead whose first upload was
// incomplete, and those are minutes apart, not milliseconds.
//
// The kept row takes the EARLIEST timestamp when the operator acted and
// whichever state and payload is actually populated.
const isStage = (v) => STAGES.some((s) => s.uid === v)
const SAME_SUBMISSION_MS = 15000
const merged = []
for (const r of ordered) {
// Look BACK for a match rather than only at the previous entry: the
// platform interleaves a DATA_UPDATE row between the two halves of one
// submission, so the rows to merge are near each other in time but not
// adjacent in the list.
let at = -1
for (let i = merged.length - 1; i >= 0; i--) {
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
if (merged[i].activity_id === r.activity_id) { at = i; break }
}
if (at >= 0) {
const prev = merged[at]
merged[at] = {
...prev,
// The settle row is the one that names the resulting stage.
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
// Whichever row actually carries the submission and the AI's working.
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
user_name: prev.user_name || r.user_name,
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
created_at: prev.created_at,
// Every raw row folded in here. The thread is keyed on RAW ids a
// message may have been recorded on the second row of a submission
// whose first row won the merge so a lookup has to try all of them.
mergedIds: [...(prev.mergedIds || [prev.id]), r.id],
}
continue
}
merged.push(r)
}
let lastStage = null
/** One step: what it was, who took it, what it wrote. */
const stepEntry = (r, ids) => {
const roles = r.user_roles || []
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
// so an AI's own field write matched the agent roster, was classed as its work,
// and appeared in the trail as an entry titled "Data updated" twice,
// while the toggle below still offered to reveal two others. The row's
// activity id is what says it is bookkeeping; the roles say who triggered
// the bookkeeping, which is a different question.
const isSystem = r.activity_id === 'DATA_UPDATE'
const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
/**
* The value view. `fields[]` is the platform's own typed rendering of the
* submission one entry per configured field with its label, data type and
* value and it is what makes a file field knowable as a file. `data` is
* the raw untyped fallback for a row the workflow could not resolve.
*/
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
const byBase = new Map()
for (const f of fields) {
const base = baseFieldId(f.field_id)
// `_system` is the platform's own marker on every submission.
if (base === '_system') continue
if (!byBase.has(base)) byBase.set(base, f)
}
// What was attached. This is the answer to "show me that the documents
// were captured": the names, from the submission that carried them, each
// one openable.
const docs = []
for (const [base, f] of byBase) {
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
for (const file of filesIn(f.value)) {
docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file })
}
}
// Log Contact is where the call lands. Offer the words themselves beside
// the summary, because they are not the same thing.
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state)
// A self-loop Capture Motor Risk runs inside Document Pending and settles
// back into it is not a move, and printing " Document Pending" against
// four consecutive entries reads as the lead bouncing.
const moved = stage && stage.uid !== lastStage
if (stage) lastStage = stage.uid
// A step that composed a WhatsApp message inside its own trigger Request
// Premium, the issuance note. The text lives in the thread; the step says
// it sent one and links there rather than quoting what is already shown.
const chat = ids.map((id) => byRow.get(id)).find(Boolean)
return {
key: r.id ?? `${r.activity_id}-${r.created_at}`,
kind,
hasCall,
sent: chat?.sent ?? 0,
chatEpisode: chat?.episodeId ?? null,
agentKey: aiRole || null,
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : stepName(r),
stage: moved ? stage : null,
when: when(r.created_at),
docs,
// The reasoning rungs of the chain of thought the employee's own text.
reasoning: REASONING
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// Conversation lines stay as plain quotes.
conversation: CONVERSATION
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// The decision's confidence, when the step recorded it ai_recommendation_confidence
// is a real field the advice step writes into the audit data, so it needs
// no backend change to reach here.
confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c)
return Number.isFinite(n) ? n : null
})(),
figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
/**
* EVERYTHING THIS STEP WROTE.
*
* Twelve fields were rendered as prose and four as money; the other
* hundred-odd were invisible. So Capture Motor Risk which writes engine
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
* add-ons showed as a heading and a timestamp, and the only way to see
* what an agent had actually decided was to open the Lead file and guess
* which values came from that step.
*
* This is not the model's internal reasoning; that is recorded on the row
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
* It is the next best and arguably more useful thing: the decision it
* committed, field by field, against the step that made it.
*/
wrote: [...byBase]
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
.filter(([, v]) => v !== null),
}
}
/**
* THE CONVERSATION IS ONE ENTRY, NOT NINE.
*
* Every WhatsApp turn used to be its own step in the trail "WhatsApp",
* "WhatsApp", "WhatsApp" each with a heading, an actor, a quote box and an
* Open thread link, so a four-message exchange about a premium took more of
* the story than the entire underwriting chain.
*
* An exchange is now one entry: who spoke, how many messages, the last two of
* them, and the whole thread one click away. It carries NO single actor, which
* is the trap the previous attempt fell into collapsing turns while keeping
* the first speaker's name printed the customer's words under Engage's chip.
* Here both sides are named on their own message and neither owns the entry.
*
* ONE ENTRY PER BURST, not one for the whole thread. The quote objection and
* the payment exchange are the same conversation resumed, but they are twenty
* minutes and four workflow steps apart. Folding them into a single entry
* would date the payment messages to the moment of the objection and place
* them above the underwriting that actually preceded them and saying what
* happened in what order is the trail's one job. So each burst sits where it
* happened, the later ones marked as a continuation, and every one of them
* opens the same complete thread.
*/
const entries = []
const placed = new Set()
const waEntry = (ep) => ({ key: 'wa-' + ep.id, kind: 'wa', ep })
for (const r of merged) {
const ids = r.mergedIds || [r.id]
const opens = episodes.filter((e) => !placed.has(e.id) && ids.includes(e.anchorRowId))
if (CHAT_ACTS.has(r.activity_id)) {
// The row IS a turn, not a step, so it never appears as one. Its exchange
// takes its place once, where the exchange began.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
continue
}
entries.push(stepEntry(r, ids))
// A step whose own message reopened the thread sits directly above the
// exchange it started.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
}
// A guard, not a case: an exchange whose anchor row did not survive the merge
// would otherwise vanish from the trail entirely.
for (const e of episodes) if (!placed.has(e.id)) entries.push(waEntry(e))
const sysCount = entries.filter((it) => it.kind === 'sys').length
const visible = showSys ? entries : entries.filter((it) => it.kind !== 'sys')
return (
<>
{sysCount ? (
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
</button>
) : null}
<ol className="tl">
{visible.map((it) => (
it.kind === 'wa' ? (
<li key={it.key} className="tl__ev tl__ev--wa">
<span className="tl__gutter" aria-hidden="true">
<span className="tl__disc tl__disc--wa"><WaGlyph /></span>
</span>
<div className="tl__body">
<div className="tl__line1">
<h4 className="tl__what tl__what--wa">
<WaGlyph />
WhatsApp
</h4>
{/* A resumed exchange says so on its own line, so nobody
reads it as a second conversation out of nowhere. */}
{it.ep.after.length ? <span className="tl__n">continued</span> : null}
<span className="tl__n">
{it.ep.count} message{it.ep.count === 1 ? '' : 's'}
</span>
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
</div>
<div className="tl__wavoices">
{voicesOf(it.ep, customerName).map((w) => (
<AgentChip
key={w.key}
agentKey={w.agentKey}
name={w.name}
size="sm"
onOpen={w.agentKey ? onOpenAgent : undefined}
/>
))}
</div>
{it.ep.after.length ? (
<p className="tl__wacont">
The same thread, picked up after {listOf(it.ep.after)}.
</p>
) : null}
{/* The last exchange, as a thread rather than a quote block
and the whole card opens the conversation, which is how
every timeline of this shape behaves. */}
<button
type="button"
className="tl__wa"
onClick={() => onOpenChat?.(it.ep.id)}
aria-label={`Open the WhatsApp conversation — ${it.ep.count} messages`}
>
<span className="tl__wapre">
{it.ep.preview.map((t) => (
<span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}>
<span className="tl__wamsg-who">
<TurnWho turn={t} customerName={customerName} />
</span>
<span className="tl__wamsg-txt">{t.text}</span>
</span>
))}
</span>
<span className="tl__wafoot">
{it.ep.count > it.ep.preview.length
? `Open the conversation — all ${it.ep.count} messages`
: 'Open the conversation'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
</div>
</li>
) : (
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
{/* The worker, as a face in the gutter. Recognition down a column
beats reading five names to notice it was one agent throughout. */}
<span className="tl__gutter" aria-hidden="true">
{it.kind === 'sys' ? (
<span className="tl__disc tl__disc--sys">·</span>
) : it.agentKey ? (
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
) : (
<span className="tl__disc tl__disc--hu">{initialsOf(it.actor)}</span>
)}
</span>
<div className="tl__body">
<div className="tl__line1">
<h4 className="tl__what">{it.what}</h4>
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
<span className="tl__when">{it.when}</span>
</div>
<div className="tl__by">
{it.kind === 'sys'
? <span className="tl__byname">System</span>
: it.agentKey
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
: <span className="tl__byname">{it.actor}</span>}
</div>
{/* What was received, named and openable. The count is stated
rather than left to be inferred from a list "3 documents
received" is the sentence the operator is looking for. */}
{it.docs.length ? (
<div className="tl__docs">
<span className="tl__docshead">
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
</span>
<div className="tl__doclist">
{it.docs.map((d) => (
<a
key={d.uuid}
className="tl__doc"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
target="_blank"
rel="noreferrer"
title={d.original_name}
>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
</svg>
{d.slot}
</a>
))}
</div>
</div>
) : null}
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
loose quote it is how the employee reached the decision, so
it renders as the chain: what it checked, what it reasoned,
what it decided. This is the answer to "why did it do that",
which is the first thing a person needs before trusting or
overriding a machine. */}
{it.kind === 'ai' && it.reasoning.length ? (
<ChainOfThought
reasoning={it.reasoning}
confidence={it.confidence}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/>
) : null}
{/* A human step's reason, and the AI step's reasoning when it is
not the story's actor (rare), stay as a plain quote. */}
{it.kind !== 'ai'
? it.reasoning.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{it.conversation.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))}
{it.figures.length ? (
<div className="tl__figs">
{it.figures.map(([k, v]) => (
<span key={k}><em>{k}</em> {v}</span>
))}
</div>
) : null}
<div className="tl__acts">
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
) : null}
{/* This step put something on WhatsApp itself. The message is in
the thread with the rest of the conversation, not quoted
here a second time. */}
{it.sent && onOpenChat ? (
<button type="button" className="tl__link" onClick={() => onOpenChat(it.chatEpisode)}>
{it.sent === 1 ? 'Sent 1 message on WhatsApp' : `Sent ${it.sent} messages on WhatsApp`}
</button>
) : null}
{/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an
itemised account of what it decided. */}
{it.wrote.length ? (
<details className="tl__wrote">
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
<dl>
{it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
))}
</dl>
</details>
) : null}
</div>
</div>
</li>
)
))}
</ol>
</>
)
}

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

@ -0,0 +1,160 @@
import { useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { NAV_GROUPS, STAGES } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
/**
* The frame: a navy bar that stays put, and a drawer behind the hamburger.
*
* On a phone the queues cannot live beside the content, and stacked above it
* they cost a screenful before the first lead. So they go in a drawer, and the
* bar carries the only two things worth permanent space: where you are, and
* who you are.
*/
/** Two letters for the avatar: a name if we have one, else the local part. */
function initials(user) {
const from = user?.name || user?.email || ''
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
if (!parts.length) return '?'
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
}
/** The badge colour is the colour of whoever is being waited on. */
function tone(stage) {
if (stage.kind === 'needs') return 'red'
if (stage.kind === 'customer') return 'amber'
return 'grey'
}
/** A queue's icon, chosen for what it is waiting on rather than decoration. */
function NavIcon({ kind }) {
if (kind === 'needs') {
return <svg viewBox="0 0 20 20" fill="none"><path d="M6 2.5h5.5L15 6v11.5H6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/><path d="M11 2.5V6h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
}
if (kind === 'customer') {
return <svg viewBox="0 0 20 20" fill="none"><path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
}
if (kind === 'waiting') {
return <svg viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="7" stroke="currentColor" strokeWidth="1.5"/><path d="M10 6v4.2l2.6 1.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
}
return <svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5 8 14l8-8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>
}
export default function Shell() {
const { user, signOut } = useZino()
const navigate = useNavigate()
const roles = rolesOf(user)
const stages = visibleStages(roles)
const { counts, ready } = useStageCounts()
const [open, setOpen] = useState(false)
const [me, setMe] = useState(false)
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
const openTotal = STAGES.filter((s) => s.kind !== 'end').reduce((t, s) => t + count(s).total, 0)
const row = (s) => {
const c = count(s)
const n = s.kind === 'end' ? c.total : c.waiting
const t = tone(s)
return (
<NavLink
key={s.uid} to={`/stage/${s.uid}`} onClick={() => setOpen(false)}
className={({ isActive }) => 'nav nav--' + (s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'red' : 'blue') + (isActive ? ' is-on' : '')}
>
<span className="nav__i" aria-hidden="true"><NavIcon kind={s.kind} /></span>
<span className="nav__t">{s.need ?? s.name}</span>
{ready ? <span className={`nav__n nav__n--${n ? t : 'grey'}`}>{n}</span> : null}
</NavLink>
)
}
return (
<div className="shell">
<header className="appbar">
<button type="button" className="appbar__burger" aria-label="Open the menu" onClick={() => setOpen(true)}>
<svg viewBox="0 0 22 22" fill="none"><path d="M3 6h16M3 11h16M3 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>
</button>
<div className="appbar__brand">
<span className="appbar__z" aria-hidden="true">Z</span>
<span className="appbar__word">ZURICH <span>kotak</span></span>
</div>
<button type="button" className="appbar__me" aria-label="Your account" onClick={() => setMe(true)}>
{initials(user)}
</button>
</header>
<main>
<Outlet />
</main>
{open ? (
<>
<div className="scrim" role="presentation" onClick={() => setOpen(false)} />
<nav className="drawer" aria-label="Queues">
<div className="drawer__head">
<button type="button" className="drawer__x" aria-label="Close the menu" onClick={() => setOpen(false)}>
<svg viewBox="0 0 20 20" fill="none"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round"/></svg>
</button>
<span className="appbar__z" aria-hidden="true">Z</span>
<span className="appbar__word">ZURICH <span>kotak</span></span>
</div>
<div className="drawer__body">
<NavLink to="/" end onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
<span className="nav__i" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none"><path d="M3.5 3.5h6v6h-6zM10.5 3.5h6v6h-6zM3.5 10.5h6v6h-6zM10.5 10.5h6v6h-6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
</span>
<span className="nav__t">Overview</span>
</NavLink>
<NavLink to="/stage/all" onClick={() => setOpen(false)} className={({ isActive }) => 'nav' + (isActive ? ' is-on' : '')}>
<span className="nav__i" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none"><circle cx="7" cy="7" r="2.6" stroke="currentColor" strokeWidth="1.5"/><path d="M2.5 16a4.5 4.5 0 0 1 9 0" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/><path d="M13 5.2a2.6 2.6 0 0 1 0 5M14.5 16a4.4 4.4 0 0 0-2-3.7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
</span>
<span className="nav__t">All leads</span>
{ready ? <span className="nav__n">{openTotal}</span> : null}
</NavLink>
{NAV_GROUPS.map((group) => {
const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
if (!inGroup.length) return null
return (
<div key={group.label}>
<h6 className="drawer__h">{group.label}</h6>
{inGroup.map(row)}
</div>
)
})}
</div>
</nav>
</>
) : null}
{me ? (
<>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMe(false)} />
<div className="sheet" role="dialog" aria-label="Your account">
<span className="sheet__grab" aria-hidden="true" />
<div className="sheet__head">
<div>
<h2>{user?.name || user?.email || 'Signed in'}</h2>
<p>{user?.email}</p>
</div>
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setMe(false)}>×</button>
</div>
<div className="sheet__body">
<button
type="button" className="btn btn--ghost btn--block"
onClick={() => { setMe(false); signOut(); navigate('/login') }}
>
Sign out
</button>
</div>
</div>
</>
) : null}
</div>
)
}

25
src/main.jsx Normal file
View File

@ -0,0 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { ZinoProvider } from './api/provider.jsx'
import { basePath, requireConfigValue } from './runtimeConfig.js'
import './styles/tokens.css'
import './styles/app.css'
/**
* The API base URL is read at RUNTIME from the config.js the server writes when
* it places the build never compiled in, so one artifact is promoted between
* environments unchanged. Same contract as the desktop console.
*/
const baseUrl = requireConfigValue('VITE_ZINO_API_URL')
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter basename={basePath()}>
<ZinoProvider baseUrl={baseUrl}>
<App />
</ZinoProvider>
</BrowserRouter>
</StrictMode>,
)

324
src/pages/Login.css Normal file
View File

@ -0,0 +1,324 @@
.login {
display: grid;
grid-template-columns: 45% 55%;
min-height: 100%;
}
/* ── Left brand panel ──────────────────────────────────────── */
.login__brand {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px 48px;
overflow: hidden;
color: var(--zk-white);
background:
linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
}
.login__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.055) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.055) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(circle at 40% 45%, #000 0%, transparent 78%);
}
.login__glow {
position: absolute;
inset: 0;
background:
radial-gradient(
520px circle at 12% 12%,
rgba(84, 149, 207, 0.38),
transparent 62%
),
radial-gradient(
620px circle at 78% 96%,
rgba(199, 235, 249, 0.16),
transparent 60%
);
}
.login__logo {
position: relative;
z-index: 1;
width: 186px;
height: auto;
border-radius: 8px;
}
.login__brand-body {
position: relative;
z-index: 1;
margin-top: auto;
margin-bottom: auto;
max-width: 480px;
}
.login__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid rgba(199, 235, 249, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--zk-cyan);
font-size: var(--fs-2xs);
font-weight: 400;
letter-spacing: 0.01em;
}
.login__badge svg {
width: 15px;
height: 15px;
}
.login__headline {
/* Zurich Kotak set their hero in Zurich Sans Light */
margin: 26px 0 0;
font-size: var(--fs-3xl);
font-weight: 300;
line-height: 1.16;
letter-spacing: -0.01em;
}
.login__headline-accent {
display: block;
background: linear-gradient(
92deg,
#a9dcf6 0%,
var(--zk-cyan) 55%,
#e9f8ff 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.login__lede {
margin: 16px 0 0;
max-width: 430px;
text-wrap: balance;
font-size: var(--fs-md);
font-weight: 300;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
}
.login__features {
margin: 36px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 22px;
}
.login__features li {
display: flex;
align-items: flex-start;
gap: 14px;
}
.login__feature-icon {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border: 1px solid rgba(199, 235, 249, 0.26);
border-radius: 9px;
background: rgba(255, 255, 255, 0.1);
color: var(--zk-cyan);
}
.login__feature-icon svg {
width: 17px;
height: 17px;
}
.login__features strong {
display: block;
font-size: var(--fs-sm);
font-weight: 500;
letter-spacing: 0.005em;
}
.login__features em {
display: block;
margin-top: 3px;
font-size: var(--fs-xs);
font-style: normal;
font-weight: 300;
color: rgba(255, 255, 255, 0.74);
}
/* ── Right form panel ──────────────────────────────────────── */
.login__panel {
display: grid;
place-items: center;
padding: 40px 24px;
background: var(--zk-tint);
}
.login__form-wrap {
width: 100%;
max-width: 360px;
}
.login__logo--mobile {
display: none;
margin-bottom: 28px;
}
.login__title {
margin: 0;
font-size: var(--fs-2xl);
font-weight: 500;
letter-spacing: -0.015em;
color: var(--zk-navy);
}
.login__subtitle {
margin: 8px 0 0;
font-size: var(--fs-xs);
font-weight: 300;
color: var(--zk-muted);
}
.login__form {
margin-top: 30px;
display: flex;
flex-direction: column;
gap: 18px;
}
.login__field span {
display: block;
margin-bottom: 7px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-ink);
}
.login__field input {
width: 100%;
padding: 11px 14px;
border: 1px solid var(--zk-line);
border-radius: 8px;
background: var(--zk-white);
font-size: var(--fs-xs);
font-weight: 400;
color: var(--zk-ink);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
}
.login__field input::placeholder {
color: var(--zk-grey);
}
.login__field input:focus {
outline: none;
border-color: var(--zk-blue);
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.14);
}
.login__error {
margin: -4px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-danger);
}
.login__submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
padding: 12px 18px;
border: 0;
border-radius: 8px;
background: var(--zk-blue);
color: var(--zk-white);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition:
background 0.16s ease,
box-shadow 0.16s ease;
}
.login__submit svg {
width: 16px;
height: 16px;
}
.login__submit:hover:not(:disabled) {
background: var(--zk-blue-dark);
}
.login__submit:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28);
}
.login__submit:disabled {
opacity: 0.72;
cursor: default;
}
.login__powered {
margin: 26px 0 0;
text-align: center;
font-size: var(--fs-2xs);
font-weight: 300;
color: var(--zk-grey);
}
.login__powered span {
font-weight: 500;
letter-spacing: 0.06em;
color: #e8552f;
text-transform: uppercase;
}
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 1024px) {
.login__brand {
padding: 32px;
}
.login__headline {
font-size: var(--fs-3xl);
}
.login__lede {
font-size: var(--fs-sm);
}
}
@media (max-width: 860px) {
.login {
grid-template-columns: 1fr;
}
.login__brand {
display: none;
}
.login__panel {
align-content: center;
background: var(--zk-white);
}
.login__logo--mobile {
display: block;
}
}

209
src/pages/Login.jsx Normal file
View File

@ -0,0 +1,209 @@
import { useState } from 'react'
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 = [
{
title: 'Instant policy issuance',
body: 'No inspection; hassle free policy',
icon: (
<>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" />
<path d="M14 3v5h5" />
<path d="m9.2 14.2 1.9 1.9 3.7-3.7" />
</>
),
},
{
title: '24/7 claim assistance',
body: 'Making claims simple, transparent, and customer-first',
icon: (
<>
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M12 7.2V12l3.2 1.9" />
</>
),
},
{
title: 'A trusted brand',
body: 'You can count on Zurich Kotak General Insurance at all times',
icon: (
<>
<path d="M12 3 4.8 5.9v5.4c0 4.3 3 8.3 7.2 9.4 4.2-1.1 7.2-5.1 7.2-9.4V5.9L12 3Z" />
<path d="m9.2 12.1 2 2 3.6-3.8" />
</>
),
},
]
export default function Login() {
const [email, setEmail] = useState('')
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()
setError('')
if (!email.trim() || !password) {
setError('Enter your email and password to continue.')
return
}
setSubmitting(true)
try {
// 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)
}
}
return (
<main className="login">
<section className="login__brand">
<div className="login__grid" aria-hidden="true" />
<div className="login__glow" aria-hidden="true" />
<img
className="login__logo"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<div className="login__brand-body">
<span className="login__badge">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.2 12.4a8.8 8.8 0 0 1 17.6 0H3.2Z" />
<path d="M12 12.4v5.9a2.4 2.4 0 0 0 4.8 0" />
</svg>
General insurance that has it all
</span>
<h1 className="login__headline">
Protecting what
<span className="login__headline-accent">
matters the most to you
</span>
</h1>
<p className="login__lede">
Get tailored insurance for all your needs, cause we cover it all
</p>
<ul className="login__features">
{FEATURES.map((feature) => (
<li key={feature.title}>
<span className="login__feature-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{feature.icon}
</svg>
</span>
<span>
<strong>{feature.title}</strong>
<em>{feature.body}</em>
</span>
</li>
))}
</ul>
</div>
</section>
<section className="login__panel">
<div className="login__form-wrap">
<img
className="login__logo login__logo--mobile"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<h2 className="login__title">Sign in</h2>
<p className="login__subtitle">
Enter your credentials to access your dashboard.
</p>
<form className="login__form" onSubmit={handleSubmit} noValidate>
<label className="login__field">
<span>Email</span>
<input
type="email"
name="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="login__field">
<span>Password</span>
<input
type="password"
name="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? (
<p className="login__error" role="alert">
{error}
</p>
) : null}
<button className="login__submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
{submitting ? null : (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12h15m0 0-5.5-5.5M19 12l-5.5 5.5" />
</svg>
)}
</button>
</form>
<p className="login__powered">
Powered by <span>zino</span>
</p>
</div>
</section>
</main>
)
}

53
src/runtimeConfig.js Normal file
View File

@ -0,0 +1,53 @@
/**
* 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 "./".
*
* It reads the TAG, not `document.baseURI`. With no <base> in the document
* which is every `npm run dev` session, where the placeholder is still an HTML
* comment baseURI falls back to the current page URL, so the basename became
* whatever deep path happened to be loaded. Open /lead/16911 directly and every
* link then rendered under it: /lead/16911/lead/11411, and again on the next
* click. Unmounted means mounted at the root.
*/
export function basePath() {
const tag = document.querySelector('base[href]')
if (!tag) return '/'
const path = new URL(tag.href, window.location.origin).pathname
// A relative href ("./") resolves against the current URL and would reintroduce
// exactly the same drift. Only an absolute mount path is a mount path.
return tag.getAttribute('href').startsWith('/') ? path : '/'
}

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

@ -0,0 +1,347 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import { APP_ID, DV_LEAD, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import { BLOCKER, GROUPS, LONG, MONEY, expiryOf, filesOf, fmt, inrShort, label } from '../api/lead.js'
import Timeline from '../components/Timeline.jsx'
/**
* One lead, on a phone.
*
* The desktop puts the story in a wide column with the record beside it. Here
* everything is one column, ordered by how often it is needed: what this lead
* wants from you, the four numbers, where it is in the journey, what happened,
* then the record. The action itself is pinned to the bottom of the screen
* on a phone the thing you came to do must be reachable without scrolling back.
*/
/** Who is holding it, in the words the header uses. */
function holdsOf(stage) {
if (!stage) return null
switch (stage.kind) {
case 'auto': return { tone: 'blue', label: 'With the AI' }
case 'customer': return { tone: 'teal', label: 'With the customer' }
case 'needs': return { tone: 'amber', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return null
}
}
/* The journey, as the desktop rail states it. */
const PATH = ['zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded']
const STEP = { 'zk-state-new': 'Filed', 'zk-state-qualified': 'Called', 'zk-state-contacted': 'Contacted',
'zk-state-docs': 'Documents', 'zk-state-quoted': 'Quoted', 'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued', 'zk-state-onboarded': 'Onboarded' }
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
const navigate = useNavigate()
const location = useLocation()
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [audit, setAudit] = useState(null)
const [labels, setLabels] = useState({})
const [open, setOpen] = useState(null) // the activity whose form is up
const [showAll, setShowAll] = useState(false)
const railRef = useRef(null)
// Sampled on a tick rather than read during render, so the "nothing for N
// minutes" line is stable within a paint.
const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => {
if (!quiet) setErr(null)
// Best-effort and never awaited with the record: a failed audit call must
// not blank the lead, and a slow one must not hold up the fields.
client.audit(instanceId)
.then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => { /* keep whatever is on screen */ })
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
// The detail view ships an output_label per field, so a field renamed
// in Studio is renamed here without a release.
const fields = r?.config?.fields
if (Array.isArray(fields)) {
setLabels(Object.fromEntries(
fields.filter((f) => f.field_key && f.output_label).map((f) => [f.field_key, f.output_label]),
))
}
})
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
useEffect(() => { load() }, [load])
// The AI works this lead in the background, so the page has to keep up with
// it. Twelve seconds is the compromise between "it moved" and battery; the
// poll is quiet, so a dropped request leaves the screen exactly as it is.
useEffect(() => {
const id = setInterval(() => { load(true); setNow(Date.now()) }, 12000)
return () => clearInterval(id)
}, [load])
// The rail scrolls, so the step the lead is actually ON has to be brought
// into view; otherwise a lead at Payment opens showing Filed.
useEffect(() => {
const el = railRef.current?.querySelector('.is-here')
el?.scrollIntoView({ block: 'nearest', inline: 'center' })
}, [row?.current_state_name])
if (err) {
return (
<div className="page">
<div className="notice">
<strong>Unable to load this lead.</strong>
<p>{describeError(err).title} · {err?.status} {err?.message}</p>
</div>
</div>
)
}
if (!row) {
return <div className="page"><div className="skel" aria-busy="true"><i /><i /><i /></div></div>
}
const stateName = row.current_state_name || ''
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage)
const actions = actionsFor(roles, stage?.uid)
const primary = actions.find((a) => a.role === 'do')
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
// "How long has this been still?" is read from the clock, which makes it
// impure in a render body. It is recomputed on every poll instead which is
// also the only moment it can have changed.
const stalled = !blocked && stage?.kind === 'auto' && now - Date.parse(row.updated_at || 0) > STALL_AFTER_MS
? { mins: Math.round((now - Date.parse(row.updated_at || 0)) / 60000), nudge: nudgeFor(stage?.uid, roles) }
: null
const e = expiryOf(row.renewal_due_date)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
const dueTone = e ? (e.tone === 'lapsed' ? 'red' : e.tone === 'urgent' ? 'amber' : '') : ''
const here = PATH.indexOf(stage?.uid)
const offPath = here < 0
// The record, grouped, empty groups dropped.
const groups = GROUPS
.map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v, f]) => f.length || v)])
.filter(([, rows]) => rows.length)
const shown = showAll ? groups : groups.slice(0, 3)
return (
<div className={'page' + (primary || blocked || stalled?.nudge ? ' page--acts' : '')}>
<header className="lhead">
<button
type="button" className="back" aria-label="Back"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" fill="none"><path d="M9.5 3.5 5 8l4.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="lhead__id">
<h1>{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p>
{row.lead_ref || `Lead ${instanceId}`}
{row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
</p>
{/* The stage and its holder sit UNDER the name, not beside it: a
customer name is as long as it is, and squeezing a pill in next to
it wrapped "Priya Raghavan" onto two lines to make room. */}
<div className="lhead__st">
<span>{stateName}</span>
{holds ? <span className={'pill pill--' + holds.tone}><span className={'dot dot--' + holds.tone} />{holds.label}</span> : null}
</div>
</div>
</header>
{note ? (
<div className="said" role="status">
<p>{note}</p>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
</div>
) : null}
{/* ONE banner. Whatever this lead needs, it says it once, here. */}
{blocked ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />Stopped: {blocked.what}</h3>
<p>{blocked.fix} The agents pick it up again on their own once it is there.</p>
</div>
) : stalled ? (
<div className="banner banner--red" role="status">
<h3><span className="dot dot--red" />Nothing for {stalled.mins} minutes</h3>
<p>
{stage.by} has not come back. That is usually the model provider being slow, not a
problem with this lead the work so far is safe.
</p>
</div>
) : stage?.uid === 'zk-state-review' ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />The AI stopped and asked for a person</h3>
{row.review_reason ? <p className="banner__said">{row.review_reason}</p> : null}
<p>{BLOCKER[row.review_blocker] ?? 'It could not complete its step.'} Nothing is running on this lead.</p>
</div>
) : primary ? (
<div className="banner banner--amber" role="status">
<h3><span className="dot dot--amber" />Your turn: {primary.label}</h3>
<p>This lead is waiting on you; the AI carries on as soon as it is done.</p>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
<div className="banner banner--teal" role="status">
<h3><span className="dot dot--teal" />{stage.doing}</h3>
<p>Their answer comes back on its own. Nothing is waiting on you.</p>
</div>
) : stage?.kind === 'auto' ? (
<div className="banner banner--blue" role="status">
<h3><span className="dot dot--blue" />{stage.doing}</h3>
<p>Handled by {stage.by}. Usually done within two minutes; this refreshes itself.</p>
</div>
) : null}
<section className="card facts" style={{ padding: '4px 0' }}>
<div className="fact">
<span>Premium</span>
<b className={premium ? '' : 'is-none'}>{premium ?? 'Not yet rated'}</b>
<small>{premium ? 'quoted, incl. GST' : 'once Rating has run'}</small>
</div>
<div className="fact">
<span>Commission</span>
<b className={commission ? '' : 'is-none'}>{commission ?? '—'}</b>
<small>{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}</small>
</div>
<div className="fact">
<span>AI confidence</span>
{Number.isFinite(conf) && conf > 0 ? (
<>
<b>{conf}%</b>
<span className="fact__tr" aria-hidden="true"><i style={{ width: Math.min(100, conf) + '%' }} /></span>
<small>on the cover advice</small>
</>
) : (<><b className="is-none"></b><small>once the Advisor has spoken</small></>)}
</div>
<div className="fact">
<span>Renewal due</span>
<b className={dueTone ? 'due--' + dueTone : ''}>{e ? e.label : '—'}</b>
<small>{e ? e.on : 'no date on file'}</small>
</div>
</section>
<section className="card steps" style={{ padding: '15px 0 13px' }}>
<div className="steps__t">
<strong>Journey</strong>
<span>{offPath ? stateName : `Step ${here + 1} of ${PATH.length}`}</span>
{holds ? <span className={'pill pill--' + holds.tone}>{holds.label}</span> : null}
</div>
<div className="steps__rail" ref={railRef}>
{PATH.map((uid, i) => (
<div key={uid} className={'step' + (offPath ? '' : i < here ? ' is-done' : i === here ? ' is-here' : '')}>
<i /><span>{i + 1}. {STEP[uid]}</span>
</div>
))}
</div>
</section>
<section className="card" style={{ marginTop: 12 }}>
<h3 className="card__h">What happened</h3>
<p className="card__s">Every step on this lead, who took it, and what they wrote.</p>
<Timeline rows={audit} />
</section>
{groups.length ? (
<section className="card" style={{ marginTop: 12 }}>
<h3 className="card__h">The record</h3>
<p className="card__s">The customer, the risk, and what this stage turns on.</p>
<div className="kv">
{shown.map(([title, rows]) => (
<section className="kv__g" key={title}>
<h4>{title}</h4>
{rows.map(([k, v, files]) => (
<div className="kv__r" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
{files.length ? (
<dd>
{files.map((f) => (
<a key={f.uuid} className="kv__file" target="_blank" rel="noreferrer"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}>
{f.original_name || 'Document'}
</a>
))}
</dd>
) : (
<dd className={MONEY.has(k) ? 'kv__num' : undefined}>
{v.length > LONG ? v.slice(0, LONG) + '…' : v}
</dd>
)}
</div>
))}
</section>
))}
</div>
{groups.length > 3 ? (
<button type="button" className="btn btn--ghost btn--block btn--sm" style={{ marginTop: 14 }}
onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show less' : `Show all ${groups.length} sections`}
</button>
) : null}
</section>
) : null}
{/* THE ACTION, pinned. On a phone the thing you came to do must be
reachable from wherever you have scrolled to. */}
{primary || blocked || stalled?.nudge ? (
<div className="actbar">
{blocked ? (
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(blocked.via)}>
Open Collect Documents
</button>
) : stalled?.nudge ? (
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(stalled.nudge.uid)}>
{stalled.nudge.label}
</button>
) : (
<>
<button type="button" className="btn btn--navy" onClick={() => setOpen(primary.uid)}>{primary.label}</button>
{actions.filter((a) => a.role === 'do' && a.uid !== primary.uid).map((a) => (
<button key={a.uid} type="button" className="btn btn--danger" onClick={() => setOpen(a.uid)}>{a.label}</button>
))}
</>
)}
</div>
) : null}
{open ? (
<>
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setOpen(null)} />
<div className="sheet" role="dialog" aria-label="Action">
<span className="sheet__grab" aria-hidden="true" />
<div className="sheet__head">
<div>
<h2>{actions.find((a) => a.uid === open)?.label ?? 'Action'}</h2>
<p>{row.customer_name || row.lead_ref}</p>
</div>
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setOpen(null)}>×</button>
</div>
<div className="sheet__body">
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
lead={row}
onCancel={() => setOpen(null)}
onStale={() => { setOpen(null); setNote('Stage changed. The actions have been refreshed.'); load() }}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
/>
</div>
</div>
</>
) : null}
</div>
)
}

147
src/screens/Leads.jsx Normal file
View File

@ -0,0 +1,147 @@
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { describeError } from '../api/errors.js'
import LeadCard from '../components/LeadCard.jsx'
/**
* A queue, as a list of cards.
*
* One record view drives every queue; the stage is a server-side filter on
* current_state_name. "all" is not a stage it is every lead in one list,
* closed ones included and dimmed, because "where did it go?" is exactly the
* question somebody opens this page to answer.
*/
export default function Leads() {
const { stageUid } = useParams()
const navigate = useNavigate()
const { client } = useZino()
const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false)
const [q, setQ] = useState('')
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
const stage = isAll
? { uid: 'all', name: 'All leads', kind: 'all', need: 'All leads' }
: STAGES.find((s) => s.uid === stageUid)
useEffect(() => {
let cancelled = false
const load = () => {
client
.recordView(RV_LEADS, {
page: 1,
limit: isAll ? 200 : 100,
sort_by: 'updated_at',
sort_order: 'desc',
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
})
.then((res) => {
if (cancelled) return
const rows = res?.data ?? res?.rows ?? []
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null })
})
.catch((error) => { if (!cancelled) setState((p) => ({ ...p, status: 'error', error })) })
}
load()
const id = setInterval(load, 20000)
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll])
if (!stage) return <div className="page"><p className="empty">Unknown stage.</p></div>
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
const needle = q.trim().toLowerCase()
const rows = (isAll && hideClosed ? state.rows.filter((r) => !CLOSED.has(r.current_state_name)) : state.rows)
.filter((r) => !needle || [r.customer_name, r.lead_ref, r.vehicle_reg, r.entity_name, r.customer_phone]
.some((v) => String(v || '').toLowerCase().includes(needle)))
// Inside a person-gated queue the rows split in two: this person's turn, and
// the ones the AI is still working inside the same state. A flat list reads
// as one queue when it is two.
const grouped = !isAll && (stage.kind === 'needs' || stage.kind === 'customer')
const mine = grouped ? rows.filter((r) => phaseOf(stage, r).kind !== 'auto') : rows
const ai = grouped ? rows.filter((r) => phaseOf(stage, r).kind === 'auto') : []
const open = (id) => navigate(`/lead/${id}`)
return (
<div className="page">
<div className="page__top">
<div>
<h1 className="page__h">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{isAll
? 'Every lead, across all stages.'
: stage.kind === 'auto' ? `Automated · ${String(stage.doing || '').toLowerCase()}`
: stage.kind === 'end' ? 'Closed — retained for reporting.'
: stage.kind === 'waiting' ? 'Held until the renewal window opens.'
: `Pending action by ${stage.by}.`}
</p>
</div>
</div>
<div className="tools">
<label className="search">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="4.6" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.6 10.6 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
value={q} onChange={(e) => setQ(e.target.value)} type="search"
placeholder="Find a lead, a registration, a reference" aria-label="Find a lead"
/>
</label>
<div className="tools__row">
{isAll && closedCount ? (
<label className={'toggle' + (hideClosed ? ' is-on' : '')}>
<input type="checkbox" checked={hideClosed} onChange={(e) => setHideClosed(e.target.checked)} />
<i aria-hidden="true" />
Hide {closedCount} closed
</label>
) : null}
<span className="tools__n">{rows.length} {rows.length === 1 ? 'lead' : 'leads'}</span>
</div>
</div>
{state.status === 'loading' ? (
<div className="skel" aria-busy="true" aria-label="Loading the queue"><i /><i /><i /><i /></div>
) : null}
{state.status === 'error' ? (
<div className="notice">
<strong>Unable to load this queue.</strong>
<p>{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div>
) : null}
{state.status === 'ready' && rows.length === 0 ? (
<p className="empty">{needle ? 'No lead matches that.' : isAll ? 'No leads yet.' : 'Nothing in this queue.'}</p>
) : null}
{state.status === 'ready' && rows.length > 0 ? (
<>
{grouped && ai.length ? (
<div className="grouphead">
<span className={'dot dot--' + (stage.kind === 'customer' ? 'teal' : 'amber')} />
{stage.kind === 'customer' ? 'With the customer' : 'Your turn'}<span>{mine.length}</span>
</div>
) : null}
{mine.map((r) => <LeadCard key={r.instance_id ?? r.id} row={r} onOpen={open} />)}
{grouped && ai.length ? (
<>
<div className="grouphead">
<span className="dot dot--blue" />With the AI<span>{ai.length} · nothing for anyone to do</span>
</div>
{ai.map((r) => <LeadCard key={r.instance_id ?? r.id} row={r} onOpen={open} />)}
</>
) : null}
</>
) : null}
</div>
)
}

264
src/screens/Overview.jsx Normal file
View File

@ -0,0 +1,264 @@
import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import { STAGES, phaseOf } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
/**
* What needs a person, first thing in the morning.
*
* Same computation as the desktop console one record-view call, everything
* derived here but ordered for a thumb: custody, then risk, then the queues
* you can actually clear, then the money, then the shape of the pipeline. The
* desktop leads with money because a manager reads it at a desk; a phone leads
* with work because that is why it came out of a pocket.
*/
const DAY = 86400000
const today = () => Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
function daysToExpiry(v) {
if (!v) return null
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
return isNaN(d) ? null : Math.round((d - today()) / DAY)
}
function ageInDays(v) {
if (!v) return null
const d = Date.parse(v)
return isNaN(d) ? null : Math.floor((Date.now() - d) / DAY)
}
/** Indian money, short — eight digits do not fit on a phone. */
function inr(n) {
if (!n) return '₹0'
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
const tidy = (s) => String(s || '').replace(/^(the|an|a)\s+/i, '').replace(/^./, (c) => c.toUpperCase())
export default function Overview() {
const { user } = useZino()
const navigate = useNavigate()
const roles = rolesOf(user)
const state = usePortfolio()
const m = useMemo(() => {
const rows = state.rows
const byStage = {}
for (const r of rows) byStage[r.current_state_name || '—'] = (byStage[r.current_state_name || '—'] || 0) + 1
const endNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name))
const open = rows.filter((r) => !endNames.has(r.current_state_name))
const won = rows.filter((r) => r.current_state_name === 'Onboarded' || r.current_state_name === 'Policy Issued')
const lost = rows.filter((r) => r.current_state_name === 'Lost / Dropped' || r.current_state_name === 'Declined')
const closed = won.length + lost.length
const gwp = won.reduce((t, r) => t + num(r.quoted_premium), 0)
const commission = won.reduce((t, r) => t + num(r.commission_amount), 0)
// Policy Issued is written business AND still open, so it appears in both
// sets; counting it in pipeline as well would bank the same premium twice.
const wonIds = new Set(won.map((r) => r.instance_id ?? r.id))
const pipeline = open.filter((r) => !wonIds.has(r.instance_id ?? r.id)).reduce((t, r) => t + num(r.quoted_premium), 0)
const dated = open.map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date) })).filter((r) => r._d !== null)
const exposure = {
lapsed: dated.filter((r) => r._d < 0),
week: dated.filter((r) => r._d >= 0 && r._d <= 7),
}
// The count is of leads a PERSON must act on, not of leads sitting in the
// state: Document Pending holds both the ones waiting for an upload and the
// ones the AI is working inside the same state.
const actionable = STAGES
.filter((s) => s.kind === 'needs' || s.kind === 'customer')
.map((s) => {
const here = open.filter((r) => r.current_state_name === s.name)
const working = here.filter((r) => phaseOf(s, r).kind === 'auto')
const waiting = here.filter((r) => phaseOf(s, r).kind !== 'auto')
return {
...s, n: waiting.length, working: working.length,
oldest: waiting.map((r) => ageInDays(r.created_at)).filter((x) => x !== null).sort((a, b) => b - a)[0],
}
})
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
let withAI = 0, withCustomer = 0, withPerson = 0
for (const r of open) {
const def = byStageDef.get(r.current_state_name)
if (!def) continue
const ph = phaseOf(def, r)
if (ph.kind === 'auto') withAI += 1
else if (ph.kind === 'customer') withCustomer += 1
else withPerson += 1
}
return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, actionable,
withAI, withCustomer, withPerson,
conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
}
}, [state.rows])
const mine = new Set(visibleStages(roles).map((s) => s.uid))
if (state.status === 'loading') {
return (
<div className="page">
<div className="skel" aria-busy="true" aria-label="Loading"><i /><i /><i /><i /></div>
</div>
)
}
if (state.status === 'error') {
return (
<div className="page">
<div className="notice">
<strong>Unable to load the book.</strong>
<p>{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div>
</div>
)
}
const total = m.open.length || 1
const pct = (n) => Math.max(n ? 3 : 0, Math.round((n / total) * 100))
const risk = m.exposure.lapsed.length + m.exposure.week.length
const waiting = m.actionable.reduce((t, s) => t + s.n, 0)
return (
<div className="page">
<div className="page__top">
<div>
<h1 className="page__h">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals, organisation-wide</p>
</div>
</div>
{/* WHO HOLDS THE WORK. Every open lead is in exactly one of three hands,
and that is the sentence this console exists to say. */}
<section className="card">
<div className="custody__top">
<b>{m.open.length}</b><span>open leads</span>
</div>
<div className="bar" aria-hidden="true">
<i className="bar__blue" style={{ width: pct(m.withAI) + '%' }} />
<i className="bar__amber" style={{ width: pct(m.withPerson) + '%' }} />
<i className="bar__teal" style={{ width: pct(m.withCustomer) + '%' }} />
</div>
<p className="custody__note">Who is holding each one right now:</p>
</section>
<div style={{ marginTop: 12 }}>
<div className="hold hold--blue">
<div className="hold__body">
<div className="hold__t"><strong>With the AI</strong><span className="pill pill--blue">Automated</span></div>
<p className="hold__d">Being qualified, rated, checked or chased. Nothing for anyone to do.</p>
</div>
<div className="hold__n"><b>{m.withAI}</b><span>leads</span></div>
</div>
<div className="hold hold--amber">
<div className="hold__body">
<div className="hold__t"><strong>Waiting on a person</strong><span className="pill pill--amber">Action owed</span></div>
<p className="hold__d">A decision or an upload is owed by an agent, underwriter or ops.</p>
</div>
<div className="hold__n"><b>{m.withPerson}</b><span>leads</span></div>
</div>
<div className="hold hold--teal">
<div className="hold__body">
<div className="hold__t"><strong>With the customer</strong><span className="pill pill--teal">Pending reply</span></div>
<p className="hold__d">A quote or a payment is theirs to answer.</p>
</div>
<div className="hold__n"><b>{m.withCustomer}</b><span>leads</span></div>
</div>
</div>
<section className={'card risk' + (risk ? '' : ' is-quiet')} style={{ marginTop: 12 }}>
<div className="risk__top">
<b>{risk}</b>
<strong>{risk === 1 ? 'renewal at risk' : 'renewals at risk'}</strong>
{risk ? <span className="pill pill--red"><span className="dot dot--red" />Attention</span> : null}
</div>
<div className="risk__pair">
<div><span>Already lapsed</span><b>{m.exposure.lapsed.length}</b></div>
<div><span>Expire within 7 days</span><b>{m.exposure.week.length}</b></div>
</div>
</section>
{/* The queues, in the order a person clears them: oldest first. */}
<h2 className="page__h" style={{ fontSize: 20, marginTop: 26 }}>Waiting on a person</h2>
<p className="page__sub" style={{ marginBottom: 14 }}>{waiting} leads, oldest first</p>
{[...m.actionable].sort((a, b) => (b.oldest ?? -1) - (a.oldest ?? -1)).map((s) => (
<button
key={s.uid} type="button"
className={'q q--' + (s.kind === 'customer' ? 'teal' : 'amber') + (s.n ? '' : ' q--clear')}
onClick={() => navigate(`/stage/${s.uid}`)}
>
<span className="q__n">{s.n}</span>
<span className="q__body">
<span className="q__t">{s.need ?? s.name}</span>
<span className="q__chips">
<span className={'pill pill--' + (s.kind === 'customer' ? 'teal' : 'amber')}>
<span className={'dot dot--' + (s.kind === 'customer' ? 'teal' : 'amber')} />{tidy(s.by)}
</span>
{s.working ? <span className="pill pill--blue">{s.working} more with the AI</span> : null}
{!mine.has(s.uid) ? <span className="pill pill--grey">View</span> : null}
</span>
<span className="q__age">
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest} day${s.oldest === 1 ? '' : 's'}` : ''}
</span>
</span>
<span className="btn btn--ghost btn--sm q__go">Open</span>
</button>
))}
<section className="kpis">
<div className="kpi">
<span>Pipeline value</span><b>{inr(m.pipeline)}</b><small>quoted, not yet on risk</small>
</div>
<div className="kpi">
<span>Written premium</span><b>{inr(m.gwp)}</b><small>{m.won.length} issued</small>
</div>
<div className="kpi">
<span>Conversion</span><b>{m.conversion === null ? '—' : m.conversion + '%'}</b><small>of leads that closed</small>
</div>
<div className="kpi">
<span>Commission</span><b>{inr(m.commission)}</b><small>payable to partners</small>
</div>
</section>
<section className="card" style={{ marginTop: 12 }}>
<h3 className="card__h">Pipeline by stage</h3>
<p className="card__s">{m.open.length} open across all stages</p>
{STAGES.filter((s) => s.kind !== 'end').map((s) => {
const n = m.byStage[s.name] || 0
const t = s.kind === 'auto' ? 'blue' : s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'amber' : 'grey'
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist' + (n ? '' : ' is-zero')}>
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<i className={'is-' + t} style={{ width: (n / m.maxStage) * 100 + '%' }} />
</span>
<span className="dist__n">{n}</span>
</Link>
)
})}
<div className="dist__sec">Closed · {m.closed}</div>
{STAGES.filter((s) => s.kind === 'end').map((s) => {
const n = m.byStage[s.name] || 0
const max = Math.max(1, ...STAGES.filter((x) => x.kind === 'end').map((x) => m.byStage[x.name] || 0))
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className="dist is-zero">
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true"><i className="is-grey" style={{ width: (n / max) * 100 + '%' }} /></span>
<span className="dist__n">{n}</span>
</Link>
)
})}
</section>
</div>
)
}

459
src/styles/app.css Normal file
View File

@ -0,0 +1,459 @@
/*
Lead Desk, on a phone.
The desktop console answers "what is the state of the book?" across a wide
grid. A phone answers "what needs me, and what happened to this one?" in a
column you scroll with a thumb. So: no tables anywhere every row of the
desktop grid becomes a CARD, because a table narrower than its columns is
either a horizontal scroll nobody finds or a squeeze nobody can read.
Same palette and the same colour rule as the console (blue = the AI holds
it, amber = a person, teal = the customer, red = risk), because it is one
product; only the scale and the density change.
*/
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: var(--zk-tint); color: var(--zk-ink); }
#root { min-height: 100dvh; }
button { font: inherit; }
/* App bar
Fixed, and padded for the notch. Everything else scrolls under it. */
.appbar {
position: sticky; top: 0; z-index: 40;
display: flex; align-items: center; gap: 12px;
height: calc(56px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 14px 0;
background: var(--zk-navy); color: #fff;
}
.appbar__burger {
width: 38px; height: 38px; flex: none; margin-left: -6px;
display: grid; place-items: center;
border: 0; border-radius: 10px; background: transparent; color: #fff; cursor: pointer;
}
.appbar__burger svg { width: 22px; height: 22px; }
.appbar__burger:active { background: rgba(255,255,255,.14); }
.appbar__brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.appbar__z {
width: 30px; height: 30px; flex: none; border-radius: 50%;
display: grid; place-items: center;
background: #fff; color: var(--zk-navy); font-weight: 700; font-size: 16px;
}
.appbar__word { font-size: 16px; font-weight: 700; letter-spacing: .05em; white-space: nowrap; }
.appbar__word span { font-weight: 400; letter-spacing: 0; opacity: .85; }
.appbar__me {
margin-left: auto; flex: none;
width: 34px; height: 34px; border-radius: 50%; border: 0; cursor: pointer;
display: grid; place-items: center;
background: #fff; color: var(--zk-navy); font-size: 13px; font-weight: 700;
}
/* ── Page ───────────────────────────────────────────────────────────────── */
.page { padding: 18px 16px calc(28px + env(safe-area-inset-bottom)); }
.page--acts { padding-bottom: calc(96px + env(safe-area-inset-bottom)); }
.page__top { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
.page__h {
margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -.01em;
line-height: 1.15; color: var(--zk-ink);
}
.page__sub { margin: 5px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.45; }
.page__act { margin-left: auto; flex: none; }
.stamp { font-size: var(--fs-2xs); color: var(--zk-grey); }
/* ── Buttons ────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 11px 16px; border-radius: 10px; border: 1px solid transparent;
font-size: var(--fs-sm); font-weight: 600; cursor: pointer; text-decoration: none;
min-height: 44px; /* a thumb needs 44px; smaller is a miss */
}
.btn svg { width: 15px; height: 15px; }
.btn--primary { background: var(--zk-blue); color: #fff; }
.btn--primary:active { background: var(--zk-navy-2); }
.btn--navy { background: var(--zk-navy); color: #fff; }
.btn--navy:active { background: var(--zk-navy-2); }
.btn--ghost { background: #fff; border-color: var(--zk-line); color: var(--zk-ink); }
.btn--ghost:active { background: var(--zk-tint); }
.btn--danger { background: #fff; border-color: var(--zk-danger-line); color: var(--zk-danger); }
.btn--sm { min-height: 36px; padding: 7px 14px; font-size: var(--fs-xs); }
.btn--block { width: 100%; }
.btn:disabled { opacity: .5; }
/* ── Pills, chips, dots ─────────────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 11px; border-radius: 999px;
font-size: var(--fs-2xs); font-weight: 600; line-height: 1.45; white-space: nowrap;
}
.pill--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
.pill--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.pill--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
.pill--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
.pill--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
.dot--blue { background: var(--zk-blue); } .dot--amber { background: var(--zk-amber); }
.dot--teal { background: var(--zk-teal); } .dot--red { background: var(--zk-danger); }
.dot--grey { background: var(--zk-grey); }
.who { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-2xs); color: var(--zk-muted); }
.who b { font-weight: 600; color: var(--zk-ink); }
/* ── Card ───────────────────────────────────────────────────────────────── */
.card {
background: #fff; border: 1px solid var(--zk-line); border-radius: 14px;
padding: 16px;
}
.card + .card { margin-top: 12px; }
.card__h { margin: 0 0 2px; font-size: var(--fs-lg); font-weight: 700; }
.card__s { margin: 0 0 12px; font-size: var(--fs-2xs); color: var(--zk-grey); }
/* ── Lead card — the row that used to be a table row ────────────────────── */
.lead {
display: block; width: 100%; text-align: left; position: relative;
background: #fff; border: 1px solid var(--zk-line); border-radius: 14px;
padding: 14px 16px; margin-bottom: 10px; cursor: pointer;
color: inherit; text-decoration: none;
}
.lead:active { background: var(--zk-tint); }
.lead__top { display: flex; align-items: flex-start; gap: 10px; }
.lead__id { min-width: 0; flex: 1; }
.lead__name {
display: block; font-size: var(--fs-md); font-weight: 700; color: var(--zk-ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lead__ref { display: block; font-size: var(--fs-2xs); color: var(--zk-grey); margin-top: 2px; }
.lead__doing { margin: 8px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.45; }
.lead__foot {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--zk-line-soft);
}
.lead__foot .who { margin-left: auto; }
/* A due chip carries its own urgency, so the date never has to be read twice. */
.due {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 10px; border-radius: 8px;
font-size: var(--fs-2xs); font-weight: 600;
background: var(--zk-line-soft); color: var(--zk-muted);
}
.due svg { width: 13px; height: 13px; }
.due small { font-weight: 400; opacity: .8; }
.due--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.due--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
/* Stalled: a red edge down the whole card. On a phone a tinted row is
invisible while scrolling; an edge is not. */
.lead--stalled { border-color: var(--zk-danger-line); }
.lead--stalled::before {
content: ''; position: absolute; left: -1px; top: -1px; bottom: -1px; width: 4px;
border-radius: 14px 0 0 14px; background: var(--zk-danger);
}
.lead__alarm { display: flex; align-items: center; gap: 7px; margin-top: 8px; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-danger); }
.lead--done { opacity: .72; }
.lead--done .lead__name { font-weight: 600; color: var(--zk-muted); }
/* ── Tools: search and a filter row ─────────────────────────────────────── */
.tools { margin-bottom: 14px; }
.search {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-radius: 12px;
background: #fff; border: 1px solid var(--zk-line); color: var(--zk-grey);
}
.search svg { width: 16px; height: 16px; flex: none; }
.search input {
flex: 1; min-width: 0; border: 0; outline: 0; background: none;
font: inherit; font-size: var(--fs-sm); color: var(--zk-ink);
}
.search:focus-within { border-color: var(--zk-blue); box-shadow: var(--ring); }
.tools__row { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
.tools__n { margin-left: auto; font-size: var(--fs-2xs); color: var(--zk-grey); }
.toggle { display: inline-flex; align-items: center; gap: 9px; font-size: var(--fs-2xs); color: var(--zk-muted); cursor: pointer; }
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle i {
width: 38px; height: 22px; border-radius: 11px; background: var(--zk-line);
position: relative; display: inline-block; flex: none; transition: background .15s;
}
.toggle i::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
border-radius: 50%; background: #fff; transition: left .15s;
}
.toggle.is-on i { background: var(--zk-blue); }
.toggle.is-on i::after { left: 18px; }
/* A group heading inside a list — "Your turn", "With the AI". */
.grouphead {
display: flex; align-items: center; gap: 9px;
margin: 18px 0 10px; font-size: var(--fs-2xs); font-weight: 700; color: var(--zk-muted);
}
.grouphead span { font-weight: 400; color: var(--zk-grey); }
.grouphead:first-child { margin-top: 0; }
/* ── Overview: custody ──────────────────────────────────────────────────── */
.custody__top { display: flex; align-items: baseline; gap: 10px; }
.custody__top b { font-size: 30px; font-weight: 700; letter-spacing: -.01em; }
.custody__top span { font-size: var(--fs-sm); color: var(--zk-muted); }
.bar { display: flex; gap: 3px; height: 10px; border-radius: 5px; overflow: hidden; background: var(--zk-line-soft); margin: 14px 0 10px; }
.bar i { display: block; height: 100%; }
.bar__blue { background: var(--zk-blue); } .bar__amber { background: var(--zk-amber-bar); } .bar__teal { background: var(--zk-teal); }
.custody__note { margin: 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.hold {
display: flex; align-items: flex-start; gap: 12px; position: relative;
background: #fff; border: 1px solid var(--zk-line); border-radius: 14px;
padding: 15px 16px 15px 19px; margin-bottom: 10px; overflow: hidden;
width: 100%; text-align: left; cursor: pointer; color: inherit;
}
.hold::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; }
.hold--blue::before { background: var(--zk-blue); }
.hold--amber::before { background: var(--zk-amber-bar); }
.hold--teal::before { background: var(--zk-teal); }
.hold__body { flex: 1; min-width: 0; }
.hold__t { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hold__t strong { font-size: var(--fs-md); font-weight: 700; }
.hold__d { margin: 5px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.45; }
.hold__n { flex: none; text-align: right; }
.hold__n b { display: block; font-size: 30px; font-weight: 700; line-height: 1; }
.hold__n span { font-size: var(--fs-3xs); color: var(--zk-grey); }
.hold--blue .hold__n b { color: var(--zk-blue); }
.hold--amber .hold__n b { color: var(--zk-amber); }
.hold--teal .hold__n b { color: var(--zk-teal); }
/* Risk — the one red thing on the screen. */
.risk { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); }
.risk__top { display: flex; align-items: center; gap: 12px; }
.risk__top b { font-size: 34px; font-weight: 700; color: var(--zk-danger); line-height: 1; }
.risk__top strong { font-size: var(--fs-md); font-weight: 700; }
.risk__top .pill { margin-left: auto; }
.risk__pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 14px; }
.risk__pair div { background: #fff; border: 1px solid var(--zk-danger-line); border-radius: 10px; padding: 11px 13px; }
.risk__pair span { display: block; font-size: var(--fs-2xs); color: var(--zk-muted); }
.risk__pair b { display: block; font-size: 22px; font-weight: 700; color: var(--zk-danger); margin-top: 3px; }
.risk.is-quiet { background: var(--zk-teal-tint); border-color: var(--zk-teal-line); }
.risk.is-quiet .risk__top b, .risk.is-quiet .risk__pair b { color: var(--zk-teal); }
.risk.is-quiet .risk__pair div { border-color: var(--zk-teal-line); }
/* KPIs, two across. */
.kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 12px; }
.kpi { background: #fff; border: 1px solid var(--zk-line); border-radius: 14px; padding: 14px; }
.kpi span { display: block; font-size: var(--fs-2xs); color: var(--zk-muted); }
.kpi b { display: block; font-size: 22px; font-weight: 700; letter-spacing: -.01em; margin: 4px 0 2px; }
.kpi small { font-size: var(--fs-3xs); color: var(--zk-grey); }
/* ── Overview: the queues ───────────────────────────────────────────────── */
.q {
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
background: #fff; border: 1px solid var(--zk-line); border-radius: 14px;
padding: 13px 14px; margin-bottom: 10px; cursor: pointer; color: inherit;
}
.q__n {
flex: none; width: 52px; height: 52px; border-radius: 12px;
display: grid; place-items: center;
background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
font-size: 22px; font-weight: 700;
}
.q--amber .q__n { background: var(--zk-amber-tint); border-color: var(--zk-amber-line); color: var(--zk-amber); }
.q--teal .q__n { background: var(--zk-teal-tint); border-color: var(--zk-teal-line); color: var(--zk-teal); }
.q--clear .q__n { color: var(--zk-grey); }
.q__body { flex: 1; min-width: 0; }
.q__t { font-size: var(--fs-md); font-weight: 700; }
.q__chips { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; margin-top: 6px; }
.q__age { display: block; margin-top: 6px; font-size: var(--fs-3xs); color: var(--zk-grey); }
.q__go { flex: none; }
/* Pipeline by stage. */
.dist { display: flex; align-items: center; gap: 12px; padding: 9px 0; text-decoration: none; color: inherit; }
.dist__l { flex: 1; min-width: 0; font-size: var(--fs-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dist__bar { flex: none; width: 96px; height: 8px; border-radius: 4px; background: var(--zk-line-soft); overflow: hidden; }
.dist__bar i { display: block; height: 100%; border-radius: 4px; background: var(--zk-navy); }
.dist__bar i.is-blue { background: var(--zk-blue); } .dist__bar i.is-amber { background: var(--zk-amber-bar); }
.dist__bar i.is-teal { background: var(--zk-teal); } .dist__bar i.is-grey { background: #AEB7C6; }
.dist__n {
flex: none; min-width: 34px; text-align: center; padding: 3px 8px; border-radius: 8px;
font-size: var(--fs-2xs); font-weight: 700; background: var(--zk-tint); color: var(--zk-ink);
}
.dist.is-zero .dist__l, .dist.is-zero .dist__n { color: var(--zk-grey); }
.dist__sec { margin: 12px 0 2px; padding-top: 12px; border-top: 1px solid var(--zk-line-soft); font-size: var(--fs-3xs); font-weight: 700; color: var(--zk-grey); }
/* ── Lead detail ────────────────────────────────────────────────────────── */
.lhead { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
.back {
flex: none; width: 38px; height: 38px; border-radius: 50%;
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted);
display: grid; place-items: center; cursor: pointer;
}
.back svg { width: 17px; height: 17px; }
.lhead__id { flex: 1; min-width: 0; }
.lhead__id h1 { margin: 0; font-size: 24px; font-weight: 700; letter-spacing: -.01em; line-height: 1.2; }
.lhead__id p { margin: 3px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.lhead__st { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-top: 9px; }
.lhead__st > span { font-size: var(--fs-2xs); color: var(--zk-muted); }
/* The banner: one sentence about what this lead needs, in its holder's colour. */
.banner { border-radius: 14px; padding: 15px 16px; margin-bottom: 12px; border: 1px solid; }
.banner h3 { margin: 0; font-size: var(--fs-md); font-weight: 700; display: flex; align-items: center; gap: 9px; }
.banner p { margin: 7px 0 0; font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-muted); }
.banner .btn { margin-top: 12px; }
.banner--blue { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.banner--amber { background: var(--zk-amber-tint); border-color: var(--zk-amber-line); }
.banner--teal { background: var(--zk-teal-tint); border-color: var(--zk-teal-line); }
.banner--red { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); }
.banner__said {
margin-top: 9px; padding: 9px 13px; background: #fff; border-radius: 0 10px 10px 0;
border-left: 3px solid var(--zk-amber-bar); font-size: var(--fs-sm); color: var(--zk-ink);
}
/* Four facts, two across. */
.facts { display: grid; grid-template-columns: 1fr 1fr; gap: 0; padding: 4px 0; }
.fact { padding: 12px 14px; }
.fact:nth-child(odd) { border-right: 1px solid var(--zk-line-soft); }
.fact:nth-child(-n+2) { border-bottom: 1px solid var(--zk-line-soft); }
.fact span { display: block; font-size: var(--fs-2xs); color: var(--zk-muted); }
.fact b { display: block; font-size: 21px; font-weight: 700; letter-spacing: -.01em; margin: 3px 0 2px; }
.fact b.is-none { font-size: var(--fs-md); font-weight: 600; color: var(--zk-grey); }
.fact b.due--red { color: var(--zk-danger); } .fact b.due--amber { color: var(--zk-amber); }
.fact small { font-size: var(--fs-3xs); color: var(--zk-grey); line-height: 1.4; display: block; }
.fact__tr { height: 5px; border-radius: 3px; background: var(--zk-line-soft); overflow: hidden; margin: 5px 0 4px; }
.fact__tr i { display: block; height: 100%; background: var(--zk-teal); border-radius: 3px; }
/* The journey, as a stepper. Eight stages will not fit as a row on a phone, so
it scrolls and the current one is always brought into view. */
.steps { padding: 15px 0 13px; }
.steps__t { display: flex; align-items: baseline; gap: 9px; padding: 0 16px 11px; }
.steps__t strong { font-size: var(--fs-md); font-weight: 700; }
.steps__t span { font-size: var(--fs-2xs); color: var(--zk-grey); }
.steps__t .pill { margin-left: auto; }
.steps__rail { display: flex; gap: 8px; overflow-x: auto; padding: 0 16px 4px; scrollbar-width: none; scroll-padding-inline: 16px; }
.steps__rail::-webkit-scrollbar { display: none; }
.step { flex: none; min-width: 84px; }
.step i { display: block; height: 4px; border-radius: 2px; background: var(--zk-line); margin-bottom: 7px; }
.step span { font-size: var(--fs-3xs); color: var(--zk-grey); white-space: nowrap; }
.step.is-done i { background: var(--zk-navy); }
.step.is-done span { color: var(--zk-muted); }
.step.is-here i { background: var(--zk-blue); }
.step.is-here span { color: var(--zk-blue); font-weight: 700; }
/* ── Timeline ───────────────────────────────────────────────────────────── */
.tl { list-style: none; margin: 0; padding: 0; }
.ev { display: grid; grid-template-columns: 30px 1fr; gap: 12px; position: relative; padding-bottom: 18px; }
.ev::before { content: ''; position: absolute; left: 14px; top: 32px; bottom: 0; width: 2px; background: var(--zk-line-soft); }
.ev:last-child::before { display: none; }
.ev:last-child { padding-bottom: 2px; }
.ev__disc {
width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
font-size: 11px; font-weight: 700; z-index: 1;
background: var(--zk-tint-blue); color: var(--zk-blue);
}
.ev__disc svg { width: 14px; height: 14px; }
.ev__disc--hu { background: var(--zk-line-soft); color: var(--zk-muted); }
.ev__disc--cu { background: var(--zk-teal-tint); color: var(--zk-teal); }
.ev__disc--sys { background: var(--zk-line-soft); color: var(--zk-grey); }
.ev__body { min-width: 0; }
.ev__l1 { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.ev__what { font-size: var(--fs-sm); font-weight: 700; color: var(--zk-ink); }
.ev__when { margin-left: auto; font-size: var(--fs-3xs); color: var(--zk-grey); white-space: nowrap; }
.ev__by { margin-top: 2px; font-size: var(--fs-2xs); color: var(--zk-muted); display: flex; align-items: center; gap: 6px; }
.ev__by b { font-weight: 600; color: var(--zk-ink); }
.ev__tag { font-size: 11px; font-weight: 700; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
.ev__say { margin-top: 8px; padding: 9px 12px; background: var(--zk-tint); border-left: 2px solid var(--zk-line); border-radius: 0 8px 8px 0; }
.ev__say b { display: block; font-size: var(--fs-3xs); letter-spacing: .06em; text-transform: uppercase; color: var(--zk-grey); margin-bottom: 4px; font-weight: 700; }
.ev__say p { margin: 0; font-size: var(--fs-xs); line-height: 1.5; }
.ev__docs { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 9px; }
.ev__doc {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px;
border: 1px solid var(--zk-line); border-radius: 8px; background: #fff;
font-size: var(--fs-2xs); color: var(--zk-ink); text-decoration: none;
}
.ev__link { margin-top: 8px; display: inline-block; font-size: var(--fs-2xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; }
/* ── Key/value list, for the record ─────────────────────────────────────── */
.kv { margin: 0; }
.kv__g { padding-top: 13px; border-top: 1px solid var(--zk-line-soft); margin-top: 13px; }
.kv__g:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.kv__g h4 { margin: 0 0 7px; font-size: var(--fs-3xs); font-weight: 700; color: var(--zk-grey); letter-spacing: .04em; text-transform: uppercase; }
.kv__r { display: grid; grid-template-columns: 42% 1fr; gap: 12px; padding: 6px 0; font-size: var(--fs-xs); }
.kv__r dt { color: var(--zk-muted); }
.kv__r dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
.kv__file { color: var(--zk-blue); font-weight: 600; text-decoration: none; display: block; overflow-wrap: anywhere; }
/* ── The action bar: what this lead needs, always in thumb reach ────────── */
.actbar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
display: flex; gap: 10px; align-items: center;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff; border-top: 1px solid var(--zk-line);
}
.actbar .btn { flex: 1; }
.actbar .btn--more { flex: none; width: 46px; padding: 0; }
/* ── Drawer ─────────────────────────────────────────────────────────────── */
.scrim { position: fixed; inset: 0; z-index: 50; background: rgba(11,42,91,.42); }
.drawer {
position: fixed; top: 0; bottom: 0; left: 0; z-index: 51;
width: min(310px, 84vw); background: #fff;
display: flex; flex-direction: column;
padding-top: env(safe-area-inset-top);
animation: drawer-in .18s var(--ease);
}
@keyframes drawer-in { from { transform: translateX(-16px); opacity: .7; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .drawer { animation: none; } }
.drawer__head {
display: flex; align-items: center; gap: 12px; height: 56px; padding: 0 14px;
background: var(--zk-navy); color: #fff; flex: none;
}
.drawer__x { width: 38px; height: 38px; margin-left: -6px; border: 0; border-radius: 10px; background: transparent; color: #fff; display: grid; place-items: center; cursor: pointer; }
.drawer__x svg { width: 20px; height: 20px; }
.drawer__body { flex: 1; overflow-y: auto; padding: 12px; overscroll-behavior: contain; }
.drawer__h { margin: 16px 4px 8px; font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--zk-grey); }
.drawer__h:first-child { margin-top: 4px; }
.nav {
display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
padding: 12px 13px; margin-bottom: 8px; border-radius: 12px;
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
font-size: var(--fs-sm); cursor: pointer; text-decoration: none;
}
.nav__i { flex: none; width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--zk-tint); color: var(--zk-muted); }
.nav__i svg { width: 17px; height: 17px; }
.nav__t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav__n { flex: none; min-width: 30px; text-align: center; padding: 3px 9px; border-radius: 999px; font-size: var(--fs-2xs); font-weight: 700; background: var(--zk-line-soft); color: var(--zk-muted); }
.nav__n--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
.nav__n--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.nav__n--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
.nav.is-on { background: var(--zk-navy); border-color: var(--zk-navy); color: #fff; font-weight: 700; }
.nav.is-on .nav__i { background: rgba(255,255,255,.14); color: #fff; }
.nav.is-on .nav__n { background: rgba(255,255,255,.16); color: #fff; }
.nav--red .nav__i { background: var(--zk-danger-tint); color: var(--zk-danger); }
.nav--amber .nav__i { background: var(--zk-amber-tint); color: var(--zk-amber); }
.nav--teal .nav__i { background: var(--zk-teal-tint); color: var(--zk-teal); }
.nav--blue .nav__i { background: var(--zk-tint-blue); color: var(--zk-blue); }
/* ── Sheet: a dialog that comes up from the bottom ──────────────────────── */
.sheet {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 61;
max-height: 92dvh; display: flex; flex-direction: column;
background: #fff; border-radius: 18px 18px 0 0;
animation: sheet-in .2s var(--ease);
}
@keyframes sheet-in { from { transform: translateY(20px); opacity: .8; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .sheet { animation: none; } }
.sheet__grab { width: 40px; height: 4px; border-radius: 2px; background: var(--zk-line); margin: 9px auto 2px; flex: none; }
.sheet__head { display: flex; align-items: flex-start; gap: 12px; padding: 8px 18px 12px; flex: none; }
.sheet__head h2 { margin: 0; font-size: var(--fs-lg); font-weight: 700; }
.sheet__head p { margin: 3px 0 0; font-size: var(--fs-2xs); color: var(--zk-muted); }
.sheet__x { margin-left: auto; flex: none; width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted); font-size: 19px; line-height: 1; cursor: pointer; }
.sheet__body { flex: 1; overflow-y: auto; padding: 0 18px calc(20px + env(safe-area-inset-bottom)); overscroll-behavior: contain; }
/* ── States ─────────────────────────────────────────────────────────────── */
.empty { margin: 0; padding: 40px 20px; text-align: center; font-size: var(--fs-xs); color: var(--zk-muted); border: 1px dashed var(--zk-line); border-radius: 14px; background: #fff; }
.notice { background: #fff; border: 1px solid var(--zk-line); border-left: 3px solid var(--zk-blue); border-radius: 4px 14px 14px 4px; padding: 15px 16px; }
.notice strong { display: block; font-weight: 700; margin-bottom: 5px; }
.notice p { margin: 0; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.5; }
.skel { display: flex; flex-direction: column; gap: 10px; }
.skel i {
display: block; height: 92px; border-radius: 14px; border: 1px solid var(--zk-line-soft);
background: linear-gradient(90deg, #fff 0, var(--zk-tint) 40%, #fff 80%);
background-size: 700px 100%; animation: zk-shimmer 1.4s linear infinite;
}
.said { display: flex; gap: 10px; align-items: flex-start; padding: 11px 14px; border-radius: 12px; background: var(--zk-teal-tint); border: 1px solid var(--zk-teal-line); color: var(--zk-teal-ink); margin-bottom: 12px; }
.said p { margin: 0; flex: 1; font-size: var(--fs-xs); }
.said button { background: none; border: 0; font-size: 18px; line-height: 1; color: inherit; cursor: pointer; }

181
src/styles/tokens.css Normal file
View File

@ -0,0 +1,181 @@
/* Typeface: Source Sans 3, self-hosted
Bundled from src/assets so Vite resolves the URLs under the sub-path mount;
an absolute /fonts/ path would 404 behind the BASE_HREF rewrite. */
@font-face { font-family: 'Source Sans 3'; font-weight: 400; font-display: swap;
src: url('../assets/fonts/source-sans-3-latin-400-normal.woff2') format('woff2'),
url('../assets/fonts/source-sans-3-latin-ext-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'Source Sans 3'; font-weight: 500; font-display: swap;
src: url('../assets/fonts/source-sans-3-latin-500-normal.woff2') format('woff2'),
url('../assets/fonts/source-sans-3-latin-ext-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'Source Sans 3'; font-weight: 600; font-display: swap;
src: url('../assets/fonts/source-sans-3-latin-600-normal.woff2') format('woff2'),
url('../assets/fonts/source-sans-3-latin-ext-600-normal.woff2') format('woff2'); }
@font-face { font-family: 'Source Sans 3'; font-weight: 700; font-display: swap;
src: url('../assets/fonts/source-sans-3-latin-700-normal.woff2') format('woff2'),
url('../assets/fonts/source-sans-3-latin-ext-700-normal.woff2') format('woff2'); }
:root {
/* PALETTE Lead Desk redesign
Shared with the desktop console, deliberately: this is the same product on
a smaller screen, and a lead that is amber on a laptop must not be orange
on a phone. Only the type scale and the spacing change below.
Colour MEANS something here, and the meaning is fixed on every screen:
blue = the AI holds it amber = a person holds it
teal = the customer holds it red = risk, and nothing else
Navy is the masthead and the brand; blue is the one action colour.
The token NAMES keep their history the whole app references --zk-blue
and friends so remapping the values here re-themes every surface. */
--zk-navy: #0B2A5B;
--zk-navy-2: #12386F;
--zk-blue: #1F55A6; /* primary action; "with the AI" */
--zk-blue-dark: #12386F;
--zk-blue-deep: #0B2A5B;
--zk-blue-mid: #4E7FC4;
--zk-blue-light: #C3D3EC; /* hairline / ring */
--zk-cyan: #E4ECF9; /* blue-soft */
--zk-tint-blue: #E4ECF9; /* blue-soft */
--zk-teal: #137A66; /* the customer; issued */
--zk-teal-ink: #0F6353;
--zk-teal-tint: #DDF1EC;
--zk-teal-line: #B8DDD3;
--zk-good: #137A66;
--zk-amber: #A86A0B; /* a person holds it */
--zk-amber-ink: #8A5609;
--zk-amber-tint: #FBF0DC;
--zk-amber-line: #EAD6AE;
--zk-amber-bar: #D99A2B; /* the brighter amber for bars and legends */
--zk-danger: #C8102E; /* Kotak red: risk only */
--zk-danger-ink: #A30D25;
--zk-danger-tint: #FBE7EA;
--zk-danger-line: #F1C5CC;
--zk-ink: #152238;
--zk-muted: #4B5A72; /* ink-2 */
--zk-grey: #7A8699; /* ink-3 */
--zk-line: #D9DFE9;
--zk-line-soft: #E9EDF3;
--zk-tint: #F2F4F8; /* the canvas */
--zk-sunk: #E9EDF3;
--zk-white: #FFFFFF;
--zk-card: #FFFFFF;
/* Shape
Flat. Cards are white on the grey canvas with a 1px line and a 10px
radius; nothing floats, nothing casts a shadow. */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 10px;
--r-lg: 10px;
--r-xl: 12px;
--r-pill: 999px;
--sh-xs: none;
--sh-sm: none;
--sh-md: none;
--sh-lg: 0 12px 32px -12px rgba(21, 34, 56, 0.28); /* dialogs only */
--sh-accent: none;
--ring: 0 0 0 3px rgba(31, 85, 166, 0.18);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--t-fast: 0.14s var(--ease);
--t: 0.2s var(--ease);
--grad-blue: var(--zk-blue);
--grad-blue-hover: var(--zk-navy-2);
/* TYPE SCALE on a 15px root, so the rem steps land on the design's
pixel sizes: 12.5 eyebrows, 13.5 small, 14 labels, 15 body, 17 card
titles, 20 tab figures, 28 page title / KPI, 44 the one risk number. */
--fs-3xs: 0.8333rem; /* 12.5px — uppercase eyebrows, table heads */
--fs-2xs: 0.9rem; /* 13.5px — meta, pills, secondary lines */
--fs-xs: 0.9333rem; /* 14px — labels */
--fs-sm: 1rem; /* 15px — body */
--fs-md: 1.0667rem; /* 16px — emphasis */
--fs-lg: 1.1333rem; /* 17px — card titles */
--fs-xl: 1.3333rem; /* 20px — tab figures, small headings */
--fs-2xl: 1.8667rem; /* 28px — page title, KPI figures */
--fs-3xl: 2.9333rem; /* 44px — renewals at risk */
--lh-tight: 1.15;
--lh-snug: 1.35;
--lh-normal: 1.4;
--fw-normal: 400;
--fw-medium: 500;
--fw-semi: 600;
--fw-bold: 700;
--fw-x: 700;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-7: 32px;
--sp-8: 40px;
--sp-9: 56px;
--font-display: 'Source Sans 3', system-ui, -apple-system, sans-serif;
--font-zurich: 'Source Sans 3', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-family: var(--font-zurich);
/* Absolute on purpose: every --fs-* token is rem and sizing the root from
one of them would be circular. */
font-size: 16px;
line-height: var(--lh-normal);
color: var(--zk-ink);
background: var(--zk-tint);
font-feature-settings: 'tnum' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; background: var(--zk-tint); }
button, input, select, textarea { font: inherit; color: inherit; }
/* One focus treatment for the whole console. */
:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
::selection { background: var(--zk-tint-blue); color: var(--zk-navy); }
* { scrollbar-width: thin; scrollbar-color: var(--zk-line) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
border: 3px solid transparent; border-radius: var(--r-pill);
background-clip: content-box; background-color: var(--zk-line);
}
::-webkit-scrollbar-thumb:hover { background-color: var(--zk-grey); }
@keyframes zk-rise {
/* Opacity only a transform here composites text onto a GPU layer and it
renders soft in Chrome on Linux. */
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes zk-shimmer {
from { background-position: -420px 0; }
to { background-position: 420px 0; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}

14
vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// Relative base, same reasoning as the desktop console: the emitted asset refs
// become "./assets/…", so the <base href> the server writes at placement
// decides where they resolve, and one build serves any mount path. It is also
// why the fonts live under src/ rather than 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: 5176 },
})