diff --git a/README.md b/README.md
index 48b325d..caf701c 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,28 @@ divergence here would be two apps disagreeing about the same lead:
submission writes three rows) is hard-won, and reimplementing it here would
mean two different accounts of the same history.
+**The dialogs are copied verbatim too** — for the same reason, and because the
+functionality has to be identical rather than merely similar:
+
+ src/api/thread.js the WhatsApp thread, built once
+ src/components/Conversation.jsx the thread, with its gaps accounted for
+ src/components/CallTranscript.jsx
+ src/components/AgentCard.jsx what an agent is and what it can reach
+ src/components/LeadFileDialog.jsx the whole record, with a filter
+ src/components/NewLeadDialog.jsx filing a lead
+ src/components/AskDesk.jsx the support desk (ops only)
+
+They are NOT restyled per file. They all render the console's `prose__scrim` /
+`prose__dlg` chrome, and `styles/app.css` re-points that chrome at phone width
+into a bottom sheet — full width, rounded top, a grab handle via `::before`,
+`env(safe-area-inset-bottom)` under the last row. One block, and every dialog
+in the app is phone-native at once, including the two ClampText already opened.
+Above 700px the console's centred dialog is kept, which is the right answer for
+a tablet.
+
+Add a dialog to the console and it can be copied here as-is. Restyle one of
+them here and the next copy silently reverts it.
+
**Written fresh for the phone:** every screen, the shell, and the stylesheet.
## The colour rule
diff --git a/src/components/AgentCard.css b/src/components/AgentCard.css
new file mode 100644
index 0000000..8eebdf3
--- /dev/null
+++ b/src/components/AgentCard.css
@@ -0,0 +1,91 @@
+/* The agent dialog. Namespaced `agc`, not `card`: the screens use `.card`
+ for every panel, and a bare `.card { max-height }` here was silently
+ capping the lead tables at 720px while their rows kept rendering. */
+.agc { max-height: min(82vh, 720px); }
+
+.agc__head {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ padding: 20px 20px 16px 22px;
+ border-bottom: 1px solid var(--zk-line-soft);
+}
+
+.agc__disc { width: 38px !important; height: 38px !important; font-size: var(--fs-xs) !important; flex: none; }
+
+.agc__head > div { flex: 1; min-width: 0; }
+
+.agc__head h3 { margin: 0 0 3px; font-size: var(--fs-md); font-weight: 500; color: var(--zk-ink); }
+.agc__head p { margin: 0; font-size: var(--fs-2xs); line-height: 1.45; color: var(--zk-muted); }
+
+.agc__head button {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 28px; height: 28px;
+ cursor: pointer;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--zk-muted);
+}
+.agc__head button svg { width: 14px; height: 14px; }
+.agc__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
+.agc__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
+
+.agc__body { overflow-y: auto; padding: 18px 22px 20px; }
+
+.agc__body section + section { margin-top: 20px; }
+
+.agc__body h4 {
+ margin: 0 0 8px;
+ font-size: var(--fs-3xs);
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--zk-grey);
+}
+
+.agc__body p { margin: 0; font-size: var(--fs-xs); line-height: 1.55; color: var(--zk-muted); }
+
+.agc__tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
+.agc__tools li {
+ padding: 11px 13px;
+ border: 1px solid var(--zk-line);
+ border-radius: var(--r-md);
+ background: var(--zk-white);
+}
+.agc__tools code {
+ display: block;
+ margin-bottom: 5px;
+ font-size: var(--fs-2xs);
+ font-weight: 600;
+ color: var(--zk-blue-dark);
+}
+.agc__tools span { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-muted); }
+
+/* "No tools" is the most reassuring fact about an agent writing into an
+ insurance file. An empty section would read as missing data instead. */
+.agc__none {
+ padding: 11px 13px;
+ border-radius: var(--r-md);
+ background: var(--zk-tint);
+ border: 1px solid var(--zk-line-soft);
+}
+
+.agc__kb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
+.agc__kb li {
+ font-size: var(--fs-2xs);
+ padding: 4px 11px;
+ border-radius: var(--r-pill);
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+}
+
+.agc__foot {
+ margin-top: 20px !important;
+ padding-top: 14px;
+ border-top: 1px solid var(--zk-line-soft);
+ font-size: var(--fs-2xs) !important;
+ color: var(--zk-grey) !important;
+}
diff --git a/src/components/AgentCard.jsx b/src/components/AgentCard.jsx
new file mode 100644
index 0000000..01c1951
--- /dev/null
+++ b/src/components/AgentCard.jsx
@@ -0,0 +1,112 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { AGENTS } from '../api/agents.js'
+import './AgentCard.css'
+
+/**
+ * What this agent is, and what it can actually reach.
+ *
+ * The question anybody asks the first time they watch an AI do a step of their
+ * job is not "how does it work" — it is "what is it ALLOWED to touch". That
+ * answer is short here and it is reassuring, so it should be one click away
+ * rather than something a demo has to be trusted on: four of the five hold no
+ * tools at all and reason from the policy wordings; the only tool in the whole
+ * roster reads a partner registry.
+ *
+ * It is also the answer that stopped being true once, silently. Intake held a
+ * tool that could telephone customers, its charter never mentioned it, and it
+ * used it on every lead believing it was a duplicate check. Nobody could see
+ * that from any screen. This is the screen that would have shown it.
+ */
+export default function AgentCard({ agentKey, onClose }) {
+ const a = AGENTS[agentKey]
+ const ref = useRef(null)
+ const restore = useRef(null)
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ if (!a) return null
+
+ return createPortal(
+
+ ) : (
+ /* Stated positively rather than left blank. "No tools" is the
+ single most reassuring fact about an agent that writes into an
+ insurance file, and an empty section reads as missing data. */
+
+ No tools. It reads the lead and the policy wordings, and writes its answer
+ back into the workflow — nothing else.
+
+ )}
+
+
+
+
What it reads
+
+ {a.knowledge.map((k) =>
{k}
)}
+
+
+
+
+ Every step it takes is recorded against the lead, with what it wrote and why.
+ It cannot confirm a premium, issue a policy or telephone anybody.
+
+
+
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/components/AskDesk.css b/src/components/AskDesk.css
new file mode 100644
index 0000000..bb9f616
--- /dev/null
+++ b/src/components/AskDesk.css
@@ -0,0 +1,121 @@
+/* Ask the desk — a panel over the console, not a page of its own. Whoever
+ opens it is in the middle of something; it should be dismissible in one
+ press and leave the work behind it visible. */
+
+.desk__scrim {
+ position: fixed; inset: 0; z-index: 60;
+ background: rgba(11, 42, 91, .32);
+ display: flex; justify-content: flex-end;
+}
+
+.desk {
+ width: min(560px, 100%); height: 100%;
+ display: flex; flex-direction: column;
+ background: var(--zk-card);
+ border-left: 1px solid var(--zk-line);
+ box-shadow: -18px 0 48px rgba(11, 42, 91, .18);
+ animation: desk-in .2s var(--ease);
+}
+@keyframes desk-in { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } }
+@media (prefers-reduced-motion: reduce) { .desk { animation: none; } }
+
+.desk__head {
+ display: flex; align-items: flex-start; gap: 16px;
+ padding: 18px 22px 14px; border-bottom: 1px solid var(--zk-line-soft); flex: none;
+}
+.desk__head h2 { margin: 0; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
+.desk__head p { margin: 3px 0 0; font-size: var(--fs-xs); color: var(--zk-muted); max-width: 46ch; }
+.desk__x {
+ margin-left: auto; flex: none; width: 32px; height: 32px; border-radius: 50%;
+ border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted);
+ font-size: 20px; line-height: 1; cursor: pointer;
+}
+.desk__x:hover { background: var(--zk-tint); color: var(--zk-ink); }
+
+.desk__feed {
+ flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
+ padding: 18px 22px; display: flex; flex-direction: column; gap: 16px;
+}
+
+/* Nothing asked yet: say what it is for, and offer four real questions. */
+.desk__empty p { margin: 0 0 12px; font-size: var(--fs-sm); color: var(--zk-muted); }
+.desk__sugg { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
+.desk__sugg button {
+ font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer;
+ padding: 9px 14px; border-radius: var(--r-sm);
+ border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
+}
+.desk__sugg button:hover:not(:disabled) { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-navy); }
+.desk__sugg button:disabled { opacity: .5; cursor: default; }
+
+.desk__row { display: flex; }
+.desk__row--user { justify-content: flex-end; }
+
+/* The question, as the operator wrote it. */
+.desk__q {
+ margin: 0; max-width: 82%;
+ padding: 10px 14px; border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md);
+ background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5;
+}
+
+/* The answer. Left-aligned and unboxed, because it is prose to be read, not a
+ message in a chat to be scanned. */
+.desk__a { max-width: 100%; font-size: var(--fs-sm); color: var(--zk-ink); }
+.desk__a p { margin: 0 0 10px; line-height: 1.55; }
+.desk__a h4 { margin: 14px 0 6px; font-size: var(--fs-md); font-weight: 700; }
+.desk__a ul, .desk__a ol { margin: 0 0 10px; padding-left: 20px; }
+.desk__a li { margin-bottom: 5px; line-height: 1.5; }
+.desk__a code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em;
+ background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
+}
+.desk__a strong { font-weight: 700; }
+
+/* What it actually ran to answer — its working, not just its conclusion. */
+.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
+.desk__tools span {
+ font-size: var(--fs-3xs); color: var(--zk-muted);
+ background: var(--zk-line-soft); padding: 2px 8px; border-radius: var(--r-pill);
+}
+
+.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); }
+.desk__dots { display: inline-flex; gap: 4px; }
+.desk__dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-blue); animation: desk-blink 1.2s infinite; }
+.desk__dots i:nth-child(2) { animation-delay: .2s; }
+.desk__dots i:nth-child(3) { animation-delay: .4s; }
+@keyframes desk-blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
+@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } }
+
+.desk__err {
+ border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint);
+ border-radius: var(--r-md); padding: 12px 14px; display: flex; flex-direction: column; gap: 3px;
+}
+.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); }
+.desk__err span { font-size: var(--fs-2xs); color: var(--zk-muted); }
+
+.desk__ask {
+ flex: none; display: flex; gap: 10px; align-items: flex-end;
+ padding: 14px 22px 18px; border-top: 1px solid var(--zk-line-soft); background: #fff;
+}
+.desk__ask textarea {
+ flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink);
+ padding: 10px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
+}
+.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
+.desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); }
+
+@media (max-width: 640px) {
+ .desk { width: 100%; border-left: 0; }
+ .desk__head, .desk__feed, .desk__ask { padding-left: 16px; padding-right: 16px; }
+}
+
+/* Ask the desk for anything countable and it answers with a table. */
+.desk__tablewrap { overflow-x: auto; margin: 0 0 12px; }
+.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); }
+.desk__a th {
+ text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap;
+ padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line);
+}
+.desk__a td { padding: 7px 12px 7px 0; border-bottom: 1px solid var(--zk-line-soft); }
+.desk__a th:last-child, .desk__a td:last-child { padding-right: 0; text-align: right; font-variant-numeric: tabular-nums; }
+.desk__a tr:last-child td { border-bottom: 0; }
diff --git a/src/components/AskDesk.jsx b/src/components/AskDesk.jsx
new file mode 100644
index 0000000..aa72756
--- /dev/null
+++ b/src/components/AskDesk.jsx
@@ -0,0 +1,261 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { SUPPORT_AGENT_ID } from '../api/config.js'
+import { useZino } from '../api/provider.jsx'
+import { describeError } from '../api/errors.js'
+import './AskDesk.css'
+
+/**
+ * Ask the desk.
+ *
+ * A support desk on the platform, bound to this app, that reads the deployed
+ * config and the live book through read-only tools and answers with evidence.
+ * It is a different kind of thing from the five AI employees: they DO the work
+ * and write to the workflow; this one only reads, and only to answer a person's
+ * question. Nothing it says changes a lead.
+ *
+ * The panel is deliberately thin. The desk is an ordinary interactive agent on
+ * the platform, so a conversation is a session and a question is a message on
+ * it; the console's own token is accepted on those routes as-is.
+ *
+ * Answers take twenty to forty seconds because it runs real queries first, so
+ * the waiting state says what it is doing rather than showing a spinner.
+ */
+
+const SUGGESTIONS = [
+ 'Which leads have not moved in over 12 hours?',
+ 'How many leads are in each stage right now?',
+ 'What reason did the AI give for each underwriting referral?',
+ 'Total written premium, and how many policies were issued?',
+]
+
+/* The desk answers in markdown. Rather than carry a parser for five features,
+ this handles exactly what it uses: headings, bullets, numbered steps, tables,
+ bold and inline code. Anything else falls through as text, which is the right
+ failure — an unrendered asterisk is legible; a crashed panel is not.
+
+ Tables earn their place: asked for anything countable the desk replies with
+ one, and rendered as raw pipes a correct answer reads like a broken one. */
+function inline(text, key) {
+ const parts = String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g)
+ return parts.map((p, i) => {
+ if (p.startsWith('**') && p.endsWith('**')) return {p.slice(2, -2)}
+ if (p.startsWith('`') && p.endsWith('`')) return {p.slice(1, -1)}
+ return p
+ })
+}
+
+/** `| a | b |` -> ['a','b']; a separator row (`|---|`) has no content. */
+const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
+const isRow = (line) => /^\s*\|.*\|\s*$/.test(line)
+const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line)
+
+function Rich({ text }) {
+ const lines = String(text || '').split('\n')
+ const out = []
+ let list = null
+ let rows = null
+
+ const flush = () => {
+ if (rows) {
+ const [head, ...body] = rows
+ out.push(
+
)
+ list = null
+ }
+
+ lines.forEach((raw, i) => {
+ const line = raw.trimEnd()
+ const bullet = line.match(/^\s*[-*]\s+(.*)$/)
+ const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
+ const heading = line.match(/^#{1,4}\s+(.*)$/)
+ if (isRow(line)) {
+ // A rule under the header is markdown's alignment row, not data. The
+ // desk sometimes omits it and sometimes leaves a blank line between
+ // rows, so neither is treated as the end of the table.
+ if (!isRule(line)) {
+ if (!rows) { flush(); rows = [] }
+ rows.push(cells(line))
+ }
+ } else if (rows && line.trim() === '') {
+ /* hold the table open across a blank line */
+ } else if (bullet) {
+ if (!list || list.ordered) { flush(); list = { ordered: false, items: [] } }
+ list.items.push(bullet[1])
+ } else if (numbered) {
+ if (!list || !list.ordered) { flush(); list = { ordered: true, items: [] } }
+ list.items.push(numbered[1])
+ } else if (heading) {
+ flush(); out.push(
)
+ }
+ })
+ flush()
+ return <>{out}>
+}
+
+/** The tools a turn ran, as chips — the desk's working, not just its answer. */
+function toolsOf(row) {
+ if (!row) return []
+ const raw = row.metadata?.tool_calls ?? row.metadata?.api_calls ?? row.metadata?.tools
+ const list = Array.isArray(raw) ? raw : []
+ return list.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
+}
+
+export default function AskDesk({ onClose }) {
+ const { client } = useZino()
+ const [sessionId, setSessionId] = useState(null)
+ const [rows, setRows] = useState([])
+ const [text, setText] = useState('')
+ const [asking, setAsking] = useState(false)
+ const [err, setErr] = useState(null)
+ const feedRef = useRef(null)
+ const inputRef = useRef(null)
+
+ // One conversation per opening of the panel. Reusing the last one would make
+ // the desk answer this week's question against last week's context, and the
+ // transcript stays on the platform either way.
+ useEffect(() => {
+ let dead = false
+ client.deskStartSession(SUPPORT_AGENT_ID)
+ .then((s) => { if (!dead) setSessionId(s?.id ?? s?.data?.id ?? null) })
+ .catch((e) => { if (!dead) setErr(e) })
+ return () => { dead = true }
+ }, [client])
+
+ useEffect(() => {
+ const el = feedRef.current
+ if (el) el.scrollTop = el.scrollHeight
+ }, [rows, asking])
+
+ useEffect(() => { inputRef.current?.focus() }, [sessionId])
+
+ const ask = useCallback(async (question) => {
+ const q = String(question ?? '').trim()
+ if (!q || !sessionId || asking) return
+ setErr(null)
+ setText('')
+ // The question shows immediately; the answer replaces the waiting line.
+ setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }])
+ setAsking(true)
+ try {
+ const res = await client.deskAsk(sessionId, q)
+ const answer = res?.agent_message
+ const called = toolsOf(res?.call_api_message)
+ setRows((prev) => [...prev, {
+ key: 'a' + (answer?.id ?? Date.now()),
+ role: 'agent',
+ content: answer?.content ?? 'The desk answered with nothing at all.',
+ tools: called,
+ }])
+ } catch (e) {
+ setErr(e)
+ } finally {
+ setAsking(false)
+ inputRef.current?.focus()
+ }
+ }, [client, sessionId, asking])
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/CallTranscript.jsx b/src/components/CallTranscript.jsx
new file mode 100644
index 0000000..832ac3a
--- /dev/null
+++ b/src/components/CallTranscript.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import './Conversation.css'
+
+/**
+ * The call, turn by turn.
+ *
+ * The voice agent hands the workflow a full transcript when a call ends, and
+ * until now Engage read it, wrote three sentences of summary, and the words
+ * themselves were dropped. So a customer said "I think it's 12,000 rupees" and
+ * "lower premium", and the file held one AI's paraphrase of that.
+ *
+ * It matters more here than it would elsewhere: `consent_artefact =
+ * verbal_call` is recorded from what the agent HEARD, and it is the artefact
+ * justifying every later contact. A colleague reviewing that months later
+ * should see the customer's own words.
+ *
+ * Rendered like the WhatsApp thread on purpose — agent right, caller left —
+ * because it is the same kind of thing and should not need learning twice.
+ */
+function turnsFrom(text) {
+ if (!text || typeof text !== 'string') return []
+ const out = []
+ // "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
+ // Split on the labels rather than on newlines: a single turn wraps, and
+ // splitting per line would shatter one sentence into four bubbles.
+ const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
+ for (const raw of text.split('\n')) {
+ const line = raw.replace(/\s+$/, '')
+ if (!line.trim()) continue
+ const m = line.match(re)
+ if (m) {
+ const who = m[1].toLowerCase()
+ out.push({
+ side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
+ text: line.slice(m[0].length).trim(),
+ })
+ } else if (out.length) {
+ out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
+ }
+ }
+ return out.filter((t) => t.text)
+}
+
+export default function CallTranscript({ text, audio, name, onClose }) {
+ const ref = useRef(null)
+ const restore = useRef(null)
+ const turns = turnsFrom(text)
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
The call with {name || 'the customer'}
+
+
+
+ {audio ? (
+
+ {/* Listen along with the transcript. preload=none so opening the
+ dialog does not pull a multi-MB WAV until the user hits play. */}
+
+
+ )) : (
+ /* A transcript that will not parse is still evidence, so it is
+ shown raw rather than replaced with "nothing to display". */
+
{text}
+ )}
+
+
+
+ Recorded by the voice agent as the call happened. This is what was said —
+ the call notes in the trail are Engage's reading of it, which is a
+ different thing.
+
+
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/components/Conversation.css b/src/components/Conversation.css
new file mode 100644
index 0000000..407e8d6
--- /dev/null
+++ b/src/components/Conversation.css
@@ -0,0 +1,152 @@
+.conv { max-height: min(80vh, 760px); }
+
+.conv__body {
+ /* The offset parent for the scroll-to-an-exchange in Conversation.jsx. */
+ position: relative;
+ overflow-y: auto;
+ padding: 18px 22px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.conv__empty {
+ margin: 0;
+ padding: 24px 0;
+ text-align: center;
+ font-size: var(--fs-xs);
+ color: var(--zk-grey);
+}
+
+/* Theirs left, ours right: the arrangement everybody reads without being told,
+ which is the whole reason to render a thread rather than a log. */
+.bub {
+ max-width: 78%;
+ padding: 9px 13px 7px;
+ border-radius: 14px;
+}
+
+.bub p {
+ margin: 0;
+ font-size: var(--fs-sm);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+/* Direct children only, and not the attribution row: the chip in there brings
+ its own spans, and CallTranscript still labels its turns with a plain one. */
+.bub > span:not(.bub__by) {
+ display: block;
+ margin-top: 4px;
+ font-size: var(--fs-2xs);
+ opacity: 0.65;
+}
+
+/* Who said it, and whether they are a person. */
+.bub__by {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 6px;
+}
+.bub__by time {
+ font-size: var(--fs-2xs);
+ color: var(--zk-grey);
+}
+/* The disc's tint and the bubble's tint are the same token, so a chip dropped
+ into a bubble loses its disc entirely. White puts it back. */
+.bub .who__disc { background: #fff; }
+
+.bub--them {
+ align-self: flex-start;
+ background: var(--zk-line-soft);
+ color: var(--zk-ink);
+ border-bottom-left-radius: 4px;
+}
+
+.bub--us {
+ align-self: flex-end;
+ background: var(--zk-tint-blue);
+ color: var(--zk-blue-dark);
+ border-bottom-right-radius: 4px;
+}
+
+.conv__note {
+ margin: 0;
+ padding: 12px 22px 18px;
+ border-top: 1px solid var(--zk-line-soft);
+ font-size: var(--fs-2xs);
+ line-height: 1.5;
+ color: var(--zk-grey);
+}
+
+/* A transcript the parser could not split into turns is still evidence.
+ Shown raw rather than replaced with "nothing to display". */
+.conv__raw {
+ margin: 0;
+ padding: 14px 16px;
+ font: inherit;
+ font-size: var(--fs-2xs);
+ line-height: 1.6;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ color: var(--zk-muted);
+ background: var(--zk-tint);
+ border-radius: var(--r-md);
+}
+
+/* The call recording, played from inside the transcript dialog. */
+.conv__audio {
+ padding: 12px 20px 4px;
+ border-bottom: 1px solid var(--zk-line-soft);
+}
+.conv__audio audio {
+ width: 100%;
+ height: 38px;
+}
+
+/* "7 messages · 2 exchanges" — the size of the thread, on the header. */
+.conv__sub {
+ margin: 2px 0 0;
+ font-size: var(--fs-2xs);
+ color: var(--zk-grey);
+}
+
+/* One burst of messages. The bubbles take their left/right alignment from the
+ flex column they sit in, so an episode has to be one too. */
+.conv__ep {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ border-radius: var(--r-md);
+}
+
+/* Opened from the trail, the exchange you arrived from is marked — and then
+ the mark fades, because a permanent tint on one part of a thread reads as a
+ state something is in rather than as "you came from here". */
+.conv__ep.is-anchor { animation: conv-found 2.4s var(--ease) forwards; }
+@keyframes conv-found {
+ 0%, 45% { box-shadow: 0 0 0 6px var(--zk-teal-tint); background: var(--zk-teal-tint); }
+ 100% { box-shadow: 0 0 0 6px transparent; background: transparent; }
+}
+
+/* THE PAUSE, ACCOUNTED FOR. Two bursts twenty minutes and four workflow steps
+ apart read as one continuous exchange without this — so a reply about the
+ premium appears to answer the payment link that followed it. */
+.conv__gap {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 6px 2px;
+ font-size: var(--fs-3xs);
+ color: var(--zk-grey);
+}
+.conv__gap::before,
+.conv__gap::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--zk-line-soft);
+}
+.conv__gap span { flex: none; }
diff --git a/src/components/Conversation.jsx b/src/components/Conversation.jsx
new file mode 100644
index 0000000..eef1564
--- /dev/null
+++ b/src/components/Conversation.jsx
@@ -0,0 +1,173 @@
+import { Fragment, useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import AgentChip from './AgentChip.jsx'
+import { buildThread, listOf } from '../api/thread.js'
+import './Conversation.css'
+
+/**
+ * The WhatsApp thread, whole, scrollable, in the order it was said.
+ *
+ * The trail's job is what happened in what order; this is the other question —
+ * what was actually said — and it is one conversation even though the workflow
+ * touches it from several places. Theirs on the left, ours on the right, oldest
+ * first, newest in view when it opens.
+ *
+ * THE THREAD IS BUILT ONCE, in api/thread.js, and shared with the timeline
+ * entry that opens it. It used to be reassembled here from the raw fields,
+ * which is how the two came to disagree about how many messages there were.
+ *
+ * WHAT THE GAPS ARE. The conversation runs in bursts: the customer argues about
+ * the premium, then KYC and the underwriting screen run, then the payment
+ * request reopens the same thread twenty minutes later. Read as one flat list
+ * those bursts run together and the reply to a quote appears to answer the
+ * payment link. So the workflow steps that happened in between are named on the
+ * divider, and the thread reads as what it is — one conversation with pauses in
+ * it that somebody can account for.
+ *
+ * WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
+ * than left for someone to discover. The console can only show what the
+ * workflow RECORDS, so anything sent from inside a trigger node and never
+ * written to a field is invisible here however certainly the customer received
+ * it. Both payment-path messages used to be exactly that (98, 99); 100 writes
+ * them to `customer_answer` and they appear. One send remains absent: the
+ * quote, which is a registered WhatsApp template rather than text we compose.
+ *
+ * A thread that quietly omitted any of this would be worse than one that says
+ * which parts it holds.
+ */
+
+function at(ts) {
+ const d = new Date(ts)
+ if (isNaN(d)) return ''
+ return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
+}
+
+/** How long the thread was quiet, and what the workflow did meanwhile. */
+function gapOf(prev, ep) {
+ const a = Date.parse(prev.to)
+ const b = Date.parse(ep.from)
+ const mins = isNaN(a) || isNaN(b) ? null : Math.round((b - a) / 60000)
+ const howLong = mins == null || mins < 1 ? 'Moments later'
+ : mins === 1 ? 'A minute later'
+ : mins < 60 ? `${mins} minutes later`
+ : mins < 120 ? 'An hour later'
+ : mins < 1440 ? `${Math.round(mins / 60)} hours later`
+ : mins < 2880 ? 'The next day'
+ : `${Math.round(mins / 1440)} days later`
+ return ep.after.length ? `${howLong} — after ${listOf(ep.after)}` : howLong
+}
+
+export default function Conversation({ rows, name, anchor, onClose }) {
+ const ref = useRef(null)
+ const bodyRef = useRef(null)
+ const restore = useRef(null)
+ const { turns, episodes } = buildThread(rows)
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ /**
+ * Opened from an exchange in the trail, it opens AT that exchange; opened
+ * from the lead header, it opens at the newest message, which is where a
+ * chat is read from.
+ *
+ * scrollTop rather than scrollIntoView: this element is inside a dialog over
+ * a frozen page, and scrollIntoView walks every scrollable ancestor to get
+ * there. `.conv__body` is the offset parent (see the CSS), so the sum is
+ * exactly the distance wanted.
+ */
+ useEffect(() => {
+ const body = bodyRef.current
+ if (!body) return
+ const target = anchor ? body.querySelector(`[data-ep="${anchor}"]`) : null
+ body.scrollTop = target ? Math.max(0, target.offsetTop - 12) : body.scrollHeight
+ }, [anchor])
+
+ return createPortal(
+
+
+ {/* Named AND marked, both directions. It stamped only the
+ time, so a thread read back months later could not say
+ which of our replies a person wrote and which Engage
+ did — and a bare "Engage" reads as a colleague. The
+ sparkle disc and the AI tag are how the rest of this
+ console says "machine"; a person keeps their initials. */}
+ {t.side === 'them'
+ ?
+ : }
+
+
+
+ ))}
+
+
+ )) : (
+
Nothing has been exchanged on WhatsApp yet.
+ )}
+
+
+
+ Shows the customer's messages and everything written back to them,
+ including the acceptance confirmation, the payment request and the
+ issuance note. One automatic send is not here: the quote, which goes
+ out as a registered WhatsApp template rather than text we compose.
+
+
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/components/LeadFileDialog.css b/src/components/LeadFileDialog.css
new file mode 100644
index 0000000..f99192e
--- /dev/null
+++ b/src/components/LeadFileDialog.css
@@ -0,0 +1,65 @@
+/* Wide and tall: this is the whole record, and squeezing thirteen groups into
+ a prose-width dialog would only move the crowding rather than remove it. */
+.lfd { width: min(1180px, 100%); max-height: min(90vh, 940px); }
+
+.lfd__sub {
+ margin: 4px 0 0;
+ font-size: var(--fs-2xs);
+ line-height: 1.5;
+ color: var(--zk-muted);
+}
+
+.lfd__tools {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 24px;
+ border-bottom: 1px solid var(--zk-line-soft);
+ background: var(--zk-tint);
+}
+
+/* Ctrl-F was how people read the open version. Behind a click that stops
+ working, so the drawer brings its own. */
+.lfd__find {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ background: var(--zk-white);
+ border: 1px solid var(--zk-line);
+ border-radius: var(--r-pill);
+}
+
+.lfd__find:focus-within { border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue); }
+.lfd__find svg { width: 14px; height: 14px; flex: none; color: var(--zk-grey); }
+
+.lfd__find input {
+ flex: 1;
+ border: 0;
+ outline: none;
+ background: none;
+ font: inherit;
+ font-size: var(--fs-xs);
+ color: var(--zk-ink);
+}
+
+.lfd__find input::-webkit-search-cancel-button { cursor: pointer; }
+
+.lfd__count {
+ flex: none;
+ font-size: var(--fs-2xs);
+ color: var(--zk-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.lfd__body { overflow-y: auto; }
+.lfd__body .file360 { padding: 18px 22px 24px; }
+
+.lfd__none {
+ margin: 0;
+ padding: 44px 24px;
+ text-align: center;
+ font-size: var(--fs-xs);
+ color: var(--zk-grey);
+}
diff --git a/src/components/LeadFileDialog.jsx b/src/components/LeadFileDialog.jsx
new file mode 100644
index 0000000..b744e92
--- /dev/null
+++ b/src/components/LeadFileDialog.jsx
@@ -0,0 +1,166 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import ClampText from './ClampText.jsx'
+import './LeadFileDialog.css'
+
+/**
+ * Everything on the lead, on demand.
+ *
+ * This used to sit open on the page. Fifty-seven fields across thirteen groups
+ * pushed the audit trail — the part anyone actually reads — a screen and a half
+ * down, and most of those fields are reference material: nobody opens a lead to
+ * find out its source channel. The page now carries the handful that matter for
+ * where the lead IS, and the complete record lives one click away in here.
+ *
+ * NOTHING IS LOST AND NOTHING IS BURIED. The groups, the order and the values
+ * are the same ones the page rendered before — this receives them already
+ * computed, so the two can never disagree. The trigger says how many fields are
+ * behind it, because a control that hides fifty-seven things should say so.
+ *
+ * The filter is the point of doing this as a drawer rather than a collapse.
+ * Ctrl-F is what people were using on the open version; with the record behind
+ * a click that stops working, so the drawer brings its own. It matches the
+ * label AND the value, so "KA01" finds the vehicle and "POSP" finds the
+ * partner, and it says how many groups it hid rather than silently emptying.
+ */
+export default function LeadFileDialog({ groups, labels, label, money, fileHref, longAt, onClose }) {
+ const ref = useRef(null)
+ const restore = useRef(null)
+ const [q, setQ] = useState('')
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ const total = groups.reduce((n, [, shown]) => n + shown.length, 0)
+
+ const shownGroups = useMemo(() => {
+ const term = q.trim().toLowerCase()
+ if (!term) return groups
+ return groups
+ .map(([title, rows]) => [
+ title,
+ rows.filter(([k, v]) => {
+ const l = String(labels[k] ?? label(k)).toLowerCase()
+ return l.includes(term) || String(v).toLowerCase().includes(term)
+ }),
+ ])
+ .filter(([, rows]) => rows.length)
+ }, [groups, q, labels, label])
+
+ const found = shownGroups.reduce((n, [, rows]) => n + rows.length, 0)
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
Lead file
+
+ Everything captured, grouped by the step that captured it.
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/components/NewLeadDialog.css b/src/components/NewLeadDialog.css
new file mode 100644
index 0000000..9885c46
--- /dev/null
+++ b/src/components/NewLeadDialog.css
@@ -0,0 +1,17 @@
+/* Wider than the prose dialogs — this holds a four-column form grid, not a
+ paragraph — and allowed to grow taller, because a form that scrolls its own
+ Submit off the bottom is worse than a tall dialog. */
+.nld { width: min(880px, 100%); max-height: min(88vh, 860px); }
+
+.nld__sub {
+ margin: 4px 0 0;
+ font-size: var(--fs-2xs);
+ line-height: 1.5;
+ color: var(--zk-muted);
+ max-width: 62ch;
+}
+
+.nld__body {
+ overflow-y: auto;
+ padding: 18px 24px 22px;
+}
diff --git a/src/components/NewLeadDialog.jsx b/src/components/NewLeadDialog.jsx
new file mode 100644
index 0000000..fbddf64
--- /dev/null
+++ b/src/components/NewLeadDialog.jsx
@@ -0,0 +1,112 @@
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useNavigate } from 'react-router-dom'
+import ActivityForm from './ActivityForm.jsx'
+import { ENTRY } from '../api/config.js'
+import { entryDoorsFor, rolesOf } from '../api/permissions.js'
+import { useZino } from '../api/provider.jsx'
+import './NewLeadDialog.css'
+
+/**
+ * Filing a lead, over the page rather than on one of its own.
+ *
+ * This was a route. Pressing New lead left whatever you were reading — a queue
+ * you were working through, a lead you were half way down — filed the form,
+ * and landed you on the new lead's file, three navigations from where you
+ * started. Filing a lead is not a destination; it is four fields and a Submit,
+ * and it belongs over the work rather than instead of it. Cancel now puts you
+ * back exactly where you were, because you never left.
+ *
+ * The door chooser stays: the entry activities have different permissions and
+ * the bank one lands the instance further along. With one door — which is
+ * today — it is skipped rather than shown as a list of one.
+ *
+ * Modal furniture is the same as the conversation and reasoning dialogs:
+ * scrim click, Escape, focus trapped in and restored out, page scroll frozen.
+ * A fourth hand-rolled variant of that would be a fourth chance to get the
+ * keyboard wrong.
+ */
+export default function NewLeadDialog({ onClose }) {
+ const { user } = useZino()
+ const doors = entryDoorsFor(rolesOf(user), ENTRY)
+ const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
+ const navigate = useNavigate()
+ const ref = useRef(null)
+ const restore = useRef(null)
+
+ useEffect(() => {
+ restore.current = document.activeElement
+ ref.current?.focus()
+ const onKey = (e) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ document.body.style.overflow = prev
+ if (restore.current instanceof HTMLElement) restore.current.focus()
+ }
+ }, [onClose])
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
{entry ? entry.label : 'New lead'}
+
+ {entry
+ ? entry.note
+ : 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'}
+
+
+
+
+
+
+ {!doors.length ? (
+
Your role does not create leads.
+ ) : !entry ? (
+
+ {doors.map((e) => (
+
+ ))}
+
+ ) : (
+ {
+ const id = res?.instance_id || res?.data?.instance_id
+ // Carry the workflow's own message across the navigation —
+ // "Lead received — Intake is qualifying it" is the answer to
+ // "did that work?", and it is only said once.
+ onClose()
+ if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
+ }}
+ />
+ )}
+
+
+
+ ,
+ document.body,
+ )
+}
diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx
index b94e906..d1b13a0 100644
--- a/src/layout/Shell.jsx
+++ b/src/layout/Shell.jsx
@@ -1,9 +1,10 @@
import { useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
-import { NAV_GROUPS, STAGES } from '../api/config.js'
+import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
+import AskDesk from '../components/AskDesk.jsx'
/**
@@ -52,6 +53,12 @@ export default function Shell() {
const { counts, ready } = useStageCounts()
const [open, setOpen] = useState(false)
const [me, setMe] = useState(false)
+ // The support desk reads the WHOLE book — every partner, every premium — so
+ // it is an ops tool rather than something a partner agent may open. The
+ // platform enforces that too (the desk is assigned per user); this only
+ // decides whether the entry is drawn.
+ const [desk, setDesk] = useState(false)
+ const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
const openTotal = STAGES.filter((s) => s.kind !== 'end').reduce((t, s) => t + count(s).total, 0)
@@ -127,6 +134,21 @@ export default function Shell() {
)
})}
+
+ {canAskDesk ? (
+
+
Ask
+
+
+ ) : null}
>
@@ -155,6 +177,8 @@ export default function Shell() {
>
) : null}
+
+ {desk ? setDesk(false)} /> : null}
)
}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index 64f91f3..68dae85 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -2,11 +2,17 @@ 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'
-import Timeline from '../components/Timeline.jsx'
/**
* One lead, on a phone.
@@ -16,6 +22,13 @@ import Timeline from '../components/Timeline.jsx'
* 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. */
@@ -31,13 +44,26 @@ function holdsOf(stage) {
}
}
-/* The journey, as the desktop rail states it. */
+/* 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()
@@ -50,7 +76,13 @@ export default function Lead() {
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)
+ // 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.
@@ -83,10 +115,14 @@ export default function Lead() {
// 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])
+ }, [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.
@@ -112,33 +148,79 @@ export default function Lead() {
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.
+ //
+ // 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?.uid, roles) }
+ ? { 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 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
- .map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v, f]) => f.length || v)])
+ // `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 shown = showAll ? groups : groups.slice(0, 3)
+ 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 (
-
- ))}
+ {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 ? (
@@ -260,7 +369,7 @@ export default function Lead() {
The record
The customer, the risk, and what this stage turns on.
{rows.map(([k, v, files]) => (
@@ -275,9 +384,22 @@ export default function Lead() {
))}
+ ) : k === 'call_transcript' ? (
+ /* A transcript is not a value. Printed as one it was two
+ thousand characters cut at a hundred and fifty. */
+
+
+
) : (
- {v.length > LONG ? v.slice(0, LONG) + '…' : v}
+ {/* 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}