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,25 +14,49 @@ 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-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. */
export const CHANNELS = {
direct: { label: 'Direct', short: 'D' },
@ -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 ? (
{error ? (() => {
const said = describeError(error)
return (
<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}
<strong>{error.status}</strong> {said.title}
{said.detail ? <p>{said.detail}</p> : null}
</div>
) : null}
)
})() : 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>
{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">
{work.map((s) => (
{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 === 'human' ? ' is-human' : '')
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '')
}
>
<span className="shell__stagename">{s.name}</span>
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
<span className="shell__stagename">{s.need ?? s.name}</span>
</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>
<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,12 +45,17 @@ 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
// `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 ?? '' }],
@ -57,13 +63,25 @@ export default function Pipeline() {
.then((res) => {
if (cancelled) return
const rows = res?.data ?? res?.rows ?? res?.records ?? []
setState({ status: 'ready', rows, error: null })
// 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
setState({ status: 'error', rows: [], error: err })
// 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 }))
})
return () => { cancelled = true }
}
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