Make the console operable: add a lead, work a lead

The pipeline could be looked at but not driven. Adds the three pieces
that make the machine walkable end to end from the UI.

- ActivityForm renders whatever /view/form-screens returns — labels,
  types, select options, mandatory flags — and submits it back. No form
  is defined in this repo, so a field added in Studio appears here with
  no code change.
- AddLead presents the three doors. Each is a separate INIT activity with
  its own permissions; the bank one lands further along because the bank
  already did CKYC.
- Lead shows the file grouped in the order it was worked, ending with
  what the sourcing agent earns, plus the activities this state allows
  and who normally performs each.

The "who normally performs this" label is presentational only. Nothing
here enforces anything — the workflow refuses server-side and the form
reports what it said, including a note when a 403 is the platform
declining rather than the console misbehaving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-24 16:11:28 +05:30
parent 7a86118169
commit 5ea627b064
9 changed files with 422 additions and 10 deletions

View File

@ -4,6 +4,7 @@ import Login from './pages/Login.jsx'
import Shell from './layout/Shell.jsx' import Shell from './layout/Shell.jsx'
import Pipeline from './screens/Pipeline.jsx' import Pipeline from './screens/Pipeline.jsx'
import Lead from './screens/Lead.jsx' import Lead from './screens/Lead.jsx'
import AddLead from './screens/AddLead.jsx'
export default function App() { export default function App() {
const { isAuthed } = useZino() const { isAuthed } = useZino()
@ -23,6 +24,7 @@ export default function App() {
<Route element={<Shell />}> <Route element={<Shell />}>
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} /> <Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
<Route path="/stage/:stageUid" element={<Pipeline />} /> <Route path="/stage/:stageUid" element={<Pipeline />} />
<Route path="/add" element={<AddLead />} />
<Route path="/lead/:instanceId" element={<Lead />} /> <Route path="/lead/:instanceId" element={<Lead />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>

View File

@ -43,3 +43,42 @@ export const CHANNELS = {
/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */ /** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */
export const RV_LEADS = 'zk-rv-leads' export const RV_LEADS = 'zk-rv-leads'
export const DV_LEAD = 'zk-dv-lead' export const DV_LEAD = 'zk-dv-lead'
/**
* Which activities are runnable from which state, and who is expected to do it.
*
* Mirrors workflow.tbl_wf_state_allowed_activities by hand the same reason
* STAGES is duplicated: the console needs the ORDER and the labels, and the API
* returns neither.
*
* `by` is presentational ONLY. Nothing here enforces anything: the workflow
* refuses server-side and the console reports what it said. Showing an action
* the signed-in user cannot perform is deliberate the refusal is the demo.
*/
export const ACTIONS = {
'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-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }],
'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }],
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }],
'zk-state-accepted': [{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }],
'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }],
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Issue Payment Link', by: 'Ops' }],
'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Payment webhook' }],
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }],
}
/** The three doors. Each is a separate INIT activity with its own permissions. */
export const ENTRY = [
{ uid: 'zk-act-init-direct', label: 'Self-Serve Lead', channel: 'direct',
note: 'Customer or call centre. Self-declared, no KYC.' },
{ uid: 'zk-act-init-bank', label: 'Partner Bank Lead', channel: 'bancassurance',
note: 'Bank RM. Carries the banks CKYC, so it starts at Qualified.' },
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
note: 'POSP or broker. This is the door the demo is about.' },
]

View File

@ -0,0 +1,43 @@
.af { display: flex; flex-direction: column; gap: 18px; }
.af__loading { color: var(--zk-muted); font-size: .86rem; }
.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
.af__field { display: flex; flex-direction: column; gap: 5px; }
.af__field--wide { grid-column: 1 / -1; }
.af__field > span {
font-size: .74rem; font-weight: 500; color: var(--zk-muted);
letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
}
.af__req {
font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
}
.af input, .af select, .af textarea {
font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px;
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
}
.af input:focus, .af select:focus, .af textarea:focus {
outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue);
}
.af textarea { resize: vertical; }
.af__multi { display: flex; flex-wrap: wrap; gap: 6px; }
.af__chip {
font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
}
.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); }
.af__actions { display: flex; justify-content: flex-end; gap: 10px; }
.af__submit {
font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
}
.af__submit:disabled { opacity: .55; cursor: default; }
.af__ghost {
font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
}
.af__err {
background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
}
.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; }
.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); }

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import './ActivityForm.css'
/**
* Renders whatever /view/form-screens returns for an activity labels, types,
* select options and which fields are mandatory and submits it straight back.
*
* Nothing about this form is defined in the frontend. Add a field to the
* activity in Studio, redeploy, and it appears here with no code change. That
* is the point: the workflow is the source of truth, and a hardcoded form would
* quietly drift from it.
*/
export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) {
const { client } = useZino()
const [schema, setSchema] = useState(null)
const [values, setValues] = useState({})
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let dead = false
setSchema(null); setError(null); setValues({})
client.formSchema(activityUid, instanceId)
.then((s) => { if (!dead) setSchema(s) })
.catch((e) => { if (!dead) setError(e) })
return () => { dead = true }
}, [client, activityUid, instanceId])
function set(id, v) { setValues((p) => ({ ...p, [id]: v })) }
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) {
const v = values[f.id]
if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue
payload[f.id] = f.data_type === 'number' ? Number(v) : v
}
try {
const res = instanceId
? await client.activity(instanceId, activityUid, payload)
: await client.start(activityUid, payload)
onDone?.(res)
} catch (err) {
setError(err)
} finally {
setBusy(false)
}
}
if (error && !schema) return <div className="af__err">Could not load the form {error.status} {error.message}</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) => {
const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
{f.data_type === 'select' ? (
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
<option value=""></option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.data_type === 'multiselect' ? (
<div className="af__multi">
{opts.map((o) => (
<button
key={o.value} type="button"
className={'af__chip' + (v.includes(o.value) ? ' is-on' : '')}
onClick={() => set(f.id, v.includes(o.value) ? v.filter((x) => x !== o.value) : [...v, o.value])}
>{o.label}</button>
))}
</div>
) : f.data_type === 'longtext' ? (
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
) : (
<input
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
value={v} onChange={(e) => set(f.id, e.target.value)}
/>
)}
</label>
)
})}
</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}
<div className="af__actions">
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
<button type="submit" className="af__submit" disabled={busy}>
{busy ? 'Submitting…' : 'Submit'}
</button>
</div>
</form>
)
}

View File

@ -49,3 +49,10 @@
} }
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; } .shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
.shell__add {
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
}
.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }

View File

@ -40,6 +40,7 @@ export default function Shell() {
<div className="shell__body"> <div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline"> <nav className="shell__nav" aria-label="Pipeline">
<NavLink to="/add" className="shell__add">+ Add a lead</NavLink>
<p className="shell__navlabel">Pipeline</p> <p className="shell__navlabel">Pipeline</p>
{work.map((s) => ( {work.map((s) => (
<NavLink <NavLink

55
src/screens/AddLead.jsx Normal file
View File

@ -0,0 +1,55 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ActivityForm from '../components/ActivityForm.jsx'
import { ENTRY } from '../api/config.js'
import './screens.css'
/**
* The three doors. Each is a separate INIT activity with its own permissions,
* and the bank one lands the instance further along because the bank already
* did CKYC. Whichever door is used, everything after it is identical.
*/
export default function AddLead() {
const [entry, setEntry] = useState(null)
const navigate = useNavigate()
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Add a lead</h1>
<p className="page__sub">Three doors, one machine. The door decides who may submit and where it lands nothing after that.</p>
</div>
</header>
{!entry ? (
<div className="doors">
{ENTRY.map((e) => (
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
<strong>{e.label}</strong>
<span className="chip">{e.channel}</span>
<p>{e.note}</p>
</button>
))}
</div>
) : (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">{entry.label}</h2>
<p className="panel__sub">{entry.note}</p>
</div>
<button type="button" className="af__ghost" onClick={() => setEntry(null)}>Change door</button>
</div>
<ActivityForm
activityUid={entry.uid}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
if (id) navigate(`/lead/${id}`)
}}
/>
</div>
)}
</section>
)
}

View File

@ -1,23 +1,132 @@
import { useParams, Link } from 'react-router-dom' import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
import './screens.css' import './screens.css'
/** The lead file. Built once the detail view is seeded. */ 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'])
/** 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']],
['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']],
['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']],
['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']],
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
]
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) return v.join(', ')
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() { export default function Lead() {
const { instanceId } = useParams() const { instanceId } = useParams()
const { client } = useZino()
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const load = useCallback(() => {
setErr(null)
client.detailView(DV_LEAD, instanceId)
.then((r) => setRow(r?.data ?? r?.record ?? r))
.catch(setErr)
}, [client, instanceId])
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>
if (!row) return <p className="empty">Loading</p>
const stateName = row.current_state_name || ''
const stage = STAGES.find((s) => s.name === stateName)
const actions = ACTIONS[stage?.uid] || []
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
<div> <div>
<h1 className="page__title">Lead {instanceId}</h1> <h1 className="page__title">{row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub">Evidence, attribution and the referral file.</p> <p className="page__sub">
{row.customer_name}{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
{row.product_line === 'motor' ? 'Motor' : 'SME Package'}
</p>
</div>
<div className="lead__stage">
<span className="lead__stagelabel">Stage</span>
<strong>{stateName || '—'}</strong>
</div> </div>
<Link className="grid__link" to="/">Back to the queue</Link>
</header> </header>
<div className="notice">
<strong>Not built yet.</strong> {actions.length ? (
<p>The lead file reads the <code>zk-dv-lead</code> detail view and the live <div className="panel">
activity forms. Both arrive with the next app-config pass.</p> <div className="panel__head">
</div> <div>
<h2 className="panel__title">What happens next</h2>
<p className="panel__sub">
These are the only activities this state allows. Who normally performs
each is shown but the workflow decides, server-side, whether you may.
</p>
</div>
</div>
<div className="acts">
{actions.map((a) => (
<button key={a.uid} type="button"
className={'act' + (open === a.uid ? ' is-open' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
<strong>{a.label}</strong>
<span className="act__by">{a.by}</span>
</button>
))}
</div>
{open ? (
<div className="acts__form">
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
onCancel={() => setOpen(null)}
onDone={() => { setOpen(null); load() }}
/>
</div>
) : null}
</div>
) : (
<div className="notice"><strong>Terminal.</strong><p>No activity runs from {stateName}.</p></div>
)}
{GROUPS.map(([title, keys]) => {
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
if (!shown.length) return null
return (
<div className="grp" key={title}>
<h3 className="grp__title">{title}</h3>
<dl className="grp__list">
{shown.map(([k, v]) => (
<div className="grp__row" key={k}>
<dt>{k.replace(/_/g, ' ')}</dt>
<dd>{v}</dd>
</div>
))}
</dl>
</div>
)
})}
<p className="lead__back"><Link className="grid__link" to="/">Back to the queue</Link></p>
</section> </section>
) )
} }

View File

@ -48,3 +48,46 @@
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
} }
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; } .chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
/* ---- doors ---- */
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.door {
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
}
.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
.door .chip { align-self: flex-start; }
/* ---- panel ---- */
.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
/* ---- actions ---- */
.acts { display: flex; flex-wrap: wrap; gap: 10px; }
.act {
font: inherit; cursor: pointer; text-align: left; background: var(--zk-white);
border: 1px solid var(--zk-line); border-radius: 5px; padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px; min-width: 190px;
}
.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
.act strong { font-size: .88rem; font-weight: 500; }
.act__by { font-size: .72rem; color: var(--zk-muted); }
.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
/* ---- lead file ---- */
.lead__stage { text-align: right; }
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
.lead__back { margin-top: 18px; }