zurich_kotak/src/screens/Lead.jsx
Yashas 07d4608888 lead: show the next step, not the permission list
The app read as a control panel. Every activity a stage allowed was
rendered as an equal button, so operating it required already knowing
the workflow — which is the opposite of what a workflow tool is for.

Two concrete failures, both visible in the data:

Ops on Document collection got SIX equal buttons. Exactly one was the
next step: upload the three documents. Capture Motor Risk, Capture SME
Risk, AI Cover Recommendation and Generate Quote are the AI's own chain,
present only so a stalled lead can be pushed by hand.

And FOUR stages offered a partner agent nothing but "Mark Lost" — New
Lead, Contacted, Underwriting referral, Premium confirmation. At each of
those the system was actively working the lead, and the only thing the
app suggested was giving up on it.

So an activity now carries what it IS at a stage, not only who may press
it:

  do      the step this stage is waiting on. Usually one. Referred has
          two, because clear and decline are a decision pair rather than
          a step and the option of not taking it.
  again   a bounded loop — retry, reminder, re-quote. Legitimate, never
          the answer to "what now".
  force   an AI employee's own job, offered to a person only so a stalled
          lead can be moved. Folded behind "Lead not moving?".
  exit    Mark Lost. Always reachable, never presented as a step.

The panel is "Next step". With a step, one button at full weight. Without
one it says what is actually happening and who has it, which is the true
answer at six of the nine working stages and was previously rendered as
an empty bar or a lone Mark Lost.

Result per role, checked rather than assumed: an underwriter sees two
buttons, on one stage, and nothing anywhere else. A partner agent sees a
step at three stages. Ops sees a step at four and can still force any
AI activity from the folded section.

Nothing about permissions changed, and the action list still matches
tbl_wf_state_allowed_activities exactly — this is only about which of
them is presented as the thing to do.
2026-09-07 17:55:51 +05:30

513 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
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 { DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import './screens.css'
function fmtWhen(ts) {
if (!ts) return null
const d = new Date(ts)
if (isNaN(d)) return String(ts)
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
/** The countdown, not just the date. On a renewal book this is the number
* that decides whether anyone should act today. */
function expiryOf(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000)
return {
days,
tone: days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later',
label: days < 0 ? Math.abs(days) + ' days overdue' : days === 0 ? 'expires today' : 'in ' + days + ' days',
on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }),
}
}
/** Whole days since a timestamp. Age and dwell time are the two numbers that
* say whether a lead is moving, and neither is on the record. */
function daysSince(ts) {
if (!ts) return null
const t = Date.parse(ts)
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000)
}
function inrShort(v) {
const n = Number(v)
if (!Number.isFinite(n) || !n) return null
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount','sme_stock_si'])
/** Field groups, in the order the lead was actually worked. */
const GROUPS = [
['Source', ['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','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 advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
['Proposal', ['acceptance_ref','accepted_at']],
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
['Outcome', ['lost_reason','welcome_sent','renewal_due']],
]
/** Past this, a value is prose and gets folded rather than printed in full. */
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',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE',
amc: 'AMC', km: 'km', posp: 'POSP',
}
function label(k) {
const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ')
return words.charAt(0).toUpperCase() + words.slice(1)
}
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(v)) {
// A file field holds an array of upload references — {uuid, original_name,
// blob_path, …} — so joining it raw prints [object Object].
return v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
.filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null
}
// No field carries a bare object today, but one arriving as JSON should not
// print as [object Object].
if (typeof v === 'object') return JSON.stringify(v)
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
return String(v)
}
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
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((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])
// `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])
// 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.
const groups = GROUPS
.map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)])
.filter(([, shown]) => shown.length)
const active = Math.min(tab, Math.max(groups.length - 1, 0))
const stateName = row.current_state_name || ''
const stage = STAGES.find((s) => s.name === stateName)
// Only what THIS user may run. The workflow refuses the rest server-side
// anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control.
const actions = actionsFor(roles, stage?.uid)
// Two different reasons for an empty action list, and they must not read the
// same. A terminal lead is finished; a live one you cannot act on belongs to
// somebody else, and saying "closed" about it would be a lie.
const isClosed = stage?.kind === 'end'
const heldByOthers = !isClosed && actions.length === 0
return (
<section>
<header className="page__head">
<div className="page__lead">
{/* React Router stamps the first entry in a session with key 'default',
so a lead opened from its own URL has nothing to go back TO and
lands on the queue instead of leaving the app. */}
<button
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div>
<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}` : ''}
</p>
</div>
</div>
<div className="lead__stage">
<span className="lead__stagelabel">Stage</span>
<strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
{/* An end state runs no activity. That is worth one line next to the
stage, not a panel of its own where the actions would be. */}
{isClosed ? <span className="lead__closed">Closed no further activity</span> : null}
{heldByOthers ? (
<span className="lead__closed">
{stage?.kind === 'auto'
? `In progress with ${stage.by}`
: `Assigned to ${stage?.by ?? 'another role'}`}
</span>
) : null}
</div>
</header>
{/* Measures, not fields. Age and dwell time are computed — neither is on
the record — and they are the two that answer "is this lead moving?",
which no label/value pair on the page could. */}
{(() => {
const e = expiryOf(row.renewal_due_date)
const age = daysSince(row.created_at)
const dwell = daysSince(row.updated_at)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
return (
<div className="lmetrics">
<div className="lm">
<span className="lm__l">Renewal</span>
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong>
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
</div>
<div className="lm">
<span className="lm__l">Lead age</span>
<strong className="lm__v">{age === null ? '—' : age === 0 ? 'Today' : age + 'd'}</strong>
<span className="lm__s">{row.created_at ? fmtWhen(row.created_at) : ''}</span>
</div>
<div className="lm">
<span className="lm__l">In this stage</span>
<strong className="lm__v">{dwell === null ? '—' : dwell === 0 ? '<1d' : dwell + 'd'}</strong>
<span className="lm__s">last activity {row.updated_at ? fmtWhen(row.updated_at) : '—'}</span>
</div>
<div className="lm">
<span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong>
<span className="lm__s">{premium ? 'quoted' : 'not yet rated'}</span>
</div>
<div className="lm">
<span className="lm__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong>
<span className="lm__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% of premium' : 'on placement'}
</span>
</div>
<div className="lm">
<span className="lm__l">Product</span>
<strong className="lm__v lm__v--sm">{PRODUCTS[row.product_line] ?? '—'}</strong>
<span className="lm__s">
{row.motor_reg_no || row.entity_name || ''}
{row.current_insurer ? ` · from ${row.current_insurer}` : ''}
</span>
</div>
{Number.isFinite(conf) && conf > 0 ? (
<div className="lm">
<span className="lm__l">AI confidence</span>
<strong className="lm__v">{conf}%</strong>
<span className="lm__s">on the cover advice</span>
</div>
) : null}
</div>
)
})()}
<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>Handled by {stage.by}. Typically completes within two minutes; this view refreshes automatically.</span>
</div>
</div>
) : null}
{/* WHAT TO DO NEXT, not what is permitted.
Every action carries a role: `do` is the step this stage is
waiting on, `again` is a bounded loop, `force` is an AI's own job
offered only so a stalled lead can be pushed by hand, `exit` is
Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */}
{(() => {
const step = actions.filter((a) => a.role === 'do')
const again = actions.filter((a) => a.role === 'again')
const force = actions.filter((a) => a.role === 'force')
const exit = actions.filter((a) => a.role === 'exit')
const extras = [...force, ...exit]
const form = open ? (
<div className="acts__form">
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
lead={row}
onCancel={() => setOpen(null)}
onStale={() => {
setOpen(null)
setNote('Stage changed. Available actions have been refreshed.')
load()
}}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
/>
</div>
) : null
if (!step.length && !again.length && !extras.length) return null
return (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">Next step</h2>
<p className="panel__sub">
{step.length
? 'This is what this lead is waiting on.'
: isClosed
? 'This lead is closed.'
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
</p>
</div>
</div>
{/* The step. One button, sized like a decision. Referred has
two because clear and decline are a pair, not a choice
between doing something and doing nothing. */}
{step.length ? (
<div className="step">
{step.map((a) => (
<button
key={a.uid} type="button"
className={'step__btn' + (open === a.uid ? ' is-open' : '')
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}
>
<strong>{a.label}</strong>
<span>{a.by}</span>
</button>
))}
</div>
) : null}
{again.length ? (
<div className="alt">
{again.map((a) => (
<button key={a.uid} type="button"
className={'alt__btn' + (open === a.uid ? ' is-open' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
{a.label}
</button>
))}
</div>
) : null}
{form}
{/* Recovery, folded. An operator needs these on the day an
agent stalls and never otherwise, and putting them in the
open makes an AI's own work look like an outstanding task. */}
{extras.length ? (
<details className="stuck">
<summary>Lead not moving?</summary>
<p className="stuck__why">
{force.length
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
: 'Close this lead if it is going nowhere.'}
</p>
<div className="alt">
{extras.map((a) => (
<button key={a.uid} type="button"
className={'alt__btn' + (a.role === 'exit' ? ' alt__btn--exit' : '')
+ (open === a.uid ? ' is-open' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
{a.label}
<em>{a.by}</em>
</button>
))}
</div>
</details>
) : null}
</div>
)
})()}
{/* The flow's own detail. Each group is one click away rather than one
long scroll, and prose is folded to a few lines — the page can be
scanned, and still holds everything for whoever wants it. */}
{groups.length ? (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">Record</h2>
<p className="panel__sub">Captured data, grouped by stage of collection.</p>
</div>
</div>
<div className="tabs" role="tablist">
{groups.map(([title, shown], i) => (
<button
key={title} type="button" role="tab" aria-selected={i === active}
className={'tab' + (i === active ? ' is-on' : '')}
onClick={() => setTab(i)}
>
{title}<span className="tab__n">{shown.length}</span>
</button>
))}
</div>
<dl className="props">
{groups[active][1].map(([k, v]) => (
v.length > LONG ? (
<div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} /></dd>
</div>
) : (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)
))}
</dl>
</div>
) : null}
</div>
{/* The story runs alongside the file rather than under it, so what was
done and what was captured can be read against each other. */}
<aside className="lead__side">
<div className="panel panel--rail">
<div className="panel__head">
<div>
<h2 className="panel__title">Audit trail</h2>
<p className="panel__sub">Every action recorded against this lead.</p>
</div>
</div>
<div className="lead__feed">
<Timeline instanceId={instanceId} />
</div>
</div>
</aside>
</div>
</section>
)
}