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 ( +
  1. +
  2. + ) + })} +
+ + {aside ? ( +
+ {aside.kind === 'end' ? 'Ended in' : 'Now in'} {aside.name} +
+ ) : null} +
+ ) +} 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. +

+ + {groups.reduce((n, [, shown]) => n + shown.length, 0)} fields +
-
- {groups.map(([title, shown], i) => ( - + +
+ {groups.map(([title, shown]) => ( +
+

{title}{shown.length}

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