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 (
{said.detail}
: null}Lead {instanceId} · {err.status} {err.message}
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])]).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 ({row.lead_ref || `Lead ${instanceId}`} {row.entity_name ? ` · ${row.entity_name}` : ''}
{note}
{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.`}
{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.'}
Captured data, grouped by stage of collection.