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:
parent
3069bf4849
commit
ea2b56e332
@ -6,7 +6,6 @@ import { PortfolioProvider } from './api/portfolio.jsx'
|
|||||||
import Overview from './screens/Overview.jsx'
|
import Overview from './screens/Overview.jsx'
|
||||||
import Pipeline from './screens/Pipeline.jsx'
|
import Pipeline from './screens/Pipeline.jsx'
|
||||||
import Lead from './screens/Lead.jsx'
|
import Lead from './screens/Lead.jsx'
|
||||||
import AddLead from './screens/AddLead.jsx'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthed } = useZino()
|
const { isAuthed } = useZino()
|
||||||
@ -28,7 +27,6 @@ export default function App() {
|
|||||||
one stage, usually empty, and somebody else's. */}
|
one stage, usually empty, and somebody else's. */}
|
||||||
<Route path="/" element={<Overview />} />
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
||||||
<Route path="/add" element={<AddLead />} />
|
|
||||||
<Route path="/lead/:instanceId" element={<Lead />} />
|
<Route path="/lead/:instanceId" element={<Lead />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@ -276,8 +276,40 @@ export const DOC_SLOTS = {
|
|||||||
export const LINE_FIELDS = {
|
export const LINE_FIELDS = {
|
||||||
gstin: 'sme',
|
gstin: 'sme',
|
||||||
udyam_no: 'sme',
|
udyam_no: 'sme',
|
||||||
// entity_name is NOT listed: the form labels it "Business Name", but motor
|
// entity_name is labelled "Business Name" on the form. A company-owned
|
||||||
// leads carry one too — a company-owned vehicle has an owner with a name.
|
// 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useZino } from '../api/provider.jsx'
|
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 { describeError, describeValidation } from '../api/errors.js'
|
||||||
import FileField from './FileField.jsx'
|
import FileField from './FileField.jsx'
|
||||||
import './ActivityForm.css'
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 ?? [])
|
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -256,7 +270,15 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
|||||||
return (
|
return (
|
||||||
<form className="af" onSubmit={submit}>
|
<form className="af" onSubmit={submit}>
|
||||||
<div className="af__grid">
|
<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 opts = f.properties?.options || []
|
||||||
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
||||||
return (
|
return (
|
||||||
|
|||||||
17
src/components/NewLeadDialog.css
Normal file
17
src/components/NewLeadDialog.css
Normal 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;
|
||||||
|
}
|
||||||
109
src/components/NewLeadDialog.jsx
Normal file
109
src/components/NewLeadDialog.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -631,45 +631,66 @@ export default function Lead() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="file360">
|
<div className="file360">
|
||||||
{groups.map(([title, shown]) => (
|
{groups.map(([title, shown]) => {
|
||||||
<section className="fgroup" key={title}>
|
/* SHORT FACTS FIRST, PROSE AFTER.
|
||||||
<h3>{title}<span>{shown.length}</span></h3>
|
Seven of these fields are AI paragraphs — the attribution
|
||||||
<dl className="props">
|
reason, the rationale, the call transcript — and rendering
|
||||||
{shown.map(([k, v, files]) => (
|
them in the same flow as "Mobile" made a card that was
|
||||||
files.length ? (
|
mostly tinted boxes with three key/value pairs lost among
|
||||||
/* An attached document is a thing to open, not a
|
them. The facts are what people scan; the reasoning is what
|
||||||
filename to read. The preview route is app-scoped
|
they occasionally open, and it is already in the trail
|
||||||
and public, so a plain link needs no token. */
|
alongside the step that wrote it. So the pairs group at the
|
||||||
<div className="prop" key={k}>
|
top where they can be read as a column, and the prose sits
|
||||||
<dt>{labels[k] ?? label(k)}</dt>
|
under a rule as a line and a link. */
|
||||||
<dd className="prop__files">
|
const facts = shown.filter(([, v, files]) => files.length || v.length <= LONG)
|
||||||
{files.map((f) => (
|
const notes = shown.filter(([, v, files]) => !files.length && v.length > LONG)
|
||||||
<a
|
return (
|
||||||
key={f.uuid}
|
<section className="fgroup" key={title}>
|
||||||
className="prop__file"
|
<h3>{title}<span>{shown.length}</span></h3>
|
||||||
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
|
||||||
target="_blank" rel="noreferrer"
|
{facts.length ? (
|
||||||
>
|
<dl className="props">
|
||||||
{f.original_name || 'Document'}
|
{facts.map(([k, v, files]) => (
|
||||||
</a>
|
<div className="prop" key={k}>
|
||||||
))}
|
<dt>{labels[k] ?? label(k)}</dt>
|
||||||
</dd>
|
{files.length ? (
|
||||||
</div>
|
/* An attached document is a thing to open, not
|
||||||
) : v.length > LONG ? (
|
a filename to read. The preview route is
|
||||||
<div className="prop prop--note" key={k}>
|
app-scoped and public, so a plain link needs
|
||||||
<dt>{labels[k] ?? label(k)}</dt>
|
no token. */
|
||||||
<dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
|
<dd className="prop__files">
|
||||||
</div>
|
{files.map((f) => (
|
||||||
) : (
|
<a
|
||||||
<div className="prop" key={k}>
|
key={f.uuid}
|
||||||
<dt>{labels[k] ?? label(k)}</dt>
|
className="prop__file"
|
||||||
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
||||||
</div>
|
target="_blank" rel="noreferrer"
|
||||||
)
|
>
|
||||||
))}
|
{f.original_name || 'Document'}
|
||||||
</dl>
|
</a>
|
||||||
</section>
|
))}
|
||||||
))}
|
</dd>
|
||||||
|
) : (
|
||||||
|
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { usePortfolio } from '../api/portfolio.jsx'
|
import { usePortfolio } from '../api/portfolio.jsx'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
||||||
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,6 +60,9 @@ export default function Overview() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const roles = rolesOf(user)
|
const roles = rolesOf(user)
|
||||||
const canFile = entryDoorsFor(roles, ENTRY).length > 0
|
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 state = usePortfolio()
|
||||||
|
|
||||||
const m = useMemo(() => {
|
const m = useMemo(() => {
|
||||||
@ -178,13 +182,13 @@ export default function Overview() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{canFile ? (
|
{canFile ? (
|
||||||
<Link to="/add" className="newlead">
|
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
||||||
strokeWidth="1.7" strokeLinecap="round" />
|
strokeWidth="1.7" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
New lead
|
New lead
|
||||||
</Link>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -328,6 +332,7 @@ export default function Overview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { useZino } from '../api/provider.jsx'
|
||||||
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||||
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.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
|
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
||||||
* for "when exactly", the relative for "is this going stale". */
|
* for "when exactly", the relative for "is this going stale". */
|
||||||
@ -73,6 +74,9 @@ export default function Pipeline() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { client, user } = useZino()
|
const { client, user } = useZino()
|
||||||
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
|
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
|
// "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.
|
// 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>
|
<span>{(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}</span>
|
||||||
</div>
|
</div>
|
||||||
{canFile ? (
|
{canFile ? (
|
||||||
<Link to="/add" className="newlead">
|
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
||||||
strokeWidth="1.7" strokeLinecap="round" />
|
strokeWidth="1.7" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
New lead
|
New lead
|
||||||
</Link>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -284,6 +288,7 @@ export default function Pipeline() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1365,36 +1365,40 @@
|
|||||||
|
|
||||||
/* ── the lead file, all of it ─────────────────────────────────────────────
|
/* ── the lead file, all of it ─────────────────────────────────────────────
|
||||||
This was thirteen tabs with one group visible at a time, so "what do we
|
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
|
know about this lead?" took thirteen clicks and a good memory. Then it was
|
||||||
rather than a single list because most groups are three or four fields and
|
thirteen groups in CSS columns, which was worse in a different way: column
|
||||||
a stacked list of thirteen short groups is a very long page of white space.
|
flow reads top-to-bottom-then-across, so a file whose groups run Source,
|
||||||
`break-inside: avoid` keeps a group whole rather than split across a column
|
Customer, Intake landed on the page in an order nobody could predict, and
|
||||||
boundary — a heading at the foot of one column with its fields at the head
|
the groups are wildly uneven — Motor risk has twelve fields, Proposal has
|
||||||
of the next is worse than an uneven column. */
|
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 {
|
.file360 {
|
||||||
padding: 4px 22px 20px;
|
padding: 4px 22px 22px;
|
||||||
columns: 2;
|
display: grid;
|
||||||
column-gap: 26px;
|
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 {
|
.fgroup {
|
||||||
break-inside: avoid;
|
padding: 12px 15px 14px;
|
||||||
display: inline-block;
|
border: 1px solid var(--zk-line-soft);
|
||||||
width: 100%;
|
border-radius: var(--r-md);
|
||||||
margin-bottom: 20px;
|
background: var(--zk-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fgroup h3 {
|
.fgroup h3 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 9px;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 7px;
|
||||||
border-bottom: 1px solid var(--zk-line-soft);
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
font-size: 0.66rem;
|
font-size: 0.64rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@ -1402,21 +1406,86 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fgroup h3 span {
|
.fgroup h3 span {
|
||||||
font-size: 0.64rem;
|
margin-left: auto;
|
||||||
|
font-size: 0.62rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: var(--zk-grey);
|
color: var(--zk-grey);
|
||||||
background: var(--zk-tint);
|
font-variant-numeric: tabular-nums;
|
||||||
border-radius: var(--r-pill);
|
|
||||||
padding: 1px 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inside a group the pairs stack, so a long value is not squeezed into a
|
/* Label and value on ONE line. Stacked pairs doubled the height of every fact
|
||||||
half-width column against a label. */
|
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 .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 {
|
||||||
.file360 .prop dd { margin: 0; font-size: 0.84rem; color: var(--zk-ink); overflow-wrap: anywhere; }
|
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 {
|
.panel__meta {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user