diff --git a/src/components/LeadFileDialog.css b/src/components/LeadFileDialog.css
new file mode 100644
index 0000000..656ad2a
--- /dev/null
+++ b/src/components/LeadFileDialog.css
@@ -0,0 +1,65 @@
+/* Wide and tall: this is the whole record, and squeezing thirteen groups into
+ a prose-width dialog would only move the crowding rather than remove it. */
+.lfd { width: min(1180px, 100%); max-height: min(90vh, 940px); }
+
+.lfd__sub {
+ margin: 4px 0 0;
+ font-size: 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);
+}
diff --git a/src/components/LeadFileDialog.jsx b/src/components/LeadFileDialog.jsx
new file mode 100644
index 0000000..d60241e
--- /dev/null
+++ b/src/components/LeadFileDialog.jsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+
+
+
Lead file
+
+ Everything captured, grouped by the step that captured it.
+
+ )
+}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index bc40645..0c917a2 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -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 ? (
-
Lead file
+
At a glance
- Everything captured, grouped by the step that captured it.
+ The customer, the risk, and what this stage turns on.
- {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 (
-
-
{title}{shown.length}
-
- {facts.length ? (
-
- {facts.map(([k, v, files]) => (
-
-
{labels[k] ?? label(k)}
- {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. */
-
+ ) : 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. */
+
+ ) : (
+
{v}
+ )}
- ) : null}
-
- )
- })}
+ ))}
+
+
+ ))}
+
+ {fieldCount ? (
+
+ ) : null}
) : null}
@@ -750,6 +799,18 @@ export default function Lead() {
{showCall ? (
setShowCall(false)} />
) : null}
+
+ {showFile ? (
+ `${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
+ onClose={() => setShowFile(false)}
+ />
+ ) : null}
)
}
diff --git a/src/screens/screens.css b/src/screens/screens.css
index 446f623..afd3a54 100644
--- a/src/screens/screens.css
+++ b/src/screens/screens.css
@@ -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;