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 (
}
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 (
{/* 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.
{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.
) : 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 ? (
<>