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 (
{describeError(err).title} · {err?.status} {err?.message}
{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. */}{note}
{blocked.fix} The agents pick it up again on their own once it is there.
{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.
“{row.review_reason}”
: null}{BLOCKER[row.review_blocker] ?? 'It could not complete its step.'} Nothing is running on this lead.
This lead is waiting on you; the AI carries on as soon as it is done.
Their answer comes back on its own. Nothing is waiting on you.
Handled by {stage.by}. Usually done within two minutes; this refreshes itself.
Every step on this lead, who took it, and what they wrote.
The customer, the risk, and what this stage turns on.
{row.customer_name || row.lead_ref}