865 lines
43 KiB
JavaScript
865 lines
43 KiB
JavaScript
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>
|
||
)
|
||
}
|