Compare commits
No commits in common. "49d5443c602e66af510f36df6da84c790cd501f7" and "ea2b56e33208b519bfabce1ed8956710f17f7792" have entirely different histories.
49d5443c60
...
ea2b56e332
@ -18,10 +18,9 @@ import './Conversation.css'
|
|||||||
* nothing after "I confirm", which read as though we had ignored somebody
|
* 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.
|
* 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
|
* Two sends remain absent by choice: the quote, which is a registered WhatsApp
|
||||||
* rather than text we compose. The "I have your message" receipt used to be a
|
* template rather than text we compose, and the "I have your message" receipt,
|
||||||
* second — it is gone from the workflow now (97), because it put a line saying
|
* which would put a line between every question and its answer.
|
||||||
* 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
|
* A thread that quietly omitted any of this would be worse than one that says
|
||||||
* which parts it holds.
|
* which parts it holds.
|
||||||
@ -122,9 +121,10 @@ export default function Conversation({ rows, name, onClose }) {
|
|||||||
|
|
||||||
<p className="conv__note">
|
<p className="conv__note">
|
||||||
Shows the customer's messages and everything written back to them,
|
Shows the customer'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
|
the quote, which goes out as a registered WhatsApp template rather than
|
||||||
text we compose.
|
text we compose, and the “I have your message” receipt sent
|
||||||
|
before anything has read it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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 “{q.trim()}”.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -7,10 +7,9 @@ import AgentChip from '../components/AgentChip.jsx'
|
|||||||
import CallTranscript from '../components/CallTranscript.jsx'
|
import CallTranscript from '../components/CallTranscript.jsx'
|
||||||
import ClampText from '../components/ClampText.jsx'
|
import ClampText from '../components/ClampText.jsx'
|
||||||
import Conversation from '../components/Conversation.jsx'
|
import Conversation from '../components/Conversation.jsx'
|
||||||
import LeadFileDialog from '../components/LeadFileDialog.jsx'
|
|
||||||
import StageRail from '../components/StageRail.jsx'
|
import StageRail from '../components/StageRail.jsx'
|
||||||
import Timeline from '../components/Timeline.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 { AGENTS } from '../api/agents.js'
|
||||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.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. */
|
/** Past this, a value is prose and gets folded rather than printed in full. */
|
||||||
const LONG = 150
|
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
|
* 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
|
* 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 [showChat, setShowChat] = useState(false)
|
||||||
const [openAgent, setOpenAgent] = useState(null)
|
const [openAgent, setOpenAgent] = useState(null)
|
||||||
const [showCall, setShowCall] = useState(false)
|
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
|
// 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
|
// 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
|
// 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
|
// anyway; showing them a row of buttons that all 403 reads as a broken app
|
||||||
// rather than as a control.
|
// rather than as a control.
|
||||||
const actions = actionsFor(roles, stage?.uid)
|
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
|
// 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
|
// 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.
|
// 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.
|
{/* EVERYTHING AT ONCE.
|
||||||
The whole record used to sit open on the page: fifty-seven
|
This was tabs — thirteen of them, one group visible at a time —
|
||||||
fields over thirteen groups, which pushed the audit trail — the
|
so "what do we know about this lead?" meant clicking thirteen
|
||||||
part people actually read — a screen and a half down. Most of it
|
times and holding the answer in your head. It is a lead FILE; a
|
||||||
is reference material. Nobody opens a lead to find its source
|
file you can only read one page of is a filing cabinet.
|
||||||
channel; they open it to see who the customer is, what is being
|
Now every group is a card, laid out in columns that reflow, so
|
||||||
insured, what has been attached, and what this stage turns on.
|
the whole 360 is one scroll and Ctrl-F finds anything. */}
|
||||||
|
{groups.length ? (
|
||||||
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">
|
||||||
<div className="panel__head">
|
<div className="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">At a glance</h2>
|
<h2 className="panel__title">Lead file</h2>
|
||||||
<p className="panel__sub">
|
<p className="panel__sub">
|
||||||
The customer, the risk, and what this stage turns on.
|
Everything captured, grouped by the step that captured it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="panel__meta">
|
||||||
|
{groups.reduce((n, [, shown]) => n + shown.length, 0)} fields
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glance">
|
<div className="file360">
|
||||||
{glance.map(([title, rows]) => (
|
{groups.map(([title, shown]) => {
|
||||||
<section className="fgroup" key={title}>
|
/* SHORT FACTS FIRST, PROSE AFTER.
|
||||||
<h3>{title}<span>{rows.length}</span></h3>
|
Seven of these fields are AI paragraphs — the attribution
|
||||||
<dl className="props">
|
reason, the rationale, the call transcript — and rendering
|
||||||
{rows.map(([k, v, files]) => (
|
them in the same flow as "Mobile" made a card that was
|
||||||
<div className="prop" key={k}>
|
mostly tinted boxes with three key/value pairs lost among
|
||||||
<dt>{labels[k] ?? label(k)}</dt>
|
them. The facts are what people scan; the reasoning is what
|
||||||
{files.length ? (
|
they occasionally open, and it is already in the trail
|
||||||
<dd className="prop__files">
|
alongside the step that wrote it. So the pairs group at the
|
||||||
{files.map((f) => (
|
top where they can be read as a column, and the prose sits
|
||||||
<a
|
under a rule as a line and a link. */
|
||||||
key={f.uuid}
|
const facts = shown.filter(([, v, files]) => files.length || v.length <= LONG)
|
||||||
className="prop__file"
|
const notes = shown.filter(([, v, files]) => !files.length && v.length > LONG)
|
||||||
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
return (
|
||||||
target="_blank" rel="noreferrer"
|
<section className="fgroup" key={title}>
|
||||||
>
|
<h3>{title}<span>{shown.length}</span></h3>
|
||||||
{f.original_name || 'Document'}
|
|
||||||
</a>
|
{facts.length ? (
|
||||||
))}
|
<dl className="props">
|
||||||
</dd>
|
{facts.map(([k, v, files]) => (
|
||||||
) : v.length > LONG ? (
|
<div className="prop" key={k}>
|
||||||
/* A stage reason can run to a paragraph —
|
<dt>{labels[k] ?? label(k)}</dt>
|
||||||
uw_referral_reason and lost_reason both do — and
|
{files.length ? (
|
||||||
a paragraph printed raw here would rebuild the
|
/* An attached document is a thing to open, not
|
||||||
wall this panel exists to remove. */
|
a filename to read. The preview route is
|
||||||
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
|
app-scoped and public, so a plain link needs
|
||||||
) : (
|
no token. */
|
||||||
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
<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>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</dl>
|
</section>
|
||||||
</section>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -799,18 +750,6 @@ export default function Lead() {
|
|||||||
{showCall ? (
|
{showCall ? (
|
||||||
<CallTranscript text={row.call_transcript} name={row.customer_name} onClose={() => setShowCall(false)} />
|
<CallTranscript text={row.call_transcript} name={row.customer_name} onClose={() => setShowCall(false)} />
|
||||||
) : null}
|
) : 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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1487,87 +1487,6 @@
|
|||||||
|
|
||||||
.file360 .clamp__more { margin-top: 3px; font-size: 0.7rem; }
|
.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 {
|
.panel__meta {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user