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 AgentChip from '../components/AgentChip.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.jsx' import Timeline from '../components/Timeline.jsx' import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' import { AGENTS } from '../api/agents.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']], // The five OCR slots led this list and were absent from it, which is how a // lead with an RC, an expiring policy and a PAN attached showed "Documents 2" // — the status and the notes. They were also absent from the view itself // until 76; adding them here without that would have changed nothing. ['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert', 'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos', 'doc_vehicle_photos','doc_financials','documents_status','documents_notes']], ['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']], ['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']], ['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']], ['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']], ['Conversation', ['customer_reply','customer_reply_from','customer_answer','answer_count']], ['Proposal', ['acceptance_ref','accepted_at']], ['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']], ['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']], ['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']], ['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']], ['Outcome', ['lost_reason','welcome_sent','renewal_due']], ] /** Past this, a value is prose and gets folded rather than printed in full. */ 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) } /** The uploaded files on a field value, if that is what it holds. */ function filesOf(v) { return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : [] } 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({}) // The audit rows live HERE, not inside Timeline, for two reasons: Timeline // fetched them once on mount and never again — so a lead worked by five // agents in three minutes showed the trail as it was when you opened the // page — and the conversation view needs the same rows. One fetch, one // poll, two readers that cannot disagree. const [audit, setAudit] = useState(null) const [showChat, setShowChat] = useState(false) const load = useCallback((quiet = false) => { if (!quiet) setErr(null) // Best-effort and never awaited with the record: a failed audit call must // not blank the lead, and a slow one must not hold up the fields. client.audit(instanceId) .then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? []))) .catch(() => { /* keep whatever is on screen */ }) return client.detailView(DV_LEAD, instanceId) .then((r) => { setRow(r?.data ?? r?.record ?? r) // The detail view ships an output_label 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 (
{said.title} {said.detail ?

{said.detail}

: null}

Lead {instanceId} · {err.status} {err.message}

) } if (!row) return

Loading…

// 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]), filesOf(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 || '' // The state, then what is really happening inside it — see phaseOf. Actions // are still looked up on the state's own uid; only the presentation moves. const stage = phaseOf(STAGES.find((s) => s.name === stateName), row) // 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. // Derived from the same audit rows the trail renders, so they cannot drift. const worked = [...new Set((audit || []) .flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))] const chatTurns = (audit || []).reduce((n, r) => { const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : [] return n + fs.filter((f) => { const b = String(f.field_id || '').replace(/_\d+$/, '') return (b === 'customer_reply' || b === 'customer_answer') && typeof f.value === 'string' && f.value.trim() }).length }, 0) const isClosed = stage?.kind === 'end' const heldByOthers = !isClosed && actions.length === 0 // An automated stage that has stopped for a stated reason. Checked before // the "in progress" strip below, which would otherwise keep promising that // something is happening for as long as the lead is left alone. const blocked = stage?.kind === 'auto' ? blockedOn(row) : null return (
{/* 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. */}

{row.customer_name || row.lead_ref || `Lead ${instanceId}`}

{row.lead_ref || `Lead ${instanceId}`} {row.entity_name ? ` · ${row.entity_name}` : ''}

Stage {stateName || '—'} {/* 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 ? Closed — no further activity : null} {heldByOthers ? ( {stage?.kind === 'auto' ? `In progress with ${stage.by}` : `Assigned to ${stage?.by ?? 'another role'}`} ) : null}
{/* 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 (
Renewal {e ? e.label : '—'} {e ? e.on : 'no date on file'}
Lead age {age === null ? '—' : age === 0 ? 'Today' : age + 'd'} {row.created_at ? fmtWhen(row.created_at) : ''}
In this stage {dwell === null ? '—' : dwell === 0 ? '<1d' : dwell + 'd'} last activity {row.updated_at ? fmtWhen(row.updated_at) : '—'}
Premium {premium ?? '—'} {premium ? 'quoted' : 'not yet rated'}
Commission {commission ?? '—'} {row.commission_rate_pct ? row.commission_rate_pct + '% of premium' : 'on placement'}
Product {PRODUCTS[row.product_line] ?? '—'} {row.motor_reg_no || row.entity_name || ''} {row.current_insurer ? ` · from ${row.current_insurer}` : ''}
{Number.isFinite(conf) && conf > 0 ? (
AI confidence {conf}% on the cover advice
) : null}
) })()}
{note ? (

{note}

) : 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?". */} {blocked ? (
) : stage?.kind === 'customer' && stage.doing ? ( /* Not an agent working, and not a task of yours either — the lead is with someone outside the business. Without this the screen went blank at exactly the stage where an operator is most likely to wonder whether something has broken. */
) : stage?.kind === 'auto' ? (
) : 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. */} {(() => { // Once the documents are in, the upload is not the step, not an // alternative to the step, and not a loop worth offering: it is // recovery. Demoting it only to 'again' still printed an Upload // documents button under a panel saying the documents had been // received, which is the contradiction that was reported. It goes // into the fold with the other recovery levers, relabelled for what // it is actually for — a wrong file, or one more. const docsDone = stage?.after === 'documents received' // Recording an acceptance a second time is not a loop, a recovery // or an alternative — it is meaningless. The customer accepted on // WhatsApp at a stated time and the lead carries the reference; // offering the button again invites somebody to overwrite that // with a worse record of the same event. const accepted = Boolean(row.acceptance_ref) const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } : a) const shown = actions .filter((a) => !(accepted && a.uid === 'zk-act-accept')) .map(recast) const step = shown.filter((a) => a.role === 'do') const again = shown.filter((a) => a.role === 'again') const force = shown.filter((a) => a.role === 'force') const exit = shown.filter((a) => a.role === 'exit') const extras = [...force, ...exit] const form = open ? (
setOpen(null)} onStale={() => { setOpen(null) setNote('Stage changed. Available actions have been refreshed.') load() }} onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} />
) : null if (!step.length && !again.length && !extras.length) return null return (

Next step

{step.length ? 'This is what this lead is waiting on.' : isClosed ? 'This lead is closed.' : blocked ? 'The chain has stopped. The fix is above.' : accepted && stage?.uid === 'zk-state-quoted' ? `Accepted by the customer${row.accepted_at ? ' on ' + row.accepted_at : ''}${row.acceptance_ref === 'whatsapp' ? ', over WhatsApp' : ''}. KYC and underwriting are running.` : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}

{/* 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 ? (
{step.map((a) => ( ))}
) : null} {again.length ? (
{again.map((a) => ( ))}
) : 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 ? (
Lead not moving?

{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.'}

{extras.map((a) => ( ))}
) : null}
) })()} {/* 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 ? (

Lead file

Everything captured, grouped by the step that captured it.

{groups.map(([title, shown], i) => ( ))}
{groups[active][1].map(([k, v, files]) => ( files.length ? ( /* An attached document is a thing to open, not a filename to read. The preview route is app-scoped and public, so a plain link works with no token plumbing. */
{labels[k] ?? label(k)}
{files.map((f) => ( {f.original_name || 'Document'} ))}
) : v.length > LONG ? (
{labels[k] ?? label(k)}
) : (
{labels[k] ?? label(k)}
{v}
) ))}
) : null}
{/* The story runs alongside the file rather than under it, so what was done and what was captured can be read against each other. */}
{showChat ? ( setShowChat(false)} /> ) : null}
) }