Compare commits

..

2 Commits

Author SHA1 Message Date
49d5443c60 console: the page carries what matters, the file holds everything
Fifty-seven fields over thirteen groups sat open on the lead page, which
pushed the audit trail — the part people actually read — a screen and a half
down. Most of it is reference material: nobody opens a lead to find its source
channel. They open it to see who the customer is, what is being insured, what
has been attached, and what this stage turns on.

So the page carries exactly that — four cards at most, the fourth chosen by the
stage the lead is in — and the complete record moves into a drawer behind one
click. Nothing is dropped: the drawer is handed the same computed groups the
page used to render, so the two cannot disagree about a value.

Hiding is only safe while it stays findable, so the trigger names the number it
is holding back rather than saying "more", and the drawer brings a filter over
labels AND values, because Ctrl-F was how people read the open version and a
click breaks that.

Long values are clamped in both places. uw_referral_reason and lost_reason run
to a paragraph, and a paragraph printed raw in a summary card would rebuild the
wall this exists to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:21:56 +05:30
db91daabf8 console: the receipt is gone from the workflow, so stop describing it
The conversation panel told the reader that two automatic sends were absent
from the thread. Only one is now — 97 removed the "I have your message"
receipt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:57:24 +05:30
5 changed files with 450 additions and 80 deletions

View File

@ -18,9 +18,10 @@ import './Conversation.css'
* nothing after "I confirm", which read as though we had ignored somebody
* agreeing to buy so 90 writes it into customer_answer and it appears.
*
* Two sends remain absent by choice: the quote, which is a registered WhatsApp
* template rather than text we compose, and the "I have your message" receipt,
* which would put a line between every question and its answer.
* One send remains absent: the quote, which is a registered WhatsApp template
* rather than text we compose. The "I have your message" receipt used to be a
* second it is gone from the workflow now (97), because it put a line saying
* only "an answer is coming" between every question and its answer.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
@ -121,10 +122,9 @@ export default function Conversation({ rows, name, onClose }) {
<p className="conv__note">
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation. Two automatic sends are not here:
including the acceptance confirmation. One automatic send is not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose, and the &ldquo;I have your message&rdquo; receipt sent
before anything has read it.
text we compose.
</p>
</div>
</div>

View 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: 0.78rem;
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: 0.84rem;
color: var(--zk-ink);
}
.lfd__find input::-webkit-search-cancel-button { cursor: pointer; }
.lfd__count {
flex: none;
font-size: 0.74rem;
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: 0.86rem;
color: var(--zk-grey);
}

View File

@ -0,0 +1,163 @@
import { useEffect, useMemo, useRef, useState } from 'react'
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 (
<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 &ldquo;{q.trim()}&rdquo;.
</p>
)}
</div>
</div>
</div>
)
}

View File

@ -7,9 +7,10 @@ import AgentChip from '../components/AgentChip.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 StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
@ -87,6 +88,44 @@ const GROUPS = [
/** Past this, a value is prose and gets folded rather than printed in full. */
const LONG = 150
/**
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
*
* Three things are always true of a lead who the customer is, what is being
* insured, and what has been attached so those are always here. The fourth
* section depends on where the lead has got to: a lead in Quoted needs the
* cover and the quote's expiry; the same lead in Onboarded needs the policy
* number and when it renews. Everything else is in the full file.
*
* THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
* carries the same hazard: a stage not listed here simply gets no fourth
* section, and a field renamed in the workflow quietly stops appearing. That is
* the safe direction to fail the full file is generated from the data and
* still holds everything but it is a mirror, not a source.
*
* The metrics strip at the top of the page already carries premium, commission,
* product, renewal date, lead age and AI confidence. Nothing is repeated here.
*/
const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'],
'zk-state-qualified': ['lead_score', 'eligibility_outcome', 'outreach_window'],
'zk-state-contacted': ['contact_outcome', 'outreach_window'],
'zk-state-docs': ['documents_status', 'motor_prev_expiry'],
'zk-state-quoted': ['ai_recommended_cover', 'quote_valid_till', 'acceptance_ref'],
'zk-state-payment': ['payment_ref', 'quote_valid_till'],
'zk-state-issued': ['policy_no', 'policy_issued_at', 'payment_ref'],
'zk-state-onboarded': ['policy_no', 'policy_issued_at', 'renewal_due', 'payout_status'],
'zk-state-referred': ['uw_outcome', 'uw_referral_reason'],
'zk-state-parked': ['contact_outcome', 'outreach_window'],
'zk-state-lost': ['lost_reason', 'contact_outcome'],
}
/** The risk, in whichever line's vocabulary this lead is written. */
const GLANCE_RISK = {
motor: ['motor_reg_no', 'motor_make_model', 'motor_mfg_year', 'motor_idv', 'motor_ncb_pct'],
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
}
/**
* Field ids are snake_case and several carry acronyms, which sentence-casing
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
@ -154,6 +193,8 @@ export default function Lead() {
const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
// The full record, on demand see the panel comment below.
const [showFile, setShowFile] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure React may render twice and get two
// answers and it also means the "nothing for 6 minutes" counter would only
@ -258,6 +299,27 @@ export default function Lead() {
// anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control.
const actions = actionsFor(roles, stage?.uid)
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
// Built from the SAME rows the full file renders, so the two can never
// disagree about a value. A section with nothing in it is dropped rather
// than rendered empty an SME lead has no registration number, and a
// heading over a blank box reads as a bug.
const pick = (keys) => keys
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
.filter(([, v]) => v !== null)
const docKeys = Object.keys(DOC_SLOTS).filter((k) => filesOf(row[k]).length)
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
const glance = [
['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
[line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])],
['Documents', pick(docKeys)],
['This stage', pick(GLANCE_STAGE[stage?.uid] || [])],
].filter(([, rows]) => rows.length)
// Two different reasons for an empty action list, and they must not read the
// same. A terminal lead is finished; a live one you cannot act on belongs to
// somebody else, and saying "closed" about it would be a lie.
@ -609,89 +671,76 @@ export default function Lead() {
)
})()}
{/* EVERYTHING AT ONCE.
This was tabs thirteen of them, one group visible at a time
so "what do we know about this lead?" meant clicking thirteen
times and holding the answer in your head. It is a lead FILE; a
file you can only read one page of is a filing cabinet.
Now every group is a card, laid out in columns that reflow, so
the whole 360 is one scroll and Ctrl-F finds anything. */}
{groups.length ? (
{/* WHAT MATTERS HERE, NOT EVERYTHING THERE IS.
The whole record used to sit open on the page: fifty-seven
fields over thirteen groups, which pushed the audit trail the
part people actually read a screen and a half down. Most of it
is reference material. Nobody opens a lead to find its source
channel; they open it to see who the customer is, what is being
insured, what has been attached, and what this stage turns on.
So the page carries that, and the complete file is one click
away with a filter on it. Hiding is only safe when it stays
findable, which is why the trigger below names the number it is
holding back rather than saying "more". */}
{glance.length ? (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">Lead file</h2>
<h2 className="panel__title">At a glance</h2>
<p className="panel__sub">
Everything captured, grouped by the step that captured it.
The customer, the risk, and what this stage turns on.
</p>
</div>
<span className="panel__meta">
{groups.reduce((n, [, shown]) => n + shown.length, 0)} fields
</span>
</div>
<div className="file360">
{groups.map(([title, shown]) => {
/* SHORT FACTS FIRST, PROSE AFTER.
Seven of these fields are AI paragraphs the attribution
reason, the rationale, the call transcript and rendering
them in the same flow as "Mobile" made a card that was
mostly tinted boxes with three key/value pairs lost among
them. The facts are what people scan; the reasoning is what
they occasionally open, and it is already in the trail
alongside the step that wrote it. So the pairs group at the
top where they can be read as a column, and the prose sits
under a rule as a line and a link. */
const facts = shown.filter(([, v, files]) => files.length || v.length <= LONG)
const notes = shown.filter(([, v, files]) => !files.length && v.length > LONG)
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 ? (
/* An attached document is a thing to open, not
a filename to read. The preview route is
app-scoped and public, so a plain link needs
no token. */
<dd className="prop__files">
{files.map((f) => (
<a
key={f.uuid}
className="prop__file"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
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 className="glance">
{glance.map(([title, rows]) => (
<section className="fgroup" key={title}>
<h3>{title}<span>{rows.length}</span></h3>
<dl className="props">
{rows.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={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer"
>
{f.original_name || 'Document'}
</a>
))}
</dd>
) : v.length > LONG ? (
/* A stage reason can run to a paragraph
uw_referral_reason and lost_reason both do and
a paragraph printed raw here would rebuild the
wall this panel exists to remove. */
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
) : (
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
)}
</div>
) : null}
</section>
)
})}
))}
</dl>
</section>
))}
</div>
{fieldCount ? (
<button type="button" className="glance__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}
</div>
) : null}
</div>
@ -750,6 +799,18 @@ export default function Lead() {
{showCall ? (
<CallTranscript text={row.call_transcript} 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}
</section>
)
}

View File

@ -1487,6 +1487,87 @@
.file360 .clamp__more { margin-top: 3px; font-size: 0.7rem; }
/* at a glance
The same card as the full file uses, so opening the drawer feels like more
of the thing you were already reading rather than a different screen. Four
sections at most, so a fixed auto-fit grid never leaves a lone card on a
row of its own the way the thirteen-group version did. */
.glance {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
padding: 4px 22px 18px;
align-items: start;
}
.glance .props { display: block; padding: 0; }
.glance .prop {
display: grid;
grid-template-columns: minmax(0, 42%) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
margin: 0;
padding: 3px 0;
border: 0;
border-radius: 0;
}
.glance .prop:hover { background: transparent; }
.glance .prop dt { font-size: 0.71rem; line-height: 1.5; color: var(--zk-grey); margin: 0; }
.glance .prop dd { font-size: 0.8rem; line-height: 1.5; color: var(--zk-ink); overflow-wrap: anywhere; }
/* Same de-weighting as the full file: at a glance a reason is one fact among
a dozen, not the entry it is in the trail. */
.glance .clamp__lead,
.glance .clamp__text {
font-size: 0.78rem;
font-weight: 400;
line-height: 1.5;
color: var(--zk-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.glance .clamp__more { margin-top: 3px; font-size: 0.7rem; }
/* HIDING IS ONLY SAFE WHILE IT STAYS FINDABLE, so this names the number it is
holding rather than saying "more". Full width and on the panel's own floor:
a link tucked into the header would be the discoverability problem that
sinks progressive disclosure. */
.glance__all {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 13px 22px;
border: 0;
border-top: 1px solid var(--zk-line-soft);
border-radius: 0 0 var(--r-lg) var(--r-lg);
background: none;
cursor: pointer;
font: inherit;
font-size: 0.82rem;
font-weight: 500;
color: var(--zk-blue-dark);
transition: background var(--t-fast);
}
.glance__all:hover { background: var(--zk-tint); }
.glance__all:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: -2px; }
.glance__all svg { width: 15px; height: 15px; flex: none; }
.glance__all b {
margin-left: auto;
font-weight: 400;
font-size: 0.74rem;
color: var(--zk-grey);
font-variant-numeric: tabular-nums;
}
.panel__meta {
flex: none;
font-size: 0.74rem;