import { useCallback, useEffect, useRef, 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 CallTranscript from '../components/CallTranscript.jsx' import ClampText from '../components/ClampText.jsx' import Conversation from '../components/Conversation.jsx' import LeadFileDialog from '../components/LeadFileDialog.jsx' import Timeline from '../components/Timeline.jsx' import { APP_ID, DV_LEAD, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' import { buildThread } from '../api/thread.js' import { BLOCKER, GROUPS, LONG, MONEY, expiryOf, filesOf, fmt, inrShort, label } from '../api/lead.js' /** * One lead, on a phone. * * The desktop puts the story in a wide column with the record beside it. Here * everything is one column, ordered by how often it is needed: what this lead * wants from you, the four numbers, where it is in the journey, what happened, * then the record. The action itself is pinned to the bottom of the screen — * on a phone the thing you came to do must be reachable without scrolling back. * * WHAT IS ONE TAP AWAY RATHER THAN ON THE PAGE. Everything the console opens in * a dialog is opened here as a bottom sheet, from the same components: the * WhatsApp thread, the call, an agent's card, the complete record, and the * actions that are not this stage's step. A phone cannot show them at once and * must not therefore lack them — a lead that can only be advanced and never * dropped is a worse tool than the desktop, not a smaller one. */ /** Who is holding it, in the words the header uses. */ function holdsOf(stage) { if (!stage) return null switch (stage.kind) { case 'auto': return { tone: 'blue', label: 'With the AI' } case 'customer': return { tone: 'teal', label: 'With the customer' } case 'needs': return { tone: 'amber', 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 } } /* The journey, as the desktop rail states it. The side states — Parked, Referred, Needs a Person, Lost, Declined — are deliberately not on it: they are departures from the path rather than points along it, and a row that included them would suggest every lead passes through. When the lead is in one, the rail says so instead. */ const PATH = ['zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs', 'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded'] const STEP = { 'zk-state-new': 'Filed', 'zk-state-qualified': 'Called', 'zk-state-contacted': 'Contacted', 'zk-state-docs': 'Documents', 'zk-state-quoted': 'Quoted', 'zk-state-payment': 'Payment', 'zk-state-issued': 'Issued', 'zk-state-onboarded': 'Onboarded' } function PhoneGlyph() { return ( ) } export default function Lead() { const { instanceId } = useParams() const { client, user } = useZino() const roles = rolesOf(user) const navigate = useNavigate() const location = useLocation() const [note, setNote] = useState(location.state?.message ?? null) const [row, setRow] = useState(null) const [err, setErr] = useState(null) const [audit, setAudit] = useState(null) const [labels, setLabels] = useState({}) const [open, setOpen] = useState(null) // the activity whose form is up // The sheets. `chat` carries which exchange to open the thread at — an // episode id from the trail, or null for "at the newest message". const [chat, setChat] = useState(null) const [openAgent, setOpenAgent] = useState(null) const [showCall, setShowCall] = useState(false) const [showFile, setShowFile] = useState(false) const [more, setMore] = useState(false) const railRef = useRef(null) // Sampled on a tick rather than read during render, so the "nothing for N // minutes" line is stable within a paint. 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 per field, so a field renamed // in Studio is renamed here without a release. 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]), )) } }) .catch((e) => { if (!quiet) setErr(e) }) }, [client, instanceId]) useEffect(() => { load() }, [load]) // The AI works this lead in the background, so the page has to keep up with // it. Twelve seconds is the compromise between "it moved" and battery; the // poll is quiet, so a dropped request leaves the screen exactly as it is. // // Not while a form is up: a poll that lands mid-submission re-renders the // sheet under whoever is typing in it. useEffect(() => { if (open) return undefined const id = setInterval(() => { load(true); setNow(Date.now()) }, 12000) return () => clearInterval(id) }, [load, open]) // The rail scrolls, so the step the lead is actually ON has to be brought // into view; otherwise a lead at Payment opens showing Filed. useEffect(() => { const el = railRef.current?.querySelector('.is-here') el?.scrollIntoView({ block: 'nearest', inline: 'center' }) }, [row?.current_state_name]) if (err) { return (
Unable to load this lead.

{describeError(err).title} · {err?.status} {err?.message}

) } if (!row) { return
} const stateName = row.current_state_name || '' const stage = phaseOf(STAGES.find((s) => s.name === stateName), row) const holds = holdsOf(stage) const blocked = stage?.kind === 'auto' ? blockedOn(row) : null // "How long has this been still?" is read from the clock, which makes it // impure in a render body. It is recomputed on every poll instead — which is // also the only moment it can have changed. // // nudgeFor takes the STAGE and the LEAD. Called with a uid and the role list, // as it was, `stage.uid` is undefined, the switch falls through, and the way // out of a stalled lead was never offered on the phone at all. const stalled = !blocked && stage?.kind === 'auto' && now - Date.parse(row.updated_at || 0) > STALL_AFTER_MS ? { mins: Math.round((now - Date.parse(row.updated_at || 0)) / 60000), nudge: nudgeFor(stage, row) } : null /* WHAT EACH ACTION IS, not merely what this role may press. `do` is the step the stage is waiting on, `again` a bounded loop, `force` an AI's own job offered only so a stalled lead can be pushed by hand, `exit` is Mark Lost. The phone rendered only `do`, so four stages offered a partner agent one button and no way to drop a lead the system was working. */ const all = actionsFor(roles, stage?.uid) // Once the documents are in, the upload is not the step and not an // alternative to it: it is recovery, for a wrong file or one more. const docsDone = stage?.after === 'documents received' // Recording an acceptance twice is not a loop — the customer accepted at a // stated time and the lead carries the reference. const accepted = Boolean(row.acceptance_ref) const shown = all .filter((a) => !(accepted && a.uid === 'zk-act-accept')) .map((a) => (docsDone && a.uid === 'zk-act-collect-docs' ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } : a)) 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 primary = step[0] ?? null const secondary = [...again, ...force, ...exit] const hasBar = Boolean(primary || blocked || stalled?.nudge || secondary.length) 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) const dueTone = e ? (e.tone === 'lapsed' ? 'red' : e.tone === 'urgent' ? 'amber' : '') : '' /* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip — documents uploaded from Qualified carry one straight past Contacted — and can go backwards. Marking everything left of the current step as done claimed steps that never happened. */ const visited = new Set((audit || []).map((r) => r.execution_state).filter((v) => PATH.includes(v))) if (stage?.uid) visited.add(stage.uid) const here = PATH.indexOf(stage?.uid) const offPath = here < 0 // The record, grouped, empty groups dropped. The same rows feed the card // below and the full-file sheet, so the two cannot disagree about a value. const groups = GROUPS // `v !== null`, the same test the console uses, rather than "has files or a // truthy value": the full-file sheet measures v.length to decide what is // prose, and a row admitted on its files alone with a null value throws // there rather than here. .map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null)]) .filter(([, rows]) => rows.length) const fieldCount = groups.reduce((n, [, rows]) => n + rows.length, 0) const glance = groups.slice(0, 3) // Counted off the THREAD, so the button and the sheet agree: scanning the // audit rows counts the platform's own repeats, one submission being // recorded up to three times. const chatTurns = buildThread(audit).turns.length const actionSheet = (uid) => { setMore(false); setOpen(uid) } return (

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

{row.lead_ref || `Lead ${instanceId}`} {row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}

{/* The stage and its holder sit UNDER the name, not beside it: a customer name is as long as it is, and squeezing a pill in next to it wrapped "Priya Raghavan" onto two lines to make room. */}
{stateName} {holds ? {holds.label} : null}
{note ? (

{note}

) : null} {/* ONE banner. Whatever this lead needs, it says it once, here. */} {blocked ? (

Stopped: {blocked.what}

{blocked.fix} The agents pick it up again on their own once it is there.

) : stalled ? (

Nothing for {stalled.mins} minutes

{stage.by} has not come back. That is usually the model provider being slow, not a problem with this lead — the work so far is safe. {stalled.nudge ? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.` : ''}

) : stage?.uid === 'zk-state-review' ? (

The AI stopped and asked for a person

{row.review_reason ?

“{row.review_reason}”

: null}

{BLOCKER[row.review_blocker] ?? 'It could not complete its step.'} Nothing is running on this lead.

) : primary ? (

Your turn: {primary.label}

This lead is waiting on you; the AI carries on as soon as it is done.

) : stage?.kind === 'customer' && stage.doing ? (

{stage.doing}

Their answer comes back on its own. Nothing is waiting on you.

) : stage?.kind === 'auto' ? (

{stage.doing}

Handled by {stage.by}. Usually done within two minutes; this refreshes itself.

) : null}
Premium {premium ?? 'Not yet rated'} {premium ? 'quoted, incl. GST' : 'once Rating has run'}
Commission {commission ?? '—'} {row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
AI confidence {Number.isFinite(conf) && conf > 0 ? ( <> {conf}% on the cover advice ) : (<>once the Advisor has spoken)}
Renewal due {e ? e.label : '—'} {e ? e.on : 'no date on file'}
Journey {offPath ? stateName : `Step ${here + 1} of ${PATH.length}`} {holds ? {holds.label} : null}
{PATH.map((uid, i) => { // Ahead of a lead that has left the path is unknowable — a referred // risk may never see payment — so nothing is called done there. const been = visited.has(uid) const isHere = uid === stage?.uid return (
{i + 1}. {STEP[uid]}
) })}

What happened

Every step on this lead, who took it, and what they wrote.

{/* The conversation, reachable from the head as well as from its own entry in the trail: on a lead worked over two days the exchange is a long scroll down. */} {chatTurns ? ( ) : null} setChat({ anchor: episode ?? null })} onOpenCall={() => setShowCall(true)} />
{groups.length ? (

The record

The customer, the risk, and what this stage turns on.

{glance.map(([title, rows]) => (

{title}

{rows.map(([k, v, files]) => (
{labels[k] ?? label(k)}
{files.length ? (
{files.map((f) => ( {f.original_name || 'Document'} ))}
) : k === 'call_transcript' ? ( /* A transcript is not a value. Printed as one it was two thousand characters cut at a hundred and fifty. */
) : (
{/* Folded rather than cut: a stage reason runs to a paragraph, and half a sentence teaches the reader that this panel is unreliable. */} {v.length > LONG ? : v}
)}
))}
))}
{fieldCount ? ( ) : null}
) : null} {/* THE ACTION, pinned. On a phone the thing you came to do must be reachable from wherever you have scrolled to — and so must the way out, which is what the overflow button holds. */} {hasBar ? (
{blocked ? ( ) : stalled?.nudge ? ( ) : primary ? ( <> {step.slice(1).map((a) => ( ))} ) : ( /* Nothing is owed by this role, so the bar carries only the way out — never dressed as the thing to do. */ )} {(primary || blocked || stalled?.nudge) && secondary.length ? ( ) : null}
) : null} {/* The actions that are not this stage's step, by what they are. */} {more ? ( <>
setMore(false)} />
) : null} {open ? ( <>
setOpen(null)} />
) : null} {chat ? ( setChat(null)} /> ) : null} {openAgent ? setOpenAgent(null)} /> : null} {showCall ? ( setShowCall(false)} /> ) : null} {showFile ? ( `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`} onClose={() => setShowFile(false)} /> ) : null}
) }