diff --git a/src/App.jsx b/src/App.jsx index f00e451..fcb00f6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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. */} } /> } /> - } /> } /> } /> diff --git a/src/api/config.js b/src/api/config.js index 49bd7b2..87403e2 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -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)) } /** diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index 77e497e..8e564b3 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -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 (
- {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 ( diff --git a/src/components/NewLeadDialog.css b/src/components/NewLeadDialog.css new file mode 100644 index 0000000..dda698f --- /dev/null +++ b/src/components/NewLeadDialog.css @@ -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; +} diff --git a/src/components/NewLeadDialog.jsx b/src/components/NewLeadDialog.jsx new file mode 100644 index 0000000..ead30a4 --- /dev/null +++ b/src/components/NewLeadDialog.jsx @@ -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 ( +
+
e.stopPropagation()} + > +
+
+

{entry ? entry.label : 'New lead'}

+

+ {entry + ? entry.note + : 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'} +

+
+ +
+ +
+ {!doors.length ? ( +

Your role does not create leads.

+ ) : !entry ? ( +
+ {doors.map((e) => ( + + ))} +
+ ) : ( + { + 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 } }) + }} + /> + )} +
+
+
+ ) +} diff --git a/src/screens/AddLead.jsx b/src/screens/AddLead.jsx deleted file mode 100644 index 578e6b2..0000000 --- a/src/screens/AddLead.jsx +++ /dev/null @@ -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 ( -
-
-

New lead

-
-

Your role does not create leads.

-
- ) - } - - return ( -
-
-
-

New lead

-

Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.

-
-
- - {!entry ? ( -
- {doors.map((e) => ( - - ))} -
- ) : ( -
-
-
-

{entry.label}

-

{entry.note}

-
- {ENTRY.length > 1 ? ( - - ) : null} -
- { - 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 } }) - }} - /> -
- )} -
- ) -} diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index a79b070..bc40645 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -631,45 +631,66 @@ export default function Lead() {
- {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.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 ( +
+

{title}{shown.length}

+ + {facts.length ? ( +
+ {facts.map(([k, v, files]) => ( +
+
{labels[k] ?? label(k)}
+ {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. */ +
+ {files.map((f) => ( + + {f.original_name || 'Document'} + + ))} +
+ ) : ( +
{v}
+ )} +
+ ))} +
+ ) : null} + + {notes.length ? ( +
+ {notes.map(([k, v]) => ( +
+ {labels[k] ?? label(k)} + +
+ ))} +
+ ) : null} +
+ ) + })}
) : null} diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index 856f409..1b710b1 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -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() { ) : null} {canFile ? ( - + ) : null} @@ -328,6 +332,7 @@ export default function Overview() { + {adding ? setAdding(false)} /> : null} ) } diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index 790b2f8..b5bf40f 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -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() { {(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'} {canFile ? ( - + ) : null} ) : null} @@ -284,6 +288,7 @@ export default function Pipeline() { ) : null} + {adding ? setAdding(false)} /> : null} ) } diff --git a/src/screens/screens.css b/src/screens/screens.css index 69ec90a..446f623 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -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;