console: the file reads as facts, and filing a lead stays on the page

The lead file put seven AI paragraphs in the same flow as "Mobile", each with
the weight the audit trail gives it, so the card was mostly quotations with the
facts lost between them — and the same prose sat in the trail two feet to the
right. Facts group at the top now; the reasoning is a caption, two lines and a
link to the dialog that already held it. Pairs go side by side rather than
stacked, and the CSS columns become a grid: column flow read top-to-bottom-then-
across, so groups landed in an order nobody could predict and tore raggedly
between 2-field and 12-field groups.

New lead was a route. Pressing it cost you your place in the queue you were
working through and left you three navigations away. It is a dialog over the
page now, with the same modal furniture as the conversation and reasoning ones.

Three of its boxes were answered by the system before anyone saw them — the
channel, the partner code and the submitting agent, all stamped by prefill from
the signed-in identity. They are hidden at RENDER only: `fields` still holds
them, so they validate and submit exactly as before. Filtering them out of
`fields` would have dropped the attribution and 400'd on the mandatory channel.
A stamped field that comes back empty is drawn anyway, so a prefill that does
not resolve cannot fail validation against a box that is not on screen.

Business Name is scoped to SME. An INIT form has no lead to filter on, so
visibility reads the live answer to Product Line first and the saved lead
second — the form narrows as it is filled, and a motor renewal drops from
twelve boxes to nine. It is optional, so hiding it cannot block a submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 18:53:16 +05:30
parent 3069bf4849
commit ea2b56e332
10 changed files with 359 additions and 159 deletions

View File

@ -6,7 +6,6 @@ import { PortfolioProvider } from './api/portfolio.jsx'
import Overview from './screens/Overview.jsx'
import Pipeline from './screens/Pipeline.jsx'
import Lead from './screens/Lead.jsx'
import AddLead from './screens/AddLead.jsx'
export default function App() {
const { isAuthed } = useZino()
@ -28,7 +27,6 @@ export default function App() {
one stage, usually empty, and somebody else's. */}
<Route path="/" element={<Overview />} />
<Route path="/stage/:stageUid" element={<Pipeline />} />
<Route path="/add" element={<AddLead />} />
<Route path="/lead/:instanceId" element={<Lead />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View File

@ -276,8 +276,40 @@ export const DOC_SLOTS = {
export const LINE_FIELDS = {
gstin: 'sme',
udyam_no: 'sme',
// entity_name is NOT listed: the form labels it "Business Name", but motor
// leads carry one too — a company-owned vehicle has an owner with a name.
// entity_name is labelled "Business Name" on the form. A company-owned
// vehicle does have an owner with a name, so this is not strictly an SME
// field — but asking a motor renewal for a business name puts an empty box
// on the shortest form in the app, and the answer is already carried by
// customer_name in every motor lead we have. It is optional, so hiding it
// cannot block a submission, and an SME lead still gets it.
entity_name: 'sme',
}
/**
* Fields the form STAMPS rather than asks.
*
* Each is resolved by the prefill pipeline from the signed-in identity
* Arjun IS Crestline Insurance Advisors, submitting through the agency door
* so all three arrived filled and read-only-in-spirit, and the New lead form
* opened with three of its eleven boxes already answered by the system. That
* is three boxes of noise in front of the four that actually need a person.
*
* HIDDEN FROM THE FORM, STILL SENT. These are not decorative: source_channel
* is mandatory, and partner_code and rm_or_agent_id are what Intake attributes
* the lead on. So they are filtered at RENDER only `fields`, which drives
* both validation and the payload, still holds them, and their prefilled
* values submit exactly as before. Filtering them out of `fields` instead
* would drop the attribution and 400 on the mandatory channel.
*/
export const STAMPED_FIELDS = new Set([
'source_channel',
'partner_code',
'rm_or_agent_id',
])
/** Whether a field is stamped by prefill and so should not be drawn. */
export function fieldIsStamped(fieldId) {
return STAMPED_FIELDS.has(baseFieldId(fieldId))
}
/**

View File

@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { baseFieldId, fieldApplies } from '../api/config.js'
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx'
import './ActivityForm.css'
@ -135,13 +135,27 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
// fields and is runnable from Contacted whatever the product is, so on an SME
// lead this would otherwise render a form with nothing in it and a live Submit
// button. Show the activity whole and let the operator see what it is asking.
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, lead)) : []
//
// THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no
// lead yet, so `fieldApplies` had nothing to filter on and every entry form
// asked a motor renewal for a business name. But the product line IS on that
// form, three boxes up the person has already said "Motor" by the time the
// question is drawn. So visibility reads the live answer first and the saved
// lead second, and the form narrows as it is filled.
const effLead = (() => {
const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line')
const chosen = live ? values[live.id] : undefined
if (!chosen) return lead
return { ...(lead || {}), product_line: chosen }
})()
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : []
// Filtering must never hide a field the workflow requires: `submit` sends only
// what is rendered, so a hidden mandatory field becomes a 400 naming something
// that is not on screen and cannot be filled. Hiding everything is the same
// failure in the large it removes the activity rather than its noise, and
// Capture Motor Risk is twelve motor fields that stay runnable on an SME lead.
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, lead)) : false
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
/**
@ -256,7 +270,15 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
return (
<form className="af" onSubmit={submit}>
<div className="af__grid">
{fields.map((f) => {
{/* Stamped fields are in `fields` they validate and they submit
but they are not drawn. See STAMPED_FIELDS.
A stamped field that came back EMPTY is drawn anyway. source_channel
is mandatory, so a prefill that did not resolve would otherwise fail
validation against a box that is not on the screen and cannot be
filled the exact failure the field filter elsewhere in this file
guards against. Hidden when it is answered; asked when it is not. */}
{fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])).map((f) => {
const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (

View File

@ -0,0 +1,17 @@
/* Wider than the prose dialogs this holds a four-column form grid, not a
paragraph and allowed to grow taller, because a form that scrolls its own
Submit off the bottom is worse than a tall dialog. */
.nld { width: min(880px, 100%); max-height: min(88vh, 860px); }
.nld__sub {
margin: 4px 0 0;
font-size: 0.78rem;
line-height: 1.5;
color: var(--zk-muted);
max-width: 62ch;
}
.nld__body {
overflow-y: auto;
padding: 18px 24px 22px;
}

View File

@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ActivityForm from './ActivityForm.jsx'
import { ENTRY } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { useZino } from '../api/provider.jsx'
import './NewLeadDialog.css'
/**
* Filing a lead, over the page rather than on one of its own.
*
* This was a route. Pressing New lead left whatever you were reading a queue
* you were working through, a lead you were half way down filed the form,
* and landed you on the new lead's file, three navigations from where you
* started. Filing a lead is not a destination; it is four fields and a Submit,
* and it belongs over the work rather than instead of it. Cancel now puts you
* back exactly where you were, because you never left.
*
* The door chooser stays: the entry activities have different permissions and
* the bank one lands the instance further along. With one door which is
* today it is skipped rather than shown as a list of one.
*
* Modal furniture is the same as the conversation and reasoning dialogs:
* scrim click, Escape, focus trapped in and restored out, page scroll frozen.
* A fourth hand-rolled variant of that would be a fourth chance to get the
* keyboard wrong.
*/
export default function NewLeadDialog({ onClose }) {
const { user } = useZino()
const doors = entryDoorsFor(rolesOf(user), ENTRY)
const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
const navigate = useNavigate()
const ref = useRef(null)
const restore = useRef(null)
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])
return (
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg nld"
role="dialog"
aria-modal="true"
aria-label="New lead"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<div>
<h3>{entry ? entry.label : 'New lead'}</h3>
<p className="nld__sub">
{entry
? entry.note
: 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'}
</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="nld__body">
{!doors.length ? (
<p className="empty">Your role does not create leads.</p>
) : !entry ? (
<div className="doors">
{doors.map((e) => (
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
<strong>{e.label}</strong>
<span className="chip">{e.channel}</span>
<p>{e.note}</p>
</button>
))}
</div>
) : (
<ActivityForm
activityUid={entry.uid}
onCancel={onClose}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
// Carry the workflow's own message across the navigation
// "Lead received Intake is qualifying it" is the answer to
// "did that work?", and it is only said once.
onClose()
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
}}
/>
)}
</div>
</div>
</div>
)
}

View File

@ -1,78 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ActivityForm from '../components/ActivityForm.jsx'
import { ENTRY } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { useZino } from '../api/provider.jsx'
import './screens.css'
/**
* The three doors. Each is a separate INIT activity with its own permissions,
* and the bank one lands the instance further along because the bank already
* did CKYC. Whichever door is used, everything after it is identical.
*/
export default function AddLead() {
const { user } = useZino()
// Only the doors this role may actually submit through. An agency door shown
// to a bank RM is a form they can fill in and then be refused for.
const doors = entryDoorsFor(rolesOf(user), ENTRY)
// One door surfaced: skip the chooser entirely rather than show a list of one.
const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
const navigate = useNavigate()
if (!doors.length) {
return (
<section>
<header className="page__head">
<div><h1 className="page__title">New lead</h1></div>
</header>
<p className="empty">Your role does not create leads.</p>
</section>
)
}
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">New lead</h1>
<p className="page__sub">Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.</p>
</div>
</header>
{!entry ? (
<div className="doors">
{doors.map((e) => (
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
<strong>{e.label}</strong>
<span className="chip">{e.channel}</span>
<p>{e.note}</p>
</button>
))}
</div>
) : (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">{entry.label}</h2>
<p className="panel__sub">{entry.note}</p>
</div>
{ENTRY.length > 1 ? (
<button type="button" className="af__ghost" onClick={() => setEntry(null)}>Change door</button>
) : null}
</div>
<ActivityForm
activityUid={entry.uid}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
// Carry the workflow's own message across the navigation "Lead
// received Intake is qualifying it" is the answer to "did that
// work?", and it is only said once.
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
}}
/>
</div>
)}
</section>
)
}

View File

@ -631,17 +631,33 @@ export default function Lead() {
</div>
<div className="file360">
{groups.map(([title, shown]) => (
{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">
{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. */
{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
@ -654,23 +670,28 @@ export default function Lead() {
</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>
) : 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>
</div>
) : null}
</div>

View File

@ -1,10 +1,11 @@
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
import './screens.css'
/**
@ -59,6 +60,9 @@ export default function Overview() {
const navigate = useNavigate()
const roles = rolesOf(user)
const canFile = entryDoorsFor(roles, ENTRY).length > 0
// Filing a lead happens over this page, not instead of it see
// NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false)
const state = usePortfolio()
const m = useMemo(() => {
@ -178,13 +182,13 @@ export default function Overview() {
</span>
) : null}
{canFile ? (
<Link to="/add" className="newlead">
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</Link>
</button>
) : null}
</div>
</header>
@ -328,6 +332,7 @@ export default function Overview() {
</div>
</div>
</div>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section>
)
}

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
/** Short absolute date plus how long ago a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */
@ -73,6 +74,9 @@ export default function Pipeline() {
const navigate = useNavigate()
const { client, user } = useZino()
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
// Over the queue rather than away from it: filing a lead used to cost you
// your place in the list you were working through.
const [adding, setAdding] = useState(false)
// "all" is not a stage it is every lead in one list, so an operator
// wanting to find one does not have to guess which queue it is in.
//
@ -176,13 +180,13 @@ export default function Pipeline() {
<span>{(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}</span>
</div>
{canFile ? (
<Link to="/add" className="newlead">
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</Link>
</button>
) : null}
</div>
) : null}
@ -284,6 +288,7 @@ export default function Pipeline() {
</table>
</div>
) : null}
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section>
)
}

View File

@ -1365,36 +1365,40 @@
/* 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. */
know about this lead?" took thirteen clicks and a good memory. Then it was
thirteen groups in CSS columns, which was worse in a different way: column
flow reads top-to-bottom-then-across, so a file whose groups run Source,
Customer, Intake landed on the page in an order nobody could predict, and
the groups are wildly uneven Motor risk has twelve fields, Proposal has
two so the columns tore raggedly.
A GRID OF CARDS instead. Reading order is left-to-right like every other
list on this screen, each group is bounded so its fields cannot be mistaken
for the next group's, and an uneven card leaves a clean gap under itself
rather than dragging the next heading up into the previous group's fields. */
.file360 {
padding: 4px 22px 20px;
columns: 2;
column-gap: 26px;
padding: 4px 22px 22px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
align-items: start;
}
@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;
padding: 12px 15px 14px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-md);
background: var(--zk-white);
}
.fgroup h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px;
padding-bottom: 6px;
margin: 0 0 9px;
padding-bottom: 7px;
border-bottom: 1px solid var(--zk-line-soft);
font-size: 0.66rem;
font-size: 0.64rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
@ -1402,21 +1406,86 @@
}
.fgroup h3 span {
font-size: 0.64rem;
margin-left: auto;
font-size: 0.62rem;
font-weight: 500;
letter-spacing: 0;
color: var(--zk-grey);
background: var(--zk-tint);
border-radius: var(--r-pill);
padding: 1px 7px;
font-variant-numeric: tabular-nums;
}
/* Inside a group the pairs stack, so a long value is not squeezed into a
half-width column against a label. */
/* Label and value on ONE line. Stacked pairs doubled the height of every fact
in the file for no gain these are two-word labels against short values,
and a label gutter is what makes a column of them scannable. */
.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; }
.file360 .prop {
display: grid;
grid-template-columns: minmax(0, 40%) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
margin: 0;
padding: 3px 0;
border: 0;
border-radius: 0;
}
.file360 .prop:hover { background: transparent; }
.file360 .prop dt {
font-size: 0.71rem;
line-height: 1.5;
color: var(--zk-grey);
margin: 0;
}
.file360 .prop dd {
font-size: 0.8rem;
line-height: 1.5;
color: var(--zk-ink);
overflow-wrap: anywhere;
}
/* the prose, folded
The AI paragraphs used to render inline with the same weight the audit
trail gives them a tinted block, a bolded finding, a "Read the reasoning"
button and with seven of them the file read as a wall of quotations with
the facts hidden between. Here they are a caption and two lines of grey.
Nothing is lost: the full text is one click away in the same dialog, and
the trail still shows each paragraph against the step that wrote it. */
.fnotes {
margin-top: 11px;
padding-top: 10px;
border-top: 1px dashed var(--zk-line);
display: grid;
gap: 10px;
}
.fnote__l {
display: block;
margin-bottom: 2px;
font-size: 0.66rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* The finding is emphasised in the trail, where it is the entry. Here it is
one fact among fifty, so it drops to body weight and two lines. */
.file360 .clamp__lead,
.file360 .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;
}
.file360 .clamp__more { margin-top: 3px; font-size: 0.7rem; }
.panel__meta {
flex: none;