diff --git a/src/components/StageRail.css b/src/components/StageRail.css
new file mode 100644
index 0000000..454b190
--- /dev/null
+++ b/src/components/StageRail.css
@@ -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; }
+}
diff --git a/src/components/StageRail.jsx b/src/components/StageRail.jsx
new file mode 100644
index 0000000..d10733d
--- /dev/null
+++ b/src/components/StageRail.jsx
@@ -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 (
+
+
+ {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 (
+
+ )
+}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index 8c9e5ea..a79b070 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -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() {
)
})()}
+
+
{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 ? (
Lead file
-
Everything captured, grouped by the step that captured it.
+
+ Everything captured, grouped by the step that captured it.
+
+ {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. */
+
- {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. */
-
diff --git a/src/screens/screens.css b/src/screens/screens.css
index 6ada5c7..69ec90a 100644
--- a/src/screens/screens.css
+++ b/src/screens/screens.css
@@ -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;
+}