Compare commits
2 Commits
ea2b56e332
...
49d5443c60
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d5443c60 | |||
| db91daabf8 |
@ -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'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 “I have your message” receipt sent
|
||||
before anything has read it.
|
||||
text we compose.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
65
src/components/LeadFileDialog.css
Normal file
65
src/components/LeadFileDialog.css
Normal 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);
|
||||
}
|
||||
163
src/components/LeadFileDialog.jsx
Normal file
163
src/components/LeadFileDialog.jsx
Normal 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 “{q.trim()}”.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user