Updated to new frontend specifications

This commit is contained in:
Likith K R 2026-09-04 13:26:20 +05:30
parent ae548a3a10
commit 66ea08e317
11 changed files with 692 additions and 133 deletions

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ dist-ssr
*.sw?
.env
CLAUDE.md

View File

@ -27,8 +27,10 @@ build does **not** — see "Runtime config" below.
A React + Vite operator console (no state library, no UI framework — plain CSS files
next to their components) for the Zurich Kotak "Lead to Policy" demo on the Zino
platform. Leads arrive through three channels — direct, bancassurance, agency — and run
one workflow state machine to policy issuance. AI employees perform almost every step;
a human appears at exactly one queue, `Referred to Underwriting`.
one workflow state machine to policy issuance. AI employees carry most of it; a person is
needed at three queues (`Contacted` → upload documents, `Referred to Underwriting` →
clear or decline, `Payment Pending` → confirm premium) and the customer holds one
(`Quoted`).
The frontend owns **no business logic**. The workflow (org `84`, app `536`, workflow
`zk_wf_lead`) is the source of truth; this repo is a thin renderer over its API.
@ -45,7 +47,11 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/
workflow tables (`STAGES`, `ACTIONS`, `ENTRY`).
- `src/api/provider.jsx` — `ZinoProvider` / `useZino()`: one client instance plus session
state. Restores an **identity**, never a permission set.
- `src/layout/Shell.jsx` — header + the pipeline sidebar (the sidebar *is* `STAGES`).
- `src/api/errors.js` — `describeError()` turns the gateway's five error shapes into a
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
list).
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding
the lead) rather than by pipeline position, with queue counts from one tallied list call.
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
`current_state_name`.
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
@ -90,11 +96,48 @@ URLs to be relative; a `public/` file referenced as `/fonts/…` stays absolute
under the `/zurich-kotak/` mount. The favicon is the one exception — it stays in
`public/brand/` and is referenced relatively.
**Activity fields are filtered by product line.** `Collect Documents` serves motor
and SME from one form — all eleven upload slots plus both lines' risk fields, to every
lead — and the activity carries no `field_rules` (`[]`), so nothing server-side decides
visibility. A motor renewal was being asked for a Udyam certificate, a stock statement
and a GSTIN. `fieldApplies()` in `api/config.js` decides: `motor_*` is motor and `sme_*`
is SME **by prefix**, so a field added to the workflow tomorrow classifies itself;
`LINE_FIELDS` names the exceptions that carry no prefix (`gstin`, `udyam_no`), and
`DOC_SLOTS` maps the upload slots plus the two genuine conditions (`doc_address_proof`
only when `kyc_outcome === 'refer'`, `doc_financials` only when `sme_sections` includes
`business_interruption`). `ActivityForm` filters on it for both render and submit.
Two things keep this safe. Every slot is `mandatory: false`, so hiding one cannot fail
schema validation. And when the filter would hide EVERY field, `ActivityForm` renders the
form whole instead — `Capture Motor Risk` is twelve motor fields and is runnable from
Contacted whatever the product is, so on an SME lead the filter would otherwise remove
the activity rather than its noise. `entity_name` is deliberately **not** SME-only: motor
leads carry a business name too.
The prefix rule is inference, not configuration — the durable home is `field_rules` on
the activity. When those are seeded, read them and delete this.
**Nothing is pushed — the console polls.** No socket, no SSE, and most steps are carried
by an AI employee that takes one to three minutes, so a lead moves while it is on screen.
The lead detail polls every 12s, queues and the sidebar counts every 30s, all gated on
`document.visibilityState` and all *quiet* — a refresh must never drop a queue back to a
skeleton under whoever is reading it.
**`STAGES` carries `kind`** — `needs` / `customer` / `auto` / `end` — answering "who is
holding this lead", plus `need` (what a queue wants done, which is what the sidebar shows)
and `doing`/`by` (what to say while an AI carries it, instead of an idle screen).
**`STAGES` and `ACTIONS` are hand-mirrored** from `workflow.tbl_wf_states` and
`workflow.tbl_wf_state_allowed_activities`, because the API returns neither the order nor
the labels the sidebar needs. An activity added to the workflow but not added to `ACTIONS`
is simply invisible — the platform allows it, the console never offers it, nothing errors.
Keep both in step with the seed SQL.
This bit once already: `zk-act-drop` (Mark Lost) was missing entirely, so no lead could be
dropped from the console. It is now appended to every state in `DROPPABLE` rather than
written into ten entries. Keep all of it in step with the seed SQL.
No endpoint exposes state → allowed activities (eight plausible paths probed, all 404) and
the detail view carries `current_state_id` but no activity list — which is *why* the map is
hand-maintained. If the platform ever serves it, delete the map.
## API shapes that have already bitten
@ -113,6 +156,12 @@ Keep both in step with the seed SQL.
claims about them.
- `/ocr-extract` takes a **reference** to an already-uploaded file, not bytes, and answers
`{ extracted, raw }`. Files therefore upload on pick, not on submit.
- `POST /view/recordview` takes the view uid in the **body**. The path form the frontend
spec documents (`/view/recordview/zk-rv-leads`) returns **404**.
- The detail view returns `{ config, data }` — `config.fields[].output_label` names every
one of the 117 fields, so labels come from the server rather than from prettified ids.
- The `message` on a `/start` or `/activity` response is written per activity ("Documents
received — capturing the risk") and is surfaced verbatim, not replaced with a toast.
- On submit, `ActivityForm` sends only fields the activity defines, drops empties (an
unknown field is fatal to schema validation) and skips `id_gen` — that reference is
issued server-side.

View File

@ -14,23 +14,47 @@ export const WORKFLOW = 'zk_wf_lead'
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
* because the sidebar has to render its ORDER, and the API returns states as a
* set with no canonical sequence. Keep in step with 02_workflow.sql.
*
* `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
* end terminal
*
* `need` names the queue by what it wants done. A queue called "Upload
* documents" answers "is anything waiting on me?"; one called "Contacted"
* describes where the lead sits and leaves the operator to work it out.
*/
export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'work' },
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' },
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' },
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' },
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' },
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' },
// The only human queue in the whole machine.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' },
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' },
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'needs', need: 'Upload documents', by: 'the partner agent' },
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'auto', doing: 'Pricing and advising…', by: 'Rating + Advisor AI' },
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'customer', need: 'Accept the proposal', by: 'the customer' },
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'auto', doing: 'Verifying identity…', by: 'KYC AI' },
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'auto', doing: 'Screening for underwriting…', by: 'KYC AI' },
// The one queue where the machine stops and a person decides.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Clear or decline', by: 'an underwriter' },
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'auto', doing: 'Requesting premium…', by: 'Engage AI' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file…', by: 'Engage AI' },
{ 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 = [
{ label: 'Needs someone', kind: 'needs' },
{ label: 'With the customer', kind: 'customer' },
{ label: 'Running by itself', kind: 'auto' },
{ label: 'Closed', kind: 'end' },
]
/** Channel presentation. source_channel is data on the instance, never a branch. */
@ -60,7 +84,7 @@ export const DV_LEAD = 'zk-dv-lead'
* the platform allows it, the console never offers it, and nothing errors.
* Collect Documents shipped in that state for one round.
*/
export const ACTIONS = {
const STATE_ACTIVITIES = {
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }],
'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' },
@ -84,6 +108,30 @@ export const ACTIONS = {
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }],
}
/**
* 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. It was missing
* from the table entirely, which is the exact failure the note above describes —
* the platform allowed it, the console never offered it, and nothing errored.
*
* Appended rather than written into all ten entries so there is one place to
* change it, and so a state added above cannot silently lose it.
*/
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'Whoever holds the lead' }
const DROPPABLE = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-risk',
'zk-state-quoted', 'zk-state-accepted', 'zk-state-kyc', 'zk-state-referred',
'zk-state-cleared', 'zk-state-payment',
]
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.
*
@ -100,3 +148,117 @@ 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, but no break_in flag exists on the instance
// to test — the field name carries the caveat and the slot stays on motor.
doc_vehicle_photos: { line: 'motor' },
// 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 NOT listed: the form labels it "Business Name", but motor
// leads carry one too — a company-owned vehicle has an owner with a name.
}
/**
* 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) {
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
}

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

@ -0,0 +1,72 @@
/**
* 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 }
}

View File

@ -1,8 +1,22 @@
import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { fieldApplies } from '../api/config.js'
import { describeError } 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.
@ -11,8 +25,14 @@ import './ActivityForm.css'
* 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, onDone, onCancel }) {
export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) {
const { client } = useZino()
const [schema, setSchema] = useState(null)
const [values, setValues] = useState({})
@ -42,13 +62,30 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
function set(id, v) { setValues((p) => ({ ...p, [id]: v })) }
// 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.
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, lead)) : []
// 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, lead)) : false
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
async function submit(e) {
e.preventDefault()
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 schema.fields) {
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
@ -63,18 +100,21 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
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 — {error.status} {error.message}</div>
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>
return (
<form className="af" onSubmit={submit}>
<div className="af__grid">
{schema.fields.map((f) => {
{fields.map((f) => {
const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (
@ -123,7 +163,7 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
) : (
<input
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
type={INPUT_TYPES[f.data_type] ?? 'text'}
value={v} onChange={(e) => set(f.id, e.target.value)}
/>
)}
@ -132,14 +172,15 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
})}
</div>
{error ? (
<div className="af__err">
<strong>{error.status}</strong> {error.message}
{error.status === 403 || String(error.message).includes('permission denied') ? (
<p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p>
) : null}
</div>
) : null}
{error ? (() => {
const said = describeError(error)
return (
<div className="af__err">
<strong>{error.status}</strong> {said.title}
{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}

View File

@ -95,8 +95,8 @@
color: var(--zk-grey);
}
.shell__navlabel--closed {
margin-top: 26px;
.shell__group + .shell__group {
margin-top: 22px;
}
/* The stage list is drawn as a rail with a node per state: the sidebar should
@ -168,13 +168,13 @@
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
}
/* The one queue where the machine stops and a person decides. */
.shell__stage.is-human .shell__stagename {
/* Queues where the machine stops and a person decides. */
.shell__stage.is-needed .shell__stagename {
font-weight: 500;
color: var(--zk-ink);
}
.shell__stage.is-human.is-active .shell__stagename {
.shell__stage.is-needed.is-active .shell__stagename {
color: var(--zk-blue-dark);
}
@ -185,19 +185,6 @@
line-height: 1.35;
}
.shell__badge {
flex: none;
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: var(--r-pill);
background: var(--grad-blue);
color: var(--zk-white);
box-shadow: var(--sh-xs);
}
.shell__main {
flex: 1;
min-width: 0;

View File

@ -1,18 +1,22 @@
import { NavLink, Outlet } from 'react-router-dom'
import { STAGES } from '../api/config.js'
import { NAV_GROUPS, STAGES } from '../api/config.js'
import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css'
/**
* The sidebar IS the pipeline, in the order a lead moves — so the shape of the
* process is legible before any record is opened. The one human queue is marked
* as such, because it is the only place the machine stops.
* The sidebar groups the pipeline by WHO IS HOLDING each lead, not by where it
* sits in the process. Fourteen flat stages answer "what is the process?", which
* is not the question anyone signing in has; these answer "is anything waiting
* on me?". Queues that need a person are named by what they want done.
*
* It deliberately carries no counts. A tally here can only come from fetching
* every lead on a timer and counting client-side, which is a second full list
* call per open tab that disagrees with the queue's own total the moment either
* one is paged. The count belongs on the queue, which already has it from the
* server.
*/
export default function Shell() {
const work = STAGES.filter((s) => s.kind !== 'end')
const closed = STAGES.filter((s) => s.kind === 'end')
return (
<div className="shell">
<header className="shell__top">
@ -35,33 +39,30 @@ export default function Shell() {
</svg>
Add a lead
</NavLink>
<p className="shell__navlabel">Pipeline</p>
<div className="shell__stages">
{work.map((s) => (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
className={({ isActive }) =>
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
}
>
<span className="shell__stagename">{s.name}</span>
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
</NavLink>
))}
</div>
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
<div className="shell__stages">
{closed.map((s) => (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
>
<span className="shell__stagename">{s.name}</span>
</NavLink>
))}
</div>
{NAV_GROUPS.map((group) => {
const stages = STAGES.filter((s) => s.kind === group.kind)
if (!stages.length) return null
return (
<div className="shell__group" key={group.kind}>
<p className="shell__navlabel">{group.label}</p>
<div className="shell__stages">
{stages.map((s) => (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
title={s.need ? s.name : undefined}
className={({ isActive }) =>
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '')
}
>
<span className="shell__stagename">{s.need ?? s.name}</span>
</NavLink>
))}
</div>
</div>
)
})}
</nav>
<main className="shell__main">

View File

@ -48,7 +48,10 @@ export default function AddLead() {
activityUid={entry.uid}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
if (id) navigate(`/lead/${id}`)
// Carry the workflow's own message across the navigation — "Lead
// received — Intake is qualifying it" is the answer to "did that
// work?", and it is only said once.
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
}}
/>
</div>

View File

@ -4,7 +4,8 @@ import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import ClampText from '../components/ClampText.jsx'
import Timeline from '../components/Timeline.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
import { ACTIONS, DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
import { describeError } from '../api/errors.js'
import './screens.css'
function fmtWhen(ts) {
@ -32,22 +33,25 @@ function expiryOf(dateStr) {
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_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. */
const GROUPS = [
['Who sourced it', ['lead_ref','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['Who sourced it', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['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','contact_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_other_si','sme_walls','sme_roof','sme_building_age_band','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_sections']],
['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_claim']],
['Contact', ['contact_outcome','outreach_window','contact_notes']],
['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','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 recommendation (non-binding)', ['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','quote_valid_till']],
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
['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_ref','premium_realised','policy_no','policy_issued_at']],
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
['How it ended', ['lost_reason','welcome_sent','renewal_due']],
]
/** Past this, a value is prose and gets folded rather than printed in full. */
@ -57,6 +61,9 @@ const LONG = 150
* 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.
*/
const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
@ -72,7 +79,18 @@ function label(k) {
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) return v.join(', ')
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)
}
@ -82,21 +100,87 @@ export default function Lead() {
const { client } = useZino()
const navigate = useNavigate()
const location = useLocation()
// What the workflow said when the last activity was submitted. It is written
// per activity — "Documents received — capturing the risk" — and is the
// workflow telling the operator what it just set in motion, so it is shown
// verbatim rather than replaced with a generic "Saved".
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0)
const [labels, setLabels] = useState({})
const load = useCallback(() => {
setErr(null)
client.detailView(DV_LEAD, instanceId)
.then((r) => setRow(r?.data ?? r?.record ?? r))
.catch(setErr)
const load = useCallback((quiet = false) => {
if (!quiet) setErr(null)
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
// The detail view ships an output_label for every field. Using the
// server's names means a field renamed in Studio is renamed here.
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]),
))
}
})
// A poll that fails leaves the screen exactly as it is. Only a first load
// becomes an error page: a gateway blip must not throw away a lead the
// operator is reading, or unmount a half-filled form under them.
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
useEffect(load, [load])
// `load` is CALLED here, not handed to useEffect: it returns a promise so the
// poll below and onDone can chain on it, and an effect that returns anything
// but a function has that value called as cleanup — React invokes the promise
// as destroy(), throws, and unmounts the whole tree to a blank screen.
useEffect(() => { load() }, [load])
if (err) return <div className="notice"><strong>Could not load lead {instanceId}.</strong><p className="notice__detail">{err.status} {err.message}</p></div>
// The message is handed over on the history entry, so it would show again
// every time this lead is reached by Back — on a lead that has since moved on.
// Consume it once, then strike it from the entry.
useEffect(() => {
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
}, [location.state, location.pathname, navigate])
// A lead that no longer exists is not a page to look at. Send the operator
// back to the queue rather than leaving them on an empty shell.
useEffect(() => {
if (err && describeError(err).kind === 'gone') {
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
return () => clearTimeout(id)
}
}, [err, navigate])
/**
* Nothing is pushed — no socket, no SSE — and most of this workflow is carried
* by AI employees that take one to three minutes a step, so a lead genuinely
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
* at all while the tab is in the background.
*/
useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the
// lead has moved on the open form unmounts — taking whatever was typed into
// it with no warning. A submission against a stale state is refused by the
// workflow anyway, and that refusal is handled properly below.
if (open) return undefined
const id = setInterval(() => {
if (document.visibilityState === 'visible') load(true)
}, 12000)
return () => clearInterval(id)
}, [load, open])
if (err) {
const said = describeError(err)
return (
<div className="notice">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
<p className="notice__detail">Lead {instanceId} · {err.status} {err.message}</p>
</div>
)
}
if (!row) return <p className="empty">Loading…</p>
// Only groups that actually hold something; an empty tab is a dead end.
@ -129,8 +213,7 @@ export default function Lead() {
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub">
{row.lead_ref || `Lead ${instanceId}`}
{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
{row.product_line === 'motor' ? 'Motor' : 'SME Package'}
{row.entity_name ? ` · ${row.entity_name}` : ''}
</p>
</div>
</div>
@ -150,6 +233,7 @@ export default function Lead() {
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
</div>
) : null })()}
<div><dt>Product</dt><dd>{PRODUCTS[row.product_line] ?? '—'}</dd></div>
{row.quoted_premium ? (
<div className="lead__meta--money">
<dt>Quoted premium</dt>
@ -164,6 +248,27 @@ export default function Lead() {
<div className="lead">
<div className="lead__main">
{note ? (
<div className="said" role="status">
<p>{note}</p>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
</div>
) : null}
{/* An AI-carried stage has no empty action bar and no idle screen: it
says what is happening and who is doing it. The activities below it
stay available — ops can run them by hand — but they are not the
answer to "why is nothing moving?". */}
{stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>{stage.by} is carrying this lead. It usually takes a minute or two, and this page is watching for it.</span>
</div>
</div>
) : null}
{actions.length ? (
<div className="panel">
<div className="panel__head">
@ -190,8 +295,14 @@ export default function Lead() {
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
lead={row}
onCancel={() => setOpen(null)}
onDone={() => { setOpen(null); load() }}
onStale={() => {
setOpen(null)
setNote('That activity is not available at this stage any more — refreshed to show what is.')
load()
}}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
/>
</div>
) : null}
@ -224,12 +335,12 @@ export default function Lead() {
{groups[active][1].map(([k, v]) => (
v.length > LONG ? (
<div className="prop prop--note" key={k}>
<dt>{label(k)}</dt>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} /></dd>
</div>
) : (
<div className="prop" key={k}>
<dt>{label(k)}</dt>
<dt>{labels[k] ?? label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
import { describeError } from '../api/errors.js'
/** Short absolute date plus how long ago — a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */
@ -44,26 +45,43 @@ export default function Pipeline() {
const { client } = useZino()
const stage = STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
useEffect(() => {
let cancelled = false
setState({ status: 'loading', rows: [], error: null })
client
.recordView(RV_LEADS, {
limit: 100,
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
})
.then((res) => {
if (cancelled) return
const rows = res?.data ?? res?.rows ?? res?.records ?? []
setState({ status: 'ready', rows, error: null })
})
.catch((err) => {
if (cancelled) return
setState({ status: 'error', rows: [], error: err })
})
return () => { cancelled = true }
// `quiet` is what makes polling bearable: the first load may show a skeleton,
// a refresh may not — dropping back to the loading state every 30 seconds
// would flash the whole queue away under whoever is reading it.
function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
limit: 100,
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
})
.then((res) => {
if (cancelled) return
const rows = res?.data ?? res?.rows ?? res?.records ?? []
// total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage.
const total = res?.pagination?.total_count ?? rows.length
setState({ status: 'ready', rows, total, error: null })
})
.catch((err) => {
if (cancelled) return
// A failed refresh must not throw away a queue that is already on
// screen; only a failed first load is an error state.
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], total: 0, error: err }))
})
}
fetchRows(false)
const id = setInterval(() => {
if (document.visibilityState === 'visible') fetchRows(true)
}, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name])
if (!stage) return <p className="empty">Unknown stage.</p>
@ -72,17 +90,20 @@ export default function Pipeline() {
<section>
<header className="page__head">
<div>
<h1 className="page__title">{stage.name}</h1>
<h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{stage.kind === 'human'
? 'The only queue in the machine that waits for a person.'
: 'Handled by the agents; shown here for visibility.'}
{stage.kind === 'auto'
? `${stage.doing} ${stage.by} is carrying these; nothing is waiting on a person.`
: stage.kind === 'end'
? 'Closed. Nothing runs from here.'
: `Waiting on ${stage.by}.`}
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
</p>
</div>
{state.status === 'ready' ? (
<div className="page__count">
<strong>{state.rows.length}</strong>
<span>{state.rows.length === 1 ? 'lead' : 'leads'}</span>
<strong>{state.total}</strong>
<span>{state.total === 1 ? 'lead' : 'leads'}</span>
</div>
) : null}
</header>
@ -101,7 +122,7 @@ export default function Pipeline() {
It is not seeded yet, so there is nothing to list. Everything else —
sign-in, routing and the pipeline itself — is live.
</p>
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div>
) : null}
@ -114,7 +135,7 @@ export default function Pipeline() {
<table className="grid">
<thead>
<tr>
<th>Customer</th><th>Channel</th>
<th>Reference</th><th>Customer</th><th>Channel</th>
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
<th>Attribution</th><th>Added</th><th aria-label="Actions" />
</tr>
@ -127,6 +148,7 @@ export default function Pipeline() {
const add = added(r.created_at)
return (
<tr key={id}>
<td className="grid__ref">{r.lead_ref || `#${id}`}</td>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
<td><span className="chip">{ch.label}</span></td>
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>

View File

@ -117,7 +117,7 @@
width: 100%;
/* Eight columns of operational data do not compress: below this the card
scrolls sideways rather than squeezing every cell into two words a line. */
min-width: 900px;
min-width: 1000px;
border-collapse: separate;
border-spacing: 0;
font-size: 0.86rem;
@ -168,6 +168,13 @@
font-variant-numeric: tabular-nums;
}
/* The lead reference is the thing an operator quotes on a call. */
.grid__ref {
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--zk-muted);
}
.grid__sub {
font-size: 0.76rem;
color: var(--zk-muted);
@ -433,6 +440,108 @@
animation: zk-rise 0.24s var(--ease) both;
}
/* ---- what the machine is doing ----
Shown where an empty action bar used to be. Most stages are carried by an AI
employee that takes a minute or two, and a screen that says nothing for two
minutes reads as broken. */
.doing {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 22px;
padding: 16px 20px;
border-radius: var(--r-lg);
border: 1px solid var(--zk-blue-light);
background: var(--zk-tint-blue);
}
.doing div {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.doing strong {
font-size: 0.95rem;
font-weight: 500;
color: var(--zk-blue-dark);
}
.doing span {
font-size: 0.82rem;
line-height: 1.5;
color: var(--zk-muted);
}
.doing__pulse {
flex: none;
margin-top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--zk-blue);
box-shadow: 0 0 0 0 rgba(33, 103, 174, 0.5);
animation: zk-pulse 1.9s var(--ease) infinite;
}
@keyframes zk-pulse {
70% { box-shadow: 0 0 0 9px rgba(33, 103, 174, 0); }
100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
}
/* ---- what the workflow said ----
The message the API returns per activity, shown verbatim: it is the workflow
telling the operator what it just set in motion. */
.said {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 22px;
padding: 14px 16px 14px 20px;
border-radius: var(--r-lg);
border: 1px solid var(--zk-line);
border-left: 3px solid var(--zk-blue);
background: var(--zk-white);
box-shadow: var(--sh-sm);
animation: zk-rise 0.24s var(--ease) both;
}
.said p {
flex: 1;
margin: 0;
font-size: 0.88rem;
line-height: 1.5;
color: var(--zk-ink);
}
.said button {
flex: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--zk-grey);
transition: background var(--t-fast), color var(--t-fast);
}
.said button:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
/* The stage a queue named by need actually maps to. */
.page__stage {
display: block;
margin-top: 4px;
font-size: 0.72rem;
color: var(--zk-grey);
}
/* ---- back ----
An icon, not a sentence: the way out of a record is a known shape and does not
need naming twice on the page. It carries a title and an aria-label, so the