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 { 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 { BLOCKER, GROUPS, LONG, MONEY, expiryOf, filesOf, fmt, inrShort, label } from '../api/lead.js' import Timeline from '../components/Timeline.jsx' /** * 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. */ /** 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. */ 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' } 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 const [showAll, setShowAll] = 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. useEffect(() => { const id = setInterval(() => { load(true); setNow(Date.now()) }, 12000) return () => clearInterval(id) }, [load]) // 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 actions = actionsFor(roles, stage?.uid) const primary = actions.find((a) => a.role === 'do') 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. 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?.uid, roles) } : null 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' : '') : '' const here = PATH.indexOf(stage?.uid) const offPath = here < 0 // The record, grouped, empty groups dropped. const groups = GROUPS .map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v, f]) => f.length || v)]) .filter(([, rows]) => rows.length) const shown = showAll ? groups : groups.slice(0, 3) 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.

) : 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) => (
{i + 1}. {STEP[uid]}
))}

What happened

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

{groups.length ? (

The record

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

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

{title}

{rows.map(([k, v, files]) => (
{labels[k] ?? label(k)}
{files.length ? (
{files.map((f) => ( {f.original_name || 'Document'} ))}
) : (
{v.length > LONG ? v.slice(0, LONG) + '…' : v}
)}
))}
))}
{groups.length > 3 ? ( ) : null}
) : null} {/* THE ACTION, pinned. On a phone the thing you came to do must be reachable from wherever you have scrolled to. */} {primary || blocked || stalled?.nudge ? (
{blocked ? ( ) : stalled?.nudge ? ( ) : ( <> {actions.filter((a) => a.role === 'do' && a.uid !== primary.uid).map((a) => ( ))} )}
) : null} {open ? ( <>
setOpen(null)} />
) : null}
) }