console: a page to monitor from, and words an operator would use

Three problems, and the first one is the reason the other two were hard
to see.

THERE WAS NO DASHBOARD. "/" redirected to the underwriting queue — one
stage, usually empty, and belonging to somebody else. Every screen in
the app answered "show me this queue"; none answered "how is the book
doing, and is anything waiting on me?", which is the only question a
person has before they have picked a queue.

So: a Today page. Three blocks, in the order somebody cares about them.
What is waiting on a person, as three cards that colour only when they
have something in them. Renewals running out, worst first, capped at 30
days because a renewal six months away is not a thing to look at today.
Then every stage with its count, zeroes included — a stage quietly
receiving nothing is only visible if its zero is on screen.

It is one list call, counted in the browser. That is honest at this size
and it is the same call the queues already make. If the book outgrows it
the answer is a counts endpoint, not a bigger page.

THE WORDS WERE WRITTEN FOR WHOEVER BUILT THE WORKFLOW. "What happens
next" was followed by "these are the only activities this state allows
— but the workflow decides, server-side, whether you may". A failed list
said "this queue has no record view yet". Panels were called "Flow
details". A closed lead read "nothing runs from here". None of that is
wrong; all of it is addressed to the wrong reader.

Now: "What you can do", "Lead details", "History", "This queue could not
be loaded". Queue names say what they want — "Documents needed",
"Underwriter to review", "Payment to confirm". The sidebar group called
"Not yet" says "Not due yet", and "Running by itself" says "The agents
have it", which is the actual claim being made.

THE QUEUE HAD NINE COLUMNS AND LED WITH THE WRONG ONE. Two were internal
vocabulary: attribution status renders "clear" or "contested" and means
nothing to an operator, and the channel is background rather than
something anyone scans a queue for. Both fold into a subtitle under the
customer. That leaves six columns and puts the renewal countdown — the
number that decides whether to act today — second instead of fifth.

Also: the timeline printed a raw slug when a step came back without a
name. It is the screen this product is demonstrated on, so a
"zk-act-doc-reminder" in the middle of an otherwise readable story is
expensive. It now reads as English.
This commit is contained in:
Yashas 2026-09-07 16:13:13 +05:30
parent eea044b9f9
commit 4c210c7e09
9 changed files with 372 additions and 53 deletions

View File

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { useZino } from './api/provider.jsx' import { useZino } from './api/provider.jsx'
import Login from './pages/Login.jsx' import Login from './pages/Login.jsx'
import Shell from './layout/Shell.jsx' import Shell from './layout/Shell.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' import AddLead from './screens/AddLead.jsx'
@ -22,7 +23,9 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<Navigate to="/" replace />} /> <Route path="/login" element={<Navigate to="/" replace />} />
<Route element={<Shell />}> <Route element={<Shell />}>
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} /> {/* The morning page. This used to redirect to the underwriting queue
one stage, usually empty, and somebody else's. */}
<Route path="/" element={<Overview />} />
<Route path="/stage/:stageUid" element={<Pipeline />} /> <Route path="/stage/:stageUid" element={<Pipeline />} />
<Route path="/add" element={<AddLead />} /> <Route path="/add" element={<AddLead />} />
<Route path="/lead/:instanceId" element={<Lead />} /> <Route path="/lead/:instanceId" element={<Lead />} />

View File

@ -35,22 +35,22 @@ export const WORKFLOW = 'zk_wf_lead'
* is the failure mode this table has, and it is silent. * is the failure mode this table has, and it is silent.
*/ */
export const STAGES = [ export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' }, { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' }, { uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Writing the document request…', by: 'Engage AI' }, { uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' },
// The principal stall in the workflow, and the one that had no stage until // The principal stall in the workflow, and the one that had no stage until
// 7 September: a lead waited for its three documents inside "Contacted", so // 7 September: a lead waited for its three documents inside "Contacted", so
// the queue that most needed watching was the one that did not exist. // the queue that most needed watching was the one that did not exist.
{ uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Upload documents', by: 'the partner agent' }, { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Documents needed', by: 'the partner agent' },
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Accept the proposal', by: 'the customer' }, { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Waiting on the customer', by: 'the customer' },
// The one queue where the machine stops and a person decides. // The one queue where the machine stops and a person decides.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Clear or decline', by: 'an underwriter' }, { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriter to review', by: 'an underwriter' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' }, { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Payment to confirm', by: 'ops' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' }, { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' },
// Nurture. There is no scheduler yet, so Resume Outreach is a button and it // Nurture. There is no scheduler yet, so Resume Outreach is a button and it
// is the only thing that wakes a parked lead — do not present this as a // is the only thing that wakes a parked lead — do not present this as a
// stage with nothing to do. // stage with nothing to do.
{ uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Resume when the window opens', by: 'you, for now' }, { uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Too early to call', by: 'nobody yet' },
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, { uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
@ -62,11 +62,11 @@ export const STAGES = [
* waiting on me?". * waiting on me?".
*/ */
export const NAV_GROUPS = [ export const NAV_GROUPS = [
{ label: 'Needs someone', kind: 'needs' }, { label: 'Waiting on a person', kind: 'needs' },
{ label: 'With the customer', kind: 'customer' }, { label: 'With the customer', kind: 'customer' },
{ label: 'Running by itself', kind: 'auto' }, { label: 'The agents have it', kind: 'auto' },
{ label: 'Not yet', kind: 'waiting' }, { label: 'Not due yet', kind: 'waiting' },
{ label: 'Closed', kind: 'end' }, { label: 'Closed', kind: 'end' },
] ]
/** Channel presentation. source_channel is data on the instance, never a branch. */ /** Channel presentation. source_channel is data on the instance, never a branch. */

View File

@ -44,6 +44,22 @@ function when(ts) {
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}` return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
} }
/**
* Last resort for a step whose activity_name did not come back. Without this
* the timeline the screen the whole product is demonstrated on prints a
* raw slug like "zk-act-doc-reminder" in the middle of an otherwise readable
* story. Turning it into "Doc Reminder" is a guess, but it is a guess that
* reads as English.
*/
function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
export default function Timeline({ instanceId }) { export default function Timeline({ instanceId }) {
const { client } = useZino() const { client } = useZino()
const [rows, setRows] = useState(null) const [rows, setRows] = useState(null)
@ -78,7 +94,7 @@ export default function Timeline({ instanceId }) {
key: r.id ?? i, key: r.id ?? i,
kind, kind,
actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'), actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : (r.activity_name || r.activity_id), what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
stage: STAGES.find((s) => s.uid === r.execution_state), stage: STAGES.find((s) => s.uid === r.execution_state),
when: when(r.created_at), when: when(r.created_at),
narrative: NARRATIVE narrative: NARRATIVE

View File

@ -273,3 +273,19 @@
padding: 22px 18px 56px; padding: 22px 18px 56px;
} }
} }
/* The way back to the overview. It sits above "Add a lead" because reading
the book is the commoner act most sessions start by looking, not filing. */
.shell__today {
display: block;
margin: 0 0 8px;
padding: 9px 12px;
border-radius: var(--r-sm);
font-size: 14px;
font-weight: 500;
color: var(--zk-ink);
text-decoration: none;
transition: background .12s, color .12s;
}
.shell__today:hover { background: var(--zk-tint); }
.shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }

View File

@ -32,6 +32,14 @@ export default function Shell() {
<div className="shell__body"> <div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline"> <nav className="shell__nav" aria-label="Pipeline">
<NavLink
to="/"
end
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
Today
</NavLink>
<NavLink to="/add" className="shell__add"> <NavLink to="/add" className="shell__add">
<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" strokeWidth="1.7" <path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.7"

View File

@ -37,21 +37,21 @@ const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium',
/** Field groups, in the order the lead was actually worked. */ /** Field groups, in the order the lead was actually worked. */
const GROUPS = [ const GROUPS = [
['Who sourced it', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']], ['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']], ['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']], ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes']], ['Contact', ['contact_outcome','outreach_window','contact_notes']],
['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','documents_notes']], ['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','documents_notes']],
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']], ['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']],
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']], ['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']],
['AI recommendation (non-binding)', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']], ['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']], ['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
['Proposal', ['acceptance_ref','accepted_at']], ['Proposal', ['acceptance_ref','accepted_at']],
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']], ['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']], ['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']], ['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']], ['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
['How it ended', ['lost_reason','welcome_sent','renewal_due']], ['Outcome', ['lost_reason','welcome_sent','renewal_due']],
] ]
/** Past this, a value is prose and gets folded rather than printed in full. */ /** Past this, a value is prose and gets folded rather than printed in full. */
@ -222,7 +222,7 @@ export default function Lead() {
<strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong> <strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
{/* An end state runs no activity. That is worth one line next to the {/* An end state runs no activity. That is worth one line next to the
stage, not a panel of its own where the actions would be. */} stage, not a panel of its own where the actions would be. */}
{!actions.length ? <span className="lead__closed">Closed nothing runs from here</span> : null} {!actions.length ? <span className="lead__closed">Closed nothing more happens</span> : null}
</div> </div>
</header> </header>
@ -243,7 +243,7 @@ export default function Lead() {
{row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null} {row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null}
{row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null} {row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null}
{row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null} {row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null}
{row.updated_at ? <div><dt>Last activity</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null} {row.updated_at ? <div><dt>Last update</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null}
</dl> </dl>
<div className="lead"> <div className="lead">
@ -264,7 +264,7 @@ export default function Lead() {
<span className="doing__pulse" aria-hidden="true" /> <span className="doing__pulse" aria-hidden="true" />
<div> <div>
<strong>{stage.doing}</strong> <strong>{stage.doing}</strong>
<span>{stage.by} is carrying this lead. It usually takes a minute or two, and this page is watching for it.</span> <span>{stage.by} has this one. It usually takes a minute or two this page updates on its own.</span>
</div> </div>
</div> </div>
) : null} ) : null}
@ -273,10 +273,11 @@ export default function Lead() {
<div className="panel"> <div className="panel">
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">What happens next</h2> <h2 className="panel__title">What you can do</h2>
<p className="panel__sub"> <p className="panel__sub">
These are the only activities this state allows. Who normally performs Everything this lead allows right now. Some of these belong to
each is shown but the workflow decides, server-side, whether you may. an agent or an underwriter if it is not yours, it will say so
when you try.
</p> </p>
</div> </div>
</div> </div>
@ -299,7 +300,7 @@ export default function Lead() {
onCancel={() => setOpen(null)} onCancel={() => setOpen(null)}
onStale={() => { onStale={() => {
setOpen(null) setOpen(null)
setNote('That activity is not available at this stage any more — refreshed to show what is.') setNote('This lead has moved on — showing what you can do now.')
load() load()
}} }}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
@ -316,8 +317,8 @@ export default function Lead() {
<div className="panel"> <div className="panel">
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">Flow details</h2> <h2 className="panel__title">Lead details</h2>
<p className="panel__sub">Everything captured on this lead, grouped in the order it was worked.</p> <p className="panel__sub">Everything captured so far, in the order it was collected.</p>
</div> </div>
</div> </div>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
@ -356,8 +357,8 @@ export default function Lead() {
<div className="panel panel--rail"> <div className="panel panel--rail">
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">What has happened</h2> <h2 className="panel__title">History</h2>
<p className="panel__sub">Every step, in order, and who took it.</p> <p className="panel__sub">Every step so far, and who took it.</p>
</div> </div>
</div> </div>
<div className="lead__feed"> <div className="lead__feed">

191
src/screens/Overview.jsx Normal file
View File

@ -0,0 +1,191 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { RV_LEADS, STAGES } from '../api/config.js'
import { describeError } from '../api/errors.js'
import './screens.css'
/**
* The page somebody opens in the morning.
*
* Every other screen answers "show me this queue". None of them answered "how
* is the book doing, and is anything waiting on me?" which is the only
* question a person has before they have picked a queue. The app used to open
* on the underwriting queue: one stage, usually empty, and somebody else's.
*
* One list call, counted here. That is honest at this size and it is the same
* call the queues make; a demo book is a few dozen leads, not a few thousand.
* If it ever outgrows that, the fix is a counts endpoint, not a bigger page.
*/
const DAY = 86400000
function daysToExpiry(dateStr) {
if (!dateStr) return null
const d = Date.parse(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
return Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
}
function expiryLabel(days) {
if (days === null) return { text: '—', tone: 'later' }
if (days < 0) return { text: Math.abs(days) + ' days overdue', tone: 'lapsed' }
if (days === 0) return { text: 'expires today', tone: 'urgent' }
if (days === 1) return { text: 'expires tomorrow', tone: 'urgent' }
return { text: 'in ' + days + ' days', tone: days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later' }
}
export default function Overview() {
const { client } = useZino()
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
useEffect(() => {
let cancelled = false
function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], error: null })
return client.recordView(RV_LEADS, { limit: 200 })
.then((res) => {
if (cancelled) return
setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? [], error: null })
})
.catch((err) => {
if (cancelled) return
// A failed refresh must never blank a page that is already readable.
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], error: err }))
})
}
fetchRows(false)
const id = setInterval(() => { if (document.visibilityState === 'visible') fetchRows(true) }, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client])
const view = useMemo(() => {
const byStage = {}
for (const r of state.rows) {
const k = r.current_state_name || '—'
byStage[k] = (byStage[k] || 0) + 1
}
const closedNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name))
const open = state.rows.filter((r) => !closedNames.has(r.current_state_name))
// Worst first, and only what is actually near. A renewal six months out is
// not a thing to look at today, so it does not compete for the space.
const running = open
.map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date) }))
.filter((r) => r._d !== null && r._d <= 30)
.sort((a, b) => a._d - b._d)
return {
byStage,
openCount: open.length,
running,
needs: STAGES.filter((s) => s.kind === 'needs'),
won: state.rows.filter((r) => r.current_state_name === 'Onboarded').length,
}
}, [state.rows])
if (state.status === 'loading') {
return (
<section>
<header className="page__head"><div><h1 className="page__title">Today</h1></div></header>
<div className="skel" aria-busy="true" aria-label="Loading"><div className="skel__row" /><div className="skel__row" /></div>
</section>
)
}
if (state.status === 'error') {
const said = describeError(state.error)
return (
<section>
<header className="page__head"><div><h1 className="page__title">Today</h1></div></header>
<div className="notice">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
</div>
</section>
)
}
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Today</h1>
<p className="page__sub">
{view.openCount} {view.openCount === 1 ? 'lead is' : 'leads are'} open ·{' '}
{view.won} onboarded so far
</p>
</div>
</header>
{/* The only section that is about the reader. Everything a person can
actually do sits in one of these three queues. */}
<h2 className="sec">Waiting on a person</h2>
<div className="cards">
{view.needs.map((s) => {
const n = view.byStage[s.name] || 0
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'card' + (n ? ' is-live' : '')}>
<span className="card__n">{n}</span>
<strong className="card__t">{s.need ?? s.name}</strong>
<span className="card__w">{n === 0 ? 'Nothing waiting' : `with ${s.by}`}</span>
</Link>
)
})}
</div>
<h2 className="sec">
Renewals running out
<span className="sec__hint">Open leads with 30 days or less on the clock</span>
</h2>
{view.running.length === 0 ? (
<p className="empty">Nothing expiring in the next 30 days.</p>
) : (
<div className="gridwrap">
<table className="grid">
<thead>
<tr><th>Customer</th><th>Renewal</th><th>Where it is</th><th aria-label="Open" /></tr>
</thead>
<tbody>
{view.running.slice(0, 8).map((r) => {
const id = r.instance_id ?? r.id
const e = expiryLabel(r._d)
return (
<tr key={id}>
<td>
{r.customer_name || r.lead_ref || `#${id}`}
<div className="grid__sub">{r.lead_ref || ''}</div>
</td>
<td><span className={'due due--' + e.tone}>{e.text}</span></td>
<td><span className="grid__sub">{r.current_state_name || '—'}</span></td>
<td className="grid__act"><Link className="grid__btn" to={`/lead/${id}`}>Open</Link></td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Every stage, including the empty ones. A stage reading zero is
information it is how you notice the agents have stopped. */}
<h2 className="sec">
The whole book
<span className="sec__hint">Every lead, by where it has got to</span>
</h2>
<div className="tally">
{STAGES.map((s) => {
const n = view.byStage[s.name] || 0
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'tally__i' + (n ? '' : ' is-zero')}>
<span className="tally__n">{n}</span>
<span className="tally__l">{s.name}</span>
</Link>
)
})}
</div>
</section>
)
}

View File

@ -93,10 +93,12 @@ export default function Pipeline() {
<h1 className="page__title">{stage.need ?? stage.name}</h1> <h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub"> <p className="page__sub">
{stage.kind === 'auto' {stage.kind === 'auto'
? `${stage.doing} ${stage.by} is carrying these; nothing is waiting on a person.` ? `${stage.doing}. Nothing here is waiting on you.`
: stage.kind === 'end' : stage.kind === 'end'
? 'Closed. Nothing runs from here.' ? 'These are finished. Nothing more happens to them.'
: `Waiting on ${stage.by}.`} : stage.kind === 'waiting'
? 'Good leads, too early to work. They come back on their own.'
: `Waiting on ${stage.by}.`}
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null} {stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
</p> </p>
</div> </div>
@ -116,18 +118,18 @@ export default function Pipeline() {
{state.status === 'error' ? ( {state.status === 'error' ? (
<div className="notice"> <div className="notice">
<strong>This queue has no record view yet.</strong> <strong>This queue could not be loaded.</strong>
<p> <p>
The console reads every queue from the <code>{RV_LEADS}</code> record view. The list of leads did not come back. Everything else sign-in and
It is not seeded yet, so there is nothing to list. Everything else the rest of the app is working, so this is worth a retry before
sign-in, routing and the pipeline itself is live. anything else.
</p> </p>
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p> <p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div> </div>
) : null} ) : null}
{state.status === 'ready' && state.rows.length === 0 ? ( {state.status === 'ready' && state.rows.length === 0 ? (
<p className="empty">No leads at this stage.</p> <p className="empty">Nothing here right now.</p>
) : null} ) : null}
{state.status === 'ready' && state.rows.length > 0 ? ( {state.status === 'ready' && state.rows.length > 0 ? (
@ -135,9 +137,8 @@ export default function Pipeline() {
<table className="grid"> <table className="grid">
<thead> <thead>
<tr> <tr>
<th>Reference</th><th>Customer</th><th>Channel</th> <th>Customer</th><th>Renewal due</th><th>Product</th>
<th>Product</th><th>Renewal due</th><th className="num">Premium</th> <th className="num">Premium</th><th>Waiting</th><th aria-label="Open" />
<th>Attribution</th><th>Added</th><th aria-label="Actions" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -148,23 +149,23 @@ export default function Pipeline() {
const add = added(r.created_at) const add = added(r.created_at)
return ( return (
<tr key={id}> <tr key={id}>
<td className="grid__ref">{r.lead_ref || `#${id}`}</td> <td>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td> {r.customer_name || '—'}
<td><span className="chip">{ch.label}</span></td> <div className="grid__sub">
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td> {r.lead_ref || `#${id}`}
{r.entity_name ? ` · ${r.entity_name}` : ''}
{ch.label ? ` · ${ch.label}` : ''}
</div>
</td>
<td> <td>
{exp {exp
? <><span className={'due due--' + exp.tone}>{exp.label}</span> ? <><span className={'due due--' + exp.tone}>{exp.label}</span>
<div className="grid__sub">{exp.on}</div></> <div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>} : <span className="grid__sub"></span>}
</td> </td>
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td> <td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
<td> <td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
{r.attribution_status === 'contested' <td><span className="grid__sub">{add.rel || add.abs}</span></td>
? <span className="chip chip--warn">Contested</span>
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
</td>
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
<td className="grid__act"> <td className="grid__act">
<Link className="grid__btn" to={`/lead/${id}`}>Open</Link> <Link className="grid__btn" to={`/lead/${id}`}>Open</Link>
</td> </td>

View File

@ -899,3 +899,86 @@
color: var(--zk-blue-dark); color: var(--zk-blue-dark);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Overview
The morning page. Three blocks, in the order a person cares: what is
waiting on me, what is running out of time, and how the book looks.
Nothing here is a chart a demo book is small enough that a number and a
name beat any visualisation of them. */
.sec {
display: flex;
align-items: baseline;
gap: 10px;
margin: 28px 0 12px;
font-size: 13px;
font-weight: 500;
letter-spacing: .02em;
text-transform: uppercase;
color: var(--zk-muted);
}
.sec:first-of-type { margin-top: 8px; }
.sec__hint {
font-size: 12px;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
color: var(--zk-grey);
}
/* Three cards, never more. These are the only queues a person acts in, and
keeping the row short is what makes it readable at a glance. */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px;
}
.card {
display: block;
padding: 18px 18px 16px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
text-decoration: none;
color: inherit;
transition: border-color .12s, box-shadow .12s, transform .12s;
}
.card:hover { border-color: var(--zk-blue-light); box-shadow: 0 2px 10px rgba(35,54,111,.07); transform: translateY(-1px); }
.card__n { display: block; font-size: 34px; line-height: 1; font-weight: 300; color: var(--zk-grey); }
.card__t { display: block; margin-top: 10px; font-size: 15px; font-weight: 500; color: var(--zk-ink); }
.card__w { display: block; margin-top: 3px; font-size: 12.5px; color: var(--zk-muted); }
/* A queue with something in it earns colour. One with nothing stays quiet
the point of the row is that the eye lands on the number that matters. */
.card.is-live { border-color: var(--zk-amber-line); background: var(--zk-amber-tint); }
.card.is-live .card__n { color: var(--zk-amber-ink); font-weight: 400; }
/* Every stage, small. Zeroes are shown rather than hidden: a stage that has
quietly stopped receiving leads is only visible if its zero is on screen. */
.tally {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(124px, 1fr));
gap: 8px;
}
.tally__i {
display: flex;
align-items: baseline;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm);
background: var(--zk-white);
text-decoration: none;
color: inherit;
transition: border-color .12s, background .12s;
}
.tally__i:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
.tally__n { font-size: 18px; font-weight: 500; color: var(--zk-ink); min-width: 1.2em; }
.tally__l { font-size: 12.5px; color: var(--zk-muted); line-height: 1.25; }
.tally__i.is-zero { background: transparent; }
.tally__i.is-zero .tally__n { color: var(--zk-grey); font-weight: 300; }
.tally__i.is-zero .tally__l { color: var(--zk-grey); }
@media (max-width: 720px) {
.cards { grid-template-columns: 1fr; }
}