console: redesign the UI — one card language, icons, filters, live trail

Visual and information-architecture pass across every screen. No API call,
payload, poll, permission rule or workflow mirror changes; forms still render
from the live schema and the timeline's merge/grouping is unchanged (only the
display order is reversible).

Design language
- Ground + white cards with one hairline and one radius; sentence-case
  headings; shared primitives in index.css (.card .pill .btn .avatar .live).
- One inline icon set (components/Icon.jsx); no more filled navy/red tiles.
- Read-side labels in api/display.js: roles and enum tokens humanized,
  customer initials/tones, money and date formatters. Nothing sent changes.

Shell
- 56px header with brand, global lead search (portfolio rows, "/" to focus)
  and identity chip with a human role label.
- Sidebar rows carry icons and a clear active bar; AI roster at the foot,
  each opening its remit card.

Login
- Operator copy instead of retail copy; centred sign-in card with show/hide
  password; demo persona picker that fills the EMAIL only.

Overview
- "Who holds the work" as four quiet cards that warm only when owed.
- Pipeline flow bar with every stage named beneath it; KPI strip in a card.
- Action rows with chevron / view-only eye; expiry bands filter the table.

Queues
- Filter bar (search, product, channel, expiry band, stalled, hide closed)
  and sortable columns, all client-side on the fetched page.
- Avatars, product/channel chips, stage chips for closed leads, tinted
  stalled rows, count inline with the title.

Lead
- Avatar + chips in the header; metrics card with the stage rail inside,
  and a branch node for Referred / Parked / Lost / Declined.
- Timeline newest-first by default (toggle), the worker's face as the node,
  one footer row per entry, AI markdown rendered as bold.
- "AI team on this lead" panel with per-agent step counts; PDF tags on docs.

Forms & dialogs
- Required marked with an asterisk + legend; "Select…" placeholder; dd Mon
  yyyy hint under date inputs; open activity form in a titled panel;
  one-line dialog footnotes.

Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens
(sme_underwriter, verbal_call, Rsa), findings cut at "1.".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 11:37:42 +05:30
parent 2965c47b71
commit e90e4204ec
28 changed files with 3192 additions and 3485 deletions

View File

@ -14,10 +14,10 @@ npm run preview
There are no tests and no test runner in this project.
`npm run lint` currently reports 12 pre-existing errors — 8 `react-hooks/refs`, 3
`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider
file exports both a component and a hook). A clean run is not the baseline; compare against
this count rather than against zero.
`npm run lint` currently reports 14 pre-existing errors — 8 `react-hooks/refs`, 3
`react-hooks/set-state-in-effect`, 3 `react-refresh/only-export-components` (the provider
and portfolio files export both a component and hooks). A clean run is not the baseline;
compare against this count rather than against zero.
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
build does **not** — see "Runtime config" below.
@ -50,12 +50,33 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/
- `src/api/errors.js``describeError()` turns the gateway's five error shapes into a
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
list).
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding
the lead) rather than by pipeline position, with queue counts from one tallied list call.
- `src/layout/Shell.jsx` — 56px header (brand, `GlobalSearch`, `UserMenu`) + the sidebar,
grouped by `NAV_GROUPS` (who is holding the lead) rather than by pipeline position, with
queue counts from one tallied list call and the AI roster at its foot.
- `src/layout/GlobalSearch.jsx` — finds a lead from anywhere. Filters the rows
`PortfolioProvider` already holds (name, business, ref, mobile, registration) — no request
of its own. `/` focuses it.
- `src/api/display.js` — read-side labels only: `roleLabel`, `humanize` (snake_case tokens →
English; names, refs, emails pass through untouched), `initials`/`toneFor` for customer
avatars, money and date formatters, `findingOf`/`leadIn`/`mdRuns` for AI prose. Nothing in
here changes a value that is SENT anywhere.
- `src/components/Icon.jsx` — the one monoline icon set. Use the NAME, never a path.
- `src/index.css` — the tokens plus the shared primitives every screen builds from:
`.card`, `.pill`, `.btn`, `.avatar`, `.live`, `.h-sec`/`.h-sub`. Screen stylesheets style
content; these style containers. Page ground is `--zk-ground`, bounded regions are white
cards.
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
`current_state_name`.
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
field groups (`GROUPS`, in the order the lead is worked).
`current_state_name`. The filter bar (search, product, channel, expiry band, stalled,
hide closed) and the column sort narrow the PAGE that came back, client-side; they never
change the request.
- `src/screens/Lead.jsx` — one lead: header (avatar, chips, Your move / whose move), a
metrics card with the `StageRail` inside it, the status strip, Next step, the timeline
(newest first by default, toggle to read as a story), and a sticky reference rail: the AI
team with per-agent step counts, then At a glance. The full record is `LeadFileDialog`
(`GROUPS`, in the order the lead is worked).
- `src/pages/Login.jsx` — sign-in plus a demo persona picker (the README accounts for org
84). Picking one fills the EMAIL only and focuses the password box — no credential lives
in the bundle. What the persona may do is still the workflow's decision.
- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
one is surfaced.
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it

214
src/api/display.js Normal file
View File

@ -0,0 +1,214 @@
/**
* How raw workflow values read on screen.
*
* The platform stores roles and enums as snake_case tokens `sme_underwriter`,
* `verbal_call`, `due_now` and the console was printing them as-is in the
* header chip, the lead file and the trail. Nothing here changes a value that
* is SENT anywhere; these are read-side labels only. A token missing from the
* maps still reads as English through `humanize`, so a value added to the
* workflow tomorrow does not come back as a slug.
*/
export const ROLE_LABELS = {
ops_admin: 'Operations',
partner_agent: 'Partner agent',
bank_rm: 'Bancassurance RM',
csr_direct: 'Direct sales',
sme_underwriter: 'SME underwriter',
ai_intake: 'Intake AI',
ai_engage: 'Engage AI',
ai_advisor: 'Advisor AI',
ai_kyc: 'KYC AI',
ai_uw_referral: 'Referral AI',
}
export function roleLabel(role) {
if (!role) return ''
return ROLE_LABELS[role] ?? humanize(role)
}
/** Enum values the workflow writes, in the words a person would use. */
const VALUE_LABELS = {
// channels & lines
direct: 'Direct',
bancassurance: 'Bancassurance',
agency: 'Agency',
motor: 'Motor',
sme_package: 'SME Package',
// consent / contact
verbal_call: 'Verbal, on the call',
written: 'Written',
whatsapp: 'WhatsApp',
email: 'Email',
connected: 'Connected',
no_answer: 'No answer',
callback: 'Callback requested',
not_interested: 'Not interested',
wrong_number: 'Wrong number',
due_now: 'Due now',
too_early: 'Too early',
lapsed: 'Lapsed',
// documents / outcomes
complete: 'Complete',
partial: 'Partial',
pending: 'Pending',
received: 'Received',
proceed: 'Proceed',
clear: 'Clear',
cleared: 'Cleared',
refer: 'Refer',
referred: 'Referred',
decline: 'Decline',
declined: 'Declined',
accepted: 'Accepted',
rejected: 'Rejected',
paid: 'Paid',
unpaid: 'Unpaid',
issued: 'Issued',
yes: 'Yes',
no: 'No',
// vehicle
petrol: 'Petrol',
diesel: 'Diesel',
cng: 'CNG',
electric: 'Electric',
hybrid: 'Hybrid',
// kyc
ckyc: 'CKYC',
okyc: 'Aadhaar OTP',
vkyc: 'Video KYC',
match: 'Match',
mismatch: 'Mismatch',
}
/** Words that keep their case when a slug is spelt out. */
const KEEP = {
pan: 'PAN', gstin: 'GSTIN', gst: 'GST', kyc: 'KYC', ai: 'AI', sme: 'SME',
uw: 'UW', od: 'OD', tp: 'TP', idv: 'IDV', ncb: 'NCB', rm: 'RM', posp: 'POSP',
rc: 'RC', otp: 'OTP', id: 'ID', bi: 'BI', ee: 'EE', wip: 'WIP', amc: 'AMC',
irdai: 'IRDAI', msme: 'MSME', rsa: 'RSA', pa: 'PA', cpa: 'CPA', ckyc: 'CKYC', sms: 'SMS',
}
/** Looks like a stored token rather than free text: lowercase words joined by
* underscores, nothing else. Names, references, emails and numbers all fail
* this test and pass through untouched. */
const TOKEN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/
export function isToken(v) {
return typeof v === 'string' && v.length <= 40 && TOKEN.test(v)
}
/**
* A snake_case token as English. Anything that is not a token a name, an
* email, a registration number, a sentence comes back exactly as given.
*/
export function humanize(v) {
if (!isToken(v)) return v
if (VALUE_LABELS[v]) return VALUE_LABELS[v]
const words = v.split('_').map((w) => KEEP[w] ?? w)
const s = words.join(' ')
return s.charAt(0).toUpperCase() + s.slice(1)
}
/** Initials for a customer or a person, two letters at most. */
export function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '·'
const a = parts[0][0] || ''
const b = parts.length > 1 ? parts[parts.length - 1][0] : (parts[0][1] || '')
return (a + b).toUpperCase()
}
/** A stable tone for a name, so the same customer wears the same colour on
* every screen. Six hues, all readable with white text. */
const TONES = ['blue', 'teal', 'violet', 'amber', 'plum', 'slate']
export function toneFor(name) {
const s = String(name || '')
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
return TONES[h % TONES.length]
}
/** Indian money, short. */
export function inrShort(v) {
const n = Number(v)
if (!Number.isFinite(n) || !n) return null
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
/** Indian money, in full. */
export function inrFull(v) {
const n = Number(v)
if (!Number.isFinite(n)) return null
return '₹' + n.toLocaleString('en-IN')
}
/** dd Mon yyyy, the way an Indian insurer writes a date. */
export function dateLong(v) {
if (!v) return ''
const d = new Date(String(v).length === 10 ? String(v) + 'T00:00:00' : v)
if (isNaN(d)) return String(v)
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })
}
/** How long ago, short. */
export function ago(ts) {
if (!ts) return ''
const d = new Date(ts)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return mins + 'm ago'
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
return Math.round(mins / 1440) + 'd ago'
}
/**
* The first sentence of an AI paragraph the employees write the conclusion
* first, so this IS the finding. Null when the split would be useless.
*/
export function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[^\d\s][.!?])(\s|$)/)
return m ? m[1].trim() : null
}
/**
* The first ~180 characters as a finding when no clean first sentence exists,
* cut at a word so it never ends mid-token.
*/
export function leadIn(text, max = 180) {
const t = String(text || '').replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
const cut = t.slice(0, max)
return cut.slice(0, Math.max(cut.lastIndexOf(' '), 100)) + '…'
}
/**
* The employees write light markdown **bold** and list markers. Rendered as
* plain runs with the bold kept, so a paragraph never shows its asterisks.
*/
export function mdRuns(text) {
const t = String(text || '')
const out = []
const re = /\*\*(.+?)\*\*/g
let last = 0, m
while ((m = re.exec(t))) {
if (m.index > last) out.push({ b: false, t: t.slice(last, m.index) })
out.push({ b: true, t: m[1] })
last = m.index + m[0].length
}
if (last < t.length) out.push({ b: false, t: t.slice(last) })
return out
}
/** The icon each queue kind wears in the sidebar and on the overview. */
export const KIND_ICON = {
needs: 'inbox',
customer: 'chat',
auto: 'bot',
waiting: 'clock',
end: 'archive',
all: 'list',
}

View File

@ -28,32 +28,37 @@
.af__field > span {
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0.03em;
color: var(--zk-muted);
color: var(--zk-ink);
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.af__req {
font-style: normal;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-blue);
background: var(--zk-tint-blue);
padding: 2px 7px;
border-radius: var(--r-pill);
font-size: var(--fs-sm);
font-weight: 600;
line-height: 1;
color: var(--zk-danger);
margin-left: -4px;
}
.af__legend {
margin: -8px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
.af__legend .af__req { margin: 0 3px 0 0; font-size: var(--fs-xs); }
.af__hint { font-size: 11.5px; color: var(--zk-muted); margin-top: -2px; }
.af input,
.af select,
.af textarea {
font: inherit;
font-size: var(--fs-xs);
padding: 10px 12px;
border-radius: var(--r-md);
border-radius: var(--r-sm);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-ink);
@ -117,7 +122,7 @@
}
.af__chip.is-on {
background: var(--grad-blue);
background: var(--zk-blue);
border-color: var(--zk-blue);
color: var(--zk-white);
box-shadow: var(--sh-xs);
@ -136,17 +141,17 @@
font-size: var(--fs-xs);
font-weight: 500;
padding: 10px 26px;
border-radius: var(--r-md);
border-radius: var(--r-sm);
cursor: pointer;
border: 0;
background: var(--grad-blue);
background: var(--zk-blue);
color: var(--zk-white);
box-shadow: var(--sh-sm);
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
}
.af__submit:hover:not(:disabled) {
background: var(--grad-blue-hover);
background: var(--zk-blue-dark);
box-shadow: var(--sh-md);
transform: translateY(-1px);
}
@ -166,7 +171,7 @@
font: inherit;
font-size: var(--fs-xs);
padding: 10px 20px;
border-radius: var(--r-md);
border-radius: var(--r-sm);
cursor: pointer;
border: 1px solid var(--zk-line);
background: var(--zk-white);
@ -211,7 +216,7 @@
.af__auto {
font-size: var(--fs-xs);
padding: 10px 12px;
border-radius: var(--r-md);
border-radius: var(--r-sm);
min-height: 41px;
border: 1px dashed var(--zk-line);
background: var(--zk-tint);

View File

@ -3,6 +3,7 @@ import { useZino } from '../api/provider.jsx'
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx'
import { dateLong } from '../api/display.js'
import './ActivityForm.css'
/**
@ -288,7 +289,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
+ (missing.includes(f.id) ? ' is-missing' : '')}
>
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
<span>{f.name}{f.mandatory ? <em className="af__req" title="Required" aria-label="required">*</em> : null}</span>
{f.data_type === 'file' || f.data_type === 'ocr' ? (
<FileField
@ -303,7 +304,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
</div>
) : f.data_type === 'select' ? (
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
<option value=""></option>
<option value="">Select</option>
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.data_type === 'multiselect' ? (
@ -318,6 +319,13 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
</div>
) : f.data_type === 'longtext' ? (
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
) : f.data_type === 'date' ? (
<>
<input type="date" value={v} onChange={(e) => set(f.id, e.target.value)} />
{/* The native picker shows the browser's locale order; the
hint says the date the way an Indian insurer writes it. */}
{v ? <small className="af__hint">{dateLong(v)}</small> : null}
</>
) : (
<input
type={INPUT_TYPES[f.data_type] ?? 'text'}
@ -329,6 +337,10 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
})}
</div>
{fields.some((f) => f.mandatory && f.data_type !== 'id_gen') ? (
<p className="af__legend"><em className="af__req">*</em> required</p>
) : null}
{/* The form's own complaint, before anything is sent. */}
{missing.length ? (
<div className="af__err af__err--soft" role="alert">

View File

@ -1,33 +1,33 @@
.who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
.who__disc {
position: relative;
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: var(--fs-2xs);
font-weight: 600;
font-size: 10px;
font-weight: var(--fw-semi);
letter-spacing: 0.02em;
color: #fff;
user-select: none;
}
.who--md .who__disc { width: 26px; height: 26px; font-size: var(--fs-2xs); }
.who--md .who__disc { width: 30px; height: 30px; font-size: 11.5px; }
.who__name {
font-size: var(--fs-2xs);
font-weight: 500;
font-weight: var(--fw-medium);
color: var(--zk-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Five agents, five hues, all readable on white at 20px and all distinguishable
from the Zurich blue the interface already uses for its own chrome. */
/* Five agents, five hues, all readable on white and all distinguishable
from the Zurich blue the interface uses for its own chrome. */
.who__disc--blue { background: #2167ae; }
.who__disc--teal { background: #0f7b78; }
.who__disc--violet { background: #5b4bb7; }
@ -41,13 +41,16 @@
cursor: pointer;
padding: 2px 8px 2px 2px;
border: 1px solid transparent;
border-radius: var(--r-pill, 999px);
border-radius: var(--r-pill);
background: transparent;
transition: background .12s, border-color .12s;
transition: background .12s, border-color .12s, transform .12s;
}
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
.who--btn:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
.who--btn:hover .who__i { opacity: 1; }
.who--btn:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* A name-less chip is a bare disc; give it a hover lift instead of a border. */
.who--btn:not(:has(.who__name)) { padding: 2px; }
.who--btn:not(:has(.who__name)):hover { background: transparent; border-color: transparent; transform: translateY(-1px); }
.who__i {
width: 12px;
@ -57,11 +60,9 @@
opacity: 0;
transition: opacity .12s;
}
.who--btn:not(:has(.who__name)) .who__i { display: none; }
/* The AI mark, on the disc rather than beside the name: it has to survive
showName={false}, and it belongs to the worker, not to the label. Sits
proud of the disc so it stays legible against every one of the five hues,
with a white ring to separate it from the colour underneath. */
/* The AI mark, on the disc rather than beside the name. */
.who__ai {
position: absolute;
right: -3px;
@ -69,12 +70,11 @@
width: 11px;
height: 11px;
fill: var(--zk-amber, #b5761f);
stroke: var(--zk-white, #fff);
stroke: var(--zk-card, #fff);
stroke-width: 0.9;
stroke-linejoin: round;
paint-order: stroke fill;
}
.who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; }
/* Said out loud for a screen reader, which cannot see a sparkle. */
@ -90,7 +90,6 @@
border: 0;
}
/* Role icon fills most of the disc, leaving room for the AI spark at the
corner. Sized off the disc so it scales with .who--md. */
/* Role icon fills most of the disc, leaving room for the AI spark. */
.who__glyph { width: 60%; height: 60%; }
.who--md .who__glyph { width: 58%; height: 58%; }
.who--md .who__glyph { width: 56%; height: 56%; }

View File

@ -96,9 +96,8 @@ export default function CallTranscript({ text, name, onClose }) {
</div>
<p className="conv__note">
Recorded by the voice agent as the call happened. This is what was said
the call notes in the trail are Engage&apos;s reading of it, which is a
different thing.
Recorded by the voice agent as the call happened. The call notes in the trail
are Engage&apos;s summary of this.
</p>
</div>
</div>

View File

@ -1,59 +1,102 @@
.cot { margin-top: var(--sp-2); }
/* The finding, always visible the one sentence that answers "what did it
conclude" without opening anything. */
/* The finding, when this component is asked to show it. */
.cot__finding {
margin: 0 0 var(--sp-2);
font-size: var(--fs-sm);
line-height: var(--lh-normal);
font-weight: var(--fw-medium);
color: var(--zk-ink);
}
/* Default toggle look, for anywhere the Timeline's footer row is not
restyling it. */
.cot__toggle {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1) 0;
gap: 6px;
padding: 4px 0;
border: 0;
background: none;
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
font-weight: var(--fw-medium);
color: var(--zk-blue-dark);
}
.cot__brain { width: 15px; height: 15px; flex: none; }
.cot__ct { color: var(--zk-grey); font-weight: var(--fw-normal); }
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); }
.cot__caret.is-open { transform: rotate(90deg); }
.cot__brain { flex: none; }
.cot__ct {
font-size: 11px;
font-weight: var(--fw-semi);
font-variant-numeric: tabular-nums;
padding: 0 6px;
border-radius: var(--r-pill);
background: var(--zk-line-soft);
color: var(--zk-muted);
}
.cot__toggle.is-open .cot__ct { background: var(--zk-blue-light); color: var(--zk-navy); }
.cot__caret { transition: transform .18s var(--ease); }
.cot__caret.is-open { transform: rotate(180deg); }
/* The chain itself: a rail of rungs. */
.cot__steps {
list-style: none;
margin: var(--sp-3) 0 0;
padding: var(--sp-3) 0 var(--sp-2);
border-left: 2px solid var(--zk-blue-light);
padding-left: var(--sp-4);
margin: 4px 0 0;
padding: 12px 16px 10px 18px;
border: 1px solid var(--zk-card-line);
border-radius: var(--r-sm);
background: var(--zk-card);
}
.cot__step { position: relative; display: grid; grid-template-columns: 82px 1fr; gap: var(--sp-3); padding: var(--sp-2) 0; }
.cot__step::before {
content: ""; position: absolute; left: calc(var(--sp-4) * -1 - 6px); top: 9px;
width: 9px; height: 9px; border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-blue);
.cot__step {
position: relative;
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: var(--sp-3);
padding: 6px 0 6px 18px;
}
.cot__step::before {
content: '';
position: absolute;
left: 0;
top: 11px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--zk-card);
border: 2px solid var(--zk-blue-light);
}
.cot__step::after {
content: '';
position: absolute;
left: 5.5px;
top: 22px;
bottom: -6px;
width: 2px;
background: var(--zk-line-soft);
}
.cot__step:last-child::after { display: none; }
.cot__step--decided::before { background: var(--zk-blue); border-color: var(--zk-blue); }
.cot__mark {
font-size: var(--fs-3xs);
font-size: 11px;
font-weight: var(--fw-semi);
letter-spacing: .06em;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--zk-blue-dark);
padding-top: 1px;
padding-top: 2px;
}
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; white-space: pre-wrap; }
.cot__b b { color: var(--zk-ink); font-weight: var(--fw-semi); }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-2); }
.cot__conf { font-size: 11.5px; font-weight: var(--fw-semi); color: var(--zk-blue-dark); background: var(--zk-tint-blue); border: 1px solid var(--zk-blue-light); padding: 1px 9px; border-radius: var(--r-pill); }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.cot__conf {
font-size: 11.5px;
font-weight: var(--fw-semi);
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
padding: 1px 9px;
border-radius: var(--r-pill);
}
@media (max-width: 720px) {
.cot__step { grid-template-columns: 1fr; gap: 2px; }
}

View File

@ -1,4 +1,6 @@
import { useState } from 'react'
import Icon from './Icon.jsx'
import { findingOf, mdRuns } from '../api/display.js'
import './ChainOfThought.css'
/**
@ -6,28 +8,17 @@ import './ChainOfThought.css'
*
* This is the trust surface. A person signing off on or overriding a
* machine's decision needs the answer to one question first: WHY did it do
* that. Before this, the reasoning existed (the employees write it as workflow
* fields, so it is already in the audit trail) but the console showed only the
* conclusion. An operator either trusted it blind or went digging. Neither is
* oversight.
*
* The chain reads the way the employee's own record reads: what it REASONED and
* what it DECIDED. It is assembled from real fields already on the step the
* employee's own words for the reasoning, the activity it committed and the
* state it moved the lead to for the decision never narrated after the fact.
* A step may carry more than one reasoning field (attribution AND eligibility,
* say); each is its own rung.
* that. The chain reads the way the employee's own record reads: what it
* REASONED and what it DECIDED, assembled from real fields already on the step
* never narrated after the fact. A step may carry more than one reasoning
* field (attribution AND eligibility, say); each is its own rung.
*
* The finding the first sentence, since the employees write the conclusion
* first is lifted out and stays visible, so the "why" can be read without
* opening the chain.
* first is lifted out so the "why" can be read without opening the chain.
* The Timeline renders that finding itself and passes `showFinding={false}`,
* so the toggle can sit in the entry's footer row beside its neighbours.
*/
function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
export default function ChainOfThought({ reasoning, decided, confidence }) {
export default function ChainOfThought({ reasoning, decided, confidence, showFinding = true }) {
const [open, setOpen] = useState(false)
const primary = reasoning.length ? reasoning[0][1] : ''
@ -36,25 +27,19 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return (
<div className="cot">
{finding ? <p className="cot__finding">{finding}</p> : null}
<>
{showFinding && finding ? <p className="cot__finding">{finding}</p> : null}
<button
type="button"
className="cot__toggle"
className={'cot__toggle' + (open ? ' is-open' : '')}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<Icon name="brain" size={14} className="cot__brain" />
Chain of thought
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="cot__ct">{steps}</span>
<Icon name="chevronDown" size={12} className={'cot__caret' + (open ? ' is-open' : '')} />
</button>
{open ? (
@ -62,11 +47,13 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
{reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
<div className="cot__b">{text}</div>
<div className="cot__b">
{mdRuns(text).map((r, j) => (r.b ? <b key={j}>{r.t}</b> : <span key={j}>{r.t}</span>))}
</div>
</li>
))}
<li className="cot__step">
<li className="cot__step cot__step--decided">
<span className="cot__mark">Decided</span>
<div className="cot__b">
<b>{decided.what}</b>
@ -82,6 +69,6 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
</li>
</ol>
) : null}
</div>
</>
)
}

View File

@ -96,8 +96,8 @@
max-height: min(76vh, 720px);
display: flex;
flex-direction: column;
background: var(--zk-white);
border-radius: var(--r-lg);
background: var(--zk-card);
border-radius: var(--r-card);
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
outline: none;
animation: zk-rise 0.18s var(--ease) both;
@ -114,8 +114,9 @@
.prose__head h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 500;
font-size: var(--fs-md);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.35;
color: var(--zk-ink);
}

View File

@ -41,17 +41,19 @@
.bub--them {
align-self: flex-start;
background: var(--zk-line-soft);
background: var(--zk-ground);
border: 1px solid var(--zk-card-line);
color: var(--zk-ink);
border-bottom-left-radius: 4px;
}
.bub--us {
align-self: flex-end;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
background: var(--zk-blue);
color: #fff;
border-bottom-right-radius: 4px;
}
.bub--us span { opacity: .8; }
.conv__note {
margin: 0;

View File

@ -122,10 +122,8 @@ export default function Conversation({ rows, name, onClose }) {
</div>
<p className="conv__note">
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation. One automatic send is not here:
the quote, which goes out as a registered WhatsApp template rather than
text we compose.
Everything the customer sent and everything written back. The quote itself
goes out as a WhatsApp template and is not shown here.
</p>
</div>
</div>

70
src/components/Icon.jsx Normal file
View File

@ -0,0 +1,70 @@
/**
* One monoline icon set for the whole console 16-unit grid, 1.6 stroke,
* round caps. Drawn inline so nothing is fetched and the colour is always
* `currentColor`. Use the NAME, never the path, so an icon changed here
* changes everywhere it appears.
*/
const PATHS = {
home: <><path d="M2.5 8.2 8 3.4l5.5 4.8" /><path d="M4 7.4V13h8V7.4" /></>,
list: <><path d="M5.5 4.2h8M5.5 8h8M5.5 11.8h8" /><circle cx="2.8" cy="4.2" r=".9" fill="currentColor" stroke="none" /><circle cx="2.8" cy="8" r=".9" fill="currentColor" stroke="none" /><circle cx="2.8" cy="11.8" r=".9" fill="currentColor" stroke="none" /></>,
inbox: <><path d="M2.5 9.5V4.8a1.3 1.3 0 0 1 1.3-1.3h8.4a1.3 1.3 0 0 1 1.3 1.3v4.7" /><path d="M2.5 9.5h3.2l.8 1.6h3l.8-1.6h3.2v2.2a1.3 1.3 0 0 1-1.3 1.3H3.8a1.3 1.3 0 0 1-1.3-1.3z" /></>,
chat: <path d="M2.8 3.6h10.4v6.4H7.4l-3 2.4v-2.4H2.8z" strokeLinejoin="round" />,
bot: <><rect x="3" y="5" width="10" height="7.5" rx="2" /><path d="M8 2.6V5M6 8.4h.01M10 8.4h.01" /><path d="M6 10.6h4" /></>,
clock: <><circle cx="8" cy="8" r="5.6" /><path d="M8 4.8V8l2.2 1.4" /></>,
archive: <><rect x="2.5" y="3" width="11" height="3" rx=".8" /><path d="M3.5 6v6.2a.8.8 0 0 0 .8.8h7.4a.8.8 0 0 0 .8-.8V6M6.5 8.8h3" /></>,
search: <><circle cx="7" cy="7" r="4.3" /><path d="M10.3 10.3 13.5 13.5" /></>,
plus: <path d="M8 3.2v9.6M3.2 8h9.6" />,
chevronRight: <path d="M6 3.5 10.5 8 6 12.5" />,
chevronLeft: <path d="M10 3.5 5.5 8 10 12.5" />,
chevronDown: <path d="M3.5 6 8 10.5 12.5 6" />,
arrowRight: <path d="M3 8h9.5M8.8 4.3 12.5 8l-3.7 3.7" />,
lock: <><rect x="3.5" y="7" width="9" height="6.5" rx="1.3" /><path d="M5.5 7V5.3a2.5 2.5 0 0 1 5 0V7" /></>,
eye: <><path d="M1.8 8s2.3-4 6.2-4 6.2 4 6.2 4-2.3 4-6.2 4S1.8 8 1.8 8z" /><circle cx="8" cy="8" r="1.8" /></>,
file: <><path d="M4 1.8h5.2L12.5 5v9.2H4z" strokeLinejoin="round" /><path d="M9 1.9v3.3h3.4" strokeLinejoin="round" /></>,
phone: <path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z" strokeLinejoin="round" />,
whatsapp: <><path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z" strokeLinejoin="round" /><path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z" fill="currentColor" stroke="none" /></>,
shield: <path d="M8 1.8 13 3.8v3.6c0 3.1-2.1 5.2-5 6.8-2.9-1.6-5-3.7-5-6.8V3.8z" strokeLinejoin="round" />,
alert: <><path d="M8 2.2 14 12.8H2z" strokeLinejoin="round" /><path d="M8 6.4v3M8 11.2h.01" /></>,
refresh: <><path d="M13 8a5 5 0 0 1-8.7 3.4M3 8a5 5 0 0 1 8.7-3.4" /><path d="M11.8 1.8v3h-3M4.2 14.2v-3h3" /></>,
signOut: <><path d="M6.5 2.8H3.6a1 1 0 0 0-1 1v8.4a1 1 0 0 0 1 1h2.9" /><path d="M10 5.2 12.8 8 10 10.8M6.3 8h6.4" /></>,
check: <path d="M3 8.4l3.2 3.2L13 4.8" />,
x: <path d="M4 4l8 8M12 4l-8 8" />,
user: <><circle cx="8" cy="5.5" r="2.7" /><path d="M2.8 13.4a5.2 5.2 0 0 1 10.4 0" /></>,
users: <><circle cx="6" cy="5.6" r="2.4" /><path d="M1.8 13a4.2 4.2 0 0 1 8.4 0" /><path d="M10.4 3.6a2.4 2.4 0 0 1 0 4M12 9.2a4.2 4.2 0 0 1 2.2 3.8" /></>,
sparkle: <path d="M8 1.6 9.6 6.4l4.8 1.6-4.8 1.6L8 14.4 6.4 9.6 1.6 8l4.8-1.6z" strokeLinejoin="round" />,
money: <><rect x="1.8" y="4" width="12.4" height="8" rx="1.3" /><circle cx="8" cy="8" r="1.9" /><path d="M4.2 6.4h.01M11.8 9.6h.01" /></>,
trend: <path d="M2 11.5 6 7.5l2.6 2.6L14 4.6M10.5 4.6H14v3.5" />,
filter: <path d="M2.5 3.5h11L9.4 8.6v4.2l-2.8-1.4V8.6z" strokeLinejoin="round" />,
sortAsc: <path d="M4.5 12.5v-9M2.2 5.8l2.3-2.3 2.3 2.3M9 4h4.5M9 8h3.5M9 12h2.5" />,
sortDesc: <path d="M4.5 3.5v9M2.2 10.2l2.3 2.3 2.3-2.3M9 4h2.5M9 8h3.5M9 12h4.5" />,
brain: <path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10" />,
calendar: <><rect x="2.5" y="3.5" width="11" height="10" rx="1.3" /><path d="M2.5 7h11M5.5 2v3M10.5 2v3" /></>,
car: <><path d="M3 9.5 4.2 5.8A1.2 1.2 0 0 1 5.3 5h5.4a1.2 1.2 0 0 1 1.1.8L13 9.5v3H3z" strokeLinejoin="round" /><circle cx="5.3" cy="10.8" r=".9" fill="currentColor" stroke="none" /><circle cx="10.7" cy="10.8" r=".9" fill="currentColor" stroke="none" /></>,
building: <><rect x="3" y="2.5" width="10" height="11" rx="1" /><path d="M6 5.5h1M9 5.5h1M6 8h1M9 8h1M7 13.5v-3h2v3" /></>,
mail: <><rect x="2" y="3.5" width="12" height="9" rx="1.3" /><path d="m2.5 4.5 5.5 4.3 5.5-4.3" /></>,
id: <><rect x="2" y="3.4" width="12" height="9.2" rx="1.4" /><circle cx="5.8" cy="7" r="1.5" /><path d="M9 6.4h3M9 8.6h3M4 10.4h5" /></>,
}
export default function Icon({ name, size = 16, className = '', strokeWidth = 1.6, ...rest }) {
const body = PATHS[name]
if (!body) return null
return (
<svg
className={'ico' + (className ? ' ' + className : '')}
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{body}
</svg>
)
}

View File

@ -1,16 +1,6 @@
/* The journey, across the top. Eight steps have to fit without wrapping on a
laptop, so the labels are short and the dots carry the state. */
.rail {
display: flex;
align-items: center;
gap: var(--sp-6);
flex-wrap: wrap;
margin: var(--sp-6) 0;
padding: var(--sp-2) 0 var(--sp-5);
border: 0;
border-bottom: 1px solid var(--zk-line);
background: transparent;
}
/* The journey, across the width of the metrics card. Eight steps have to fit
on a laptop, so the labels are short and the dots carry the state. */
.rail { width: 100%; }
.rail ol {
display: flex;
@ -18,8 +8,6 @@
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 0;
}
.rail__s {
@ -27,18 +15,18 @@
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
gap: 8px;
flex: 1;
min-width: 0;
text-align: center;
}
.rail__dot {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
border-radius: 50%;
flex: none;
background: var(--zk-white);
background: var(--zk-card);
border: 2px solid var(--zk-line);
z-index: 1;
transition: background var(--t-fast), border-color var(--t-fast);
@ -57,7 +45,7 @@
/* The connector sits behind the dots, drawn from each step to the next. */
.rail__bar {
position: absolute;
top: 6px;
top: 5px;
left: 50%;
width: 100%;
height: 2px;
@ -71,33 +59,62 @@
.is-here .rail__dot {
background: var(--zk-blue);
border-color: var(--zk-blue);
box-shadow: 0 0 0 5px rgba(33, 103, 174, 0.15);
}
.is-here .rail__l {
color: var(--zk-blue-dark);
font-weight: var(--fw-semi);
font-size: var(--fs-xs);
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.15);
}
.is-here .rail__l { color: var(--zk-blue-dark); font-weight: var(--fw-semi); }
/* Off the path Parked, Referred, Lost, Declined. Named rather than drawn
into the row: these are departures, not points every lead passes. */
.rail__off {
flex: none;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
/* Beyond a branch the path is unknowable, so it is drawn faint. */
.is-faint .rail__dot { border-color: var(--zk-line-soft); }
.is-faint .rail__l { color: var(--zk-line); }
/* The branch: a node hanging beneath the stage the lead left the line at. */
.rail__branch {
position: absolute;
top: 100%;
left: 50%;
margin-top: 6px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 2;
}
.rail__branch::before {
content: '';
width: 2px;
height: 10px;
background: var(--zk-amber-line);
}
.rail__bdot {
width: 12px;
height: 12px;
border-radius: 3px;
transform: rotate(45deg);
background: var(--zk-amber);
box-shadow: 0 0 0 4px var(--zk-amber-tint);
}
.rail__bl {
display: flex;
align-items: baseline;
gap: 5px;
margin-top: 2px;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
color: var(--zk-amber-ink);
background: var(--zk-amber-tint);
border: 1px solid var(--zk-amber-line);
padding: var(--sp-2) var(--sp-4);
white-space: nowrap;
padding: 2px 10px;
border-radius: var(--r-pill);
background: var(--zk-amber-tint);
}
.rail__off.is-closed {
color: var(--zk-muted);
background: var(--zk-tint);
border-color: var(--zk-line);
}
.rail__bl small { font-size: 10.5px; font-weight: var(--fw-medium); opacity: .8; }
.rail__branch.is-closed::before { background: var(--zk-line); }
.rail__branch.is-closed .rail__bdot { background: var(--zk-grey); box-shadow: 0 0 0 4px var(--zk-line-soft); }
.rail__branch.is-closed .rail__bl { color: var(--zk-muted); background: var(--zk-line-soft); }
/* Room for the branch beneath the rail when one is drawn. */
.rail:has(.rail__branch) { padding-bottom: 52px; }
@media (max-width: 900px) {
.rail__l { display: none; }
.rail { padding: 14px 18px; }
}

View File

@ -4,28 +4,23 @@ import './StageRail.css'
/**
* Where this lead has been, where it is, and what is left.
*
* The page said the stage in one chip in the corner and nothing about the
* journey, so "how far along is this?" the first question anyone asks about
* a lead could only be answered by knowing the workflow by heart.
*
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
* (documents uploaded from Qualified carried one straight past Contacted) and
* can go backwards (a callback returns it to Awaiting Contact). Colouring
* everything left of the current stage would have claimed steps that never
* happened. The trail knows which states this lead actually entered; this
* reads that.
* and can go backwards, so colouring everything left of the current stage
* would claim steps that never happened. The trail knows which states this
* lead actually entered; this reads that.
*
* The side states Parked, Referred, Lost, Declined are deliberately NOT on
* the rail. They are departures from the path rather than points along it, and
* putting them in a row would suggest every lead passes through. When the lead
* is in one, the rail says so at the end instead.
* The side states Parked, Referred, Lost, Declined are not points every
* lead passes through, so they are not on the main rail. When the lead is in
* one, it is drawn as a BRANCH: a node hanging off the last stage the lead
* reached on the path, so the picture says "it left the line here", rather
* than a pill floating at the end of the row.
*/
const PATH = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
]
/** Short enough to fit eight across; the queue names are written for a sidebar. */
/** Short enough to fit eight across. */
const SHORT = {
'zk-state-new': 'Filed',
'zk-state-qualified': 'Called',
@ -35,47 +30,60 @@ const SHORT = {
'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued',
'zk-state-onboarded': 'Onboarded',
'zk-state-referred': 'Referred',
'zk-state-parked': 'Parked',
'zk-state-lost': 'Lost',
'zk-state-declined': 'Declined',
}
export default function StageRail({ audit, currentName }) {
const current = STAGES.find((s) => s.name === currentName)
const visited = new Set(
(audit || [])
.map((r) => r.execution_state)
.filter((v) => PATH.includes(v)),
)
const visitedList = (audit || [])
.map((r) => r.execution_state)
.filter((v) => PATH.includes(v))
const visited = new Set(visitedList)
if (current) visited.add(current.uid)
const currentIndex = PATH.indexOf(current?.uid)
// A lead sitting somewhere off the path Parked, Referred, Lost, Declined.
const aside = current && currentIndex === -1 ? current : null
// Where the branch hangs: the furthest point along the path the lead
// reached before it left.
const reached = Math.max(-1, ...PATH.map((uid, i) => (visited.has(uid) ? i : -1)))
const branchAfter = aside ? Math.max(0, reached) : -1
return (
<div className="rail" aria-label="Progress">
<ol>
{PATH.map((uid, i) => {
const been = visited.has(uid)
const here = uid === current?.uid
// Ahead of a lead that has left the path is unknowable a referred
// risk may never see payment so nothing after it is called "to do".
const state = here ? 'here' : been ? 'done' : 'todo'
// Ahead of a lead that has left the path is unknowable, so nothing
// after the branch is called "to do" with any confidence it is
// drawn faint.
const state = here ? 'here' : been ? 'done' : aside && i > branchAfter ? 'faint' : 'todo'
const nextDone = visited.has(PATH[i + 1])
return (
<li key={uid} className={'rail__s is-' + state}>
<li key={uid} className={'rail__s is-' + state + (i === branchAfter && aside ? ' has-branch' : '')}>
<span className="rail__dot" aria-hidden="true" />
<span className="rail__l">{SHORT[uid]}</span>
{i < PATH.length - 1 ? (
<span className={'rail__bar' + (visited.has(PATH[i + 1]) ? ' is-done' : '')} aria-hidden="true" />
<span className={'rail__bar' + (nextDone ? ' is-done' : '')} aria-hidden="true" />
) : null}
{i === branchAfter && aside ? (
<span className={'rail__branch' + (aside.kind === 'end' ? ' is-closed' : '')}>
<span className="rail__bdot" aria-hidden="true" />
<span className="rail__bl">
<small>{aside.kind === 'end' ? 'Ended' : 'Now'}</small>
{SHORT[aside.uid] || aside.name}
</span>
</span>
) : null}
</li>
)
})}
</ol>
{aside ? (
<div className={'rail__off' + (aside.kind === 'end' ? ' is-closed' : '')}>
{aside.kind === 'end' ? 'Ended in' : 'Now in'} <strong>{aside.name}</strong>
</div>
) : null}
</div>
)
}

View File

@ -1,351 +1,259 @@
/* The trail. The worker's face is the node on the rail; the entry is a title,
a finding, and one footer row of controls. */
.tl {
list-style: none;
margin: 0;
padding: 0 0 0 4px;
padding: 0;
}
.tl__loading,
.tl__err {
color: var(--zk-muted);
font-size: var(--fs-xs);
padding: 14px 2px;
}
.tl__err {
color: var(--zk-danger-ink);
}
.tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
.tl__err { color: var(--zk-danger-ink); }
.tl__item {
position: relative;
padding: 0 0 var(--sp-6) var(--sp-7);
border-left: 2px solid var(--zk-line-soft);
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 0 14px;
padding-bottom: var(--sp-5);
}
.tl__item:last-child {
border-left-color: transparent;
padding-bottom: 4px;
}
.tl__dot {
/* The connecting line, drawn from node to node. */
.tl__item::before {
content: '';
position: absolute;
left: -8px;
top: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-grey);
box-shadow: 0 0 0 4px var(--zk-white);
left: 17px;
top: 40px;
bottom: 0;
width: 2px;
background: var(--zk-line-soft);
}
.tl__item:last-child::before { display: none; }
.tl__item:last-child { padding-bottom: 4px; }
.tl__node {
display: grid;
place-items: center;
width: 36px;
height: 36px;
}
/* The agent disc at md is 26px; centre it and ring it in white so the line
passes cleanly behind. */
.tl__node .who--md .who__disc { width: 32px; height: 32px; box-shadow: 0 0 0 3px var(--zk-card); }
.tl__node .who--btn { padding: 0; border: 0; }
.tl__node .who__i { display: none; }
.tl__node .avatar { width: 32px; height: 32px; font-size: 12px; box-shadow: 0 0 0 3px var(--zk-card); }
.tl__sysdot { width: 12px; height: 12px; border-radius: 50%; background: var(--zk-line); box-shadow: 0 0 0 3px var(--zk-card); }
.tl__wanode {
display: grid; place-items: center;
width: 32px; height: 32px; border-radius: 50%;
color: #fff; background: var(--zk-wa);
box-shadow: 0 0 0 3px var(--zk-card);
}
/* Machine work is blue, people are amber, the platform itself is grey so a
file can be read at a glance for how much of it was done by hand. */
.tl__item--ai .tl__dot {
border-color: var(--zk-blue);
background: var(--zk-blue);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(33, 103, 174, 0.12);
}
.tl__item--human .tl__dot {
border-color: var(--zk-amber);
background: var(--zk-white);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(181, 118, 31, 0.14);
}
.tl__item--sys .tl__dot {
border-color: var(--zk-line);
background: var(--zk-line);
}
/* The newest entry is marked — it is what the page is refreshed for. */
.tl--newest .tl__item.is-latest .tl__body { background: var(--zk-ground); }
.tl__body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
margin-left: calc(var(--sp-3) * -1);
gap: 6px;
min-width: 0;
padding: 6px 12px 10px;
margin: 0 0 0 -4px;
border-radius: var(--r-md);
transition: background var(--t-fast);
}
.tl__item:hover .tl__body {
background: var(--zk-tint);
}
.tl__item:hover .tl__body { background: var(--zk-ground); }
.tl__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-1) var(--sp-2);
justify-content: space-between;
gap: 12px;
}
/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps
into a ragged block that is harder to scan than two clean rows. */
.tl__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-1) var(--sp-3);
}
.tl__what {
font-size: var(--fs-sm);
font-weight: var(--fw-semi);
letter-spacing: -0.008em;
color: var(--zk-ink);
}
.tl__who {
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0.03em;
padding: 3px 9px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.tl__who--ai {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
.tl__who--human {
background: var(--zk-amber-tint);
color: var(--zk-amber-ink);
}
.tl__who--sys {
background: var(--zk-tint);
color: var(--zk-muted);
border: 1px solid var(--zk-line);
}
.tl__title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
.tl__what { font-size: var(--fs-sm); font-weight: var(--fw-semi); letter-spacing: -0.008em; color: var(--zk-ink); }
.tl__item--sys .tl__what { color: var(--zk-muted); font-weight: var(--fw-medium); }
.tl__stage {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-2xs);
color: var(--zk-blue);
font-weight: var(--fw-medium);
color: var(--zk-blue-dark);
padding: 1px 8px 1px 6px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
}
.tl__when { flex: none; font-size: var(--fs-2xs); color: var(--zk-grey); font-variant-numeric: tabular-nums; white-space: nowrap; }
.tl__when em { font-style: normal; }
.tl__when {
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
.tl__say {
margin-top: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-left: 2px solid var(--zk-blue-light);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: var(--zk-tint);
}
.tl__item:hover .tl__say {
background: var(--zk-white);
}
.tl__saylabel {
display: block;
font-size: var(--fs-3xs);
.tl__meta { display: flex; align-items: center; gap: 8px; margin-top: -2px; }
.tl__who { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-2xs); color: var(--zk-muted); }
.tl__who--cust { color: var(--zk-wa); font-weight: var(--fw-medium); }
.tl__aitag {
font-size: 9.5px;
font-weight: var(--fw-semi);
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--zk-grey);
margin-bottom: var(--sp-2);
letter-spacing: .06em;
padding: 1px 5px;
border-radius: 4px;
color: var(--zk-amber-ink);
background: var(--zk-amber-tint);
}
.tl__say p {
margin: 0;
/* The finding — body copy, not a quote box. */
.tl__finding {
margin: 2px 0 0;
font-size: var(--fs-sm);
line-height: var(--lh-normal);
color: var(--zk-muted);
max-width: 76ch;
color: var(--zk-ink);
max-width: 78ch;
}
.tl__figs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
/* A quoted line: a person's reason, or what was said to/by the customer. */
.tl__say {
margin-top: 2px;
padding: 10px 14px;
border-left: 3px solid var(--zk-blue-light);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
background: var(--zk-card);
border-top: 1px solid var(--zk-line-soft);
border-right: 1px solid var(--zk-line-soft);
border-bottom: 1px solid var(--zk-line-soft);
}
.tl__say--chat { border-left-color: var(--zk-wa); }
.tl__saylabel {
display: block;
font-size: 11px;
font-weight: var(--fw-medium);
color: var(--zk-grey);
margin-bottom: 4px;
}
.tl__say p { margin: 0; font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-ink); max-width: 76ch; }
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.tl__figs span {
font-size: var(--fs-2xs);
color: var(--zk-ink);
font-variant-numeric: tabular-nums;
padding: 4px 11px;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--zk-white);
border: 1px solid var(--zk-line);
background: var(--zk-card);
border: 1px solid var(--zk-card-line);
}
.tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; }
.tl__figs em {
font-style: normal;
font-size: var(--fs-2xs);
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 5px;
}
/* ---- system noise ---- */
.tl__toggle {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
cursor: pointer;
margin-bottom: 14px;
padding: 5px 12px;
border-radius: var(--r-pill);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.tl__toggle:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
color: var(--zk-blue-dark);
}
/* What was attached
The audit trail's job at this step is to prove receipt. A line saying
"Collect Documents" and nothing else left the operator to open the record
and count for themselves and until the views were fixed, the record could
not see the files either. */
/* Documents received */
.tl__docs {
margin-top: 8px;
padding: 9px 11px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm, 6px);
background: var(--zk-tint);
margin-top: 2px;
padding: 9px 12px;
border: 1px solid var(--zk-card-line);
border-radius: var(--r-sm);
background: var(--zk-card);
}
.tl__docshead {
display: block;
font-size: var(--fs-3xs);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--zk-muted);
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
color: var(--zk-ink);
margin-bottom: 7px;
}
.tl__docshead svg { color: var(--zk-blue); }
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
.tl__doc {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
text-decoration: none;
padding: 4px 10px 4px 8px;
border-radius: var(--r-pill, 999px);
background: var(--zk-white);
border: 1px solid var(--zk-line);
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
transition: border-color .12s, background .12s;
transition: background .12s;
}
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; }
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.tl__doc:hover { background: var(--zk-blue-light); color: var(--zk-navy); }
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* the conversation, as one entry
Every WhatsApp turn used to be its own entry, so a four-message exchange
took more of the trail than the whole underwriting chain. */
.tl__wa {
/* ONE FOOTER ROW. Chain of thought, fields written, the call, the thread
ghost buttons side by side, and whichever is opened unfolds beneath the
whole row. `details` is display:contents so its summary sits in the row
and its list drops below it. */
.tl__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-top: 4px;
}
.tl__foot .cot__toggle,
.tl__wrote > summary,
.tl__open {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
font-weight: 500;
color: #0f7b45;
}
.tl__wa svg { width: 15px; height: 15px; }
.tl__n {
font-size: var(--fs-2xs);
font-variant-numeric: tabular-nums;
color: var(--zk-muted);
padding: 1px 8px;
border-radius: var(--r-pill, 999px);
background: var(--zk-line-soft);
}
/* The channel performs these as the system, but the person who typed is the
actor worth naming. "System · Customer Reply" said the opposite. */
.tl__who--cust { color: #0f7b45; font-weight: 500; }
.tl__open {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
cursor: pointer;
padding: 0;
border: 0;
background: none;
color: var(--zk-blue);
margin-left: auto;
}
.tl__open:hover { color: var(--zk-blue-dark); text-decoration: underline; }
.tl__open:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
/* A chat entry's dot takes the channel's colour, so a scan down the rail shows
where the conversation happened without reading a word. */
.tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; }
/* what this step wrote
Folded by default: most steps write a dozen fields and the trail has to
stay readable as a story. Opened, the entry becomes an itemised account of
what the agent decided which is the closest an app JWT can get to its
reasoning, since ai_reasoning is on the row but not in the API. */
.tl__wrote { margin-top: 8px; }
.tl__wrote > summary {
font-weight: var(--fw-medium);
color: var(--zk-muted);
padding: 4px 10px;
border-radius: var(--r-sm);
border: 1px solid var(--zk-card-line);
background: var(--zk-card);
cursor: pointer;
list-style: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue);
white-space: nowrap;
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.tl__foot .cot__toggle:hover,
.tl__wrote > summary:hover,
.tl__open:hover { border-color: var(--zk-blue-light); color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
.tl__foot .cot__toggle.is-open,
.tl__wrote[open] > summary { border-color: var(--zk-blue-light); color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
.tl__foot .cot__toggle { order: 1; }
.tl__wrote { display: contents; }
.tl__wrote > summary { order: 2; }
.tl__wrote > summary::-webkit-details-marker { display: none; }
.tl__wrote > summary::after {
content: '';
width: 5px; height: 5px;
border-right: 1.4px solid currentColor;
border-bottom: 1.4px solid currentColor;
transform: rotate(45deg) translate(-1px, -1px);
transition: transform var(--t-fast);
.tl__open { order: 3; }
.tl__foot .cot__steps { order: 10; flex-basis: 100%; }
.tl__wrote > dl { order: 11; flex-basis: 100%; }
.tl__wcaret { transition: transform var(--t-fast); }
.tl__wrote[open] .tl__wcaret { transform: rotate(180deg); }
.tl__wrote > dl {
margin: 4px 0 0;
padding: 10px 14px;
border: 1px solid var(--zk-card-line);
border-radius: var(--r-sm);
background: var(--zk-card);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 4px 24px;
}
.tl__wrote[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.tl__wrote > summary:hover { color: var(--zk-blue-dark); }
.tl__wrote dl > div { display: grid; grid-template-columns: minmax(0, 45%) minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 3px 0; }
.tl__wrote dt { font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
.tl__wrote dd { margin: 0; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
.tl__wrote dl {
margin: 8px 0 0;
padding: 10px 12px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm, 6px);
background: var(--zk-tint);
display: flex;
flex-direction: column;
gap: 6px;
}
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
.tl__wrote dt {
flex: none;
width: 42%;
/* Platform noise, folded — offered at the foot rather than the head. */
.tl__toggle {
font: inherit;
font-size: var(--fs-2xs);
color: var(--zk-grey);
overflow-wrap: anywhere;
font-weight: var(--fw-medium);
cursor: pointer;
margin: 8px 0 0 50px;
padding: 5px 12px;
border-radius: var(--r-pill);
border: 1px dashed var(--zk-line);
background: transparent;
color: var(--zk-muted);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.tl__toggle:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
.tl__wrote dd {
margin: 0;
flex: 1;
min-width: 0;
font-size: var(--fs-2xs);
color: var(--zk-ink);
overflow-wrap: anywhere;
@media (max-width: 720px) {
.tl__head { flex-direction: column; align-items: flex-start; gap: 2px; }
}

View File

@ -3,31 +3,18 @@ import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx'
import Icon from './Icon.jsx'
import { AGENTS } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import { findingOf, humanize, initials, leadIn, mdRuns, toneFor } from '../api/display.js'
import './Timeline.css'
/* The roster lives in api/agents.js now one short name, one colour and one
set of initials per employee, so the same worker looks the same everywhere
it appears. This file only needs to know which roles are agents. */
/* The roster lives in api/agents.js one short name, one colour and one set
of initials per employee, so the same worker looks the same everywhere. */
/**
* The fields worth surfacing per activity an agent's reasoning, a rule's
* output, a call's notes. Everything else stays in the file below; a timeline
* that shows every field is a table, not a story.
*
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
* field ids, which carry a per-form suffix the call notes arrive as
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
* global ids directly, as this did, meant almost nothing ever matched: the
* timeline showed a bare list of activity names, and the one narrative line
* that did appear was an accident (a DATA_UPDATE row happens to write the
* unsuffixed key).
*/
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
"Reasoned" rung of the chain of thought the employee's own words for why
it did what it did rather than loose quote blocks. The first present one
is the finding shown without opening the chain. */
"Reasoned" rung of the chain of thought. The first present one is the
finding shown without opening the chain. */
const REASONING = [
['attribution_reason', 'Attribution'],
['eligibility_reason', 'Eligibility'],
@ -43,7 +30,7 @@ const REASONING = [
]
/* CONVERSATION lines stay as plain quotes they are what was said to and by
the customer, not the machine's reasoning, and belong beside the thread. */
the customer, not the machine's reasoning. */
const CONVERSATION = [
['customer_reply', 'The customer said'],
['customer_answer', 'We replied'],
@ -54,15 +41,10 @@ const NARRATIVE = [...REASONING, ...CONVERSATION]
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
/**
* Fields already shown elsewhere in the entry, so the "what it wrote" list
* does not repeat them: the narrative prose above it, the money figures below
* it, the document chips, and the platform's own bookkeeping.
*/
/** Fields already shown elsewhere in the entry. */
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system'])
/** A value as one short line. Long prose is already in the narrative block, so
* anything here is a field value, not a paragraph. */
/** A value as one short line. */
function short(f) {
const v = f.value
if (v === null || v === undefined || v === '') return null
@ -70,20 +52,15 @@ function short(f) {
const named = v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
.filter(Boolean)
return named.length ? named.join(', ') : null
return named.length ? named.map(humanize).join(', ') : null
}
if (typeof v === 'object') return null
const t = String(v).trim()
if (!t) return null
// A value long enough to be prose belongs in the narrative block, not in a
// list of what changed truncating it here would show half a sentence and
// teach the reader that this list is unreliable.
return t.length > 90 ? t.slice(0, 88) + '…' : t
if (t.length > 90) return t.slice(0, 88) + '…'
return humanize(t)
}
/** Document slots, in the order they are worth reading. Labels are shorter than
* the workflow's own "Registration Certificate (RC)" is a chip, not a form
* label and a slot missing from here still renders under its server label. */
const DOC_LABELS = {
doc_rc: 'RC',
doc_prev_policy: 'Expiring policy',
@ -101,23 +78,20 @@ const DOC_LABELS = {
const FILE_TYPES = new Set(['file', 'ocr'])
function when(ts) {
if (!ts) return ''
if (!ts) return { abs: '', rel: '' }
const d = new Date(ts)
const mins = Math.round((Date.now() - d.getTime()) / 60000)
const rel = mins < 1 ? 'just now'
: mins < 60 ? `${mins}m ago`
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
: `${Math.round(mins / 1440)}d ago`
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
return {
abs: 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.
*/
/** Last resort for a step whose activity_name did not come back. */
function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
@ -134,19 +108,20 @@ function filesIn(value) {
return value.filter((f) => f && typeof f === 'object' && f.uuid)
}
/**
* `rows` is owned by the lead page and refreshed on its poll, so the trail
* keeps up with a lead that five agents are working through in three minutes.
* This component fetched them itself once on mount and never again.
*/
/** The two activities that ARE the WhatsApp thread. */
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {
/**
* `rows` is owned by the lead page and refreshed on its poll, so the trail
* keeps up with a lead that five agents are working through in three minutes.
*
* `newestFirst` flips the reading order. The story reads forwards; a live
* demo wants the latest step at the top, where the eye lands. The merge and
* the grouping below are always done oldest-first and the finished list is
* reversed at the end, so the order never changes what is merged.
*/
export default function Timeline({ rows, newestFirst = false, onOpenAgent, onOpenChat, onOpenCall }) {
const { client } = useZino()
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
// anything. They are the bulk of a busy trail and they are hidden until asked
// for the story is what people and agents did.
const [showSys, setShowSys] = useState(false)
if (!rows) return <p className="tl__loading">Loading the timeline</p>
@ -155,33 +130,15 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
// Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
// submission separately, told apart by execution_state:
//
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
// zk-act-qualify zk-state-qualified the one that moved the lead
//
// Rendered literally that is "Qualify Lead" three times in a row, which
// reads as the AI having done the same thing three times. Only the row
// carrying a real state means anything to somebody reading the file.
//
// Collapsed on (same activity, within fifteen seconds) rather than on the
// execution_state alone, because a genuine repeat has to survive: Collect
// Documents really is performed twice on a lead whose first upload was
// incomplete, and those are minutes apart, not milliseconds.
//
// The kept row takes the EARLIEST timestamp when the operator acted and
// whichever state and payload is actually populated.
// ONE SUBMISSION WRITES SEVERAL ROWS, told apart by execution_state. Only the
// row carrying a real state means anything to somebody reading the file.
// Collapsed on (same activity, within fifteen seconds), because a genuine
// repeat has to survive.
const isStage = (v) => STAGES.some((s) => s.uid === v)
const SAME_SUBMISSION_MS = 15000
const merged = []
for (const r of ordered) {
// Look BACK for a match rather than only at the previous entry: the
// platform interleaves a DATA_UPDATE row between the two halves of one
// submission, so the rows to merge are near each other in time but not
// adjacent in the list.
let at = -1
for (let i = merged.length - 1; i >= 0; i--) {
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
@ -192,9 +149,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
const prev = merged[at]
merged[at] = {
...prev,
// The settle row is the one that names the resulting stage.
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
// Whichever row actually carries the submission and the AI's working.
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
user_name: prev.user_name || r.user_name,
@ -209,22 +164,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
let lastStage = null
const items = merged.map((r, i) => {
const roles = r.user_roles || []
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
// so an AI's own field write matched the agent roster, was classed as its work,
// and appeared in the trail as an entry titled "Data updated" twice,
// while the toggle below still offered to reveal two others. The row's
// activity id is what says it is bookkeeping; the roles say who triggered
// the bookkeeping, which is a different question.
const isSystem = r.activity_id === 'DATA_UPDATE'
const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
/**
* The value view. `fields[]` is the platform's own typed rendering of the
* submission one entry per configured field with its label, data type and
* value and it is what makes a file field knowable as a file. `data` is
* the raw untyped fallback for a row the workflow could not resolve.
*/
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
@ -232,14 +175,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
const byBase = new Map()
for (const f of fields) {
const base = baseFieldId(f.field_id)
// `_system` is the platform's own marker on every submission.
if (base === '_system') continue
if (!byBase.has(base)) byBase.set(base, f)
}
// What was attached. This is the answer to "show me that the documents
// were captured": the names, from the submission that carried them, each
// one openable.
const docs = []
for (const [base, f] of byBase) {
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
@ -248,20 +187,16 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
}
}
// The channel performs these as the system, but the meaningful actor is
// the person who typed. "System · Customer Reply" told the reader the
// opposite of what happened.
const isChat = CHAT_ACTS.has(r.activity_id)
// Log Contact is where the call lands. Offer the words themselves beside
// the summary, because they are not the same thing.
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state)
// A self-loop Capture Motor Risk runs inside Document Pending and settles
// back into it is not a move, and printing " Document Pending" against
// four consecutive entries reads as the lead bouncing.
const moved = stage && stage.uid !== lastStage
if (stage) lastStage = stage.uid
const reasoning = REASONING
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== '')
return {
key: r.id ?? i,
kind: isChat ? 'chat' : kind,
@ -273,17 +208,11 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
stage: moved ? stage : null,
when: when(r.created_at),
docs,
// The reasoning rungs of the chain of thought the employee's own text.
reasoning: REASONING
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// Conversation lines stay as plain quotes.
reasoning,
finding: kind === 'ai' && reasoning.length ? (findingOf(reasoning[0][1]) ?? leadIn(reasoning[0][1])) : null,
conversation: CONVERSATION
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// The decision's confidence, when the step recorded it ai_recommendation_confidence
// is a real field the advice step writes into the audit data, so it needs
// no backend change to reach here.
confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c)
@ -292,22 +221,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
/**
* EVERYTHING THIS STEP WROTE.
*
* Twelve fields were rendered as prose and four as money; the other
* hundred-odd were invisible. So Capture Motor Risk which writes engine
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
* add-ons showed as a heading and a timestamp, and the only way to see
* what an agent had actually decided was to open the Lead file and guess
* which values came from that step.
*
* This is not the model's internal reasoning; that is recorded on the row
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
* It is the next best and arguably more useful thing: the decision it
* committed, field by field, against the step that made it.
*/
// Everything this step wrote, field by field.
wrote: [...byBase]
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
@ -315,31 +229,15 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
}
})
/**
* THE CONVERSATION IS ONE THING, NOT NINE.
*
* Every WhatsApp turn was its own entry "Customer Reply", "Customer
* Reply", "Customer Reply", "Answer the Customer" each with a heading, an
* actor and a quote box, so a four-message exchange occupied more of the
* trail than the entire underwriting chain. A resend made it worse: the same
* sentence printed four times because the customer sent it four times.
*
* A contiguous run of chat turns now collapses to one entry that says how
* many messages, shows the last of them, and opens the thread. The trail goes
* back to being a list of decisions, and the conversation goes back to being
* a conversation.
*
* Contiguous, not global: a second exchange after underwriting is a separate
* episode in this lead's story and should read as one.
*/
// THE CONVERSATION IS ONE THING, NOT NINE. A contiguous run of chat turns
// collapses to one entry that says how many messages, shows the last of
// them, and opens the thread.
const grouped = []
for (const it of items) {
const last = grouped[grouped.length - 1]
if (it.isChat && last && last.isChat) {
last.count += 1
last.when = it.when
// Keep the newest line as the preview an operator scanning the trail
// wants where the conversation GOT to, not where it started.
if (it.conversation.length) last.conversation = it.conversation
last.docs = last.docs.concat(it.docs)
continue
@ -348,65 +246,79 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
}
const sysCount = grouped.filter((it) => it.kind === 'sys').length
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
const visible = (showSys ? grouped : grouped.filter((it) => it.kind !== 'sys'))
const listed = newestFirst ? [...visible].reverse() : visible
return (
<>
{sysCount ? (
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
</button>
) : null}
<ol className={'tl' + (newestFirst ? ' tl--newest' : '')}>
{listed.map((it, idx) => (
<li key={it.key} className={'tl__item tl__item--' + it.kind + (idx === 0 && newestFirst ? ' is-latest' : '')}>
{/* THE WORKER IS THE NODE. The face on the rail says who did it
before a word is read: a machine wears its role icon, a person
their initials, the customer the WhatsApp mark. */}
<div className="tl__node">
{it.kind === 'sys' ? (
<span className="tl__sysdot" aria-hidden="true" />
) : it.isChat && !it.agentKey ? (
<span className="tl__wanode" aria-hidden="true"><Icon name="whatsapp" size={16} /></span>
) : it.agentKey ? (
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
) : (
<span className={'avatar avatar--' + toneFor(it.actor)} aria-hidden="true">{initials(it.actor)}</span>
)}
</div>
<ol className="tl">
{visible.map((it) => (
<li key={it.key} className={'tl__item tl__item--' + it.kind}>
<span className="tl__dot" aria-hidden="true" />
<div className="tl__body">
<div className="tl__head">
{it.isChat ? (
<span className="tl__wa">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
fill="currentColor" />
</svg>
WhatsApp
</span>
) : (
<div className="tl__title">
<strong className="tl__what">{it.what}</strong>
)}
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
{it.stage && !it.isChat ? <span className="tl__stage"> {it.stage.name}</span> : null}
</div>
{/* The worker, as a face. Recognition down a column beats
reading five names to notice it was one agent throughout. */}
<div className="tl__meta">
{it.kind === 'sys'
? <span className="tl__who tl__who--sys">system</span>
: it.isChat && !it.agentKey
? <span className="tl__who tl__who--cust">the customer</span>
: <AgentChip agentKey={it.agentKey} name={it.actor} onOpen={onOpenAgent} />}
<span className="tl__when">{it.when}</span>
{it.isChat && onOpenChat ? (
<button type="button" className="tl__open" onClick={onOpenChat}>
Open thread
</button>
) : null}
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__open" onClick={onOpenCall}>
Hear the call
</button>
) : null}
{it.count > 1 ? <span className="pill">{it.count} messages</span> : null}
{it.stage && !it.isChat ? (
<span className="tl__stage"><Icon name="arrowRight" size={12} />{it.stage.name}</span>
) : null}
</div>
<span className="tl__when" title={it.when.abs}>{it.when.rel}<em> · {it.when.abs}</em></span>
</div>
{/* What was received, named and openable. The count is stated
rather than left to be inferred from a list "3 documents
received" is the sentence the operator is looking for. */}
<div className="tl__meta">
{it.kind === 'sys'
? <span className="tl__who">platform</span>
: it.isChat && !it.agentKey
? <span className="tl__who tl__who--cust">the customer</span>
: <span className="tl__who">{it.actor}{it.agentKey ? <span className="tl__aitag">AI</span> : null}</span>}
</div>
{/* The finding the one sentence that answers "what did it
conclude" as the entry's body. */}
{it.finding ? (
<p className="tl__finding">
{mdRuns(it.finding).map((r, j) => (r.b ? <b key={j}>{r.t}</b> : <span key={j}>{r.t}</span>))}
</p>
) : null}
{/* A human step's reason, and an AI step whose reasoning has
no clean first sentence, stay as a quote. */}
{it.kind !== 'ai'
? it.reasoning.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{it.conversation.map(([label, v]) => (
<div className="tl__say tl__say--chat" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))}
{it.docs.length ? (
<div className="tl__docs">
<span className="tl__docshead">
<Icon name="file" size={13} />
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
</span>
<div className="tl__doclist">
@ -419,12 +331,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
rel="noreferrer"
title={d.original_name}
>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
</svg>
{d.slot}
</a>
))}
@ -432,39 +338,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div>
) : null}
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
loose quote it is how the employee reached the decision, so
it renders as the chain: what it checked, what it reasoned,
what it decided. This is the answer to "why did it do that",
which is the first thing a person needs before trusting or
overriding a machine. */}
{it.kind === 'ai' && it.reasoning.length ? (
<ChainOfThought
reasoning={it.reasoning}
confidence={it.confidence}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/>
) : null}
{/* A human step's reason, and the AI step's reasoning when it is
not the story's actor (rare), stay as a plain quote. */}
{it.kind !== 'ai'
? it.reasoning.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{/* What was said to and by the customer — always a plain quote. */}
{it.conversation.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))}
{it.figures.length ? (
<div className="tl__figs">
{it.figures.map(([k, v]) => (
@ -473,23 +346,55 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div>
) : null}
{/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an
itemised account of what it decided. */}
{it.wrote.length ? (
<details className="tl__wrote">
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
<dl>
{it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
))}
</dl>
</details>
{/* ONE FOOTER ROW: the chain of thought, what was written, the
call, the thread. These used to stack as three rows of blue
links under every entry. */}
{(it.kind === 'ai' && it.reasoning.length) || it.wrote.length || (it.isChat && onOpenChat) || (it.hasCall && onOpenCall) ? (
<div className="tl__foot">
{it.kind === 'ai' && it.reasoning.length ? (
<ChainOfThought
reasoning={it.reasoning}
confidence={it.confidence}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
showFinding={false}
/>
) : null}
{it.wrote.length ? (
<details className="tl__wrote">
<summary>
<Icon name="list" size={13} />
{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written
<Icon name="chevronDown" size={12} className="tl__wcaret" />
</summary>
<dl>
{it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
))}
</dl>
</details>
) : null}
{it.isChat && onOpenChat ? (
<button type="button" className="tl__open" onClick={onOpenChat}>
<Icon name="whatsapp" size={13} /> Open thread
</button>
) : null}
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__open" onClick={onOpenCall}>
<Icon name="phone" size={13} /> Read the call
</button>
) : null}
</div>
) : null}
</div>
</li>
))}
</ol>
{sysCount ? (
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
{showSys ? 'Hide' : 'Show'} {sysCount} platform update{sysCount === 1 ? '' : 's'}
</button>
) : null}
</>
)
}

View File

@ -56,6 +56,23 @@
--zk-danger-tint: #fdeceb;
--zk-danger-line: #f0c2bd;
/* Surfaces
One ground, one card. The page sits on a cool off-white and every
bounded region is a white card with a hairline and a whisper of shadow,
so the eye reads groups before it reads text. */
--zk-ground: #f4f6f9;
--zk-card: #ffffff;
--zk-card-line: #e4e9ef;
--zk-hover: #f7f9fc;
--zk-green: #1f9d63;
--zk-green-ink: #157347;
--zk-green-tint: #e6f6ee;
--zk-green-line: #b4e3c9;
--zk-violet: #5b4bb7;
--zk-violet-tint: #eeebfa;
--zk-slate: #5b6b7f;
--zk-wa: #0f7b45;
/* Shape, depth, motion
One scale, used everywhere. The palette above is untouched; every shadow
below is the brand navy at low alpha, so depth reads as part of the brand
@ -66,6 +83,9 @@
--r-lg: 16px;
--r-xl: 20px;
--r-pill: 999px;
--r-card: 14px;
--sh-card: 0 1px 2px rgba(35, 54, 111, 0.04), 0 1px 3px rgba(35, 54, 111, 0.06);
--sh-card-hover: 0 1px 2px rgba(35, 54, 111, 0.05), 0 8px 20px -10px rgba(35, 54, 111, 0.18);
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05);
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07);
@ -141,7 +161,7 @@
ground the generic admin-template look. The content ground is white
now; the sidebar is the only tinted surface, and sections are separated
by hairlines and space rather than boxed in shadowed cards. */
background: var(--zk-white);
background: var(--zk-ground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
@ -159,7 +179,144 @@ body,
body {
margin: 0;
background: var(--zk-white);
background: var(--zk-ground);
}
/* Shared primitives
Used by every screen so a card, an eyebrow or an avatar looks the same
wherever it appears. Screen stylesheets style CONTENT; these style the
containers. */
.card {
background: var(--zk-card);
border: 1px solid var(--zk-card-line);
border-radius: var(--r-card);
box-shadow: var(--sh-card);
}
/* A small label above a value. Sentence case, quiet, never letter-spaced
into a shout. */
.eyebrow {
display: block;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
color: var(--zk-muted);
letter-spacing: 0.01em;
}
/* Section heading: a real heading at 16-18px, with an optional one-line
subtitle. */
.h-sec {
margin: 0;
font-size: var(--fs-lg);
font-weight: var(--fw-semi);
letter-spacing: -0.015em;
line-height: var(--lh-tight);
color: var(--zk-ink);
}
.h-sub {
margin: 4px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-muted);
line-height: 1.5;
}
/* A round face for a person or a customer. */
.avatar {
display: inline-grid;
place-items: center;
flex: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
letter-spacing: 0.02em;
color: #fff;
background: var(--zk-slate);
user-select: none;
}
.avatar--sm { width: 26px; height: 26px; font-size: 11px; }
.avatar--lg { width: 44px; height: 44px; font-size: var(--fs-sm); }
.avatar--blue { background: var(--zk-blue); }
.avatar--teal { background: var(--zk-teal); }
.avatar--violet { background: var(--zk-violet); }
.avatar--amber { background: var(--zk-amber); }
.avatar--plum { background: #8a3d6b; }
.avatar--slate { background: var(--zk-slate); }
/* Small status pills: product line, channel, stage, tone. */
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: var(--r-pill);
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
line-height: 1.5;
white-space: nowrap;
color: var(--zk-muted);
background: var(--zk-line-soft);
}
.pill--blue { color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
.pill--teal { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
.pill--amber { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
.pill--red { color: var(--zk-danger-ink); background: var(--zk-danger-tint); }
.pill--green { color: var(--zk-green-ink); background: var(--zk-green-tint); }
.pill--violet { color: var(--zk-violet); background: var(--zk-violet-tint); }
.pill--line { background: transparent; border: 1px solid var(--zk-line); }
/* Buttons. Three weights, one shape. */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 14px;
border-radius: var(--r-sm);
border: 1px solid var(--zk-line);
background: var(--zk-card);
color: var(--zk-ink);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
line-height: 1.3;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
}
.btn:hover { background: var(--zk-hover); border-color: var(--zk-grey); }
.btn:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.btn:disabled { opacity: .55; cursor: default; }
.btn--primary { background: var(--zk-blue); border-color: var(--zk-blue); color: #fff; }
.btn--primary:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
.btn--ghost { border-color: transparent; background: transparent; color: var(--zk-blue-dark); }
.btn--ghost:hover { background: var(--zk-tint-blue); border-color: transparent; }
.btn--sm { padding: 5px 10px; font-size: var(--fs-2xs); border-radius: var(--r-xs); }
.btn--danger { color: var(--zk-danger-ink); border-color: var(--zk-danger-line); }
.btn--danger:hover { background: var(--zk-danger-tint); border-color: var(--zk-danger); }
/* A live dot: something is refreshing or in motion. */
.live {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: var(--fs-2xs);
color: var(--zk-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.live__dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--zk-green);
box-shadow: 0 0 0 0 rgba(31, 157, 99, .5);
animation: zk-live 2.2s var(--ease) infinite;
}
@keyframes zk-live {
70% { box-shadow: 0 0 0 7px rgba(31, 157, 99, 0); }
100% { box-shadow: 0 0 0 0 rgba(31, 157, 99, 0); }
}
button,

121
src/layout/GlobalSearch.jsx Normal file
View File

@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { STAGES } from '../api/config.js'
import { initials, toneFor } from '../api/display.js'
import Icon from '../components/Icon.jsx'
/**
* Find a lead from anywhere.
*
* Reads the book the PortfolioProvider already holds the same rows the
* sidebar tallies and the overview count so this costs no request and can
* never disagree with them. Matches on the customer, the business, the
* reference and the mobile number, because those are the four things a caller
* gives you.
*/
export default function GlobalSearch() {
const { rows } = usePortfolio()
const navigate = useNavigate()
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const [cursor, setCursor] = useState(0)
const wrap = useRef(null)
const input = useRef(null)
const hits = useMemo(() => {
const term = q.trim().toLowerCase()
if (term.length < 2) return []
const out = []
for (const r of rows) {
const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no]
.filter(Boolean).map((x) => String(x).toLowerCase())
if (hay.some((h) => h.includes(term))) out.push(r)
if (out.length >= 8) break
}
return out
}, [rows, q])
// Close on an outside click, and jump to the box on "/".
useEffect(() => {
function onDown(e) { if (!wrap.current?.contains(e.target)) setOpen(false) }
function onKey(e) {
if (e.key === '/' && !/input|textarea|select/i.test(document.activeElement?.tagName || '')) {
e.preventDefault()
input.current?.focus()
}
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [])
function go(r) {
const id = r.instance_id ?? r.id
setQ(''); setOpen(false)
navigate(`/lead/${id}`)
}
function onKeyDown(e) {
if (e.key === 'Escape') { setOpen(false); input.current?.blur(); return }
if (!hits.length) return
if (e.key === 'ArrowDown') { e.preventDefault(); setCursor((c) => (c + 1) % hits.length) }
if (e.key === 'ArrowUp') { e.preventDefault(); setCursor((c) => (c - 1 + hits.length) % hits.length) }
if (e.key === 'Enter') { e.preventDefault(); go(hits[cursor]) }
}
const showList = open && q.trim().length >= 2
return (
<div className={'gs' + (open ? ' is-open' : '')} ref={wrap} role="search">
<Icon name="search" className="gs__icon" />
<input
ref={input}
type="search"
value={q}
placeholder="Search a lead — name, reference, mobile…"
aria-label="Search leads"
onChange={(e) => { setQ(e.target.value); setCursor(0); setOpen(true) }}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
<kbd className="gs__kbd" aria-hidden="true">/</kbd>
{showList ? (
<div className="gs__menu" role="listbox">
{hits.length ? hits.map((r, i) => {
const id = r.instance_id ?? r.id
const stage = STAGES.find((s) => s.name === r.current_state_name)
return (
<button
key={id}
type="button"
role="option"
aria-selected={i === cursor}
className={'gs__hit' + (i === cursor ? ' is-cursor' : '')}
onMouseEnter={() => setCursor(i)}
onClick={() => go(r)}
>
<span className={'avatar avatar--sm avatar--' + toneFor(r.customer_name)}>
{initials(r.customer_name)}
</span>
<span className="gs__t">
<strong>{r.customer_name || r.lead_ref || `#${id}`}</strong>
<span>{r.lead_ref}{r.mobile ? ` · ${r.mobile}` : ''}</span>
</span>
<span className={'pill ' + (stage?.kind === 'end' ? '' : stage?.kind === 'needs' ? 'pill--amber' : 'pill--blue')}>
{r.current_state_name}
</span>
</button>
)
}) : (
<p className="gs__none">No lead matches &ldquo;{q.trim()}&rdquo;.</p>
)}
</div>
) : null}
</div>
)
}

View File

@ -2,62 +2,128 @@
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--zk-tint);
background: var(--zk-ground);
}
/* ── Top bar ───────────────────────────────────────────────── */
/* Top bar
56px, white, three zones: brand, search, identity. */
.shell__top {
position: sticky;
top: 0;
z-index: 30;
display: flex;
display: grid;
grid-template-columns: 232px minmax(0, 1fr) auto;
align-items: center;
justify-content: space-between;
gap: var(--sp-6);
height: 72px;
padding: 0 var(--sp-7);
background: var(--zk-white);
border-bottom: 1px solid var(--zk-line);
height: 56px;
padding: 0 var(--sp-5) 0 var(--sp-5);
background: var(--zk-card);
border-bottom: 1px solid var(--zk-card-line);
}
.shell__brand {
display: flex;
align-items: center;
gap: 16px;
gap: 12px;
min-width: 0;
}
.shell__brand img {
height: 30px;
height: 26px;
width: auto;
display: block;
border-radius: 4px;
}
.shell__brandtext {
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.2;
padding-left: 16px;
padding-left: 12px;
border-left: 1px solid var(--zk-line);
font-size: var(--fs-sm);
font-weight: var(--fw-semi);
letter-spacing: -0.01em;
color: var(--zk-ink);
white-space: nowrap;
}
.shell__brandtext strong {
font-size: var(--fs-md);
font-weight: var(--fw-semi);
letter-spacing: -0.012em;
/* ── Global search ───────────────────────────────────────────── */
.gs {
position: relative;
justify-self: center;
width: min(100%, 520px);
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 10px 0 12px;
border-radius: var(--r-sm);
border: 1px solid var(--zk-card-line);
background: var(--zk-ground);
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
}
.gs:hover { border-color: var(--zk-line); }
.gs:focus-within { background: var(--zk-card); border-color: var(--zk-blue); box-shadow: var(--ring); }
.gs__icon { flex: none; color: var(--zk-grey); }
.gs:focus-within .gs__icon { color: var(--zk-blue); }
.gs input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: none;
font: inherit;
font-size: var(--fs-xs);
color: var(--zk-ink);
}
.shell__brandtext span {
font-size: var(--fs-3xs);
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
.gs input::placeholder { color: var(--zk-grey); }
.gs input::-webkit-search-cancel-button { cursor: pointer; }
.gs__kbd {
flex: none;
font: inherit;
font-size: 11px;
line-height: 1;
padding: 3px 6px;
border-radius: 4px;
border: 1px solid var(--zk-line);
background: var(--zk-card);
color: var(--zk-grey);
}
.gs:focus-within .gs__kbd { display: none; }
/* ── Body ──────────────────────────────────────────────────── */
.gs__menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
z-index: 40;
padding: 6px;
border-radius: var(--r-md);
background: var(--zk-card);
border: 1px solid var(--zk-card-line);
box-shadow: var(--sh-lg);
animation: zk-rise 0.14s var(--ease) both;
}
.gs__hit {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 10px;
border: 0;
border-radius: var(--r-sm);
background: none;
text-align: left;
font: inherit;
color: var(--zk-ink);
cursor: pointer;
}
.gs__hit.is-cursor { background: var(--zk-tint-blue); }
.gs__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.gs__t strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs__t span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gs__none { margin: 0; padding: 14px 12px; font-size: var(--fs-2xs); color: var(--zk-muted); }
/* ── Body ────────────────────────────────────────────────────── */
.shell__body {
display: flex;
flex: 1;
@ -67,136 +133,196 @@
.shell__nav {
position: sticky;
top: 72px;
top: 56px;
align-self: flex-start;
width: 264px;
width: 248px;
flex: none;
min-height: calc(100vh - 72px);
max-height: calc(100vh - 72px);
min-height: calc(100vh - 56px);
max-height: calc(100vh - 56px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--zk-tint);
border-right: 1px solid var(--zk-line);
padding: var(--sp-6) var(--sp-4) var(--sp-8);
background: var(--zk-card);
border-right: 1px solid var(--zk-card-line);
padding: var(--sp-4) var(--sp-3) var(--sp-5);
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.shell__navtop { display: flex; flex-direction: column; gap: 2px; }
.shell__navlabel {
margin: 0 0 var(--sp-3);
padding: 0 var(--sp-3);
font-size: var(--fs-3xs);
font-weight: var(--fw-semi);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-grey);
}
.shell__group + .shell__group {
margin-top: var(--sp-7);
}
/* The stage list is drawn as a rail with a node per state: the sidebar should
look like the pipeline it represents, not like a list of links. */
.shell__stages {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.shell__stages::before {
content: '';
position: absolute;
left: 18px;
top: 20px;
bottom: 20px;
width: 2px;
border-radius: var(--r-pill);
background: linear-gradient(180deg, var(--zk-line) 0%, var(--zk-line-soft) 100%);
}
.shell__stage {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-3) var(--sp-3) 40px;
border-radius: var(--r-md);
gap: 8px;
margin: 0 0 6px;
padding: 0 var(--sp-3);
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
color: var(--zk-grey);
}
.shell__group { display: flex; flex-direction: column; }
.shell__stages {
display: flex;
flex-direction: column;
gap: 2px;
}
/* One shape for every nav row: icon, label, count. */
.shell__stage,
.shell__today {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 8px var(--sp-3);
border-radius: var(--r-sm);
text-decoration: none;
color: var(--zk-muted);
font-size: var(--fs-xs);
transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
font-weight: var(--fw-medium);
line-height: 1.35;
transition: background var(--t-fast), color var(--t-fast);
}
.shell__ico { flex: none; color: var(--zk-grey); transition: color var(--t-fast); }
.shell__stage:hover,
.shell__today:hover { background: var(--zk-hover); color: var(--zk-ink); }
.shell__stage:hover .shell__ico,
.shell__today:hover .shell__ico { color: var(--zk-muted); }
.shell__stage::before {
content: '';
position: absolute;
left: 13px;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-line);
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
}
.shell__stage:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
.shell__stage:hover::before {
border-color: var(--zk-blue-light);
}
.shell__stage.is-active {
.shell__stage.is-active,
.shell__today.is-active {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
font-weight: 500;
}
.shell__stage.is-active::before {
.shell__stage.is-active .shell__ico,
.shell__today.is-active .shell__ico { color: var(--zk-blue); }
.shell__stage.is-active::before,
.shell__today.is-active::before {
content: '';
position: absolute;
left: -12px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--zk-blue);
border-color: var(--zk-blue);
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
}
/* Queues where the machine stops and a person decides. */
.shell__stage.is-needed .shell__stagename {
font-weight: 500;
color: var(--zk-ink);
}
.shell__stage.is-needed { color: var(--zk-ink); }
.shell__stage.is-needed .shell__ico { color: var(--zk-amber); }
.shell__stage.is-needed.is-active .shell__ico { color: var(--zk-blue); }
.shell__stage.is-needed.is-active .shell__stagename {
.shell__stagename { flex: 1; min-width: 0; }
.shell__stage.is-empty { color: var(--zk-grey); font-weight: var(--fw-normal); }
.shell__stage.is-empty .shell__ico { color: var(--zk-line); }
/* ── Queue tallies ───────────────────────────────────────────── */
.shell__tally { display: flex; align-items: center; gap: 6px; flex: none; }
.shell__n {
min-width: 22px;
padding: 1px 7px;
border-radius: var(--r-pill);
text-align: center;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
font-variant-numeric: tabular-nums;
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
}
.shell__stage.is-needed .shell__n { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
.shell__stage.is-active .shell__n { color: #fff; background: var(--zk-blue); }
.shell__n.is-zero { color: var(--zk-grey); background: transparent; font-weight: var(--fw-normal); }
.shell__auto {
font-style: normal;
font-size: var(--fs-2xs);
font-variant-numeric: tabular-nums;
color: var(--zk-blue-mid);
}
.shell__auto::after { content: '·'; margin-left: 5px; color: var(--zk-line); }
.shell__urgent { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-danger); flex: none; }
.shell__grouptally {
font-size: 11px;
font-weight: var(--fw-semi);
font-variant-numeric: tabular-nums;
color: #fff;
background: var(--zk-amber);
border-radius: var(--r-pill);
padding: 1px 7px;
}
/* Stage names wrap rather than truncate: "Referred to Underwriting" is the one
queue a person has to find, and it is also the longest label. */
.shell__stagename {
min-width: 0;
line-height: 1.35;
/* ── Closed, folded ──────────────────────────────────────────── */
.shell__closed > summary {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
list-style: none;
padding: 6px var(--sp-3);
border-radius: var(--r-sm);
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
color: var(--zk-grey);
}
.shell__closed > summary::-webkit-details-marker { display: none; }
.shell__closed > summary:hover { color: var(--zk-muted); background: var(--zk-hover); }
.shell__closed > summary b {
margin-left: auto;
font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
font-variant-numeric: tabular-nums;
color: var(--zk-grey);
}
.shell__caret { transition: transform var(--t-fast); color: var(--zk-grey); }
.shell__closed[open] .shell__caret { transform: rotate(90deg); }
.shell__closed .shell__stages { margin-top: 4px; }
/* ── AI team, at the foot ─────────────────────────────────────── */
.shell__team {
margin-top: auto;
padding: var(--sp-4) var(--sp-3) 0;
border-top: 1px solid var(--zk-line-soft);
}
.shell__team .shell__navlabel { padding: 0; }
.shell__teamrow { display: flex; flex-wrap: wrap; gap: 4px; }
.shell__teamnote {
display: block;
margin-top: 8px;
font-size: 11.5px;
line-height: 1.45;
color: var(--zk-grey);
}
/* ── Main ────────────────────────────────────────────────────── */
.shell__main {
flex: 1;
min-width: 0;
background: var(--zk-white);
width: 100%;
max-width: 1460px;
max-width: 1440px;
margin: 0 auto;
padding: 30px 34px 76px;
padding: var(--sp-6) var(--sp-7) var(--sp-9);
}
/* ── Narrow viewports: the rail lies down and scrolls ────────── */
@media (max-width: 1100px) {
.shell__top { grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-4); }
.shell__brandtext { display: none; }
}
/* ── Narrow viewports: the rail lies down and scrolls ──────── */
@media (max-width: 900px) {
.shell__body {
flex-direction: column;
}
.shell__top { grid-template-columns: auto auto; }
.gs { display: none; }
.shell__body { flex-direction: column; }
.shell__nav {
position: static;
@ -204,167 +330,21 @@
min-height: 0;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--zk-line);
padding: 14px 16px 16px;
}
.shell__stages {
border-bottom: 1px solid var(--zk-card-line);
padding: 10px 14px;
flex-direction: row;
overflow-x: auto;
padding-bottom: 4px;
flex-wrap: wrap;
gap: 8px;
}
/* Laid out horizontally the stages are a scrolling strip of pills, and a rail
behind them would just be a line struck through the row. */
.shell__stages::before {
display: none;
}
.shell__stage {
padding: 8px 14px;
.shell__navtop, .shell__group, .shell__closed { display: contents; }
.shell__navlabel, .shell__team, .shell__closed > summary { display: none; }
.shell__stages { flex-direction: row; flex-wrap: wrap; gap: 6px; }
.shell__stage, .shell__today {
padding: 6px 12px;
white-space: nowrap;
background: var(--zk-white);
border: 1px solid var(--zk-line);
background: var(--zk-card);
}
.shell__stage::before {
display: none;
}
.shell__main {
padding: 22px 18px 56px;
}
.shell__stage.is-active::before, .shell__today.is-active::before { display: none; }
.shell__main { padding: 18px 16px 48px; }
}
/* 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: var(--fs-xs);
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); }
/* Queue tallies
Three numbers, deliberately unequal in weight, because they are unequal in
importance: what needs YOU, what an agent holds, and whether anything in
there is about to lapse. */
.shell__tally {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
/* What is waiting on a person. The only figure with full contrast. */
.shell__n {
min-width: 20px;
padding: 1px 6px;
border-radius: var(--r-pill);
text-align: center;
font-size: var(--fs-2xs);
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
}
/* Zero is shown, not hidden. "Nothing here" is an answer, and a queue that
disappears when it empties makes the sidebar move under the cursor. */
.shell__n.is-zero {
color: var(--zk-grey);
background: transparent;
font-weight: 400;
}
/* What an agent is carrying. Present, quiet, and never added to the badge
it is in the queue, it is not your work. */
.shell__auto {
font-style: normal;
font-size: var(--fs-2xs);
font-variant-numeric: tabular-nums;
color: var(--zk-blue-mid);
}
.shell__auto::after {
content: '·';
margin-left: 5px;
color: var(--zk-line);
}
/* A renewal inside a week sitting in this queue. */
.shell__urgent {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--zk-amber);
flex: none;
}
.shell__stage.is-empty { color: var(--zk-grey); }
.shell__stage.is-empty::before { border-color: var(--zk-line-soft); }
.shell__stage.is-empty.is-needed .shell__stagename { color: var(--zk-muted); font-weight: 400; }
/* The one number worth carrying on a group heading: everything this role has
to clear, across its queues. */
.shell__navlabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.shell__grouptally {
font-size: var(--fs-2xs);
font-weight: 600;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
color: var(--zk-white);
background: var(--zk-blue);
border-radius: var(--r-pill);
padding: 1px 7px;
}
/* Closed, folded
Three of the twelve queues, opened about once a week. */
.shell__closed { margin-top: 22px; }
.shell__closed > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
list-style: none;
padding: 0 10px 10px;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zk-grey);
}
.shell__closed > summary::-webkit-details-marker { display: none; }
.shell__closed > summary:hover { color: var(--zk-muted); }
.shell__closed > summary b {
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
color: var(--zk-grey);
}
.shell__closed > summary::after {
content: '';
order: -1;
width: 5px; height: 5px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
transition: transform var(--t-fast);
}
.shell__closed[open] > summary::after { transform: rotate(45deg); }

View File

@ -1,8 +1,15 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import AgentCard from '../components/AgentCard.jsx'
import AgentChip from '../components/AgentChip.jsx'
import Icon from '../components/Icon.jsx'
import { KIND_ICON } from '../api/display.js'
import GlobalSearch from './GlobalSearch.jsx'
import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css'
@ -13,22 +20,13 @@ import './Shell.css'
* is not the question anyone signing in has; these answer "is anything waiting
* on me?". Queues that need a person are named by what they want done.
*
* IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could
* only come from a second full list call that would then disagree with the
* queue's own total. That was right about the cost and wrong about the
* conclusion with no numbers, the only way to learn whether anything is
* waiting on you is to open all nine queues in turn, which is precisely the
* question navigation exists to answer. The overview's existing fetch moved
* into PortfolioProvider and both surfaces read it, so this is one call fewer
* than before rather than one more.
* The counts come from PortfolioProvider one fetch shared with the overview,
* so the two cannot disagree. The badge counts leads whose PHASE needs a
* person, not leads in the state (see phaseOf); a dot marks a queue holding a
* renewal inside a week.
*
* The badge counts leads whose PHASE needs a person, not leads in the state
* see phaseOf. A dot marks a queue holding a renewal inside a week, which is
* the only reason to open one queue before another.
*
* The closed states are folded away. They are three of the twelve, they are
* reporting rather than work, and at equal weight they made the live queues
* harder to find.
* The closed states are folded away: three of the twelve, reporting rather
* than work, and at equal weight they made the live queues harder to find.
*/
export default function Shell() {
const { user } = useZino()
@ -37,6 +35,7 @@ export default function Shell() {
// lives on the overview, which counts the whole book for everyone.
const stages = visibleStages(roles)
const { counts, ready } = useStageCounts()
const [openAgent, setOpenAgent] = useState(null)
// One queue row. Extracted because the live groups and the folded Closed
// group render the same thing and drifted apart when they did not.
@ -54,12 +53,13 @@ export default function Shell() {
+ (ready && !n && !c.working ? ' is-empty' : '')
}
>
<Icon name={KIND_ICON[s.kind] || 'list'} className="shell__ico" />
<span className="shell__stagename">{s.need ?? s.name}</span>
{ready ? (
<span className="shell__tally">
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null}
{/* The agents' share is shown in the middle dot notation rather
than added in: it is in the queue, it is not your work. */}
{/* The agents' share is shown apart rather than added in: it is in
the queue, it is not your work. */}
{c.working ? <em className="shell__auto" title={`${c.working} being worked by an agent`}>{c.working}</em> : null}
<b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
</span>
@ -73,35 +73,24 @@ export default function Shell() {
<header className="shell__top">
<div className="shell__brand">
<img src={logo} alt="Zurich Kotak General Insurance" />
<div className="shell__brandtext">
<strong>Lead Desk</strong>
<span>Lead to Policy</span>
</div>
<span className="shell__brandtext">Lead Desk</span>
</div>
<GlobalSearch />
<UserMenu />
</header>
<div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline">
<NavLink
to="/"
end
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
Overview
</NavLink>
<NavLink
to="/stage/all"
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
All leads
</NavLink>
{/* "New lead" used to sit here. A sidebar is for moving between
places; filing a lead is an ACTION, and an action in a list of
destinations is the one item that does not behave like its
neighbours. It lives on the screens where leads are looked at
the overview and the queues beside the thing it adds to. */}
<div className="shell__navtop">
<NavLink to="/" end className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
<Icon name="home" className="shell__ico" />
Overview
</NavLink>
<NavLink to="/stage/all" className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
<Icon name="list" className="shell__ico" />
All leads
</NavLink>
</div>
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
const inGroup = stages.filter((s) => s.kind === group.kind)
@ -120,11 +109,11 @@ export default function Shell() {
)
})}
{/* Closed. Folded, because it is reporting rather than work and at
equal weight with the live queues it made them harder to find. */}
{/* Closed. Folded, because it is reporting rather than work. */}
{stages.some((s) => s.kind === 'end') ? (
<details className="shell__closed">
<summary>
<Icon name="chevronRight" className="shell__caret" />
Closed
{ready ? (
<b>{stages.filter((s) => s.kind === 'end')
@ -134,12 +123,26 @@ export default function Shell() {
<div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
</details>
) : null}
{/* The team that carries the work. Five employees, each one click
from what it does and what it may touch. */}
<div className="shell__team">
<p className="shell__navlabel">AI employees</p>
<div className="shell__teamrow">
{Object.keys(AGENTS).map((k) => (
<AgentChip key={k} agentKey={k} size="md" showName={false} onOpen={setOpenAgent} />
))}
</div>
<span className="shell__teamnote">Working every open lead. Click one to see its remit.</span>
</div>
</nav>
<main className="shell__main">
<Outlet />
</main>
</div>
{openAgent ? <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> : null}
</div>
)
}

View File

@ -1,96 +1,43 @@
.um {
position: relative;
}
.um { position: relative; justify-self: end; }
.um__trigger {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 5px 12px 5px 5px;
padding: 4px 10px 4px 4px;
border-radius: var(--r-pill);
background: transparent;
border: 1px solid transparent;
color: inherit;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.um__trigger:hover {
background: var(--zk-tint);
border-color: var(--zk-line);
}
.um__trigger.is-open {
background: var(--zk-tint-blue);
border-color: var(--zk-blue-light);
}
.um__trigger:focus-visible {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.um__avatar {
flex: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--grad-blue);
color: var(--zk-white);
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0.03em;
box-shadow: var(--sh-xs);
}
.um__avatar--lg {
width: 40px;
height: 40px;
font-size: var(--fs-xs);
}
.um__trigger:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
.um__trigger.is-open { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.um__trigger:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.um__id {
display: flex;
flex-direction: column;
text-align: left;
line-height: 1.3;
line-height: 1.25;
}
.um__id strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
.um__id > span { font-size: 11.5px; color: var(--zk-muted); }
.um__id strong {
font-size: var(--fs-xs);
font-weight: 500;
}
.um__id > span {
font-size: var(--fs-2xs);
color: var(--zk-muted);
}
.um__caret {
width: 12px;
height: 12px;
flex: none;
color: var(--zk-grey);
transition: transform var(--t);
}
.um__trigger.is-open .um__caret {
transform: rotate(180deg);
}
.um__caret { flex: none; color: var(--zk-grey); transition: transform var(--t); }
.um__trigger.is-open .um__caret { transform: rotate(180deg); }
.um__menu {
position: absolute;
top: calc(100% + 10px);
top: calc(100% + 8px);
right: 0;
z-index: 40;
min-width: 300px;
padding: 7px;
border-radius: var(--r-lg);
background: var(--zk-white);
border: 1px solid var(--zk-line);
padding: 6px;
border-radius: var(--r-md);
background: var(--zk-card);
border: 1px solid var(--zk-card-line);
box-shadow: var(--sh-lg);
transform-origin: top right;
animation: zk-rise 0.16s var(--ease) both;
@ -106,56 +53,31 @@
.um__headid {
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
line-height: 1.35;
min-width: 0;
}
.um__headid strong {
font-size: var(--fs-xs);
font-weight: 500;
}
.um__headid span {
font-size: var(--fs-2xs);
color: var(--zk-muted);
overflow-wrap: anywhere;
}
.um__headid .um__roles {
margin-top: 4px;
align-self: flex-start;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
border-radius: var(--r-pill);
padding: 2px 9px;
}
.um__headid strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
.um__headid > span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
.um__roles { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.um__item {
display: block;
display: flex;
align-items: center;
gap: 9px;
width: 100%;
text-align: left;
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
padding: 10px 11px;
font-size: var(--fs-xs);
padding: 9px 11px;
border-radius: var(--r-sm);
background: none;
border: 0;
border-top: 1px solid var(--zk-line-soft);
border-radius: 0 0 var(--r-sm) var(--r-sm);
color: var(--zk-ink);
transition: background var(--t-fast), color var(--t-fast);
}
.um__item:hover {
background: var(--zk-tint);
color: var(--zk-blue-dark);
}
.um__item:focus-visible {
outline: none;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
.um__item:hover { background: var(--zk-hover); color: var(--zk-blue-dark); }
.um__item:focus-visible { outline: none; background: var(--zk-tint-blue); color: var(--zk-blue-dark); }

View File

@ -1,20 +1,15 @@
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { initials, roleLabel } from '../api/display.js'
import Icon from '../components/Icon.jsx'
import './UserMenu.css'
/** First letters of the first two words — a name is all we have for an avatar. */
function initials(user) {
const from = user?.name || user?.email || ''
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
if (!parts.length) return '?'
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
}
/**
* The signed-in identity, with sign-out folded inside it.
*
* The role stays visible on the trigger rather than only inside the menu: RBAC is
* the demo, so who you are signed in as should never take a click to find out.
* It reads as a label "SME underwriter" never as the stored slug.
*/
export default function UserMenu() {
const { user, signOut } = useZino()
@ -43,7 +38,8 @@ export default function UserMenu() {
}, [open])
const name = user?.name || user?.email || 'Signed in'
const roles = (user?.roles || []).join(', ')
const roles = (user?.roles || []).map(roleLabel).filter(Boolean)
const who = initials(user?.name || (user?.email || '').replace(/@.*$/, '').replace(/[._-]+/g, ' '))
return (
<div className="um" ref={wrapRef}>
@ -55,28 +51,30 @@ export default function UserMenu() {
aria-haspopup="menu"
aria-expanded={open}
>
<span className="um__avatar" aria-hidden="true">{initials(user)}</span>
<span className="avatar avatar--sm avatar--blue" aria-hidden="true">{who}</span>
<span className="um__id">
<strong>{name}</strong>
<span>{roles || user?.email}</span>
<span>{roles.join(' · ') || user?.email}</span>
</span>
<svg className="um__caret" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
<Icon name="chevronDown" size={14} className="um__caret" />
</button>
{open ? (
<div className="um__menu" role="menu">
<div className="um__head">
<span className="um__avatar um__avatar--lg" aria-hidden="true">{initials(user)}</span>
<span className="avatar avatar--lg avatar--blue" aria-hidden="true">{who}</span>
<div className="um__headid">
<strong>{name}</strong>
{user?.email ? <span>{user.email}</span> : null}
{roles ? <span className="um__roles">{roles}</span> : null}
{roles.length ? (
<span className="um__roles">
{roles.map((r) => <span key={r} className="pill pill--blue">{r}</span>)}
</span>
) : null}
</div>
</div>
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
<Icon name="signOut" />
Sign out
</button>
</div>

View File

@ -1,6 +1,6 @@
.login {
display: grid;
grid-template-columns: 45% 55%;
grid-template-columns: minmax(380px, 44%) minmax(0, 1fr);
min-height: 100%;
}
@ -9,12 +9,10 @@
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px 48px;
overflow: hidden;
color: var(--zk-white);
background:
linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
background: linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
}
.login__grid {
@ -31,22 +29,14 @@
position: absolute;
inset: 0;
background:
radial-gradient(
520px circle at 12% 12%,
rgba(84, 149, 207, 0.38),
transparent 62%
),
radial-gradient(
620px circle at 78% 96%,
rgba(199, 235, 249, 0.16),
transparent 60%
);
radial-gradient(520px circle at 12% 12%, rgba(84, 149, 207, 0.38), transparent 62%),
radial-gradient(620px circle at 78% 96%, rgba(199, 235, 249, 0.16), transparent 60%);
}
.login__logo {
position: relative;
z-index: 1;
width: 186px;
width: 170px;
height: auto;
border-radius: 8px;
}
@ -54,153 +44,146 @@
.login__brand-body {
position: relative;
z-index: 1;
margin-top: auto;
margin-bottom: auto;
margin: auto 0;
padding: 48px 0 24px;
max-width: 480px;
}
.login__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid rgba(199, 235, 249, 0.34);
gap: 9px;
padding: 6px 14px 6px 10px;
border: 1px solid rgba(199, 235, 249, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--zk-cyan);
font-size: var(--fs-2xs);
font-weight: 400;
letter-spacing: 0.01em;
font-weight: var(--fw-medium);
}
.login__badge svg {
width: 15px;
height: 15px;
.login__badgedot {
width: 7px; height: 7px; border-radius: 50%;
background: #7ee2b8;
box-shadow: 0 0 0 0 rgba(126, 226, 184, .6);
animation: zk-live 2.2s var(--ease) infinite;
}
.login__headline {
/* Zurich Kotak set their hero in Zurich Sans Light */
margin: 26px 0 0;
font-size: var(--fs-3xl);
font-weight: 300;
line-height: 1.16;
letter-spacing: -0.01em;
}
.login__headline-accent {
display: block;
background: linear-gradient(
92deg,
#a9dcf6 0%,
var(--zk-cyan) 55%,
#e9f8ff 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin: 22px 0 0;
font-size: 44px;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.03em;
}
.login__lede {
margin: 16px 0 0;
max-width: 430px;
margin: 14px 0 0;
max-width: 440px;
text-wrap: balance;
font-size: var(--fs-md);
font-weight: 300;
line-height: 1.5;
color: rgba(255, 255, 255, 0.8);
line-height: 1.55;
color: rgba(255, 255, 255, 0.82);
}
.login__features {
margin: 36px 0 0;
margin: 34px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 22px;
}
.login__features li {
display: flex;
align-items: flex-start;
gap: 14px;
gap: 18px;
}
.login__features li { display: flex; align-items: flex-start; gap: 14px; }
.login__feature-icon {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
width: 36px;
height: 36px;
border: 1px solid rgba(199, 235, 249, 0.26);
border-radius: 9px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
color: var(--zk-cyan);
}
.login__feature-icon svg {
width: 17px;
height: 17px;
}
.login__features strong {
display: block;
font-size: var(--fs-sm);
font-weight: 500;
letter-spacing: 0.005em;
}
.login__features em {
display: block;
margin-top: 3px;
font-size: var(--fs-xs);
font-style: normal;
font-weight: 300;
line-height: 1.5;
color: rgba(255, 255, 255, 0.74);
}
.login__team {
margin-top: 36px;
padding-top: 22px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
}
.login__team > span {
display: block;
margin-bottom: 10px;
font-size: var(--fs-2xs);
color: rgba(255, 255, 255, 0.6);
}
.login__teamrow { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.login__teamrow .who__name { color: rgba(255, 255, 255, 0.86); }
/* ── Right form panel ──────────────────────────────────────── */
.login__panel {
display: grid;
place-items: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 22px;
padding: 40px 24px;
background: var(--zk-tint);
background: var(--zk-ground);
}
.login__form-wrap {
.login__card {
width: 100%;
max-width: 360px;
max-width: 440px;
padding: 34px 36px 30px;
background: var(--zk-card);
border: 1px solid var(--zk-card-line);
border-radius: var(--r-xl);
box-shadow: var(--sh-md);
}
.login__logo--mobile {
display: none;
margin-bottom: 28px;
}
.login__logo--mobile { display: none; margin-bottom: 24px; }
.login__title {
margin: 0;
font-size: var(--fs-2xl);
font-weight: 500;
letter-spacing: -0.015em;
letter-spacing: -0.02em;
color: var(--zk-navy);
}
.login__subtitle {
margin: 8px 0 0;
margin: 6px 0 0;
font-size: var(--fs-xs);
font-weight: 300;
color: var(--zk-muted);
}
.login__form {
margin-top: 30px;
margin-top: 26px;
display: flex;
flex-direction: column;
gap: 18px;
gap: 16px;
}
.login__field span {
display: block;
margin-bottom: 7px;
margin-bottom: 6px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-ink);
@ -210,31 +193,45 @@
width: 100%;
padding: 11px 14px;
border: 1px solid var(--zk-line);
border-radius: 8px;
border-radius: var(--r-sm);
background: var(--zk-white);
font-size: var(--fs-xs);
font-weight: 400;
color: var(--zk-ink);
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.login__field input::placeholder { color: var(--zk-grey); }
.login__field input:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.login__field input::placeholder {
.login__pw { position: relative; }
.login__pw input { padding-right: 44px; }
.login__eye {
position: absolute;
right: 6px; top: 50%;
transform: translateY(-50%);
display: grid; place-items: center;
width: 32px; height: 32px;
border: 0; border-radius: 6px;
background: none;
color: var(--zk-grey);
cursor: pointer;
}
.login__field input:focus {
outline: none;
border-color: var(--zk-blue);
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.14);
}
.login__eye:hover { background: var(--zk-hover); color: var(--zk-ink); }
.login__eye[aria-pressed="true"] { color: var(--zk-blue); }
.login__error {
margin: -4px 0 0;
display: flex;
align-items: flex-start;
gap: 8px;
margin: -2px 0 0;
padding: 10px 12px;
border-radius: var(--r-sm);
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
font-size: var(--fs-2xs);
color: var(--zk-danger);
line-height: 1.45;
color: var(--zk-danger-ink);
}
.login__error svg { flex: none; margin-top: 2px; }
.login__submit {
display: inline-flex;
@ -244,44 +241,67 @@
margin-top: 4px;
padding: 12px 18px;
border: 0;
border-radius: 8px;
border-radius: var(--r-sm);
background: var(--zk-blue);
color: var(--zk-white);
font-size: var(--fs-xs);
font-weight: 500;
cursor: pointer;
transition:
background 0.16s ease,
box-shadow 0.16s ease;
transition: background 0.16s ease, box-shadow 0.16s ease;
}
.login__submit:hover:not(:disabled) { background: var(--zk-blue-dark); }
.login__submit:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28); }
.login__submit:disabled { opacity: 0.72; cursor: default; }
.login__submit svg {
width: 16px;
height: 16px;
/* ── Personas ─────────────────────────────────────────────── */
.login__personas {
margin-top: 26px;
padding-top: 20px;
border-top: 1px solid var(--zk-line-soft);
}
.login__personas-l {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin: 0 0 10px;
}
.login__personas-l span { font-size: var(--fs-2xs); font-weight: 500; color: var(--zk-ink); }
.login__personas-l em { font-size: 11.5px; font-style: normal; color: var(--zk-grey); }
.login__submit:hover:not(:disabled) {
background: var(--zk-blue-dark);
}
.login__personagrid { display: flex; flex-direction: column; gap: 4px; }
.login__submit:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28);
}
.login__submit:disabled {
opacity: 0.72;
cursor: default;
.persona {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
width: 100%;
padding: 7px 10px 7px 7px;
border: 1px solid transparent;
border-radius: var(--r-sm);
background: transparent;
text-align: left;
font: inherit;
color: var(--zk-ink);
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.persona:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
.persona.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.persona:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.persona__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.persona__t strong { font-size: var(--fs-2xs); font-weight: 500; }
.persona__t span { font-size: 11.5px; color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.persona__ok { color: var(--zk-blue); }
.login__powered {
margin: 26px 0 0;
margin: 0;
text-align: center;
font-size: var(--fs-2xs);
font-weight: 300;
color: var(--zk-grey);
}
.login__powered span {
font-weight: 500;
letter-spacing: 0.06em;
@ -291,34 +311,13 @@
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 1024px) {
.login__brand {
padding: 32px;
}
.login__headline {
font-size: var(--fs-3xl);
}
.login__lede {
font-size: var(--fs-sm);
}
.login__brand { padding: 32px; }
.login__headline { font-size: 38px; }
}
@media (max-width: 860px) {
.login {
grid-template-columns: 1fr;
}
.login__brand {
display: none;
}
.login__panel {
align-content: center;
background: var(--zk-white);
}
.login__logo--mobile {
display: block;
}
.login { grid-template-columns: 1fr; }
.login__brand { display: none; }
.login__logo--mobile { display: block; }
.login__card { padding: 26px 22px; }
}

View File

@ -1,51 +1,64 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { ORG_ID } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import AgentChip from '../components/AgentChip.jsx'
import Icon from '../components/Icon.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Login.css'
const FEATURES = [
/**
* What the console is, said to the person about to use it not what the
* insurer sells. The old panel carried retail copy ("we cover it all"); this is
* an operator's tool, and the three lines are the three claims the demo makes.
*/
const CLAIMS = [
{
title: 'Instant policy issuance',
body: 'No inspection; hassle free policy',
icon: (
<>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" />
<path d="M14 3v5h5" />
<path d="m9.2 14.2 1.9 1.9 3.7-3.7" />
</>
),
icon: 'bot',
title: 'Autonomous by default',
body: 'Five AI employees qualify, call, chase, price and check every renewal lead.',
},
{
title: '24/7 claim assistance',
body: 'Making claims simple, transparent, and customer-first',
icon: (
<>
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M12 7.2V12l3.2 1.9" />
</>
),
icon: 'user',
title: 'A person at the gates',
body: 'Documents, the underwriting call and the premium sign-off stay with people.',
},
{
title: 'A trusted brand',
body: 'You can count on Zurich Kotak General Insurance at all times',
icon: (
<>
<path d="M12 3 4.8 5.9v5.4c0 4.3 3 8.3 7.2 9.4 4.2-1.1 7.2-5.1 7.2-9.4V5.9L12 3Z" />
<path d="m9.2 12.1 2 2 3.6-3.8" />
</>
),
icon: 'brain',
title: 'Every decision on record',
body: 'Each step shows who took it, what it wrote, and the reasoning behind it.',
},
]
/**
* Demo sign-ins. These are the accounts listed in README.md for org 84; the
* picker only saves typing the address during a walkthrough. No password is
* held here the gateway authenticates every sign-in as it always did, and
* anything a persona can do is still decided by the workflow, server-side.
*/
const PERSONAS = [
{ email: 'sanjay.uw@zurichkotak.example', name: 'Sanjay Bhat', role: 'SME underwriter', does: 'Clears or declines referrals', tone: 'blue' },
{ email: 'arjun.posp@zurichkotak.example', name: 'Arjun Shetty', role: 'Partner agent', does: 'Files leads, uploads documents', tone: 'teal' },
{ email: 'meera.rm@zurichkotak.example', name: 'Meera Rao', role: 'Bancassurance RM', does: 'Files partner-bank leads', tone: 'violet' },
{ email: 'kavya.csr@zurichkotak.example', name: 'Kavya Nair', role: 'Direct sales', does: 'Files self-serve leads', tone: 'amber' },
{ email: 'deepa.ops@zurichkotak.example', name: 'Deepa Iyer', role: 'Operations', does: 'Sees every queue, confirms premiums', tone: 'plum' },
]
function initialsOf(name) {
const p = name.split(' ')
return (p[0][0] + (p[1]?.[0] || '')).toUpperCase()
}
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const { signIn } = useZino()
const navigate = useNavigate()
const pwRef = useRef(null)
async function handleSubmit(event) {
event.preventDefault()
@ -72,83 +85,69 @@ export default function Login() {
}
}
function pick(p) {
setEmail(p.email)
setError('')
// The address is filled; the password is still theirs to type.
pwRef.current?.focus()
}
const picked = PERSONAS.find((p) => p.email === email)
return (
<main className="login">
<section className="login__brand">
<div className="login__grid" aria-hidden="true" />
<div className="login__glow" aria-hidden="true" />
<img
className="login__logo"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<img className="login__logo" src={logo} alt="Zurich Kotak General Insurance" />
<div className="login__brand-body">
<span className="login__badge">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3.2 12.4a8.8 8.8 0 0 1 17.6 0H3.2Z" />
<path d="M12 12.4v5.9a2.4 2.4 0 0 0 4.8 0" />
</svg>
General insurance that has it all
<span className="login__badgedot" aria-hidden="true" />
Lead to Policy · operator console
</span>
<h1 className="login__headline">
Protecting what
<span className="login__headline-accent">
matters the most to you
</span>
Lead Desk
</h1>
<p className="login__lede">
Get tailored insurance for all your needs, cause we cover it all
Every motor and SME renewal lead, from the first call to the issued policy
carried by AI employees, with people at the decisions that need one.
</p>
<ul className="login__features">
{FEATURES.map((feature) => (
<li key={feature.title}>
{CLAIMS.map((c) => (
<li key={c.title}>
<span className="login__feature-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{feature.icon}
</svg>
<Icon name={c.icon} size={18} strokeWidth={1.5} />
</span>
<span>
<strong>{feature.title}</strong>
<em>{feature.body}</em>
<strong>{c.title}</strong>
<em>{c.body}</em>
</span>
</li>
))}
</ul>
<div className="login__team">
<span>The team</span>
<div className="login__teamrow">
{Object.keys(AGENTS).map((k) => (
<AgentChip key={k} agentKey={k} size="md" />
))}
</div>
</div>
</div>
</section>
<section className="login__panel">
<div className="login__form-wrap">
<img
className="login__logo login__logo--mobile"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<div className="login__card">
<img className="login__logo login__logo--mobile" src={logo} alt="Zurich Kotak General Insurance" />
<h2 className="login__title">Sign in</h2>
<p className="login__subtitle">
Enter your credentials to access your dashboard.
</p>
<p className="login__subtitle">Use your Lead Desk account for organisation {ORG_ID}.</p>
<form className="login__form" onSubmit={handleSubmit} noValidate>
<label className="login__field">
@ -157,7 +156,7 @@ export default function Login() {
type="email"
name="email"
autoComplete="email"
placeholder="you@company.com"
placeholder="you@zurichkotak.example"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
@ -165,44 +164,69 @@ export default function Login() {
<label className="login__field">
<span>Password</span>
<input
type="password"
name="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<div className="login__pw">
<input
ref={pwRef}
type={showPw ? 'text' : 'password'}
name="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button
type="button"
className="login__eye"
onClick={() => setShowPw((v) => !v)}
aria-label={showPw ? 'Hide password' : 'Show password'}
aria-pressed={showPw}
>
<Icon name="eye" />
</button>
</div>
</label>
{error ? (
<p className="login__error" role="alert">
<Icon name="alert" size={14} />
{error}
</p>
) : null}
<button className="login__submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
{submitting ? null : (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 12h15m0 0-5.5-5.5M19 12l-5.5 5.5" />
</svg>
)}
{submitting ? 'Signing in…' : picked ? `Sign in as ${picked.name.split(' ')[0]}` : 'Sign in'}
{submitting ? null : <Icon name="arrowRight" />}
</button>
</form>
<p className="login__powered">
Powered by <span>zino</span>
</p>
<div className="login__personas">
<p className="login__personas-l">
<span>Demo personas</span>
<em>pick one to fill the email</em>
</p>
<div className="login__personagrid">
{PERSONAS.map((p) => (
<button
key={p.email}
type="button"
className={'persona' + (email === p.email ? ' is-on' : '')}
onClick={() => pick(p)}
>
<span className={'avatar avatar--' + p.tone} aria-hidden="true">{initialsOf(p.name)}</span>
<span className="persona__t">
<strong>{p.name}</strong>
<span>{p.role} · {p.does}</span>
</span>
{email === p.email ? <Icon name="check" className="persona__ok" /> : null}
</button>
))}
</div>
</div>
</div>
<p className="login__powered">
Powered by <span>zino</span>
</p>
</section>
</main>
)

View File

@ -7,13 +7,15 @@ import AgentChip from '../components/AgentChip.jsx'
import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import Icon from '../components/Icon.jsx'
import LeadFileDialog from '../components/LeadFileDialog.jsx'
import StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { APP_ID, CHANNELS, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import { humanize, initials, toneFor } from '../api/display.js'
import './screens.css'
/** The countdown, not just the date. On a renewal book this is the number
@ -50,10 +52,6 @@ const GROUPS = [
['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']],
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
// The five OCR slots led this list and were absent from it, which is how a
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
// the status and the notes. They were also absent from the view itself
// until 76; adding them here without that would have changed nothing.
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
@ -74,22 +72,10 @@ const GROUPS = [
const LONG = 150
/**
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
*
* Three things are always true of a lead who the customer is, what is being
* insured, and what has been attached so those are always here. The fourth
* section depends on where the lead has got to: a lead in Quoted needs the
* cover and the quote's expiry; the same lead in Onboarded needs the policy
* number and when it renews. Everything else is in the full file.
*
* THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
* carries the same hazard: a stage not listed here simply gets no fourth
* section, and a field renamed in the workflow quietly stops appearing. That is
* the safe direction to fail the full file is generated from the data and
* still holds everything but it is a mirror, not a source.
*
* The metrics strip at the top of the page already carries premium, commission,
* product, renewal date, lead age and AI confidence. Nothing is repeated here.
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED. Three things are always true of a
* lead who the customer is, what is being insured, and what has been
* attached so those are always here. The fourth section depends on where
* the lead has got to. Everything else is in the full file.
*/
const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'],
@ -111,14 +97,7 @@ const GLANCE_RISK = {
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
}
/**
* Field ids are snake_case and several carry acronyms, which sentence-casing
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
* other word passes through.
*
* This is the FALLBACK. The detail view ships an output_label for every field
* and that is preferred this covers a field the config does not describe.
*/
/** Fallback labels for a field the config does not describe. */
const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
@ -136,73 +115,70 @@ function filesOf(v) {
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
}
/** Fields whose value is a code that must NOT be spelt out. */
const VERBATIM = new Set(['lead_ref', 'pan', 'gstin', 'udyam_no', 'motor_reg_no', 'partner_code', 'policy_no',
'payment_ref', 'kyc_ref', 'acceptance_ref', 'dedupe_match_ref', 'payout_ref', 'product_code', 'motor_prev_policy_no', 'email', 'mobile'])
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(v)) {
// A file field holds an array of upload references {uuid, original_name,
// blob_path, } so joining it raw prints [object Object].
return v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : humanize(x)))
.filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null
}
// No field carries a bare object today, but one arriving as JSON should not
// print as [object Object].
if (typeof v === 'object') return JSON.stringify(v)
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
return String(v)
// Stored tokens read as English; references, names and prose pass through.
return VERBATIM.has(k) ? String(v) : humanize(String(v))
}
/**
* Whose move it is, right now the question an operator opens a lead to
* answer, said as a coloured pill in the header. Derived from the stage's own
* kind, so it stays in step with the workflow: an AI-run stage is with the AI,
* a customer-gated one is with the customer, a person-gated one is waiting on
* someone, and a closed lead holds nobody.
* Whose move it is, right now said as a coloured pill in the header.
*/
function holdsOf(stage) {
if (!stage) return null
switch (stage.kind) {
case 'auto': return { tone: 'ai', label: 'With the AI' }
case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' }
case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' }
case 'auto': return { tone: 'ai', icon: 'bot', label: 'With the AI' }
case 'customer': return { tone: 'cust', icon: 'chat', label: 'With the customer' }
case 'needs': return { tone: 'person', icon: 'user', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', icon: 'clock', label: 'In nurture' }
case 'end': return { tone: 'grey', icon: 'archive', label: 'Closed' }
default: return null
}
}
/** A file's extension, for the little type tag on a document chip. */
function extOf(name) {
const m = String(name || '').match(/\.([a-z0-9]{2,5})$/i)
return m ? m[1].toUpperCase() : 'FILE'
}
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
const navigate = useNavigate()
const location = useLocation()
// What the workflow said when the last activity was submitted. It is written
// per activity "Documents received capturing the risk" and is the
// workflow telling the operator what it just set in motion, so it is shown
// What the workflow said when the last activity was submitted shown
// verbatim rather than replaced with a generic "Saved".
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const [labels, setLabels] = useState({})
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline
// fetched them once on mount and never again so a lead worked by five
// agents in three minutes showed the trail as it was when you opened the
// page and the conversation view needs the same rows. One fetch, one
// poll, two readers that cannot disagree.
// The audit rows live HERE, not inside Timeline: one fetch, one poll, and
// the conversation view reads the same rows.
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false)
// The full record, on demand see the panel comment below.
const [showFile, setShowFile] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the
// wall clock while rendering is impure React may render twice and get two
// answers and it also means the "nothing for 6 minutes" counter would only
// move when something else happened to re-render the page. This ticks it.
// Newest first by default: on a live demo the step that just happened is
// the one everybody is looking for. One click reads it as a story instead.
const [newestFirst, setNewestFirst] = useState(true)
// A clock in state rather than Date.now() in the render body.
const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => {
@ -215,8 +191,6 @@ export default function Lead() {
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
// The detail view ships an output_label for every field. Using the
// server's names means a field renamed in Studio is renamed here.
const fields = r?.config?.fields
if (Array.isArray(fields)) {
setLabels(Object.fromEntries(
@ -225,26 +199,19 @@ export default function Lead() {
}
})
// A poll that fails leaves the screen exactly as it is. Only a first load
// becomes an error page: a gateway blip must not throw away a lead the
// operator is reading, or unmount a half-filled form under them.
// becomes an error page.
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
// `load` is CALLED here, not handed to useEffect: it returns a promise so the
// poll below and onDone can chain on it, and an effect that returns anything
// but a function has that value called as cleanup React invokes the promise
// as destroy(), throws, and unmounts the whole tree to a blank screen.
// `load` is CALLED here, not handed to useEffect: it returns a promise.
useEffect(() => { load() }, [load])
// The message is handed over on the history entry, so it would show again
// every time this lead is reached by Back on a lead that has since moved on.
// Consume it once, then strike it from the entry.
// The message is handed over on the history entry; consume it once.
useEffect(() => {
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
}, [location.state, location.pathname, navigate])
// A lead that no longer exists is not a page to look at. Send the operator
// back to the queue rather than leaving them on an empty shell.
// A lead that no longer exists is not a page to look at.
useEffect(() => {
if (err && describeError(err).kind === 'gone') {
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
@ -252,22 +219,14 @@ export default function Lead() {
}
}, [err, navigate])
/**
* Nothing is pushed no socket, no SSE and most of this workflow is carried
* by AI employees that take one to three minutes a step, so a lead genuinely
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
* at all while the tab is in the background.
*/
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id)
}, [])
useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the
// lead has moved on the open form unmounts taking whatever was typed into
// it with no warning. A submission against a stale state is refused by the
// workflow anyway, and that refusal is handled properly below.
// Not while a form is open: a successful poll rewrites `actions`, and if
// the lead has moved on the open form unmounts with whatever was typed.
if (open) return undefined
const id = setInterval(() => {
if (document.visibilityState === 'visible') load(true)
@ -285,7 +244,15 @@ export default function Lead() {
</div>
)
}
if (!row) return <p className="empty">Loading</p>
if (!row) {
return (
<section aria-busy="true">
<header className="page__head"><div className="page__lead"><span className="skel__disc" /><div><div className="skel__line skel__line--title" /><div className="skel__line" /></div></div></header>
<div className="skel skel--tiles"><div /><div /><div /><div /></div>
<div className="skel"><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /></div>
</section>
)
}
// Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS
@ -296,22 +263,12 @@ export default function Lead() {
.filter(([, shown]) => shown.length)
const stateName = row.current_state_name || ''
// The state, then what is really happening inside it see phaseOf. Actions
// are still looked up on the state's own uid; only the presentation moves.
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage)
// Only what THIS user may run. The workflow refuses the rest server-side
// anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control.
// Only what THIS user may run. The workflow refuses the rest server-side.
const actions = actionsFor(roles, stage?.uid)
// THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do
// now" is the first question an operator asks of a lead, and it was answered
// only by scrolling past the metrics and the stage rail to the Next step
// panel. When this user has an action, it belongs top-right, where the eye
// goes for the next move; when they do not, the passive status stays there
// instead. `role: 'do'` is the committing step never a re-run or a
// recovery lever, which must not be dressed as the thing to do.
// THE ONE THING THIS USER MUST DO, promoted to the header.
const acceptedAlready = Boolean(row.acceptance_ref)
const primaryAction = actions.find(
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
@ -319,16 +276,11 @@ export default function Lead() {
const goToAction = () => {
if (!primaryAction) return
setOpen(primaryAction.uid)
// Let the form mount, then bring the working surface into view.
requestAnimationFrame(() =>
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
}
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
// Built from the SAME rows the full file renders, so the two can never
// disagree about a value. A section with nothing in it is dropped rather
// than rendered empty an SME lead has no registration number, and a
// heading over a blank box reads as a bug.
const pick = (keys) => keys
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
.filter(([, v]) => v !== null)
@ -337,25 +289,22 @@ export default function Lead() {
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
const glance = [
['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
[line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])],
['Documents', pick(docKeys)],
// THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything
// else on this lead was typed or derived; this happened out loud, and
// until now its outcome sat in the full file with the source channel while
// the words themselves were reachable only from a button halfway down the
// trail. It gets its own card whenever a call has actually happened.
['The call', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
['This stage', pick(GLANCE_STAGE[stage?.uid] || [])],
].filter(([, rows]) => rows.length)
['Customer', 'user', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
[line === 'sme' ? 'The risk' : 'The vehicle', line === 'sme' ? 'building' : 'car', pick(GLANCE_RISK[line])],
['Documents', 'file', pick(docKeys)],
['The call', 'phone', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
['This stage', 'sparkle', pick(GLANCE_STAGE[stage?.uid] || [])],
].filter(([, , rows]) => rows.length)
// Two different reasons for an empty action list, and they must not read the
// same. A terminal lead is finished; a live one you cannot act on belongs to
// somebody else, and saying "closed" about it would be a lie.
// Derived from the same audit rows the trail renders, so they cannot drift.
const worked = [...new Set((audit || [])
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
// Who worked this lead, and how many steps each took. Derived from the same
// audit rows the trail renders, so they cannot drift.
const stepsBy = {}
for (const r of audit || []) {
if (r.activity_id === 'DATA_UPDATE') continue
const k = (r.user_roles || []).find((x) => AGENTS[x])
if (k) stepsBy[k] = (stepsBy[k] || 0) + 1
}
const worked = Object.keys(AGENTS).filter((k) => stepsBy[k])
const chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => {
@ -365,74 +314,53 @@ export default function Lead() {
}).length
}, 0)
// An automated stage that has stopped for a stated reason. Checked before
// the "in progress" strip below, which would otherwise keep promising that
// something is happening for as long as the lead is left alone.
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
// An automated step that has sat too long. Distinct from `blocked`, which is
// the workflow refusing for a stated reason this is the agent not having
// come back, and the cause is usually outside this app entirely: a slow or
// failing model provider, a thinking budget that ran out mid-sentence, a
// wake that never arrived. The console cannot see any of that, so it reports
// the only thing it can know nothing has happened for a while and offers
// the way out rather than going on promising two minutes.
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null
const e = expiryOf(row.renewal_due_date)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
const ch = CHANNELS[row.source_channel]
const openAction = open ? [...actions, { uid: 'zk-act-collect-docs', label: 'Replace or add a document', by: 'you' }].find((a) => a.uid === open) : null
return (
<section>
<header className="page__head">
<header className="page__head page__head--lead">
<div className="page__lead">
{/* React Router stamps the first entry in a session with key 'default',
so a lead opened from its own URL has nothing to go back TO and
lands on the queue instead of leaving the app. */}
<button
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
<Icon name="chevronLeft" />
</button>
<div>
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub">
{row.lead_ref || `Lead ${instanceId}`}
{row.entity_name ? ` · ${row.entity_name}` : ''}
</p>
<span className={'avatar avatar--lg avatar--' + toneFor(row.customer_name)} aria-hidden="true">{initials(row.customer_name)}</span>
<div className="page__idblock">
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub page__sub--chips">
<span className="page__ref">{row.lead_ref || `Lead ${instanceId}`}</span>
{row.product_line ? <span className={'pill ' + (line === 'motor' ? 'pill--blue' : 'pill--violet')}>{PRODUCTS[row.product_line] ?? row.product_line}</span> : null}
{ch ? <span className="pill pill--line">{ch.label}</span> : null}
{row.entity_name ? <span className="page__entity">{row.entity_name}</span> : null}
</p>
</div>
</div>
{/* WHOSE MOVE IT IS. The stage name is in the rail below; this says
who holds the lead right now, which is the question the header is
really being asked. The customer tone reuses the teal cust palette. */}
{primaryAction ? (
/* YOUR MOVE. When this user owns the next step, the header carries it
as the prominent thing amber, because it is waiting on a person
and pressing it opens the form below and scrolls to it. */
/* YOUR MOVE. When this user owns the next step, the header carries
it as the prominent thing. */
<button type="button" className="cta" onClick={goToAction}>
<span className="cta__txt">
<small>Your move</small>
<b>{primaryAction.label}</b>
</span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
<Icon name="arrowRight" />
</button>
) : holds ? (
<div className={'holds holds--' + (holds.pillTone === 'cust' ? 'cust' : holds.tone)}>
<span className="holds__puck" aria-hidden="true">
{holds.tone === 'ai' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 3a2 2 0 0 0-2 2 2 2 0 0 0-1 3.6A2 2 0 0 0 4 12a2 2 0 0 0 2 2M14 3a2 2 0 0 1 2 2 2 2 0 0 1 1 3.6A2 2 0 0 1 16 12a2 2 0 0 1-2 2M10 3.5v12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
) : holds.pillTone === 'cust' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M4 5h12v8h-6l-3 2.5V13H4z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
) : holds.tone === 'person' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="6.5" r="2.8" fill="none" stroke="currentColor" strokeWidth="1.5"/><path d="M4.5 15.5a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M10 5.5v4.5l3 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5"/></svg>
)}
</span>
<div className={'holds holds--' + holds.tone}>
<span className="holds__puck" aria-hidden="true"><Icon name={holds.icon} size={16} /></span>
<span className="holds__txt">
<small>Right now</small>
<b>{holds.label}</b>
@ -441,86 +369,67 @@ export default function Lead() {
) : null}
</header>
{/* Measures, not fields. Age and dwell time are computed neither is on
the record and they are the two that answer "is this lead moving?",
which no label/value pair on the page could. */}
{(() => {
const e = expiryOf(row.renewal_due_date)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
// FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal
// is about; the AI's confidence in the cover sits beside it; the
// renewal countdown is the one deadline that matters. Dropped: lead age
// and time-in-stage (they overlapped each other and the feed carries
// timing), and product/vehicle (the rail's "The vehicle" holds them).
// Nothing is lost every removed measure is still on the page once,
// where it belongs.
return (
<div className="lmetrics">
<div className="lm lm--hero">
<span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong>
<span className="lm__s">{premium ? 'quoted, incl. GST' : 'not yet rated'}</span>
</div>
<div className="lm lm--hero">
<span className="lm__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong>
<span className="lm__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
</span>
</div>
{Number.isFinite(conf) && conf > 0 ? (
<div className="lm">
<span className="lm__l">AI confidence</span>
<strong className="lm__v">{conf}%</strong>
<span className="lm__s">on the cover advice</span>
</div>
) : null}
<div className="lm">
<span className="lm__l">Renewal</span>
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong>
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
</div>
{/* Measures, not fields. FOUR NUMBERS: money leads, the AI's confidence
sits beside it, the renewal countdown is the one deadline that matters. */}
<div className="lmetrics card">
<div className="lm lm--hero">
<span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong>
<span className="lm__s">{premium ? 'quoted, incl. GST' : 'not yet rated'}</span>
</div>
<div className="lm lm--hero">
<span className="lm__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong>
<span className="lm__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
</span>
</div>
{Number.isFinite(conf) && conf > 0 ? (
<div className="lm">
<span className="lm__l">AI confidence</span>
<strong className="lm__v">{conf}%</strong>
<span className="lm__s">on the cover advice</span>
</div>
)
})()}
<StageRail audit={audit} currentName={stateName} />
) : null}
<div className="lm">
<span className="lm__l">Renewal</span>
<strong className={'lm__v' + (e ? ' lm__v--' + e.tone : '')}>{e ? e.label : '—'}</strong>
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
</div>
<div className="lm lm--rail">
<StageRail audit={audit} currentName={stateName} />
</div>
</div>
<div className="lead">
<div className="lead__main">
{note ? (
<div className="said" role="status">
<Icon name="check" className="said__ico" />
<p>{note}</p>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss"><Icon name="x" size={14} /></button>
</div>
) : null}
{/* An AI-carried stage has no empty action bar and no idle screen: it
says what is happening and who is doing it. The activities below it
stay available ops can run them by hand but they are not the
answer to "why is nothing moving?". */}
says what is happening and who is doing it. */}
{blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<span className="doing__ico"><Icon name="alert" size={18} /></span>
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
<button type="button" className="doing__fix" onClick={() => setOpen(blocked.via)}>
Open Collect Documents
</button>
</div>
</div>
) : stalled ? (
<div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" />
<span className="doing__ico"><Icon name="clock" size={18} /></span>
<div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span>
@ -532,22 +441,15 @@ export default function Lead() {
: ' There is no step to re-run from here; the actions below are the way on.'}
</span>
{stalled.nudge ? (
<button
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
<button type="button" className="doing__fix" onClick={() => setOpen(stalled.nudge.uid)}>
{stalled.nudge.label}
</button>
) : null}
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either the lead
is with someone outside the business. Without this the screen
went blank at exactly the stage where an operator is most likely
to wonder whether something has broken. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<span className="doing__ico"><Icon name="chat" size={18} /></span>
<div>
<strong>{stage.doing}</strong>
<span>
@ -558,7 +460,7 @@ export default function Lead() {
</div>
) : stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<span className="doing__ico doing__ico--pulse"><Icon name="bot" size={18} /></span>
<div>
<strong>{stage.doing}</strong>
<span>
@ -570,26 +472,12 @@ export default function Lead() {
</div>
) : null}
{/* WHAT TO DO NEXT, not what is permitted.
Every action carries a role: `do` is the step this stage is
waiting on, `again` is a bounded loop, `force` is an AI's own job
offered only so a stalled lead can be pushed by hand, `exit` is
Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */}
{/* WHAT TO DO NEXT, not what is permitted. `do` is the step this
stage is waiting on, `again` a bounded loop, `force` an AI's own
job offered only so a stalled lead can be pushed by hand, `exit`
Mark Lost. */}
{(() => {
// Once the documents are in, the upload is not the step, not an
// alternative to the step, and not a loop worth offering: it is
// recovery. Demoting it only to 'again' still printed an Upload
// documents button under a panel saying the documents had been
// received, which is the contradiction that was reported. It goes
// into the fold with the other recovery levers, relabelled for what
// it is actually for a wrong file, or one more.
const docsDone = stage?.after === 'documents received'
// Recording an acceptance a second time is not a loop, a recovery
// or an alternative it is meaningless. The customer accepted on
// WhatsApp at a stated time and the lead carries the reference;
// offering the button again invites somebody to overwrite that
// with a worse record of the same event.
const accepted = Boolean(row.acceptance_ref)
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
@ -605,6 +493,15 @@ export default function Lead() {
const form = open ? (
<div className="acts__form">
<div className="acts__formhead">
<div>
<h3>{openAction?.label ?? 'Complete the step'}</h3>
{openAction?.by ? <p>Performed by {openAction.by}</p> : null}
</div>
<button type="button" className="btn btn--ghost btn--sm" onClick={() => setOpen(null)}>
<Icon name="x" size={13} /> Close
</button>
</div>
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
@ -623,26 +520,16 @@ export default function Lead() {
if (!step.length && !again.length && !extras.length) return null
return (
<div className="panel" id="lead-next">
{/* THE HEADING IS FOR A REAL STEP ONLY. When there is no `do`
action, the "what is happening / nothing owed" story is
already told once by the status strip above and the
header so repeating it here as "Next step: nothing is
waiting on you" was the same sentence a third time. The
secondary actions (add a document, mark lost) still render
below, without the framing. */}
<div className="card sect" id="lead-next">
{step.length ? (
<div className="panel__head">
<div className="sect__head">
<div>
<h2 className="panel__title">Next step</h2>
<p className="panel__sub">This is what this lead is waiting on.</p>
<h2 className="h-sec">Next step</h2>
<p className="h-sub">This is what this lead is waiting on.</p>
</div>
</div>
) : null}
{/* The step. One button, sized like a decision. Referred has
two because clear and decline are a pair, not a choice
between doing something and doing nothing. */}
{step.length ? (
<div className="step">
{step.map((a) => (
@ -652,8 +539,11 @@ export default function Lead() {
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}
>
<strong>{a.label}</strong>
<span>{a.by}</span>
<span className="step__ico"><Icon name={a.uid === 'zk-act-uw-decline' ? 'x' : 'check'} /></span>
<span className="step__t">
<strong>{a.label}</strong>
<span>{a.by}</span>
</span>
</button>
))}
</div>
@ -673,12 +563,9 @@ export default function Lead() {
{form}
{/* Recovery, folded. An operator needs these on the day an
agent stalls and never otherwise, and putting them in the
open makes an AI's own work look like an outstanding task. */}
{extras.length ? (
<details className="stuck">
<summary>Lead not moving?</summary>
<summary><Icon name="chevronRight" size={12} className="stuck__caret" />Lead not moving?</summary>
<p className="stuck__why">
{force.length
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
@ -701,38 +588,31 @@ export default function Lead() {
)
})()}
{/* THE STORY IS THE MAIN COLUMN NOW.
This sat in a 384px gutter with its own inner scrollbar, beside a
panel of reference fields that had the whole width. That was
exactly backwards. The trail is what this product is FOR it is
the record of work nobody watched happen and putting it in a
sidebar said it was a footnote to the form fields.
It gets the wide column and the page's own scroll. The reference
data moved to the rail, where short key/value pairs belong and
where a narrow measure costs nothing. */}
<div className="panel">
<div className="panel__head">
{/* THE STORY IS THE MAIN COLUMN. */}
<div className="card sect">
<div className="sect__head">
<div>
<h2 className="panel__title">What happened</h2>
<p className="panel__sub">
Every step on this lead, who took it, and what they wrote.
</p>
<h2 className="h-sec">What happened</h2>
<p className="h-sub">Every step on this lead, who took it, and what they wrote.</p>
</div>
<div className="sect__tools">
{chatTurns ? (
<button type="button" className="btn btn--sm" onClick={() => setShowChat(true)}>
<Icon name="whatsapp" size={13} />
Conversation
<b className="btn__n">{chatTurns}</b>
</button>
) : null}
<div className="seg" role="group" aria-label="Order">
<button type="button" className={'seg__b' + (newestFirst ? ' is-on' : '')} aria-pressed={newestFirst} onClick={() => setNewestFirst(true)}>Newest first</button>
<button type="button" className={'seg__b' + (!newestFirst ? ' is-on' : '')} aria-pressed={!newestFirst} onClick={() => setNewestFirst(false)}>As it happened</button>
</div>
</div>
{chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Conversation
<b>{chatTurns}</b>
</button>
) : null}
</div>
<Timeline
rows={audit}
newestFirst={newestFirst}
onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)}
onOpenCall={() => setShowCall(true)}
@ -743,44 +623,43 @@ export default function Lead() {
{/* Reference, not narrative. Sticky, so the facts stay put while the
story is read against them. */}
<aside className="lead__side">
{/* Who worked this lead. Five agents carry it and their names appear
only inside individual entries, so the team was never visible at
a glance. */}
{/* The team on this lead: who, what it does, how many steps. */}
{worked.length ? (
<div className="panel panel--crew">
<span className="crew__l">Worked by</span>
<div className="crew">
{worked.map((k) => <AgentChip key={k} agentKey={k} onOpen={setOpenAgent} />)}
<div className="card sect sect--tight">
<div className="sect__head">
<div>
<h2 className="h-sec">AI team on this lead</h2>
<p className="h-sub">{Object.values(stepsBy).reduce((a, b) => a + b, 0)} steps taken without a person.</p>
</div>
</div>
<ul className="crew">
{worked.map((k) => (
<li key={k} className="crew__row">
<AgentChip agentKey={k} size="md" showName={false} onOpen={setOpenAgent} />
<div className="crew__t">
<button type="button" className="crew__name" onClick={() => setOpenAgent(k)}>{AGENTS[k].full}</button>
<span>{AGENTS[k].does}</span>
</div>
<b className="crew__n">{stepsBy[k]}</b>
</li>
))}
</ul>
</div>
) : null}
{/* WHAT MATTERS HERE, NOT EVERYTHING THERE IS.
The whole record used to sit open on the page: fifty-seven
fields over thirteen groups, which pushed the audit trail the
part people actually read a screen and a half down. Most of it
is reference material. Nobody opens a lead to find its source
channel; they open it to see who the customer is, what is being
insured, what has been attached, and what this stage turns on.
So the page carries that, and the complete file is one click
away with a filter on it. Hiding is only safe when it stays
findable, which is why the trigger below names the number it is
holding back rather than saying "more". */}
{glance.length ? (
<div className="panel">
<div className="panel__head">
<div className="card sect sect--tight">
<div className="sect__head">
<div>
<h2 className="panel__title">At a glance</h2>
<p className="panel__sub">
The customer, the risk, and what this stage turns on.
</p>
<h2 className="h-sec">At a glance</h2>
<p className="h-sub">The customer, the risk, and what this stage turns on.</p>
</div>
</div>
<div className="glance">
{glance.map(([title, rows]) => (
{glance.map(([title, icon, rows]) => (
<section className="fgroup" key={title}>
<h3>{title}<span>{rows.length}</span></h3>
<h3><Icon name={icon} size={14} />{title}<span>{rows.length}</span></h3>
<dl className="props">
{rows.map(([k, v, files]) => (
<div className="prop" key={k}>
@ -790,19 +669,17 @@ export default function Lead() {
{files.map((f) => (
<a
key={f.uuid}
className="prop__file"
className="doc"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer"
title={f.original_name}
>
{f.original_name || 'Document'}
<span className="doc__ext">{extOf(f.original_name)}</span>
<span className="doc__name">{f.original_name || 'Document'}</span>
</a>
))}
</dd>
) : v.length > LONG ? (
/* A stage reason can run to a paragraph
uw_referral_reason and lost_reason both do and
a paragraph printed raw here would rebuild the
wall this panel exists to remove. */
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
) : (
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
@ -811,11 +688,8 @@ export default function Lead() {
))}
</dl>
{title === 'The call' && row.call_transcript ? (
<button type="button" className="glance__act" onClick={() => setShowCall(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
</svg>
<button type="button" className="btn btn--sm glance__act" onClick={() => setShowCall(true)}>
<Icon name="phone" size={13} />
Read what was actually said
</button>
) : null}
@ -825,12 +699,10 @@ export default function Lead() {
{fieldCount ? (
<button type="button" className="glance__all" onClick={() => setShowFile(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor"
strokeWidth="1.4" strokeLinecap="round" />
</svg>
<Icon name="list" size={15} />
Open the full lead file
<b>{fieldCount} fields</b>
<Icon name="chevronRight" size={14} />
</button>
) : null}
</div>

View File

@ -5,7 +5,9 @@ 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 { initials, toneFor, inrShort, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
import Icon from '../components/Icon.jsx'
import './screens.css'
/**
@ -41,10 +43,7 @@ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
/** Indian money, short. A dashboard tile has no room for eight digits. */
function inr(n) {
if (!n) return '₹0'
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
return inrShort(n) ?? '₹0'
}
function expiryTone(d) {
@ -55,6 +54,9 @@ function expiryTone(d) {
return 'later'
}
/** The colour a stage's share of the book wears in the flow bar. */
const FLOW_TONE = { needs: 'amber', customer: 'teal', auto: 'blue', waiting: 'grey', end: 'line' }
export default function Overview() {
const { user } = useZino()
const navigate = useNavigate()
@ -63,6 +65,9 @@ export default function Overview() {
// 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)
// Which renewal bucket the table below is narrowed to. Null is "all due
// within 30 days", as before.
const [bucket, setBucket] = useState(null)
const state = usePortfolio()
const m = useMemo(() => {
@ -135,8 +140,7 @@ export default function Overview() {
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
// Every open lead is in exactly one of three hands: a person's, the AI's,
// or the customer's. The page led with money, which is the outcome; this
// is the state, and it is the sentence the whole product exists to say.
// or the customer's.
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
let withAI = 0, withCustomer = 0, withPerson = 0
for (const r of open) {
@ -148,9 +152,14 @@ export default function Overview() {
else withPerson += 1
}
// The flow: every stage in pipeline order with its share of the book.
const flow = STAGES.map((s) => ({ ...s, n: byStage[s.name] || 0 }))
const flowOpen = flow.filter((s) => s.kind !== 'end')
const flowTotal = flowOpen.reduce((t, s) => t + s.n, 0)
return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
withAI, withCustomer, withPerson,
withAI, withCustomer, withPerson, flow, flowOpen, flowTotal,
conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
}
@ -176,11 +185,16 @@ export default function Overview() {
return (
<section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
<div className="skel skel--tiles" aria-busy="true"><div /><div /><div /><div /></div>
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /></div>
</section>
)
}
const shownAttention = bucket
? m.exposure[bucket]
: m.attention
return (
<section>
<header className="page__head">
@ -188,55 +202,97 @@ export default function Overview() {
<h1 className="page__title">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals · organisation-wide</p>
</div>
{/* The action sits with the thing it acts on. It was in the sidebar,
which is for moving between places an action among destinations
is the one item that does not behave like its neighbours. */}
<div className="page__right">
{state.at ? (
<span className="stamp">
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
<span className="live" title="Refreshes every 30 seconds while this tab is visible">
<span className="live__dot" aria-hidden="true" />
Live · {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span>
) : null}
{canFile ? (
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
<button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
<Icon name="plus" />
New lead
</button>
) : null}
</div>
</header>
{/* Who holds the work, right now. Three numbers, one sentence: this many
are being worked by the AI, this many are waiting on a person, this
many on a customer. It sits above the money because it is the claim
the money is evidence for. */}
{/* Who holds the work, right now. Three numbers and one risk, as four
quiet cards the one that is actually owed warms up. */}
<div className="now">
<div className="now__cell now__cell--ai">
<div className="now__cell card now__cell--ai">
<span className="now__ico"><Icon name="bot" size={18} /></span>
<span className="now__l">With the AI</span>
<strong className="now__v">{m.withAI}</strong>
<span className="now__s">being qualified, rated, checked or chased no one waiting</span>
<span className="now__s">being qualified, rated, checked or chased</span>
</div>
<div className={'now__cell now__cell--person' + (m.withPerson ? ' is-live' : '')}>
<div className={'now__cell card now__cell--person' + (m.withPerson ? ' is-live' : '')}>
<span className="now__ico"><Icon name="user" size={18} /></span>
<span className="now__l">Waiting on a person</span>
<strong className="now__v">{m.withPerson}</strong>
<span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span>
</div>
<div className="now__cell now__cell--cust">
<div className="now__cell card now__cell--cust">
<span className="now__ico"><Icon name="chat" size={18} /></span>
<span className="now__l">With the customer</span>
<strong className="now__v">{m.withCustomer}</strong>
<span className="now__s">a quote or a payment is theirs to answer</span>
</div>
<div className={'now__cell now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}>
<div className={'now__cell card now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}>
<span className="now__ico"><Icon name="alert" size={18} /></span>
<span className="now__l">Renewals at risk</span>
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong>
<span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span>
</div>
</div>
<div className="kpis">
{/* The pipeline as one bar. Every open stage in order, sized by its
share, coloured by who holds it and every stage, zeroes included,
named beneath so a stage that has quietly stopped receiving leads is
visible as a zero. */}
<div className="card flow">
<div className="flow__head">
<div>
<h2 className="h-sec">Pipeline</h2>
<p className="h-sub">{m.open.length} open leads across {m.flowOpen.filter((s) => s.n).length} stages · {m.closed} closed</p>
</div>
<div className="flow__legend">
<span><i className="flow__sw flow__sw--blue" />AI</span>
<span><i className="flow__sw flow__sw--amber" />Person</span>
<span><i className="flow__sw flow__sw--teal" />Customer</span>
<span><i className="flow__sw flow__sw--grey" />Scheduled</span>
</div>
</div>
<div className="flow__bar" aria-hidden="true">
{m.flowOpen.filter((s) => s.n).map((s) => (
<Link
key={s.uid}
to={`/stage/${s.uid}`}
className={'flow__seg flow__seg--' + FLOW_TONE[s.kind]}
style={{ flexGrow: s.n }}
title={`${s.name} · ${s.n}`}
>
<span>{s.n}</span>
</Link>
))}
{!m.flowTotal ? <span className="flow__seg flow__seg--line" style={{ flexGrow: 1 }} /> : null}
</div>
<ol className="flow__stages">
{m.flow.map((s) => (
<li key={s.uid}>
<Link to={`/stage/${s.uid}`} className={'flow__stage' + (s.n ? '' : ' is-zero') + (s.kind === 'end' ? ' is-end' : '')}>
<Icon name={KIND_ICON[s.kind]} size={13} className={'flow__ki flow__ki--' + FLOW_TONE[s.kind]} />
<span className="flow__sname">{s.name}</span>
<b>{s.n}</b>
</Link>
</li>
))}
</ol>
</div>
{/* Money and outcome, one row. */}
<div className="card kpis">
<div className="kpi">
<span className="kpi__l">Open leads</span>
<strong className="kpi__v">{m.open.length}</strong>
@ -265,11 +321,10 @@ export default function Overview() {
</div>
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
than a task, shown to the role that owns it and to nobody else. It sat
fourth in a list of six and read like any other item, which is the
wrong weight for the step that decides whether cover begins. */}
than a task, shown to the role that owns it and to nobody else. */}
{m.approvals.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr card' + (s.n ? ' is-live' : '')}>
<span className="appr__ico"><Icon name="lock" size={18} /></span>
<strong className="appr__n">{s.n}</strong>
<span className="appr__t">
{s.approvalLabel}
@ -277,52 +332,74 @@ export default function Overview() {
</span>
<span className="appr__go">
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
<Icon name="chevronRight" />
</span>
</Link>
))}
<div className="split">
<div>
<h2 className="sec">Action required</h2>
<div className="card sect">
<div className="sect__head">
<div>
<h2 className="h-sec">Action required</h2>
<p className="h-sub">Queues where a person has to move a lead on.</p>
</div>
</div>
<div className="qlist">
{m.actionable.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
<strong className="q__n">{s.n}</strong>
<span className={'q__ico q__ico--' + s.kind}><Icon name={KIND_ICON[s.kind]} size={16} /></span>
<span className="q__t">
{s.need ?? s.name}
<em>{s.by}</em>
<em>{s.by}{s.working ? ` · +${s.working} with the AI` : ''}</em>
</span>
<span className="q__age">
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
{s.working ? <b className="q__auto">+{s.working} with the AI</b> : null}
</span>
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
<strong className="q__n">{s.n}</strong>
{!mine.has(s.uid)
? <span className="q__go q__go--ro" title="View only for your role"><Icon name="eye" size={14} /></span>
: <span className="q__go"><Icon name="chevronRight" size={14} /></span>}
</Link>
))}
</div>
</div>
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
<div className="buckets">
<div className="bucket bucket--lapsed">
<strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span>
</div>
<div className="bucket bucket--urgent">
<strong>{m.exposure.week.length}</strong><span>Within 7 days</span>
</div>
<div className="bucket bucket--soon">
<strong>{m.exposure.month.length}</strong><span>8 to 30 days</span>
<div className="card sect">
<div className="sect__head">
<div>
<h2 className="h-sec">Renewal exposure</h2>
<p className="h-sub">Open leads by time to expiry. Pick a band to narrow the list.</p>
</div>
</div>
<div className="buckets" role="group" aria-label="Expiry band">
{[
['lapsed', 'Lapsed', m.exposure.lapsed.length],
['week', 'Within 7 days', m.exposure.week.length],
['month', '8 to 30 days', m.exposure.month.length],
].map(([k, label, n]) => (
<button
key={k}
type="button"
className={'bucket bucket--' + k + (bucket === k ? ' is-on' : '')}
aria-pressed={bucket === k}
onClick={() => setBucket(bucket === k ? null : k)}
>
<strong>{n}</strong><span>{label}</span>
</button>
))}
</div>
{m.attention.length ? (
<div className="gridwrap">
{shownAttention.length ? (
<div className="gridwrap gridwrap--flush">
<table className="grid grid--tight">
<thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead>
<tbody>
{m.attention.slice(0, 8).map((r) => {
{shownAttention.slice(0, 8).map((r) => {
const id = r.instance_id ?? r.id
const st = STAGES.find((s) => s.name === r.current_state_name)
return (
<tr
key={id}
@ -336,44 +413,35 @@ export default function Overview() {
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div>
<div className="grid__who">
<span className={'avatar avatar--sm avatar--' + toneFor(r.customer_name)}>{initials(r.customer_name)}</span>
<div>
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div>
</div>
</div>
</td>
<td><span className={'due due--' + expiryTone(r._d)}>
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
</span></td>
<td><span className="grid__sub">{r.current_state_name}</span></td>
<td><span className={'pill ' + (st?.kind === 'needs' ? 'pill--amber' : st?.kind === 'customer' ? 'pill--teal' : 'pill--blue')}>{r.current_state_name}</span></td>
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
{shownAttention.length > 8 ? (
<Link to="/stage/all" className="grid__more">See all {shownAttention.length} in the lead list <Icon name="arrowRight" size={13} /></Link>
) : null}
</div>
) : (
<p className="empty">No renewals due within 30 days.</p>
<p className="empty empty--flush">
<Icon name="check" size={18} />
{bucket ? 'No renewals in this band.' : 'No renewals due within 30 days.'}
</p>
)}
</div>
<div>
<h2 className="sec">Pipeline distribution</h2>
{/* Every stage, zeroes included. A stage that has quietly stopped
receiving leads is only visible if its zero is on the page. */}
<div className="dist">
{STAGES.map((s) => {
const n = m.byStage[s.name] || 0
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + s.kind} />
</span>
<span className="dist__n">{n}</span>
</Link>
)
})}
</div>
</div>
</div>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section>

View File

@ -1,10 +1,13 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
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 { initials, toneFor, inrFull, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
import Icon from '../components/Icon.jsx'
import './screens.css'
/** Short absolute date plus how long ago a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */
@ -27,21 +30,15 @@ function added(ts) {
* Is an automated stage actually moving?
*
* The agents carry six of the nine stages, and a lead sitting in one is in
* exactly one of two states: being worked right now, or stuck. The row showed
* neither, so a lead three hours into "Calling the customer" looked identical
* to one thirty seconds in which is the failure this console exists to catch.
*
* The threshold is generous on purpose. An employee wake takes a minute or two
* and a scheduled retry can be hours out, so `stalled` means "longer than any
* exactly one of two states: being worked right now, or stuck. The threshold
* is generous on purpose. An employee wake takes a minute or two and a
* scheduled retry can be hours out, so `stalled` means "longer than any
* normal step", not "longer than average".
*/
function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null
// `updated_at` ONLY. Falling back to created_at dates the measure from when
// the lead was filed rather than from its last step, so every lead older than
// half an hour reports "stalled" an alarm on every row is an alarm on none.
// The view returns the column since 76; before that it was silently absent,
// which is exactly how that fallback got there.
// the lead was filed rather than from its last step.
const t = Date.parse(row.updated_at)
if (isNaN(t)) return null
const mins = (Date.now() - t) / 60000
@ -50,8 +47,7 @@ function progress(row, stage) {
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
}
/** Days to expiry, and how alarmed to be about it. This is a renewal book
* the countdown is the most operationally useful number in the row. */
/** Days to expiry, and how alarmed to be about it. */
function expiry(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
@ -61,30 +57,36 @@ function expiry(dateStr) {
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
}
import './screens.css'
/** The tone a closed state's chip wears. */
const END_TONE = { 'Onboarded': 'green', 'Policy Issued': 'green', 'Lost / Dropped': '', 'Declined': 'red' }
const SORTS = {
customer: (r) => String(r.customer_name || '').toLowerCase(),
stage: (r) => STAGES.findIndex((s) => s.name === r.current_state_name),
renewal: (r) => { const t = Date.parse(String(r.renewal_due_date || '').substring(0, 10)); return isNaN(t) ? Infinity : t },
premium: (r) => Number(r.quoted_premium) || 0,
waiting: (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? 0 : t },
}
/**
* One record view drives every queue; the stage is a server-side filter on
* current_state_name. Filtering server-side rather than fetching everything and
* narrowing in the browser is what keeps a queue honest once there are more
* leads than one page.
*
* The filter bar below narrows the PAGE that came back product, channel,
* expiry band, stalled client-side, on data already fetched. It never
* changes the request.
*/
export default function Pipeline() {
const { stageUid } = useParams()
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.
//
// IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
// or onboarded disappeared from the page called "All leads" the one place
// somebody goes when they cannot find something. A lead that ended is still
// a lead, and "where did it go?" is exactly the question this page exists to
// answer. Closed rows are shown, and dimmed, and can be hidden by choice.
// "all" is not a stage it is every lead in one list. IT MEANS ALL: closed
// rows are shown, dimmed, and can be hidden by choice.
const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false)
const stage = isAll
@ -92,33 +94,38 @@ export default function Pipeline() {
: STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
const [at, setAt] = useState(null)
// Filters. Reset when the queue changes so a product filter does not follow
// the operator into a queue where it hides everything.
const [q, setQ] = useState('')
const [product, setProduct] = useState('')
const [channel, setChannel] = useState('')
const [band, setBand] = useState('')
const [stalledOnly, setStalledOnly] = useState(false)
const [sort, setSort] = useState({ key: 'waiting', dir: 'desc' })
useEffect(() => { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false) }, [stageUid])
useEffect(() => {
let cancelled = false
// `quiet` is what makes polling bearable: the first load may show a skeleton,
// a refresh may not dropping back to the loading state every 30 seconds
// would flash the whole queue away under whoever is reading it.
// a refresh may not.
function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
limit: isAll ? 200 : 100,
// No stage filter on the all view. Closed leads are dropped below
// rather than in the query: one request beats three negations, and
// 200 covers a demo book comfortably.
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
})
.then((res) => {
if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? []
// Nothing is dropped here any more. Whether closed leads are shown
// is a choice made below, on data already fetched a filter that
// throws rows away at the query cannot be undone by a toggle.
// total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage.
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null })
setAt(new Date())
})
.catch((err) => {
if (cancelled) return
@ -136,62 +143,175 @@ export default function Pipeline() {
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll])
// Split rather than filtered at the query, so the header can say how many
// are closed instead of silently showing fewer than the page claims.
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
const CLOSED = useMemo(() => new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)), [])
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
const shownRows = isAll && hideClosed
? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
: state.rows
// Decorate once, then filter and sort on the decorated row.
const decorated = useMemo(() => state.rows.map((r) => {
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
return { r, rowStage, prog: progress(r, rowStage), exp: expiry(r.renewal_due_date), add: added(r.created_at) }
}), [state.rows])
const canStall = decorated.some((d) => d.prog)
const channelsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.source_channel).filter(Boolean))], [state.rows])
const productsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.product_line).filter(Boolean))], [state.rows])
const shown = useMemo(() => {
const term = q.trim().toLowerCase()
let out = decorated.filter(({ r, prog, exp }) => {
if (isAll && hideClosed && CLOSED.has(r.current_state_name)) return false
if (product && r.product_line !== product) return false
if (channel && r.source_channel !== channel) return false
if (stalledOnly && prog?.state !== 'stalled') return false
if (band) {
if (!exp) return false
if (band === 'lapsed' && exp.days >= 0) return false
if (band === 'week' && (exp.days < 0 || exp.days > 7)) return false
if (band === 'month' && (exp.days <= 7 || exp.days > 30)) return false
}
if (term) {
const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no, r.current_state_name]
.filter(Boolean).map((x) => String(x).toLowerCase())
if (!hay.some((h) => h.includes(term))) return false
}
return true
})
const f = SORTS[sort.key]
if (f) {
out = [...out].sort((a, b) => {
const x = f(a.r), y = f(b.r)
const c = x < y ? -1 : x > y ? 1 : 0
return sort.dir === 'asc' ? c : -c
})
}
return out
}, [decorated, q, product, channel, band, stalledOnly, sort, isAll, hideClosed, CLOSED])
const filtering = Boolean(q.trim() || product || channel || band || stalledOnly || (isAll && hideClosed))
const stalledCount = decorated.filter((d) => d.prog?.state === 'stalled').length
function toggleSort(key) {
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'customer' || key === 'stage' ? 'asc' : 'desc' }))
}
// A sortable header cell. A plain function, not a component: it is called
// in place, so React never sees a new component type per render.
const sortHead = (k, text, className) => (
<th key={k} className={className} aria-sort={sort.key === k ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
<button type="button" className={'grid__sort' + (sort.key === k ? ' is-on' : '')} onClick={() => toggleSort(k)}>
{text}
<Icon name={sort.key === k && sort.dir === 'asc' ? 'sortAsc' : 'sortDesc'} size={13} />
</button>
</th>
)
if (!stage) return <p className="empty">Unknown stage.</p>
const headCount = state.status === 'ready' ? (isAll ? state.rows.length - (hideClosed ? closedCount : 0) : state.total) : null
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{isAll
? 'Every lead not yet closed, across all stages.'
: stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end'
? 'Closed — retained for reporting.'
: stage.kind === 'waiting'
? 'Held until the renewal window opens. Re-enters outreach automatically.'
: `Pending action by ${stage.by}.`}
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
</p>
<div className="page__lead">
<span className={'page__kind page__kind--' + stage.kind}><Icon name={KIND_ICON[stage.kind] || 'list'} size={20} /></span>
<div>
<h1 className="page__title">
{stage.need ?? stage.name}
{headCount !== null ? <span className="page__n">{headCount}</span> : null}
</h1>
<p className="page__sub">
{isAll
? 'Every lead, open and closed, across all stages.'
: stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end'
? 'Closed — retained for reporting.'
: stage.kind === 'waiting'
? 'Held until the renewal window opens. Re-enters outreach automatically.'
: `Pending action by ${stage.by}.`}
{stage.need ? <span className="page__stage"> · Stage: {stage.name}</span> : null}
</p>
</div>
</div>
{state.status === 'ready' ? (
<div className="page__right">
{/* Offered only where there is something to hide, and OFF by
default: this page's job is that nothing disappears from it. */}
{isAll && closedCount ? (
<label className="toggle">
<input type="checkbox" checked={hideClosed}
onChange={(ev) => setHideClosed(ev.target.checked)} />
Hide {closedCount} closed
</label>
) : null}
<div className="page__count">
<strong>{isAll ? shownRows.length : state.total}</strong>
<span>{(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}</span>
</div>
{canFile ? (
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
<div className="page__right">
{at ? (
<span className="live" title="Refreshes every 30 seconds while this tab is visible">
<span className="live__dot" aria-hidden="true" />
Live · {at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span>
) : null}
{canFile ? (
<button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
<Icon name="plus" />
New lead
</button>
) : null}
</div>
) : null}
) : null}
</div>
</header>
{state.status === 'ready' && state.rows.length ? (
<div className="filters card">
<label className="filters__q">
<Icon name="search" />
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filter by name, reference, mobile, registration…"
aria-label="Filter this list"
/>
</label>
{productsPresent.length > 1 ? (
<select className="filters__sel" value={product} onChange={(e) => setProduct(e.target.value)} aria-label="Product line">
<option value="">All products</option>
<option value="motor">Motor</option>
<option value="sme_package">SME Package</option>
</select>
) : null}
{channelsPresent.length > 1 ? (
<select className="filters__sel" value={channel} onChange={(e) => setChannel(e.target.value)} aria-label="Channel">
<option value="">All channels</option>
{channelsPresent.map((c) => <option key={c} value={c}>{CHANNELS[c]?.label || c}</option>)}
</select>
) : null}
<select className="filters__sel" value={band} onChange={(e) => setBand(e.target.value)} aria-label="Renewal band">
<option value="">Any expiry</option>
<option value="lapsed">Lapsed</option>
<option value="week">Within 7 days</option>
<option value="month">8 to 30 days</option>
</select>
{canStall ? (
<button
type="button"
className={'filters__tog' + (stalledOnly ? ' is-on' : '')}
aria-pressed={stalledOnly}
onClick={() => setStalledOnly((v) => !v)}
>
<span className="run__dot run__dot--stalled" aria-hidden="true" />
Stalled{stalledCount ? ` · ${stalledCount}` : ''}
</button>
) : null}
{isAll && closedCount ? (
<button
type="button"
className={'filters__tog' + (hideClosed ? ' is-on' : '')}
aria-pressed={hideClosed}
onClick={() => setHideClosed((v) => !v)}
>
<Icon name="archive" size={13} />
Hide {closedCount} closed
</button>
) : null}
{filtering ? (
<span className="filters__count">
{shown.length} of {state.rows.length}
<button type="button" className="btn btn--ghost btn--sm" onClick={() => { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false); setHideClosed(false) }}>
Clear
</button>
</span>
) : null}
</div>
) : null}
{state.status === 'loading' ? (
<div className="skel" aria-busy="true" aria-label="Loading the queue">
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
@ -206,39 +326,36 @@ export default function Pipeline() {
</div>
) : null}
{state.status === 'ready' && shownRows.length === 0 ? (
<p className="empty">{isAll ? 'No leads yet.' : 'No records in this stage.'}</p>
{state.status === 'ready' && shown.length === 0 ? (
<p className="empty">
<Icon name={filtering ? 'filter' : 'check'} size={18} />
{filtering ? 'Nothing matches these filters.' : isAll ? 'No leads yet.' : 'Nothing in this queue.'}
</p>
) : null}
{state.status === 'ready' && shownRows.length > 0 ? (
<div className="gridwrap">
{state.status === 'ready' && shown.length > 0 ? (
<div className="gridwrap card">
<table className="grid">
<thead>
<tr>
<th>Customer</th>
<th>{isAll ? 'Stage' : 'Status'}</th>
<th>Renewal due</th>
<th className="num">Premium</th>
<th>Waiting</th>
{sortHead('customer', 'Customer')}
{sortHead('stage', isAll ? 'Stage' : 'Status')}
{sortHead('renewal', 'Renewal due')}
{sortHead('premium', 'Premium', 'num')}
{sortHead('waiting', 'Added')}
</tr>
</thead>
<tbody>
{shownRows.map((r) => {
{shown.map(({ r, rowStage, prog, exp, add }) => {
const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
const exp = expiry(r.renewal_due_date)
const add = added(r.created_at)
// What is really happening in the state, not just its name
// a lead whose documents are in is being worked by the AI, not
// waiting on the agent whose queue it is sitting in.
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
const prog = progress(r, rowStage)
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '' }
const closed = CLOSED.has(r.current_state_name)
return (
<tr
key={id}
className={'grid__row'
+ (prog ? ' is-' + prog.state : '')
+ (CLOSED.has(r.current_state_name) ? ' is-done' : '')}
+ (closed ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
@ -248,11 +365,16 @@ export default function Pipeline() {
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
{r.lead_ref || `#${id}`}
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{ch.label ? ` · ${ch.label}` : ''}
<div className="grid__who">
<span className={'avatar avatar--' + toneFor(r.customer_name)}>{initials(r.customer_name)}</span>
<div className="grid__id">
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
<span className="grid__ref">{r.lead_ref || `#${id}`}</span>
{r.product_line ? <span className={'pill ' + (r.product_line === 'motor' ? 'pill--blue' : 'pill--violet')}>{r.product_line === 'motor' ? 'Motor' : 'SME'}</span> : null}
{ch.label ? <span className="pill pill--line">{ch.label}</span> : null}
</div>
</div>
</div>
</td>
{/* Who holds it, and where an agent does whether it is
@ -265,8 +387,10 @@ export default function Pipeline() {
<span className="run__dot" aria-hidden="true" />
{prog.label}
</span>
) : closed ? (
<span className={'pill ' + (END_TONE[r.current_state_name] ? 'pill--' + END_TONE[r.current_state_name] : '')}>{r.current_state_name}</span>
) : isAll ? (
<span className="grid__sub">{r.current_state_name || '—'}</span>
<span className={'pill ' + (rowStage?.kind === 'needs' ? 'pill--amber' : rowStage?.kind === 'customer' ? 'pill--teal' : 'pill--blue')}>{r.current_state_name || '—'}</span>
) : (
<span className="grid__sub">
{rowStage?.by ? `with ${rowStage.by}` : '—'}
@ -279,8 +403,8 @@ export default function Pipeline() {
<div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>}
</td>
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
<td className="num">{r.quoted_premium ? inrFull(r.quoted_premium) : <span className="grid__sub"></span>}</td>
<td><span className="grid__sub" title={add.abs}>{add.rel || add.abs}</span></td>
</tr>
)
})}

File diff suppressed because it is too large Load Diff