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. +

+
+ +
+ +
+ + + {q.trim() ? `${found} of ${total} fields` : `${total} fields`} + +
+ +
+ {shownGroups.length ? ( +
+ {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 ( +
+

{title}{shown.length}

+ + {facts.length ? ( +
+ {facts.map(([k, v, files]) => ( +
+
{labels[k] ?? label(k)}
+ {files.length ? ( +
+ {files.map((f) => ( + + {f.original_name || 'Document'} + + ))} +
+ ) : ( +
{v}
+ )} +
+ ))} +
+ ) : null} + + {notes.length ? ( +
+ {notes.map(([k, v]) => ( +
+ {labels[k] ?? label(k)} + +
+ ))} +
+ ) : null} +
+ ) + })} +
+ ) : ( +

+ Nothing on this lead matches “{q.trim()}”. +

+ )} +
+
+
+ ) +} 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.reduce((n, [, shown]) => n + shown.length, 0)} fields -
-
- {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. */ -
- {files.map((f) => ( - - {f.original_name || 'Document'} - - ))} -
- ) : ( -
{v}
- )} -
- ))} -
- ) : null} - - {notes.length ? ( -
- {notes.map(([k, v]) => ( -
- {labels[k] ?? label(k)} - -
- ))} +
+ {glance.map(([title, rows]) => ( +
+

{title}{rows.length}

+
+ {rows.map(([k, v, files]) => ( +
+
{labels[k] ?? label(k)}
+ {files.length ? ( +
+ {files.map((f) => ( + + {f.original_name || 'Document'} + + ))} +
+ ) : 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;