From 4c210c7e09de46b386270e974c5656e3bda1521c Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 7 Sep 2026 16:13:13 +0530 Subject: [PATCH] console: a page to monitor from, and words an operator would use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.jsx | 5 +- src/api/config.js | 28 +++--- src/components/Timeline.jsx | 18 +++- src/layout/Shell.css | 16 +++ src/layout/Shell.jsx | 8 ++ src/screens/Lead.jsx | 31 +++--- src/screens/Overview.jsx | 191 ++++++++++++++++++++++++++++++++++++ src/screens/Pipeline.jsx | 45 ++++----- src/screens/screens.css | 83 ++++++++++++++++ 9 files changed, 372 insertions(+), 53 deletions(-) create mode 100644 src/screens/Overview.jsx diff --git a/src/App.jsx b/src/App.jsx index f62f6d1..758a770 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { useZino } from './api/provider.jsx' import Login from './pages/Login.jsx' import Shell from './layout/Shell.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' @@ -22,7 +23,9 @@ export default function App() { } /> }> - } /> + {/* The morning page. This used to redirect to the underwriting queue — + one stage, usually empty, and somebody else's. */} + } /> } /> } /> } /> diff --git a/src/api/config.js b/src/api/config.js index 3cf5bee..c644799 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -35,22 +35,22 @@ export const WORKFLOW = 'zk_wf_lead' * is the failure mode this table has, and it is silent. */ export const STAGES = [ - { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' }, - { uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' }, - { uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Writing the document request…', by: 'Engage 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: 'the voice agent' }, + { 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 // 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. - { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Upload documents', by: 'the partner agent' }, - { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Accept the proposal', by: 'the customer' }, + { 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: 'Waiting on the customer', by: 'the customer' }, // 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-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' }, - { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file…', by: 'Engage AI' }, + { 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: 'Payment to confirm', by: 'ops' }, + { 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 // is the only thing that wakes a parked lead — do not present this as a // 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-lost', name: 'Lost / Dropped', kind: 'end' }, { uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, @@ -62,11 +62,11 @@ export const STAGES = [ * waiting on me?". */ export const NAV_GROUPS = [ - { label: 'Needs someone', kind: 'needs' }, - { label: 'With the customer', kind: 'customer' }, - { label: 'Running by itself', kind: 'auto' }, - { label: 'Not yet', kind: 'waiting' }, - { label: 'Closed', kind: 'end' }, + { label: 'Waiting on a person', kind: 'needs' }, + { label: 'With the customer', kind: 'customer' }, + { label: 'The agents have it', kind: 'auto' }, + { label: 'Not due yet', kind: 'waiting' }, + { label: 'Closed', kind: 'end' }, ] /** Channel presentation. source_channel is data on the instance, never a branch. */ diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 24d6168..edbf460 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -44,6 +44,22 @@ function when(ts) { 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 }) { const { client } = useZino() const [rows, setRows] = useState(null) @@ -78,7 +94,7 @@ export default function Timeline({ instanceId }) { key: r.id ?? i, kind, 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), when: when(r.created_at), narrative: NARRATIVE diff --git a/src/layout/Shell.css b/src/layout/Shell.css index 4ae1b2c..03c77ff 100644 --- a/src/layout/Shell.css +++ b/src/layout/Shell.css @@ -273,3 +273,19 @@ 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); } diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx index 3093250..ec77596 100644 --- a/src/layout/Shell.jsx +++ b/src/layout/Shell.jsx @@ -32,6 +32,14 @@ export default function Shell() {
@@ -243,7 +243,7 @@ export default function Lead() { {row.current_insurer ?
Current insurer
{row.current_insurer}
: null} {row.motor_reg_no ?
Registration
{row.motor_reg_no}
: null} {row.created_at ?
Lead added
{fmtWhen(row.created_at)}
: null} - {row.updated_at ?
Last activity
{fmtWhen(row.updated_at)}
: null} + {row.updated_at ?
Last update
{fmtWhen(row.updated_at)}
: null}
@@ -264,7 +264,7 @@ export default function Lead() {
) : null} @@ -273,10 +273,11 @@ export default function Lead() {
-

What happens next

+

What you can do

- These are the only activities this state allows. Who normally performs - each is shown — but the workflow decides, server-side, whether you may. + Everything this lead allows right now. Some of these belong to + an agent or an underwriter — if it is not yours, it will say so + when you try.

@@ -299,7 +300,7 @@ export default function Lead() { onCancel={() => setOpen(null)} onStale={() => { 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() }} onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} @@ -316,8 +317,8 @@ export default function Lead() {
-

Flow details

-

Everything captured on this lead, grouped in the order it was worked.

+

Lead details

+

Everything captured so far, in the order it was collected.

@@ -356,8 +357,8 @@ export default function Lead() {
-

What has happened

-

Every step, in order, and who took it.

+

History

+

Every step so far, and who took it.

diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx new file mode 100644 index 0000000..ecf3ffa --- /dev/null +++ b/src/screens/Overview.jsx @@ -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 ( +
+

Today

+
+
+ ) + } + + if (state.status === 'error') { + const said = describeError(state.error) + return ( +
+

Today

+
+ {said.title} + {said.detail ?

{said.detail}

: null} +

{state.error?.status} {state.error?.message}

+
+
+ ) + } + + return ( +
+
+
+

Today

+

+ {view.openCount} {view.openCount === 1 ? 'lead is' : 'leads are'} open ·{' '} + {view.won} onboarded so far +

+
+
+ + {/* The only section that is about the reader. Everything a person can + actually do sits in one of these three queues. */} +

Waiting on a person

+
+ {view.needs.map((s) => { + const n = view.byStage[s.name] || 0 + return ( + + {n} + {s.need ?? s.name} + {n === 0 ? 'Nothing waiting' : `with ${s.by}`} + + ) + })} +
+ +

+ Renewals running out + Open leads with 30 days or less on the clock +

+ {view.running.length === 0 ? ( +

Nothing expiring in the next 30 days.

+ ) : ( +
+ + + + + + {view.running.slice(0, 8).map((r) => { + const id = r.instance_id ?? r.id + const e = expiryLabel(r._d) + return ( + + + + + + + ) + })} + +
CustomerRenewalWhere it is
+ {r.customer_name || r.lead_ref || `#${id}`} +
{r.lead_ref || ''}
+
{e.text}{r.current_state_name || '—'}Open
+
+ )} + + {/* Every stage, including the empty ones. A stage reading zero is + information — it is how you notice the agents have stopped. */} +

+ The whole book + Every lead, by where it has got to +

+
+ {STAGES.map((s) => { + const n = view.byStage[s.name] || 0 + return ( + + {n} + {s.name} + + ) + })} +
+
+ ) +} diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index b0c4a15..975f5ce 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -93,10 +93,12 @@ export default function Pipeline() {

{stage.need ?? stage.name}

{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' - ? 'Closed. Nothing runs from here.' - : `Waiting on ${stage.by}.`} + ? 'These are finished. Nothing more happens to them.' + : stage.kind === 'waiting' + ? 'Good leads, too early to work. They come back on their own.' + : `Waiting on ${stage.by}.`} {stage.need ? Stage · {stage.name} : null}

@@ -116,18 +118,18 @@ export default function Pipeline() { {state.status === 'error' ? (
- This queue has no record view yet. + This queue could not be loaded.

- The console reads every queue from the {RV_LEADS} record view. - It is not seeded yet, so there is nothing to list. Everything else — - sign-in, routing and the pipeline itself — is live. + The list of leads did not come back. Everything else — sign-in and + the rest of the app — is working, so this is worth a retry before + anything else.

{describeError(state.error).title} · {state.error?.status} {state.error?.message}

) : null} {state.status === 'ready' && state.rows.length === 0 ? ( -

No leads at this stage.

+

Nothing here right now.

) : null} {state.status === 'ready' && state.rows.length > 0 ? ( @@ -135,9 +137,8 @@ export default function Pipeline() { - - - + @@ -148,23 +149,23 @@ export default function Pipeline() { const add = added(r.created_at) return ( - - - - + - - - + + + diff --git a/src/screens/screens.css b/src/screens/screens.css index cfa499f..8528b2d 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -899,3 +899,86 @@ color: var(--zk-blue-dark); 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; } +}
ReferenceCustomerChannelProductRenewal duePremiumAttributionAdded + CustomerRenewal dueProductPremiumWaiting
{r.lead_ref || `#${id}`}{r.customer_name || '—'}
{r.entity_name || ''}
{ch.label}{r.product_line === 'motor' ? 'Motor' : 'SME Package'} + {r.customer_name || '—'} +
+ {r.lead_ref || `#${id}`} + {r.entity_name ? ` · ${r.entity_name}` : ''} + {ch.label ? ` · ${ch.label}` : ''} +
+
{exp ? <>{exp.label}
{exp.on}
: }
{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'} - {r.attribution_status === 'contested' - ? Contested - : {r.attribution_status || '—'}} - {add.abs}
{add.rel}
{r.product_line === 'motor' ? 'Motor' : 'SME Package'}{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}{add.rel || add.abs} Open