Everything the console can do, on the phone
The phone app shipped with the API layer and the timeline copied from the
console but none of its dialogs, so several things it appeared to offer did
nothing at all:
- Tapping a WhatsApp exchange in the trail called an onOpenChat that was
never passed. The entry rendered, said "Open the conversation — all 7
messages", and there was no conversation to open.
- No screen had a New lead button. The entry activity, its permissions and
its form were all present and unreachable.
- An agent chip opened nothing; the call transcript and the recording were
unreachable; `call_transcript` was rendered as a field value, which is two
thousand characters cut at a hundred and fifty.
- The record showed its first three groups and truncated every long value
mid-sentence. There was no way to see the other sixty-odd fields.
- The action bar rendered only the stage's `do` actions, so Mark Lost, the
re-quote, the reminders and every recovery lever were unreachable — four
stages offered a partner agent one button and no way to drop a lead the
system was working.
- `nudgeFor` was called as nudgeFor(stage.uid, roles) rather than
nudgeFor(stage, lead), so `stage.uid` was undefined, the switch fell
through, and the way out of a stalled lead was never offered.
- The journey rail marked every step left of the current one as done, which
claims steps that never happened: a lead whose documents arrived from
Qualified skips Contacted, and a callback sends it backwards.
The dialogs are COPIED from the console rather than rewritten — the
conversation, the call, the agent card, the lead file, the new-lead form, and
the support desk — for the same reason api/ and Timeline.jsx already were: the
functionality has to be identical, and a second implementation is a second
account of the same lead.
What makes that work on a phone is one CSS block rather than six components.
They all render the console's prose__scrim / prose__dlg chrome, so app.css
re-points that chrome at phone width into a bottom sheet: full width, rounded
top, a grab handle via ::before so the copied markup is untouched, and
env(safe-area-inset-bottom) under the last row. Every dialog in the app becomes
phone-native at once, including the two ClampText was already opening. Above
700px the console's centred dialog is kept, which is right for a tablet.
Also here: the secondary actions get a sheet of their own, grouped by what they
ARE (optional, "lead not moving?", the way out) rather than listed as equals;
the record's long values fold instead of being cut, and the whole file opens
with a filter; the rail reads the audit for which steps this lead actually
entered; the overview grows the sign-off band for the role that owns it; and
the drawer grows Ask the desk for ops.
Verified: build clean, lint unchanged at the app's own baseline of 15, every
class the copied dialogs use resolves in the bundled CSS, and every dialog's
prop contract matches its call site. Not verified in a browser — no browser
tooling in this session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
abac22baeb
commit
3bc7d4c355
22
README.md
22
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
|
||||
|
||||
91
src/components/AgentCard.css
Normal file
91
src/components/AgentCard.css
Normal file
@ -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;
|
||||
}
|
||||
112
src/components/AgentCard.jsx
Normal file
112
src/components/AgentCard.jsx
Normal file
@ -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(
|
||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="prose__dlg agc"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={a.full}
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="agc__head">
|
||||
<span className={`who__disc who__disc--${a.tone} agc__disc`} aria-hidden="true">{a.initials}</span>
|
||||
<div>
|
||||
<h3>{a.full}</h3>
|
||||
<p>{a.does.charAt(0).toUpperCase() + a.does.slice(1)}.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="agc__body">
|
||||
<section>
|
||||
<h4>When it runs</h4>
|
||||
<p>{a.wakes}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>What it can reach</h4>
|
||||
{a.tools.length ? (
|
||||
<ul className="agc__tools">
|
||||
{a.tools.map((t) => (
|
||||
<li key={t.name}>
|
||||
<code>{t.name}</code>
|
||||
<span>{t.does}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
/* 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. */
|
||||
<p className="agc__none">
|
||||
No tools. It reads the lead and the policy wordings, and writes its answer
|
||||
back into the workflow — nothing else.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>What it reads</h4>
|
||||
<ul className="agc__kb">
|
||||
{a.knowledge.map((k) => <li key={k}>{k}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p className="agc__foot">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
121
src/components/AskDesk.css
Normal file
121
src/components/AskDesk.css
Normal file
@ -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; }
|
||||
261
src/components/AskDesk.jsx
Normal file
261
src/components/AskDesk.jsx
Normal file
@ -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 <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong>
|
||||
if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code>
|
||||
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(
|
||||
<div className="desk__tablewrap" key={`t${out.length}`}>
|
||||
<table>
|
||||
<thead><tr>{head.map((c, i) => <th key={i}>{inline(c, `h${i}`)}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{body.map((r, ri) => (
|
||||
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
rows = null
|
||||
}
|
||||
if (!list) return
|
||||
out.push(list.ordered
|
||||
? <ol key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ol>
|
||||
: <ul key={`l${out.length}`}>{list.items.map((t, i) => <li key={i}>{inline(t, `${out.length}-${i}`)}</li>)}</ul>)
|
||||
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(<h4 key={i}>{inline(heading[1], i)}</h4>)
|
||||
} else if (line.trim() === '') {
|
||||
flush()
|
||||
} else {
|
||||
flush(); out.push(<p key={i}>{inline(line, i)}</p>)
|
||||
}
|
||||
})
|
||||
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 (
|
||||
<div className="desk__scrim" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="desk" role="dialog" aria-label="Ask the desk"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="desk__head">
|
||||
<div>
|
||||
<h2>Ask the desk</h2>
|
||||
<p>Reads the book and answers with evidence. It cannot change anything.</p>
|
||||
</div>
|
||||
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<div className="desk__feed" ref={feedRef}>
|
||||
{rows.length === 0 && !asking ? (
|
||||
<div className="desk__empty">
|
||||
<p>Ask about a lead, a queue, or the book as a whole. Try:</p>
|
||||
<div className="desk__sugg">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className={'desk__row desk__row--' + r.role}>
|
||||
{r.role === 'user'
|
||||
? <p className="desk__q">{r.content}</p>
|
||||
: (
|
||||
<div className="desk__a">
|
||||
<Rich text={r.content} />
|
||||
{r.tools?.length ? (
|
||||
<div className="desk__tools">
|
||||
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{asking ? (
|
||||
<div className="desk__row desk__row--agent">
|
||||
<div className="desk__wait">
|
||||
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
Reading the book — this takes half a minute.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? (
|
||||
<div className="desk__err" role="alert">
|
||||
<strong>{describeError(err).title}</strong>
|
||||
<span>{err?.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="desk__ask"
|
||||
onSubmit={(e) => { e.preventDefault(); ask(text) }}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
rows={2}
|
||||
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'}
|
||||
disabled={!sessionId || asking}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter is a new line. A question is usually
|
||||
// one line, and reaching for a button every time is friction.
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>
|
||||
Ask
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/CallTranscript.jsx
Normal file
118
src/components/CallTranscript.jsx
Normal file
@ -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(
|
||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="prose__dlg conv"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Call transcript"
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="prose__head">
|
||||
<h3>The call with {name || 'the customer'}</h3>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{audio ? (
|
||||
<div className="conv__audio">
|
||||
{/* Listen along with the transcript. preload=none so opening the
|
||||
dialog does not pull a multi-MB WAV until the user hits play. */}
|
||||
<audio controls preload="none" src={audio}>
|
||||
Your browser cannot play this recording.
|
||||
</audio>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="conv__body">
|
||||
{turns.length ? turns.map((t, i) => (
|
||||
<div key={i} className={'bub bub--' + t.side}>
|
||||
<p>{t.text}</p>
|
||||
<span>{t.side === 'us' ? 'Meera' : (name || 'the customer')}</span>
|
||||
</div>
|
||||
)) : (
|
||||
/* A transcript that will not parse is still evidence, so it is
|
||||
shown raw rather than replaced with "nothing to display". */
|
||||
<pre className="conv__raw">{text}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="conv__note">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
152
src/components/Conversation.css
Normal file
152
src/components/Conversation.css
Normal file
@ -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; }
|
||||
173
src/components/Conversation.jsx
Normal file
173
src/components/Conversation.jsx
Normal file
@ -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(
|
||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="prose__dlg conv"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="WhatsApp conversation"
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="prose__head">
|
||||
<div>
|
||||
<h3>WhatsApp with {name || 'the customer'}</h3>
|
||||
{turns.length ? (
|
||||
<p className="conv__sub">
|
||||
{turns.length} message{turns.length === 1 ? '' : 's'}
|
||||
{episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="conv__body" ref={bodyRef}>
|
||||
{episodes.length ? episodes.map((ep, i) => (
|
||||
<Fragment key={ep.id}>
|
||||
{i > 0 ? (
|
||||
<div className="conv__gap">
|
||||
<span>{gapOf(episodes[i - 1], ep)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={'conv__ep' + (anchor === ep.id ? ' is-anchor' : '')}
|
||||
data-ep={ep.id}
|
||||
>
|
||||
{ep.turns.map((t) => (
|
||||
<div key={t.key} className={'bub bub--' + t.side}>
|
||||
<p>{t.text}</p>
|
||||
<span className="bub__by">
|
||||
{/* 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'
|
||||
? <AgentChip name={name || 'the customer'} size="xs" />
|
||||
: <AgentChip agentKey={t.agentKey} name={t.by} size="xs" />}
|
||||
<time dateTime={t.at}>{at(t.at)}</time>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Fragment>
|
||||
)) : (
|
||||
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="conv__note">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
65
src/components/LeadFileDialog.css
Normal file
65
src/components/LeadFileDialog.css
Normal file
@ -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);
|
||||
}
|
||||
166
src/components/LeadFileDialog.jsx
Normal file
166
src/components/LeadFileDialog.jsx
Normal file
@ -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(
|
||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="prose__dlg lfd"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Lead file"
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="prose__head">
|
||||
<div>
|
||||
<h3>Lead file</h3>
|
||||
<p className="lfd__sub">
|
||||
Everything captured, grouped by the step that captured it.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lfd__tools">
|
||||
<label className="lfd__find">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M10.2 10.2 13.5 13.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Find a field or a value…"
|
||||
aria-label="Filter the lead file"
|
||||
/>
|
||||
</label>
|
||||
<span className="lfd__count">
|
||||
{q.trim() ? `${found} of ${total} fields` : `${total} fields`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="lfd__body">
|
||||
{shownGroups.length ? (
|
||||
<div className="file360">
|
||||
{shownGroups.map(([title, shown]) => {
|
||||
const facts = shown.filter(([, v, files]) => files.length || v.length <= longAt)
|
||||
const notes = shown.filter(([, v, files]) => !files.length && v.length > longAt)
|
||||
return (
|
||||
<section className="fgroup" key={title}>
|
||||
<h3>{title}<span>{shown.length}</span></h3>
|
||||
|
||||
{facts.length ? (
|
||||
<dl className="props">
|
||||
{facts.map(([k, v, files]) => (
|
||||
<div className="prop" key={k}>
|
||||
<dt>{labels[k] ?? label(k)}</dt>
|
||||
{files.length ? (
|
||||
<dd className="prop__files">
|
||||
{files.map((f) => (
|
||||
<a key={f.uuid} className="prop__file" href={fileHref(f)}
|
||||
target="_blank" rel="noreferrer">
|
||||
{f.original_name || 'Document'}
|
||||
</a>
|
||||
))}
|
||||
</dd>
|
||||
) : (
|
||||
<dd className={money.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{notes.length ? (
|
||||
<div className="fnotes">
|
||||
{notes.map(([k, v]) => (
|
||||
<div className="fnote" key={k}>
|
||||
<span className="fnote__l">{labels[k] ?? label(k)}</span>
|
||||
<ClampText text={v} title={labels[k] ?? label(k)} lines={2} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="lfd__none">
|
||||
Nothing on this lead matches “{q.trim()}”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
17
src/components/NewLeadDialog.css
Normal file
17
src/components/NewLeadDialog.css
Normal file
@ -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;
|
||||
}
|
||||
112
src/components/NewLeadDialog.jsx
Normal file
112
src/components/NewLeadDialog.jsx
Normal file
@ -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(
|
||||
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="prose__dlg nld"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="New lead"
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="prose__head">
|
||||
<div>
|
||||
<h3>{entry ? entry.label : 'New lead'}</h3>
|
||||
<p className="nld__sub">
|
||||
{entry
|
||||
? entry.note
|
||||
: 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="nld__body">
|
||||
{!doors.length ? (
|
||||
<p className="empty">Your role does not create leads.</p>
|
||||
) : !entry ? (
|
||||
<div className="doors">
|
||||
{doors.map((e) => (
|
||||
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
||||
<strong>{e.label}</strong>
|
||||
<span className="chip">{e.channel}</span>
|
||||
<p>{e.note}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ActivityForm
|
||||
activityUid={entry.uid}
|
||||
onCancel={onClose}
|
||||
onDone={(res) => {
|
||||
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 } })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@ -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() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{canAskDesk ? (
|
||||
<div>
|
||||
<h6 className="drawer__h">Ask</h6>
|
||||
<button
|
||||
type="button" className="nav nav--desk"
|
||||
onClick={() => { setOpen(false); setDesk(true) }}
|
||||
>
|
||||
<span className="nav__i" aria-hidden="true">
|
||||
<svg viewBox="0 0 20 20" fill="none"><path d="M3 4.5h14v9h-8l-3.5 3v-3H3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
||||
</span>
|
||||
<span className="nav__t">Ask the desk</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
@ -155,6 +177,8 @@ export default function Shell() {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{desk ? <AskDesk onClose={() => setDesk(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={'page' + (primary || blocked || stalled?.nudge ? ' page--acts' : '')}>
|
||||
<div className={'page' + (hasBar ? ' page--acts' : '')}>
|
||||
<header className="lhead">
|
||||
<button
|
||||
type="button" className="back" aria-label="Back"
|
||||
@ -181,6 +263,7 @@ export default function Lead() {
|
||||
<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.
|
||||
{stalled.nudge ? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : stage?.uid === 'zk-state-review' ? (
|
||||
@ -241,18 +324,44 @@ export default function Lead() {
|
||||
{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>
|
||||
))}
|
||||
{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 (
|
||||
<div key={uid} className={'step' + (isHere ? ' is-here' : been ? ' is-done' : '')}>
|
||||
<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} />
|
||||
{/* 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 ? (
|
||||
<button type="button" className="kv__call" style={{ marginBottom: 12 }} onClick={() => setChat({ anchor: null })}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||
</svg>
|
||||
WhatsApp conversation
|
||||
<em style={{ fontStyle: 'normal', color: 'var(--zk-grey)', fontWeight: 400 }}>
|
||||
{chatTurns} message{chatTurns === 1 ? '' : 's'}
|
||||
</em>
|
||||
</button>
|
||||
) : null}
|
||||
<Timeline
|
||||
rows={audit}
|
||||
customerName={row.customer_name}
|
||||
onOpenAgent={setOpenAgent}
|
||||
onOpenChat={(episode) => setChat({ anchor: episode ?? null })}
|
||||
onOpenCall={() => setShowCall(true)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{groups.length ? (
|
||||
@ -260,7 +369,7 @@ export default function Lead() {
|
||||
<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]) => (
|
||||
{glance.map(([title, rows]) => (
|
||||
<section className="kv__g" key={title}>
|
||||
<h4>{title}</h4>
|
||||
{rows.map(([k, v, files]) => (
|
||||
@ -275,9 +384,22 @@ export default function Lead() {
|
||||
</a>
|
||||
))}
|
||||
</dd>
|
||||
) : k === 'call_transcript' ? (
|
||||
/* A transcript is not a value. Printed as one it was two
|
||||
thousand characters cut at a hundred and fifty. */
|
||||
<dd>
|
||||
<button type="button" className="kv__call" onClick={() => setShowCall(true)}>
|
||||
<PhoneGlyph />Read what was said
|
||||
</button>
|
||||
</dd>
|
||||
) : (
|
||||
<dd className={MONEY.has(k) ? 'kv__num' : undefined}>
|
||||
{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
|
||||
? <ClampText text={v} title={labels[k] ?? label(k)} lines={3} />
|
||||
: v}
|
||||
</dd>
|
||||
)}
|
||||
</div>
|
||||
@ -285,38 +407,115 @@ export default function Lead() {
|
||||
</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`}
|
||||
{fieldCount ? (
|
||||
<button type="button" className="kv__all" onClick={() => setShowFile(true)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
Open the full lead file
|
||||
<b>{fieldCount} fields</b>
|
||||
</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 ? (
|
||||
reachable from wherever you have scrolled to — and so must the way
|
||||
out, which is what the overflow button holds. */}
|
||||
{hasBar ? (
|
||||
<div className="actbar">
|
||||
{blocked ? (
|
||||
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(blocked.via)}>
|
||||
<button type="button" className="btn btn--navy" onClick={() => setOpen(blocked.via)}>
|
||||
Open Collect Documents
|
||||
</button>
|
||||
) : stalled?.nudge ? (
|
||||
<button type="button" className="btn btn--navy btn--block" onClick={() => setOpen(stalled.nudge.uid)}>
|
||||
<button type="button" className="btn btn--navy" onClick={() => setOpen(stalled.nudge.uid)}>
|
||||
{stalled.nudge.label}
|
||||
</button>
|
||||
) : (
|
||||
) : primary ? (
|
||||
<>
|
||||
<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) => (
|
||||
{step.slice(1).map((a) => (
|
||||
<button key={a.uid} type="button" className="btn btn--danger" onClick={() => setOpen(a.uid)}>{a.label}</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
/* Nothing is owed by this role, so the bar carries only the way
|
||||
out — never dressed as the thing to do. */
|
||||
<button type="button" className="btn btn--ghost" onClick={() => setMore(true)}>Other actions</button>
|
||||
)}
|
||||
{(primary || blocked || stalled?.nudge) && secondary.length ? (
|
||||
<button
|
||||
type="button" className="btn btn--ghost btn--more"
|
||||
aria-label="Other actions" onClick={() => setMore(true)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" style={{ width: 18, height: 18 }}>
|
||||
<circle cx="8" cy="3.2" r="1.4" fill="currentColor" />
|
||||
<circle cx="8" cy="8" r="1.4" fill="currentColor" />
|
||||
<circle cx="8" cy="12.8" r="1.4" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* The actions that are not this stage's step, by what they are. */}
|
||||
{more ? (
|
||||
<>
|
||||
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setMore(false)} />
|
||||
<div className="sheet" role="dialog" aria-label="Other actions">
|
||||
<span className="sheet__grab" aria-hidden="true" />
|
||||
<div className="sheet__head">
|
||||
<div>
|
||||
<h2>Other actions</h2>
|
||||
<p>{stateName}</p>
|
||||
</div>
|
||||
<button type="button" className="sheet__x" aria-label="Close" onClick={() => setMore(false)}>×</button>
|
||||
</div>
|
||||
<div className="sheet__body">
|
||||
<div className="acts">
|
||||
{again.length ? (
|
||||
<>
|
||||
<h4 className="acts__h">Optional</h4>
|
||||
{again.map((a) => (
|
||||
<button key={a.uid} type="button" className="act" onClick={() => actionSheet(a.uid)}>
|
||||
{a.label}<em>{a.by}</em>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{force.length ? (
|
||||
<>
|
||||
<h4 className="acts__h">Lead not moving?</h4>
|
||||
<p className="acts__why">
|
||||
These normally run by themselves. Use one only if this lead has been
|
||||
sitting longer than it should.
|
||||
</p>
|
||||
{force.map((a) => (
|
||||
<button key={a.uid} type="button" className="act" onClick={() => actionSheet(a.uid)}>
|
||||
{a.label}<em>{a.by}</em>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{exit.length ? (
|
||||
<>
|
||||
<h4 className="acts__h">Close it</h4>
|
||||
{exit.map((a) => (
|
||||
<button key={a.uid} type="button" className="act act--exit" onClick={() => actionSheet(a.uid)}>
|
||||
{a.label}<em>{a.by}</em>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<div className="scrim" style={{ zIndex: 60 }} role="presentation" onClick={() => setOpen(null)} />
|
||||
@ -324,7 +523,7 @@ export default function Lead() {
|
||||
<span className="sheet__grab" aria-hidden="true" />
|
||||
<div className="sheet__head">
|
||||
<div>
|
||||
<h2>{actions.find((a) => a.uid === open)?.label ?? 'Action'}</h2>
|
||||
<h2>{shown.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>
|
||||
@ -342,6 +541,38 @@ export default function Lead() {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{chat ? (
|
||||
<Conversation
|
||||
rows={audit}
|
||||
name={row.customer_name}
|
||||
anchor={chat.anchor}
|
||||
onClose={() => setChat(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{openAgent ? <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> : null}
|
||||
|
||||
{showCall ? (
|
||||
<CallTranscript
|
||||
text={row.call_transcript}
|
||||
audio={row.call_recording_url}
|
||||
name={row.customer_name}
|
||||
onClose={() => setShowCall(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFile ? (
|
||||
<LeadFileDialog
|
||||
groups={groups}
|
||||
labels={labels}
|
||||
label={label}
|
||||
money={MONEY}
|
||||
longAt={LONG}
|
||||
fileHref={(f) => `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
||||
onClose={() => setShowFile(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||
import { ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import LeadCard from '../components/LeadCard.jsx'
|
||||
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||
|
||||
/**
|
||||
* A queue, as a list of cards.
|
||||
@ -16,7 +18,11 @@ import LeadCard from '../components/LeadCard.jsx'
|
||||
export default function Leads() {
|
||||
const { stageUid } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { client } = useZino()
|
||||
const { client, user } = useZino()
|
||||
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
|
||||
// Over the queue rather than away from it: filing a lead used to cost you
|
||||
// your place in the list you were working through.
|
||||
const [adding, setAdding] = useState(false)
|
||||
const isAll = stageUid === 'all'
|
||||
const [hideClosed, setHideClosed] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
@ -82,6 +88,16 @@ export default function Leads() {
|
||||
: `Pending action by ${stage.by}.`}
|
||||
</p>
|
||||
</div>
|
||||
{canFile ? (
|
||||
<div className="page__act">
|
||||
<button type="button" className="btn btn--primary btn--sm" onClick={() => setAdding(true)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
New lead
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="tools">
|
||||
@ -142,6 +158,8 @@ export default function Leads() {
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { usePortfolio } from '../api/portfolio.jsx'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { STAGES, phaseOf } from '../api/config.js'
|
||||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
||||
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||
|
||||
/**
|
||||
* What needs a person, first thing in the morning.
|
||||
@ -43,6 +44,11 @@ export default function Overview() {
|
||||
const { user } = useZino()
|
||||
const navigate = useNavigate()
|
||||
const roles = rolesOf(user)
|
||||
const canFile = entryDoorsFor(roles, ENTRY).length > 0
|
||||
// Filing a lead happens OVER this page, not instead of it: the counts and
|
||||
// the poll behind the sheet carry on, and Cancel puts you back here rather
|
||||
// than three navigations away.
|
||||
const [adding, setAdding] = useState(false)
|
||||
const state = usePortfolio()
|
||||
|
||||
const m = useMemo(() => {
|
||||
@ -104,6 +110,11 @@ export default function Overview() {
|
||||
}, [state.rows])
|
||||
|
||||
const mine = new Set(visibleStages(roles).map((s) => s.uid))
|
||||
// The sign-off queues this ROLE owns — computed here rather than inside the
|
||||
// memo, which would then have to depend on a role array rebuilt every render.
|
||||
// Empty for everyone else, so the band never appears to a partner agent who
|
||||
// could not act on it anyway.
|
||||
const approvals = m.actionable.filter((s) => s.approval && roles.includes(s.approval))
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
@ -135,8 +146,31 @@ export default function Overview() {
|
||||
<h1 className="page__h">Portfolio overview</h1>
|
||||
<p className="page__sub">Motor and SME renewals, organisation-wide</p>
|
||||
</div>
|
||||
{canFile ? (
|
||||
<div className="page__act">
|
||||
<button type="button" className="btn btn--primary btn--sm" onClick={() => setAdding(true)}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
New lead
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* SIGN-OFF, not work: the one queue that is a person's approval rather
|
||||
than a task — confirming money has arrived — shown to the role that
|
||||
owns it and to nobody else. */}
|
||||
{approvals.map((s) => (
|
||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? '' : ' is-quiet')}>
|
||||
<span className="appr__n">{s.n}</span>
|
||||
<span className="appr__t">
|
||||
{s.approvalLabel}
|
||||
<em>{s.approvalNote}</em>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* WHO HOLDS THE WORK. Every open lead is in exactly one of three hands,
|
||||
and that is the sentence this console exists to say. */}
|
||||
<section className="card">
|
||||
@ -259,6 +293,8 @@ export default function Overview() {
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -457,3 +457,177 @@ button { font: inherit; }
|
||||
.said { display: flex; gap: 10px; align-items: flex-start; padding: 11px 14px; border-radius: 12px; background: var(--zk-teal-tint); border: 1px solid var(--zk-teal-line); color: var(--zk-teal-ink); margin-bottom: 12px; }
|
||||
.said p { margin: 0; flex: 1; font-size: var(--fs-xs); }
|
||||
.said button { background: none; border: 0; font-size: 18px; line-height: 1; color: inherit; cursor: pointer; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
EVERY DIALOG COMES UP FROM THE BOTTOM.
|
||||
|
||||
The dialogs are copied from the desktop console verbatim — the conversation,
|
||||
the call, the agent card, the lead file, the new-lead form — because they are
|
||||
the same functionality and a reimplementation would be a second account of
|
||||
the same lead. What they must NOT be is a centred desktop modal on a phone:
|
||||
at 390px the console's `prose` chrome leaves 24px of scrim down each side and
|
||||
anchors the panel in the middle of the screen, where a thumb cannot reach its
|
||||
close button.
|
||||
|
||||
So the chrome is re-pointed rather than the components rewritten. This block
|
||||
turns `.prose__scrim` / `.prose__dlg` into a bottom sheet, and every dialog
|
||||
in the app becomes phone-native at once — including the two ClampText already
|
||||
opened. Above phone width the console's own centred dialog is kept, which is
|
||||
the right answer for a tablet.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
@media (max-width: 700px) {
|
||||
.prose__scrim { padding: 0; align-items: flex-end; }
|
||||
|
||||
/* Two classes deep, so it beats the per-dialog widths (.lfd, .nld, .conv)
|
||||
that the copied stylesheets set for a desktop viewport. */
|
||||
.prose__scrim .prose__dlg {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 92dvh;
|
||||
border-radius: 18px 18px 0 0;
|
||||
/* The safe-area pad sits below the last child of the flex column, so the
|
||||
final message clears the home indicator instead of hiding under it. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
box-shadow: 0 -8px 40px -12px rgba(11, 42, 91, .34);
|
||||
animation: sheet-in .2s var(--ease) both;
|
||||
}
|
||||
|
||||
/* The grab handle, without touching the copied markup: the dialog is a flex
|
||||
column, so its ::before is simply its first row. It is what says "this
|
||||
came up from the bottom and goes back down" on a phone. */
|
||||
.prose__scrim .prose__dlg::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 40px; height: 4px;
|
||||
margin: 9px auto 0;
|
||||
border-radius: 2px;
|
||||
background: var(--zk-line);
|
||||
}
|
||||
|
||||
/* A dialog that also carries the .card class would otherwise take the phone
|
||||
card's own border and padding on top of its head/body padding. */
|
||||
.prose__scrim .prose__dlg.card { padding: 0 0 env(safe-area-inset-bottom); border: 0; }
|
||||
|
||||
.prose__head { padding: 10px 16px 12px; }
|
||||
.prose__body { padding: 14px 16px 20px; }
|
||||
.lfd__tools { padding: 10px 16px; gap: 10px; }
|
||||
.lfd__body .file360 { padding: 14px 16px 20px; }
|
||||
.nld__body { padding: 14px 16px 18px; }
|
||||
.conv__body { padding: 14px 16px 6px; }
|
||||
.conv__note { padding: 12px 16px 16px; }
|
||||
.conv__audio { padding: 10px 16px 4px; }
|
||||
.agc__head { padding: 10px 16px 12px; }
|
||||
.agc__body { padding: 14px 16px 20px; }
|
||||
}
|
||||
|
||||
/* ── The record, as the copied lead-file dialog renders it ──────────────────
|
||||
`.fgroup` / `.prop` are the console's field-group vocabulary, which the
|
||||
copied LeadFileDialog uses. The console styles them in its own screens.css;
|
||||
these are the phone's metrics for the same markup — a 40% label column
|
||||
rather than a fixed 150px, which is what stops a long value wrapping to
|
||||
four words a line at 390px. */
|
||||
.file360 { display: flex; flex-direction: column; }
|
||||
.fgroup { padding-top: 14px; border-top: 1px solid var(--zk-line-soft); margin-top: 14px; }
|
||||
.fgroup:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
|
||||
.fgroup h3 {
|
||||
display: flex; justify-content: space-between; gap: 10px; margin: 0 0 6px;
|
||||
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .04em;
|
||||
text-transform: uppercase; color: var(--zk-grey);
|
||||
}
|
||||
.fgroup h3 span { font-weight: 400; text-transform: none; letter-spacing: 0; }
|
||||
.props { margin: 0; }
|
||||
.prop { display: grid; grid-template-columns: 40% 1fr; gap: 12px; padding: 6px 0; font-size: var(--fs-xs); }
|
||||
.prop dt { color: var(--zk-muted); overflow-wrap: anywhere; }
|
||||
.prop dd { margin: 0; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.prop__num { font-variant-numeric: tabular-nums; }
|
||||
.prop__files { display: flex; flex-direction: column; gap: 5px; }
|
||||
.prop__file { color: var(--zk-blue); font-weight: 600; text-decoration: none; overflow-wrap: anywhere; }
|
||||
|
||||
/* A value long enough to be prose gets its own block rather than a squeezed
|
||||
right-hand column. */
|
||||
.fnotes { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
|
||||
.fnote { background: var(--zk-tint); border-radius: 10px; padding: 10px 12px; }
|
||||
.fnote__l {
|
||||
display: block; margin-bottom: 4px;
|
||||
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .04em;
|
||||
text-transform: uppercase; color: var(--zk-grey);
|
||||
}
|
||||
|
||||
/* The entry doors, for the day a second one is surfaced. One door skips the
|
||||
chooser, which is why this has never rendered yet. */
|
||||
.doors { display: flex; flex-direction: column; gap: 10px; }
|
||||
.door {
|
||||
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||
background: #fff; border: 1px solid var(--zk-line); border-radius: 12px; padding: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
.door strong { display: block; font-size: var(--fs-md); font-weight: 700; }
|
||||
.door p { margin: 6px 0 0; font-size: var(--fs-2xs); color: var(--zk-muted); line-height: 1.45; }
|
||||
.chip {
|
||||
display: inline-block; margin-top: 6px; padding: 3px 9px; border-radius: 999px;
|
||||
font-size: var(--fs-3xs); font-weight: 700; text-transform: capitalize;
|
||||
background: var(--zk-tint-blue); color: var(--zk-blue);
|
||||
}
|
||||
|
||||
/* ── The record card's own affordances ──────────────────────────────────── */
|
||||
/* The call is the one thing on a lead that cannot be read as a field: printed
|
||||
as a value it is 2,000 characters of transcript truncated to 150. */
|
||||
.kv__call {
|
||||
display: inline-flex; align-items: center; gap: 7px; margin-top: 4px;
|
||||
padding: 8px 12px; border: 1px solid var(--zk-line); border-radius: 10px;
|
||||
background: #fff; color: var(--zk-blue); font-size: var(--fs-2xs); font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kv__call svg { width: 14px; height: 14px; }
|
||||
|
||||
/* "Open the full lead file · 66 fields" — a control that hides sixty-six
|
||||
things should say how many. */
|
||||
.kv__all {
|
||||
display: flex; align-items: center; gap: 9px; width: 100%; margin-top: 14px;
|
||||
padding: 12px 14px; border: 1px solid var(--zk-line); border-radius: 12px;
|
||||
background: #fff; color: var(--zk-blue); font-size: var(--fs-xs); font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kv__all svg { width: 15px; height: 15px; flex: none; }
|
||||
.kv__all b { margin-left: auto; font-weight: 400; color: var(--zk-grey); }
|
||||
|
||||
/* ── The secondary actions, in a sheet ─────────────────────────────────────
|
||||
Every action a stage allows this role, laid out by what it IS: the step, a
|
||||
bounded loop, an AI's own job offered only so a stalled lead can be pushed,
|
||||
and the way out. The phone showed only the step, which left Mark Lost and
|
||||
every recovery lever unreachable. */
|
||||
.acts { display: flex; flex-direction: column; gap: 9px; }
|
||||
.acts__h {
|
||||
margin: 14px 0 2px; font-size: var(--fs-3xs); font-weight: 700;
|
||||
letter-spacing: .05em; text-transform: uppercase; color: var(--zk-grey);
|
||||
}
|
||||
.acts__h:first-child { margin-top: 0; }
|
||||
.acts__why { margin: 0 0 4px; font-size: var(--fs-2xs); color: var(--zk-muted); line-height: 1.45; }
|
||||
.act {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||
padding: 13px 14px; border: 1px solid var(--zk-line); border-radius: 12px;
|
||||
background: #fff; color: var(--zk-ink); font-size: var(--fs-sm); font-weight: 600;
|
||||
cursor: pointer; min-height: 48px;
|
||||
}
|
||||
.act:active { background: var(--zk-tint); }
|
||||
.act em { margin-left: auto; font-style: normal; font-size: var(--fs-3xs); font-weight: 400; color: var(--zk-grey); }
|
||||
.act--exit { color: var(--zk-danger); border-color: var(--zk-danger-line); }
|
||||
.act--exit em { color: var(--zk-danger); opacity: .75; }
|
||||
|
||||
/* ── The sign-off band, for the role that owns it ───────────────────────── */
|
||||
.appr {
|
||||
display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
|
||||
margin-bottom: 12px; padding: 14px 15px; cursor: pointer; color: inherit;
|
||||
background: var(--zk-amber-tint); border: 1px solid var(--zk-amber-line); border-radius: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.appr__n { flex: none; font-size: 30px; font-weight: 700; line-height: 1; color: var(--zk-amber); }
|
||||
.appr__t { flex: 1; min-width: 0; font-size: var(--fs-sm); font-weight: 700; }
|
||||
.appr__t em { display: block; margin-top: 4px; font-style: normal; font-size: var(--fs-2xs); font-weight: 400; color: var(--zk-muted); line-height: 1.45; }
|
||||
.appr.is-quiet { background: #fff; border-color: var(--zk-line); }
|
||||
.appr.is-quiet .appr__n { color: var(--zk-grey); }
|
||||
|
||||
/* ── Ask the desk, from the drawer ─────────────────────────────────────────
|
||||
The desk reads the WHOLE book, so it is an ops tool and the button is drawn
|
||||
only for the role that may see one. */
|
||||
.nav--desk .nav__i { background: var(--zk-navy); color: #fff; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user