A separate app from the desktop console, talking to the same platform with the same client. Not a breakpoint on the old one: the two answer different questions. The console answers "what is the state of the book?" across a wide grid; a phone answers "what needs me, and what happened to this one?" in a column you scroll with a thumb. The old console at 390px showed why. Tables scrolled sideways, nine queues stacked above the content ate the first screenful, and the action you came to perform sat below the fold. So every row of every table is a CARD — customer, stage, when it is due, who is holding it, and one line about what is happening. Everything else is one tap away. The queues live in a drawer. The action a lead is waiting on is pinned to the bottom of the screen, where a thumb already is. Shared with the console, because a divergence would be two apps disagreeing about the same lead: the whole api/ layer, ActivityForm, and Timeline — whose audit-row merging (one submission writes three rows) is hard-won and must not be reimplemented twice. Written fresh: the shell, the three screens, and the stylesheet. The colour rule is unchanged and is the product in four colours: blue the AI holds it, amber a person, teal the customer, red risk and nothing else. A PWA, so Add to Home Screen gives a full-screen app; the layout pads for the notch and the home indicator. Verified at 402x874: login, overview, drawer, a queue, and a lead, with no horizontal overflow on any of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
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 (
|
||
<div className="page">
|
||
<div className="notice">
|
||
<strong>Unable to load this lead.</strong>
|
||
<p>{describeError(err).title} · {err?.status} {err?.message}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (!row) {
|
||
return <div className="page"><div className="skel" aria-busy="true"><i /><i /><i /></div></div>
|
||
}
|
||
|
||
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 (
|
||
<div className={'page' + (primary || blocked || stalled?.nudge ? ' page--acts' : '')}>
|
||
<header className="lhead">
|
||
<button
|
||
type="button" className="back" aria-label="Back"
|
||
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
|
||
>
|
||
<svg viewBox="0 0 16 16" fill="none"><path d="M9.5 3.5 5 8l4.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||
</button>
|
||
<div className="lhead__id">
|
||
<h1>{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
|
||
<p>
|
||
{row.lead_ref || `Lead ${instanceId}`}
|
||
{row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
|
||
</p>
|
||
{/* 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. */}
|
||
<div className="lhead__st">
|
||
<span>{stateName}</span>
|
||
{holds ? <span className={'pill pill--' + holds.tone}><span className={'dot dot--' + holds.tone} />{holds.label}</span> : null}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{note ? (
|
||
<div className="said" role="status">
|
||
<p>{note}</p>
|
||
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ONE banner. Whatever this lead needs, it says it once, here. */}
|
||
{blocked ? (
|
||
<div className="banner banner--amber" role="status">
|
||
<h3><span className="dot dot--amber" />Stopped: {blocked.what}</h3>
|
||
<p>{blocked.fix} The agents pick it up again on their own once it is there.</p>
|
||
</div>
|
||
) : stalled ? (
|
||
<div className="banner banner--red" role="status">
|
||
<h3><span className="dot dot--red" />Nothing for {stalled.mins} minutes</h3>
|
||
<p>
|
||
{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.
|
||
</p>
|
||
</div>
|
||
) : stage?.uid === 'zk-state-review' ? (
|
||
<div className="banner banner--amber" role="status">
|
||
<h3><span className="dot dot--amber" />The AI stopped and asked for a person</h3>
|
||
{row.review_reason ? <p className="banner__said">“{row.review_reason}”</p> : null}
|
||
<p>{BLOCKER[row.review_blocker] ?? 'It could not complete its step.'} Nothing is running on this lead.</p>
|
||
</div>
|
||
) : primary ? (
|
||
<div className="banner banner--amber" role="status">
|
||
<h3><span className="dot dot--amber" />Your turn: {primary.label}</h3>
|
||
<p>This lead is waiting on you; the AI carries on as soon as it is done.</p>
|
||
</div>
|
||
) : stage?.kind === 'customer' && stage.doing ? (
|
||
<div className="banner banner--teal" role="status">
|
||
<h3><span className="dot dot--teal" />{stage.doing}</h3>
|
||
<p>Their answer comes back on its own. Nothing is waiting on you.</p>
|
||
</div>
|
||
) : stage?.kind === 'auto' ? (
|
||
<div className="banner banner--blue" role="status">
|
||
<h3><span className="dot dot--blue" />{stage.doing}</h3>
|
||
<p>Handled by {stage.by}. Usually done within two minutes; this refreshes itself.</p>
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="card facts" style={{ padding: '4px 0' }}>
|
||
<div className="fact">
|
||
<span>Premium</span>
|
||
<b className={premium ? '' : 'is-none'}>{premium ?? 'Not yet rated'}</b>
|
||
<small>{premium ? 'quoted, incl. GST' : 'once Rating has run'}</small>
|
||
</div>
|
||
<div className="fact">
|
||
<span>Commission</span>
|
||
<b className={commission ? '' : 'is-none'}>{commission ?? '—'}</b>
|
||
<small>{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}</small>
|
||
</div>
|
||
<div className="fact">
|
||
<span>AI confidence</span>
|
||
{Number.isFinite(conf) && conf > 0 ? (
|
||
<>
|
||
<b>{conf}%</b>
|
||
<span className="fact__tr" aria-hidden="true"><i style={{ width: Math.min(100, conf) + '%' }} /></span>
|
||
<small>on the cover advice</small>
|
||
</>
|
||
) : (<><b className="is-none">—</b><small>once the Advisor has spoken</small></>)}
|
||
</div>
|
||
<div className="fact">
|
||
<span>Renewal due</span>
|
||
<b className={dueTone ? 'due--' + dueTone : ''}>{e ? e.label : '—'}</b>
|
||
<small>{e ? e.on : 'no date on file'}</small>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card steps" style={{ padding: '15px 0 13px' }}>
|
||
<div className="steps__t">
|
||
<strong>Journey</strong>
|
||
<span>{offPath ? stateName : `Step ${here + 1} of ${PATH.length}`}</span>
|
||
{holds ? <span className={'pill pill--' + holds.tone}>{holds.label}</span> : null}
|
||
</div>
|
||
<div className="steps__rail" ref={railRef}>
|
||
{PATH.map((uid, i) => (
|
||
<div key={uid} className={'step' + (offPath ? '' : i < here ? ' is-done' : i === here ? ' is-here' : '')}>
|
||
<i /><span>{i + 1}. {STEP[uid]}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card" style={{ marginTop: 12 }}>
|
||
<h3 className="card__h">What happened</h3>
|
||
<p className="card__s">Every step on this lead, who took it, and what they wrote.</p>
|
||
<Timeline rows={audit} />
|
||
</section>
|
||
|
||
{groups.length ? (
|
||
<section className="card" style={{ marginTop: 12 }}>
|
||
<h3 className="card__h">The record</h3>
|
||
<p className="card__s">The customer, the risk, and what this stage turns on.</p>
|
||
<div className="kv">
|
||
{shown.map(([title, rows]) => (
|
||
<section className="kv__g" key={title}>
|
||
<h4>{title}</h4>
|
||
{rows.map(([k, v, files]) => (
|
||
<div className="kv__r" key={k}>
|
||
<dt>{labels[k] ?? label(k)}</dt>
|
||
{files.length ? (
|
||
<dd>
|
||
{files.map((f) => (
|
||
<a key={f.uuid} className="kv__file" target="_blank" rel="noreferrer"
|
||
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}>
|
||
{f.original_name || 'Document'}
|
||
</a>
|
||
))}
|
||
</dd>
|
||
) : (
|
||
<dd className={MONEY.has(k) ? 'kv__num' : undefined}>
|
||
{v.length > LONG ? v.slice(0, LONG) + '…' : v}
|
||
</dd>
|
||
)}
|
||
</div>
|
||
))}
|
||
</section>
|
||
))}
|
||
</div>
|
||
{groups.length > 3 ? (
|
||
<button type="button" className="btn btn--ghost btn--block btn--sm" style={{ marginTop: 14 }}
|
||
onClick={() => setShowAll((v) => !v)}>
|
||
{showAll ? 'Show less' : `Show all ${groups.length} sections`}
|
||
</button>
|
||
) : null}
|
||
</section>
|
||
) : 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 ? (
|
||
<div className="actbar">
|
||
{blocked ? (
|
||
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(blocked.via)}>
|
||
Open Collect Documents
|
||
</button>
|
||
) : stalled?.nudge ? (
|
||
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(stalled.nudge.uid)}>
|
||
{stalled.nudge.label}
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button type="button" className="btn btn--navy" onClick={() => setOpen(primary.uid)}>{primary.label}</button>
|
||
{actions.filter((a) => a.role === 'do' && a.uid !== primary.uid).map((a) => (
|
||
<button key={a.uid} type="button" className="btn btn--danger" onClick={() => setOpen(a.uid)}>{a.label}</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{open ? (
|
||
<>
|
||
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setOpen(null)} />
|
||
<div className="sheet" role="dialog" aria-label="Action">
|
||
<span className="sheet__grab" aria-hidden="true" />
|
||
<div className="sheet__head">
|
||
<div>
|
||
<h2>{actions.find((a) => a.uid === open)?.label ?? 'Action'}</h2>
|
||
<p>{row.customer_name || row.lead_ref}</p>
|
||
</div>
|
||
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setOpen(null)}>×</button>
|
||
</div>
|
||
<div className="sheet__body">
|
||
<ActivityForm
|
||
activityUid={open}
|
||
instanceId={Number(instanceId)}
|
||
lead={row}
|
||
onCancel={() => setOpen(null)}
|
||
onStale={() => { setOpen(null); setNote('Stage changed. The actions have been refreshed.'); load() }}
|
||
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|