console: the lead file is a file again, and the journey is on the page

THE FILE WAS THIRTEEN TABS. One group of fields visible at a time, so
"what do we know about this lead?" meant thirteen clicks and a good
memory — on a page called the Lead FILE. A file you can read one page of
at a time is a filing cabinet.

Every group is now a card, laid out in columns that reflow to the width:
three on a wide screen, two normally, one on a laptop. The whole 360 is
one scroll and Ctrl-F finds anything. Groups avoid breaking across a
column boundary — a heading at the foot of one column with its fields at
the head of the next is worse than an uneven column — and inside a group
the label/value pairs stack, so a long value is not squeezed into half a
column against its label.

THE JOURNEY IS AT THE TOP. The page named the current stage in a corner
chip and said nothing about the path, so "how far along is this?" — the
first question anyone asks — needed the workflow memorised.

Passed steps are read from the AUDIT, not assumed from the order. A lead
can skip (documents uploaded from Qualified carried one straight past
Contacted) and can go backwards (a callback returns it to Awaiting
Contact); colouring everything left of the current stage would claim
steps that never happened. The side states — Parked, Referred, Lost,
Declined — are deliberately off the rail: they are departures from the
path, not points every lead passes, and a row implying otherwise would be
a lie about the process. When the lead is in one, the rail names it at
the end instead.

THE TRAIL STOPS READING AS A MARGIN NOTE. 384px beside a full-width file
made a column of two-line entries look like an aside; it is the half of
the page that says what happened. Widened to 440.

Layout only. The form, the actions, the audit fetch and every submission
path are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 18:37:32 +05:30
parent de8b158ca9
commit 3069bf4849
4 changed files with 300 additions and 107 deletions

View File

@ -0,0 +1,96 @@
/* The journey, across the top. Eight steps have to fit without wrapping on a
laptop, so the labels are short and the dots carry the state. */
.rail {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 16px 22px;
border-radius: var(--r-lg);
border: 1px solid var(--zk-line);
background: var(--zk-white);
}
.rail ol {
display: flex;
align-items: flex-start;
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 0;
}
.rail__s {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
flex: 1;
min-width: 0;
text-align: center;
}
.rail__dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex: none;
background: var(--zk-white);
border: 2px solid var(--zk-line);
z-index: 1;
}
.rail__l {
font-size: 0.72rem;
color: var(--zk-grey);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* The connector sits behind the dots, drawn from each step to the next. */
.rail__bar {
position: absolute;
top: 4.5px;
left: 50%;
width: 100%;
height: 2px;
background: var(--zk-line-soft);
}
.rail__bar.is-done { background: var(--zk-blue-light); }
.is-done .rail__dot { background: var(--zk-blue-light); border-color: var(--zk-blue-light); }
.is-done .rail__l { color: var(--zk-muted); }
.is-here .rail__dot {
background: var(--zk-blue);
border-color: var(--zk-blue);
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.16);
}
.is-here .rail__l { color: var(--zk-blue-dark); font-weight: 600; }
/* Off the path Parked, Referred, Lost, Declined. Named rather than drawn
into the row: these are departures, not points every lead passes. */
.rail__off {
flex: none;
font-size: 0.78rem;
color: var(--zk-amber-ink);
background: var(--zk-amber-tint);
border: 1px solid var(--zk-amber-line);
padding: 6px 14px;
border-radius: var(--r-pill);
}
.rail__off.is-closed {
color: var(--zk-muted);
background: var(--zk-tint);
border-color: var(--zk-line);
}
@media (max-width: 900px) {
.rail__l { display: none; }
.rail { padding: 14px 18px; }
}

View File

@ -0,0 +1,81 @@
import { STAGES } from '../api/config.js'
import './StageRail.css'
/**
* Where this lead has been, where it is, and what is left.
*
* The page said the stage in one chip in the corner and nothing about the
* journey, so "how far along is this?" the first question anyone asks about
* a lead could only be answered by knowing the workflow by heart.
*
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
* (documents uploaded from Qualified carried one straight past Contacted) and
* can go backwards (a callback returns it to Awaiting Contact). Colouring
* everything left of the current stage would have claimed steps that never
* happened. The trail knows which states this lead actually entered; this
* reads that.
*
* The side states Parked, Referred, Lost, Declined are deliberately NOT on
* the rail. They are departures from the path rather than points along it, and
* putting them in a row would suggest every lead passes through. When the lead
* is in one, the rail says so at the end instead.
*/
const PATH = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
]
/** Short enough to fit eight across; the queue names are written for a sidebar. */
const SHORT = {
'zk-state-new': 'Filed',
'zk-state-qualified': 'Called',
'zk-state-contacted': 'Contacted',
'zk-state-docs': 'Documents',
'zk-state-quoted': 'Quoted',
'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued',
'zk-state-onboarded': 'Onboarded',
}
export default function StageRail({ audit, currentName }) {
const current = STAGES.find((s) => s.name === currentName)
const visited = new Set(
(audit || [])
.map((r) => r.execution_state)
.filter((v) => PATH.includes(v)),
)
if (current) visited.add(current.uid)
const currentIndex = PATH.indexOf(current?.uid)
// A lead sitting somewhere off the path Parked, Referred, Lost, Declined.
const aside = current && currentIndex === -1 ? current : null
return (
<div className="rail" aria-label="Progress">
<ol>
{PATH.map((uid, i) => {
const been = visited.has(uid)
const here = uid === current?.uid
// Ahead of a lead that has left the path is unknowable a referred
// risk may never see payment so nothing after it is called "to do".
const state = here ? 'here' : been ? 'done' : 'todo'
return (
<li key={uid} className={'rail__s is-' + state}>
<span className="rail__dot" aria-hidden="true" />
<span className="rail__l">{SHORT[uid]}</span>
{i < PATH.length - 1 ? (
<span className={'rail__bar' + (visited.has(PATH[i + 1]) ? ' is-done' : '')} aria-hidden="true" />
) : null}
</li>
)
})}
</ol>
{aside ? (
<div className={'rail__off' + (aside.kind === 'end' ? ' is-closed' : '')}>
{aside.kind === 'end' ? 'Ended in' : 'Now in'} <strong>{aside.name}</strong>
</div>
) : null}
</div>
)
}

View File

@ -7,6 +7,7 @@ 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 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 { AGENTS } from '../api/agents.js'
@ -143,7 +144,6 @@ export default function Lead() {
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0)
const [labels, setLabels] = useState({})
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline
// fetched them once on mount and never again so a lead worked by five
@ -249,7 +249,6 @@ export default function Lead() {
keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null),
])
.filter(([, shown]) => shown.length)
const active = Math.min(tab, Math.max(groups.length - 1, 0))
const stateName = row.current_state_name || ''
// The state, then what is really happening inside it see phaseOf. Actions
@ -391,6 +390,8 @@ export default function Lead() {
)
})()}
<StageRail audit={audit} currentName={stateName} />
<div className="lead">
<div className="lead__main">
{note ? (
@ -608,62 +609,68 @@ export default function Lead() {
)
})()}
{/* The flow's own detail. Each group is one click away rather than one
long scroll, and prose is folded to a few lines the page can be
scanned, and still holds everything for whoever wants it. */}
{/* 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">Lead file</h2>
<p className="panel__sub">Everything captured, grouped by the step that captured it.</p>
<p className="panel__sub">
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="tabs" role="tablist">
{groups.map(([title, shown], i) => (
<button
key={title} type="button" role="tab" aria-selected={i === active}
className={'tab' + (i === active ? ' is-on' : '')}
onClick={() => setTab(i)}
>
{title}<span className="tab__n">{shown.length}</span>
</button>
<div className="file360">
{groups.map(([title, shown]) => (
<section className="fgroup" key={title}>
<h3>{title}<span>{shown.length}</span></h3>
<dl className="props">
{shown.map(([k, v, files]) => (
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. */
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<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>
</div>
) : v.length > LONG ? (
<div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
</div>
) : (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)
))}
</dl>
</section>
))}
</div>
<dl className="props">
{groups[active][1].map(([k, v, files]) => (
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 works with no token plumbing. */
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<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>
</div>
) : v.length > LONG ? (
<div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
</div>
) : (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)
))}
</dl>
</div>
) : null}
</div>

View File

@ -588,7 +588,10 @@
/* ---- lead: the file beside the story ---- */
.lead {
display: grid;
grid-template-columns: minmax(0, 1fr) 384px;
/* Wider than it was. At 384px the trail was a column of two-line entries
beside a full-width file, which read as a margin note rather than as the
other half of the page and it is the half that says what happened. */
grid-template-columns: minmax(0, 1fr) 440px;
gap: 20px;
align-items: start;
}
@ -616,7 +619,7 @@
}
.lead__feed {
max-height: calc(100vh - 210px);
max-height: calc(100vh - 240px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 22px 20px;
@ -642,62 +645,6 @@
what a tab is for; a pill segment reads as a filter on shared content. The
2px rule lives on the button, so a strip that wraps to two rows still marks
the active one correctly. */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 20px;
border-bottom: 1px solid var(--zk-line);
}
.tab {
font: inherit;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 13px;
background: none;
border: 0;
border-bottom: 2px solid transparent;
border-radius: var(--r-sm) var(--r-sm) 0 0;
color: var(--zk-muted);
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.tab:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
.tab.is-on {
color: var(--zk-blue-dark);
font-weight: 500;
border-bottom-color: var(--zk-blue);
}
.tab__n {
font-size: 0.66rem;
font-variant-numeric: tabular-nums;
min-width: 18px;
padding: 1px 6px;
border-radius: var(--r-pill);
background: var(--zk-line-soft);
color: var(--zk-muted);
text-align: center;
}
.tab.is-on .tab__n {
background: var(--zk-tint-blue);
color: var(--zk-blue);
}
/* ---- property rows ----
Facts read as label/value pairs on hairlines rather than as a wall of filled
boxes: the tint was carrying no information and made every field shout as
loudly as every other. */
.props {
margin: 0;
display: grid;
@ -1415,3 +1362,65 @@
.newlead svg { width: 14px; height: 14px; }
.newlead:hover { background: var(--grad-blue-hover); box-shadow: var(--sh-md); transform: translateY(-1px); }
.newlead:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
/* the lead file, all of it
This was thirteen tabs with one group visible at a time, so "what do we
know about this lead?" took thirteen clicks and a good memory. Columns
rather than a single list because most groups are three or four fields and
a stacked list of thirteen short groups is a very long page of white space.
`break-inside: avoid` keeps a group whole rather than split across a column
boundary a heading at the foot of one column with its fields at the head
of the next is worse than an uneven column. */
.file360 {
padding: 4px 22px 20px;
columns: 2;
column-gap: 26px;
}
@media (min-width: 1500px) { .file360 { columns: 3; } }
@media (max-width: 1000px) { .file360 { columns: 1; } }
.fgroup {
break-inside: avoid;
display: inline-block;
width: 100%;
margin-bottom: 20px;
}
.fgroup h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--zk-line-soft);
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-blue-dark);
}
.fgroup h3 span {
font-size: 0.64rem;
font-weight: 500;
letter-spacing: 0;
color: var(--zk-grey);
background: var(--zk-tint);
border-radius: var(--r-pill);
padding: 1px 7px;
}
/* Inside a group the pairs stack, so a long value is not squeezed into a
half-width column against a label. */
.file360 .props { display: block; padding: 0; }
.file360 .prop { padding: 5px 0; border: 0; }
.file360 .prop dt { font-size: 0.7rem; color: var(--zk-grey); margin-bottom: 1px; }
.file360 .prop dd { margin: 0; font-size: 0.84rem; color: var(--zk-ink); overflow-wrap: anywhere; }
.panel__meta {
flex: none;
font-size: 0.74rem;
color: var(--zk-grey);
font-variant-numeric: tabular-nums;
}