Compare commits

..

No commits in common. "49d5443c602e66af510f36df6da84c790cd501f7" and "ea2b56e33208b519bfabce1ed8956710f17f7792" have entirely different histories.

5 changed files with 80 additions and 450 deletions

View File

@ -18,10 +18,9 @@ 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.
*
* 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.
* 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.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
@ -122,9 +121,10 @@ 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. One automatic send is not here:
including the acceptance confirmation. Two automatic sends are not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose.
text we compose, and the &ldquo;I have your message&rdquo; receipt sent
before anything has read it.
</p>
</div>
</div>

View File

@ -1,65 +0,0 @@
/* 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

@ -1,163 +0,0 @@
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,10 +7,9 @@ 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, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { APP_ID, 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'
@ -88,44 +87,6 @@ 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
@ -193,8 +154,6 @@ 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
@ -299,27 +258,6 @@ 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.
@ -671,76 +609,89 @@ export default function Lead() {
)
})()}
{/* 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 ? (
{/* 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 ? (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">At a glance</h2>
<h2 className="panel__title">Lead file</h2>
<p className="panel__sub">
The customer, the risk, and what this stage turns on.
Everything captured, grouped by the step that captured it.
</p>
</div>
<span className="panel__meta">
{groups.reduce((n, [, shown]) => n + shown.length, 0)} fields
</span>
</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 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>
))}
</dl>
</section>
))}
) : null}
</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>
@ -799,18 +750,6 @@ 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,87 +1487,6 @@
.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;