zurich_kotak/src/screens/Lead.jsx

865 lines
43 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 AgentCard from '../components/AgentCard.jsx'
import AgentChip from '../components/AgentChip.jsx'
import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import LeadFileDialog from '../components/LeadFileDialog.jsx'
import StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, 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'
/** 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' }),
}
}
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','call_transcript']],
// 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
/**
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
*
* Three things are always true of a lead — who the customer is, what is being
* insured, and what has been attached — so those are always here. The fourth
* section depends on where the lead has got to: a lead in Quoted needs the
* cover and the quote's expiry; the same lead in Onboarded needs the policy
* number and when it renews. Everything else is in the full file.
*
* THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
* carries the same hazard: a stage not listed here simply gets no fourth
* section, and a field renamed in the workflow quietly stops appearing. That is
* the safe direction to fail — the full file is generated from the data and
* still holds everything — but it is a mirror, not a source.
*
* The metrics strip at the top of the page already carries premium, commission,
* product, renewal date, lead age and AI confidence. Nothing is repeated here.
*/
const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'],
'zk-state-qualified': ['lead_score', 'eligibility_outcome', 'outreach_window'],
'zk-state-contacted': ['contact_outcome', 'outreach_window'],
'zk-state-docs': ['documents_status', 'motor_prev_expiry'],
'zk-state-quoted': ['ai_recommended_cover', 'quote_valid_till', 'acceptance_ref'],
'zk-state-payment': ['payment_ref', 'quote_valid_till'],
'zk-state-issued': ['policy_no', 'policy_issued_at', 'payment_ref'],
'zk-state-onboarded': ['policy_no', 'policy_issued_at', 'renewal_due', 'payout_status'],
'zk-state-referred': ['uw_outcome', 'uw_referral_reason'],
'zk-state-parked': ['contact_outcome', 'outreach_window'],
'zk-state-lost': ['lost_reason', 'contact_outcome'],
}
/** The risk, in whichever line's vocabulary this lead is written. */
const GLANCE_RISK = {
motor: ['motor_reg_no', 'motor_make_model', 'motor_mfg_year', 'motor_idv', 'motor_ncb_pct'],
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
}
/**
* 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)
}
/**
* Whose move it is, right now — the question an operator opens a lead to
* answer, said as a coloured pill in the header. Derived from the stage's own
* kind, so it stays in step with the workflow: an AI-run stage is with the AI,
* a customer-gated one is with the customer, a person-gated one is waiting on
* someone, and a closed lead holds nobody.
*/
function holdsOf(stage) {
if (!stage) return null
switch (stage.kind) {
case 'auto': return { tone: 'ai', label: 'With the AI' }
case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' }
case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return null
}
}
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 [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 [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
// The full record, on demand — see the panel comment below.
const [showFile, setShowFile] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure — React may render twice and get two
// answers — and it also means the "nothing for 6 minutes" counter would only
// move when something else happened to re-render the page. This ticks it.
const [now, setNow] = useState(() => Date.now())
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(() => {
const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id)
}, [])
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]), filesOf(row[k])]).filter(([, v]) => v !== null),
])
.filter(([, shown]) => shown.length)
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)
const holds = holdsOf(stage)
// 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)
// THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do
// now" is the first question an operator asks of a lead, and it was answered
// only by scrolling past the metrics and the stage rail to the Next step
// panel. When this user has an action, it belongs top-right, where the eye
// goes for the next move; when they do not, the passive status stays there
// instead. `role: 'do'` is the committing step — never a re-run or a
// recovery lever, which must not be dressed as the thing to do.
const acceptedAlready = Boolean(row.acceptance_ref)
const primaryAction = actions.find(
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
) || null
const goToAction = () => {
if (!primaryAction) return
setOpen(primaryAction.uid)
// Let the form mount, then bring the working surface into view.
requestAnimationFrame(() =>
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
}
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
// Built from the SAME rows the full file renders, so the two can never
// disagree about a value. A section with nothing in it is dropped rather
// than rendered empty — an SME lead has no registration number, and a
// heading over a blank box reads as a bug.
const pick = (keys) => keys
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
.filter(([, v]) => v !== null)
const docKeys = Object.keys(DOC_SLOTS).filter((k) => filesOf(row[k]).length)
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
const glance = [
['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
[line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])],
['Documents', pick(docKeys)],
// THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything
// else on this lead was typed or derived; this happened out loud, and
// until now its outcome sat in the full file with the source channel while
// the words themselves were reachable only from a button halfway down the
// trail. It gets its own card whenever a call has actually happened.
['The call', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
['This stage', pick(GLANCE_STAGE[stage?.uid] || [])],
].filter(([, rows]) => rows.length)
// 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)
// 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
// An automated step that has sat too long. Distinct from `blocked`, which is
// the workflow refusing for a stated reason — this is the agent not having
// come back, and the cause is usually outside this app entirely: a slow or
// failing model provider, a thinking budget that ran out mid-sentence, a
// wake that never arrived. The console cannot see any of that, so it reports
// the only thing it can know — nothing has happened for a while — and offers
// the way out rather than going on promising two minutes.
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null
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>
{/* WHOSE MOVE IT IS. The stage name is in the rail below; this says
who holds the lead right now, which is the question the header is
really being asked. The customer tone reuses the teal cust palette. */}
{primaryAction ? (
/* YOUR MOVE. When this user owns the next step, the header carries it
as the prominent thing — amber, because it is waiting on a person —
and pressing it opens the form below and scrolls to it. */
<button type="button" className="cta" onClick={goToAction}>
<span className="cta__txt">
<small>Your move</small>
<b>{primaryAction.label}</b>
</span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
) : holds ? (
<div className={'holds holds--' + (holds.pillTone === 'cust' ? 'cust' : holds.tone)}>
<span className="holds__puck" aria-hidden="true">
{holds.tone === 'ai' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 3a2 2 0 0 0-2 2 2 2 0 0 0-1 3.6A2 2 0 0 0 4 12a2 2 0 0 0 2 2M14 3a2 2 0 0 1 2 2 2 2 0 0 1 1 3.6A2 2 0 0 1 16 12a2 2 0 0 1-2 2M10 3.5v12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
) : holds.pillTone === 'cust' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M4 5h12v8h-6l-3 2.5V13H4z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
) : holds.tone === 'person' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="6.5" r="2.8" fill="none" stroke="currentColor" strokeWidth="1.5"/><path d="M4.5 15.5a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M10 5.5v4.5l3 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5"/></svg>
)}
</span>
<span className="holds__txt">
<small>Right now</small>
<b>{holds.label}</b>
</span>
</div>
) : null}
</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 premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
// FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal
// is about; the AI's confidence in the cover sits beside it; the
// renewal countdown is the one deadline that matters. Dropped: lead age
// and time-in-stage (they overlapped each other and the feed carries
// timing), and product/vehicle (the rail's "The vehicle" holds them).
// Nothing is lost — every removed measure is still on the page once,
// where it belongs.
return (
<div className="lmetrics">
<div className="lm lm--hero">
<span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong>
<span className="lm__s">{premium ? 'quoted, incl. GST' : 'not yet rated'}</span>
</div>
<div className="lm lm--hero">
<span className="lm__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong>
<span className="lm__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
</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 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>
)
})()}
<StageRail audit={audit} currentName={stateName} />
<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?". */}
{blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents
</button>
</div>
</div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
{stage.by} has not come back. That is usually the model provider being
slow or dropping a request, not a problem with this lead the work so far
is safe and nothing has been lost.
{stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : 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. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Their answer comes back to this lead on its own Engage reads it and
records the acceptance. Nothing is waiting on you.
</span>
</div>
</div>
) : stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Handled by {stage.by}.
{stage.after ? ` The lead stays in ${stateName} until a quote exists — nothing is waiting on you.` : ''}
{' '}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. */}
{(() => {
// 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 ? (
<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" id="lead-next">
{/* THE HEADING IS FOR A REAL STEP ONLY. When there is no `do`
action, the "what is happening / nothing owed" story is
already told once — by the status strip above and the
header — so repeating it here as "Next step: nothing is
waiting on you" was the same sentence a third time. The
secondary actions (add a document, mark lost) still render
below, without the framing. */}
{step.length ? (
<div className="panel__head">
<div>
<h2 className="panel__title">Next step</h2>
<p className="panel__sub">This is what this lead is waiting on.</p>
</div>
</div>
) : null}
{/* 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 STORY IS THE MAIN COLUMN NOW.
This sat in a 384px gutter with its own inner scrollbar, beside a
panel of reference fields that had the whole width. That was
exactly backwards. The trail is what this product is FOR — it is
the record of work nobody watched happen — and putting it in a
sidebar said it was a footnote to the form fields.
It gets the wide column and the page's own scroll. The reference
data moved to the rail, where short key/value pairs belong and
where a narrow measure costs nothing. */}
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">What happened</h2>
<p className="panel__sub">
Every step on this lead, who took it, and what they wrote.
</p>
</div>
{chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Conversation
<b>{chatTurns}</b>
</button>
) : null}
</div>
<Timeline
rows={audit}
onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)}
onOpenCall={() => setShowCall(true)}
/>
</div>
</div>
{/* Reference, not narrative. Sticky, so the facts stay put while the
story is read against them. */}
<aside className="lead__side">
{/* Who worked this lead. Five agents carry it and their names appear
only inside individual entries, so the team was never visible at
a glance. */}
{worked.length ? (
<div className="panel panel--crew">
<span className="crew__l">Worked by</span>
<div className="crew">
{worked.map((k) => <AgentChip key={k} agentKey={k} onOpen={setOpenAgent} />)}
</div>
</div>
) : null}
{/* WHAT MATTERS HERE, NOT EVERYTHING THERE IS.
The whole record used to sit open on the page: fifty-seven
fields over thirteen groups, which pushed the audit trail — the
part people actually read — a screen and a half down. Most of it
is reference material. Nobody opens a lead to find its source
channel; they open it to see who the customer is, what is being
insured, what has been attached, and what this stage turns on.
So the page carries that, and the complete file is one click
away with a filter on it. Hiding is only safe when it stays
findable, which is why the trigger below names the number it is
holding back rather than saying "more". */}
{glance.length ? (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">At a glance</h2>
<p className="panel__sub">
The customer, the risk, and what this stage turns on.
</p>
</div>
</div>
<div className="glance">
{glance.map(([title, rows]) => (
<section className="fgroup" key={title}>
<h3>{title}<span>{rows.length}</span></h3>
<dl className="props">
{rows.map(([k, v, files]) => (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
{files.length ? (
<dd className="prop__files">
{files.map((f) => (
<a
key={f.uuid}
className="prop__file"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer"
>
{f.original_name || 'Document'}
</a>
))}
</dd>
) : v.length > LONG ? (
/* A stage reason can run to a paragraph —
uw_referral_reason and lost_reason both do — and
a paragraph printed raw here would rebuild the
wall this panel exists to remove. */
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
) : (
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
)}
</div>
))}
</dl>
{title === 'The call' && row.call_transcript ? (
<button type="button" className="glance__act" onClick={() => setShowCall(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Read what was actually said
</button>
) : null}
</section>
))}
</div>
{fieldCount ? (
<button type="button" className="glance__all" onClick={() => setShowFile(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor"
strokeWidth="1.4" strokeLinecap="round" />
</svg>
Open the full lead file
<b>{fieldCount} fields</b>
</button>
) : null}
</div>
) : null}
</aside>
</div>
{showChat ? (
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
) : null}
{openAgent ? (
<AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} />
) : null}
{showCall ? (
<CallTranscript text={row.call_transcript} name={row.customer_name} onClose={() => setShowCall(false)} />
) : null}
{showFile ? (
<LeadFileDialog
groups={groups}
labels={labels}
label={label}
money={MONEY}
longAt={LONG}
fileHref={(f) => `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
onClose={() => setShowFile(false)}
/>
) : null}
</section>
)
}