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. 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 `npm run lint` currently reports 14 pre-existing errors — 8 `react-hooks/refs`, 3
`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider `react-hooks/set-state-in-effect`, 3 `react-refresh/only-export-components` (the provider
file exports both a component and a hook). A clean run is not the baseline; compare against and portfolio files export both a component and hooks). A clean run is not the baseline;
this count rather than against zero. compare against this count rather than against zero.
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
build does **not** — see "Runtime config" below. 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 - `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 sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
list). list).
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding - `src/layout/Shell.jsx` — 56px header (brand, `GlobalSearch`, `UserMenu`) + the sidebar,
the lead) rather than by pipeline position, with queue counts from one tallied list call. 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 - `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
`current_state_name`. `current_state_name`. The filter bar (search, product, channel, expiry band, stalled,
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then hide closed) and the column sort narrow the PAGE that came back, client-side; they never
field groups (`GROUPS`, in the order the lead is worked). 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 - `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
one is surfaced. one is surfaced.
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it - `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 { .af__field > span {
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: 500; font-weight: 500;
letter-spacing: 0.03em; color: var(--zk-ink);
color: var(--zk-muted);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
} }
.af__req { .af__req {
font-style: normal; font-style: normal;
font-size: var(--fs-3xs); font-size: var(--fs-sm);
font-weight: 500; font-weight: 600;
letter-spacing: 0.08em; line-height: 1;
text-transform: uppercase; color: var(--zk-danger);
color: var(--zk-blue); margin-left: -4px;
background: var(--zk-tint-blue);
padding: 2px 7px;
border-radius: var(--r-pill);
} }
.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 input,
.af select, .af select,
.af textarea { .af textarea {
font: inherit; font: inherit;
font-size: var(--fs-xs); font-size: var(--fs-xs);
padding: 10px 12px; padding: 10px 12px;
border-radius: var(--r-md); border-radius: var(--r-sm);
border: 1px solid var(--zk-line); border: 1px solid var(--zk-line);
background: var(--zk-white); background: var(--zk-white);
color: var(--zk-ink); color: var(--zk-ink);
@ -117,7 +122,7 @@
} }
.af__chip.is-on { .af__chip.is-on {
background: var(--grad-blue); background: var(--zk-blue);
border-color: var(--zk-blue); border-color: var(--zk-blue);
color: var(--zk-white); color: var(--zk-white);
box-shadow: var(--sh-xs); box-shadow: var(--sh-xs);
@ -136,17 +141,17 @@
font-size: var(--fs-xs); font-size: var(--fs-xs);
font-weight: 500; font-weight: 500;
padding: 10px 26px; padding: 10px 26px;
border-radius: var(--r-md); border-radius: var(--r-sm);
cursor: pointer; cursor: pointer;
border: 0; border: 0;
background: var(--grad-blue); background: var(--zk-blue);
color: var(--zk-white); color: var(--zk-white);
box-shadow: var(--sh-sm); box-shadow: var(--sh-sm);
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast); transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
} }
.af__submit:hover:not(:disabled) { .af__submit:hover:not(:disabled) {
background: var(--grad-blue-hover); background: var(--zk-blue-dark);
box-shadow: var(--sh-md); box-shadow: var(--sh-md);
transform: translateY(-1px); transform: translateY(-1px);
} }
@ -166,7 +171,7 @@
font: inherit; font: inherit;
font-size: var(--fs-xs); font-size: var(--fs-xs);
padding: 10px 20px; padding: 10px 20px;
border-radius: var(--r-md); border-radius: var(--r-sm);
cursor: pointer; cursor: pointer;
border: 1px solid var(--zk-line); border: 1px solid var(--zk-line);
background: var(--zk-white); background: var(--zk-white);
@ -211,7 +216,7 @@
.af__auto { .af__auto {
font-size: var(--fs-xs); font-size: var(--fs-xs);
padding: 10px 12px; padding: 10px 12px;
border-radius: var(--r-md); border-radius: var(--r-sm);
min-height: 41px; min-height: 41px;
border: 1px dashed var(--zk-line); border: 1px dashed var(--zk-line);
background: var(--zk-tint); 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 { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js' import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx' import FileField from './FileField.jsx'
import { dateLong } from '../api/display.js'
import './ActivityForm.css' import './ActivityForm.css'
/** /**
@ -288,7 +289,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
+ (f.data_type === 'longtext' ? ' af__field--wide' : '') + (f.data_type === 'longtext' ? ' af__field--wide' : '')
+ (missing.includes(f.id) ? ' is-missing' : '')} + (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' ? ( {f.data_type === 'file' || f.data_type === 'ocr' ? (
<FileField <FileField
@ -303,7 +304,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
</div> </div>
) : f.data_type === 'select' ? ( ) : f.data_type === 'select' ? (
<select value={v} onChange={(e) => set(f.id, e.target.value)}> <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>)} {opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
) : f.data_type === 'multiselect' ? ( ) : f.data_type === 'multiselect' ? (
@ -318,6 +319,13 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
</div> </div>
) : f.data_type === 'longtext' ? ( ) : f.data_type === 'longtext' ? (
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} /> <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 <input
type={INPUT_TYPES[f.data_type] ?? 'text'} type={INPUT_TYPES[f.data_type] ?? 'text'}
@ -329,6 +337,10 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
})} })}
</div> </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. */} {/* The form's own complaint, before anything is sent. */}
{missing.length ? ( {missing.length ? (
<div className="af__err af__err--soft" role="alert"> <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 { .who__disc {
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
flex: none; flex: none;
width: 20px; width: 22px;
height: 20px; height: 22px;
border-radius: 50%; border-radius: 50%;
font-size: var(--fs-2xs); font-size: 10px;
font-weight: 600; font-weight: var(--fw-semi);
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: #fff; color: #fff;
user-select: none; 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 { .who__name {
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: 500; font-weight: var(--fw-medium);
color: var(--zk-muted); color: var(--zk-muted);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Five agents, five hues, all readable on white at 20px and all distinguishable /* Five agents, five hues, all readable on white and all distinguishable
from the Zurich blue the interface already uses for its own chrome. */ from the Zurich blue the interface uses for its own chrome. */
.who__disc--blue { background: #2167ae; } .who__disc--blue { background: #2167ae; }
.who__disc--teal { background: #0f7b78; } .who__disc--teal { background: #0f7b78; }
.who__disc--violet { background: #5b4bb7; } .who__disc--violet { background: #5b4bb7; }
@ -41,13 +41,16 @@
cursor: pointer; cursor: pointer;
padding: 2px 8px 2px 2px; padding: 2px 8px 2px 2px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--r-pill, 999px); border-radius: var(--r-pill);
background: transparent; 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:hover .who__i { opacity: 1; }
.who--btn:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } .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 { .who__i {
width: 12px; width: 12px;
@ -57,11 +60,9 @@
opacity: 0; opacity: 0;
transition: opacity .12s; 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 /* The AI mark, on the disc rather than beside the name. */
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. */
.who__ai { .who__ai {
position: absolute; position: absolute;
right: -3px; right: -3px;
@ -69,12 +70,11 @@
width: 11px; width: 11px;
height: 11px; height: 11px;
fill: var(--zk-amber, #b5761f); fill: var(--zk-amber, #b5761f);
stroke: var(--zk-white, #fff); stroke: var(--zk-card, #fff);
stroke-width: 0.9; stroke-width: 0.9;
stroke-linejoin: round; stroke-linejoin: round;
paint-order: stroke fill; paint-order: stroke fill;
} }
.who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; } .who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; }
/* Said out loud for a screen reader, which cannot see a sparkle. */ /* Said out loud for a screen reader, which cannot see a sparkle. */
@ -90,7 +90,6 @@
border: 0; border: 0;
} }
/* Role icon fills most of the disc, leaving room for the AI spark at the /* Role icon fills most of the disc, leaving room for the AI spark. */
corner. Sized off the disc so it scales with .who--md. */
.who__glyph { width: 60%; height: 60%; } .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> </div>
<p className="conv__note"> <p className="conv__note">
Recorded by the voice agent as the call happened. This is what was said Recorded by the voice agent as the call happened. The call notes in the trail
the call notes in the trail are Engage&apos;s reading of it, which is a are Engage&apos;s summary of this.
different thing.
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,59 +1,102 @@
.cot { margin-top: var(--sp-2); } /* The finding, when this component is asked to show it. */
/* The finding, always visible the one sentence that answers "what did it
conclude" without opening anything. */
.cot__finding { .cot__finding {
margin: 0 0 var(--sp-2); margin: 0 0 var(--sp-2);
font-size: var(--fs-sm); font-size: var(--fs-sm);
line-height: var(--lh-normal); line-height: var(--lh-normal);
font-weight: var(--fw-medium);
color: var(--zk-ink); color: var(--zk-ink);
} }
/* Default toggle look, for anywhere the Timeline's footer row is not
restyling it. */
.cot__toggle { .cot__toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--sp-2); gap: 6px;
padding: var(--sp-1) 0; padding: 4px 0;
border: 0; border: 0;
background: none; background: none;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: var(--fw-semi); font-weight: var(--fw-medium);
color: var(--zk-blue-dark); color: var(--zk-blue-dark);
} }
.cot__brain { width: 15px; height: 15px; flex: none; } .cot__brain { flex: none; }
.cot__ct { color: var(--zk-grey); font-weight: var(--fw-normal); } .cot__ct {
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); } font-size: 11px;
.cot__caret.is-open { transform: rotate(90deg); } 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 { .cot__steps {
list-style: none; list-style: none;
margin: var(--sp-3) 0 0; margin: 4px 0 0;
padding: var(--sp-3) 0 var(--sp-2); padding: 12px 16px 10px 18px;
border-left: 2px solid var(--zk-blue-light); border: 1px solid var(--zk-card-line);
padding-left: var(--sp-4); 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 {
.cot__step::before { position: relative;
content: ""; position: absolute; left: calc(var(--sp-4) * -1 - 6px); top: 9px; display: grid;
width: 9px; height: 9px; border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-blue); 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 { .cot__mark {
font-size: var(--fs-3xs); font-size: 11px;
font-weight: var(--fw-semi); font-weight: var(--fw-semi);
letter-spacing: .06em; letter-spacing: .04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--zk-blue-dark); 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__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__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); border: 1px solid var(--zk-blue-light); padding: 1px 9px; border-radius: var(--r-pill); } .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 { useState } from 'react'
import Icon from './Icon.jsx'
import { findingOf, mdRuns } from '../api/display.js'
import './ChainOfThought.css' import './ChainOfThought.css'
/** /**
@ -6,28 +8,17 @@ import './ChainOfThought.css'
* *
* This is the trust surface. A person signing off on or overriding a * 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 * 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 * that. The chain reads the way the employee's own record reads: what it
* fields, so it is already in the audit trail) but the console showed only the * REASONED and what it DECIDED, assembled from real fields already on the step
* conclusion. An operator either trusted it blind or went digging. Neither is * never narrated after the fact. A step may carry more than one reasoning
* oversight. * field (attribution AND eligibility, say); each is its own rung.
*
* 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.
* *
* The finding the first sentence, since the employees write the conclusion * 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 * first is lifted out so the "why" can be read without opening the chain.
* 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) { export default function ChainOfThought({ reasoning, decided, confidence, showFinding = true }) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
export default function ChainOfThought({ reasoning, decided, confidence }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const primary = reasoning.length ? reasoning[0][1] : '' 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 const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return ( return (
<div className="cot"> <>
{finding ? <p className="cot__finding">{finding}</p> : null} {showFinding && finding ? <p className="cot__finding">{finding}</p> : null}
<button <button
type="button" type="button"
className="cot__toggle" className={'cot__toggle' + (open ? ' is-open' : '')}
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
> >
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true"> <Icon name="brain" size={14} className="cot__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"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
Chain of thought Chain of thought
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span> <span className="cot__ct">{steps}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true"> <Icon name="chevronDown" size={12} className={'cot__caret' + (open ? ' is-open' : '')} />
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
{open ? ( {open ? (
@ -62,11 +47,13 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
{reasoning.map(([label, text], i) => ( {reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}> <li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span> <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>
))} ))}
<li className="cot__step"> <li className="cot__step cot__step--decided">
<span className="cot__mark">Decided</span> <span className="cot__mark">Decided</span>
<div className="cot__b"> <div className="cot__b">
<b>{decided.what}</b> <b>{decided.what}</b>
@ -82,6 +69,6 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
</li> </li>
</ol> </ol>
) : null} ) : null}
</div> </>
) )
} }

View File

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

View File

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

View File

@ -122,10 +122,8 @@ export default function Conversation({ rows, name, onClose }) {
</div> </div>
<p className="conv__note"> <p className="conv__note">
Shows the customer&apos;s messages and everything written back to them, Everything the customer sent and everything written back. The quote itself
including the acceptance confirmation. One automatic send is not here: goes out as a WhatsApp template and is not shown here.
the quote, which goes out as a registered WhatsApp template rather than
text we compose.
</p> </p>
</div> </div>
</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 /* The journey, across the width of the metrics card. Eight steps have to fit
laptop, so the labels are short and the dots carry the state. */ on a laptop, so the labels are short and the dots carry the state. */
.rail { .rail { width: 100%; }
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;
}
.rail ol { .rail ol {
display: flex; display: flex;
@ -18,8 +8,6 @@
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
flex: 1;
min-width: 0;
} }
.rail__s { .rail__s {
@ -27,18 +15,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--sp-3); gap: 8px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
text-align: center; text-align: center;
} }
.rail__dot { .rail__dot {
width: 14px; width: 12px;
height: 14px; height: 12px;
border-radius: 50%; border-radius: 50%;
flex: none; flex: none;
background: var(--zk-white); background: var(--zk-card);
border: 2px solid var(--zk-line); border: 2px solid var(--zk-line);
z-index: 1; z-index: 1;
transition: background var(--t-fast), border-color var(--t-fast); 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. */ /* The connector sits behind the dots, drawn from each step to the next. */
.rail__bar { .rail__bar {
position: absolute; position: absolute;
top: 6px; top: 5px;
left: 50%; left: 50%;
width: 100%; width: 100%;
height: 2px; height: 2px;
@ -71,33 +59,62 @@
.is-here .rail__dot { .is-here .rail__dot {
background: var(--zk-blue); background: var(--zk-blue);
border-color: var(--zk-blue); border-color: var(--zk-blue);
box-shadow: 0 0 0 5px rgba(33, 103, 174, 0.15); 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);
font-size: var(--fs-xs);
} }
.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 /* Beyond a branch the path is unknowable, so it is drawn faint. */
into the row: these are departures, not points every lead passes. */ .is-faint .rail__dot { border-color: var(--zk-line-soft); }
.rail__off { .is-faint .rail__l { color: var(--zk-line); }
flex: none;
font-size: var(--fs-xs); /* The branch: a node hanging beneath the stage the lead left the line at. */
font-weight: var(--fw-medium); .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); color: var(--zk-amber-ink);
background: var(--zk-amber-tint); white-space: nowrap;
border: 1px solid var(--zk-amber-line); padding: 2px 10px;
padding: var(--sp-2) var(--sp-4);
border-radius: var(--r-pill); border-radius: var(--r-pill);
background: var(--zk-amber-tint);
} }
.rail__off.is-closed { .rail__bl small { font-size: 10.5px; font-weight: var(--fw-medium); opacity: .8; }
color: var(--zk-muted); .rail__branch.is-closed::before { background: var(--zk-line); }
background: var(--zk-tint); .rail__branch.is-closed .rail__bdot { background: var(--zk-grey); box-shadow: 0 0 0 4px var(--zk-line-soft); }
border-color: var(--zk-line); .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) { @media (max-width: 900px) {
.rail__l { display: none; } .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. * 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 * 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 * and can go backwards, so colouring everything left of the current stage
* can go backwards (a callback returns it to Awaiting Contact). Colouring * would claim steps that never happened. The trail knows which states this
* everything left of the current stage would have claimed steps that never * lead actually entered; this reads that.
* 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 side states Parked, Referred, Lost, Declined are not points every
* the rail. They are departures from the path rather than points along it, and * lead passes through, so they are not on the main rail. When the lead is in
* putting them in a row would suggest every lead passes through. When the lead * one, it is drawn as a BRANCH: a node hanging off the last stage the lead
* is in one, the rail says so at the end instead. * 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 = [ const PATH = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs', 'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded', '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 = { const SHORT = {
'zk-state-new': 'Filed', 'zk-state-new': 'Filed',
'zk-state-qualified': 'Called', 'zk-state-qualified': 'Called',
@ -35,47 +30,60 @@ const SHORT = {
'zk-state-payment': 'Payment', 'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued', 'zk-state-issued': 'Issued',
'zk-state-onboarded': 'Onboarded', '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 }) { export default function StageRail({ audit, currentName }) {
const current = STAGES.find((s) => s.name === currentName) const current = STAGES.find((s) => s.name === currentName)
const visited = new Set( const visitedList = (audit || [])
(audit || [])
.map((r) => r.execution_state) .map((r) => r.execution_state)
.filter((v) => PATH.includes(v)), .filter((v) => PATH.includes(v))
) const visited = new Set(visitedList)
if (current) visited.add(current.uid) if (current) visited.add(current.uid)
const currentIndex = PATH.indexOf(current?.uid) const currentIndex = PATH.indexOf(current?.uid)
// A lead sitting somewhere off the path Parked, Referred, Lost, Declined. // A lead sitting somewhere off the path Parked, Referred, Lost, Declined.
const aside = current && currentIndex === -1 ? current : null 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 ( return (
<div className="rail" aria-label="Progress"> <div className="rail" aria-label="Progress">
<ol> <ol>
{PATH.map((uid, i) => { {PATH.map((uid, i) => {
const been = visited.has(uid) const been = visited.has(uid)
const here = uid === current?.uid const here = uid === current?.uid
// Ahead of a lead that has left the path is unknowable a referred // Ahead of a lead that has left the path is unknowable, so nothing
// risk may never see payment so nothing after it is called "to do". // after the branch is called "to do" with any confidence it is
const state = here ? 'here' : been ? 'done' : 'todo' // drawn faint.
const state = here ? 'here' : been ? 'done' : aside && i > branchAfter ? 'faint' : 'todo'
const nextDone = visited.has(PATH[i + 1])
return ( 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__dot" aria-hidden="true" />
<span className="rail__l">{SHORT[uid]}</span> <span className="rail__l">{SHORT[uid]}</span>
{i < PATH.length - 1 ? ( {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} ) : null}
</li> </li>
) )
})} })}
</ol> </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> </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 { .tl {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0 0 0 4px; padding: 0;
} }
.tl__loading, .tl__loading,
.tl__err { .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
color: var(--zk-muted); .tl__err { color: var(--zk-danger-ink); }
font-size: var(--fs-xs);
padding: 14px 2px;
}
.tl__err {
color: var(--zk-danger-ink);
}
.tl__item { .tl__item {
position: relative; position: relative;
padding: 0 0 var(--sp-6) var(--sp-7); display: grid;
border-left: 2px solid var(--zk-line-soft); grid-template-columns: 36px minmax(0, 1fr);
gap: 0 14px;
padding-bottom: var(--sp-5);
} }
.tl__item:last-child { /* The connecting line, drawn from node to node. */
border-left-color: transparent; .tl__item::before {
padding-bottom: 4px; content: '';
}
.tl__dot {
position: absolute; position: absolute;
left: -8px; left: 17px;
top: 3px; top: 40px;
width: 14px; bottom: 0;
height: 14px; width: 2px;
border-radius: 50%; background: var(--zk-line-soft);
background: var(--zk-white); }
border: 2px solid var(--zk-grey); .tl__item:last-child::before { display: none; }
box-shadow: 0 0 0 4px var(--zk-white); .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 /* The newest entry is marked — it is what the page is refreshed for. */
file can be read at a glance for how much of it was done by hand. */ .tl--newest .tl__item.is-latest .tl__body { background: var(--zk-ground); }
.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);
}
.tl__body { .tl__body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-2); gap: 6px;
padding: var(--sp-2) var(--sp-3) var(--sp-3); min-width: 0;
margin-left: calc(var(--sp-3) * -1); padding: 6px 12px 10px;
margin: 0 0 0 -4px;
border-radius: var(--r-md); border-radius: var(--r-md);
transition: background var(--t-fast); transition: background var(--t-fast);
} }
.tl__item:hover .tl__body { background: var(--zk-ground); }
.tl__item:hover .tl__body {
background: var(--zk-tint);
}
.tl__head { .tl__head {
display: flex; display: flex;
flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: var(--sp-1) var(--sp-2); justify-content: space-between;
gap: 12px;
} }
.tl__title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps .tl__what { font-size: var(--fs-sm); font-weight: var(--fw-semi); letter-spacing: -0.008em; color: var(--zk-ink); }
into a ragged block that is harder to scan than two clean rows. */ .tl__item--sys .tl__what { color: var(--zk-muted); font-weight: var(--fw-medium); }
.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__stage { .tl__stage {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--fs-2xs); 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 { .tl__meta { display: flex; align-items: center; gap: 8px; margin-top: -2px; }
font-size: var(--fs-2xs); .tl__who { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-2xs); color: var(--zk-muted); }
color: var(--zk-grey); .tl__who--cust { color: var(--zk-wa); font-weight: var(--fw-medium); }
} .tl__aitag {
font-size: 9.5px;
.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);
font-weight: var(--fw-semi); font-weight: var(--fw-semi);
letter-spacing: 0.09em; letter-spacing: .06em;
text-transform: uppercase; padding: 1px 5px;
color: var(--zk-grey); border-radius: 4px;
margin-bottom: var(--sp-2); color: var(--zk-amber-ink);
background: var(--zk-amber-tint);
} }
.tl__say p { /* The finding — body copy, not a quote box. */
margin: 0; .tl__finding {
margin: 2px 0 0;
font-size: var(--fs-sm); font-size: var(--fs-sm);
line-height: var(--lh-normal); line-height: var(--lh-normal);
color: var(--zk-muted); color: var(--zk-ink);
max-width: 76ch; max-width: 78ch;
} }
.tl__figs { /* A quoted line: a person's reason, or what was said to/by the customer. */
display: flex; .tl__say {
flex-wrap: wrap; margin-top: 2px;
gap: 8px; padding: 10px 14px;
margin-top: 9px; 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 { .tl__figs span {
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
color: var(--zk-ink); color: var(--zk-ink);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
padding: 4px 11px; padding: 3px 10px;
border-radius: var(--r-pill); border-radius: var(--r-pill);
background: var(--zk-white); background: var(--zk-card);
border: 1px solid var(--zk-line); 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 { /* Documents received */
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. */
.tl__docs { .tl__docs {
margin-top: 8px; margin-top: 2px;
padding: 9px 11px; padding: 9px 12px;
border: 1px solid var(--zk-line-soft); border: 1px solid var(--zk-card-line);
border-radius: var(--r-sm, 6px); border-radius: var(--r-sm);
background: var(--zk-tint); background: var(--zk-card);
} }
.tl__docshead { .tl__docshead {
display: block; display: flex;
font-size: var(--fs-3xs); align-items: center;
font-weight: 600; gap: 6px;
letter-spacing: 0.02em; font-size: var(--fs-2xs);
text-transform: uppercase; font-weight: var(--fw-medium);
color: var(--zk-muted); color: var(--zk-ink);
margin-bottom: 7px; margin-bottom: 7px;
} }
.tl__docshead svg { color: var(--zk-blue); }
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; } .tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
.tl__doc { .tl__doc {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: var(--fw-medium);
text-decoration: none; text-decoration: none;
padding: 4px 10px 4px 8px; padding: 3px 10px;
border-radius: var(--r-pill, 999px); border-radius: var(--r-pill);
background: var(--zk-white); background: var(--zk-tint-blue);
border: 1px solid var(--zk-line);
color: var(--zk-blue-dark); 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-blue-light); color: var(--zk-navy); }
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } .tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* the conversation, as one entry /* ONE FOOTER ROW. Chain of thought, fields written, the call, the thread
Every WhatsApp turn used to be its own entry, so a four-message exchange ghost buttons side by side, and whichever is opened unfolds beneath the
took more of the trail than the whole underwriting chain. */ whole row. `details` is display:contents so its summary sits in the row
.tl__wa { 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; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; 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: inherit;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: 500; font-weight: var(--fw-medium);
cursor: pointer; color: var(--zk-muted);
padding: 0; padding: 4px 10px;
border: 0; border-radius: var(--r-sm);
background: none; border: 1px solid var(--zk-card-line);
color: var(--zk-blue); background: var(--zk-card);
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 {
cursor: pointer; cursor: pointer;
list-style: none; list-style: none;
display: inline-flex; white-space: nowrap;
align-items: center; transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
gap: 6px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue);
} }
.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::-webkit-details-marker { display: none; }
.tl__wrote > summary::after { .tl__open { order: 3; }
content: ''; .tl__foot .cot__steps { order: 10; flex-basis: 100%; }
width: 5px; height: 5px; .tl__wrote > dl { order: 11; flex-basis: 100%; }
border-right: 1.4px solid currentColor; .tl__wcaret { transition: transform var(--t-fast); }
border-bottom: 1.4px solid currentColor; .tl__wrote[open] .tl__wcaret { transform: rotate(180deg); }
transform: rotate(45deg) translate(-1px, -1px);
transition: transform var(--t-fast); .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 dl > div { display: grid; grid-template-columns: minmax(0, 45%) minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 3px 0; }
.tl__wrote > summary:hover { color: var(--zk-blue-dark); } .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 { /* Platform noise, folded — offered at the foot rather than the head. */
margin: 8px 0 0; .tl__toggle {
padding: 10px 12px; font: inherit;
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%;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
color: var(--zk-grey); font-weight: var(--fw-medium);
overflow-wrap: anywhere; 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 { @media (max-width: 720px) {
margin: 0; .tl__head { flex-direction: column; align-items: flex-start; gap: 2px; }
flex: 1;
min-width: 0;
font-size: var(--fs-2xs);
color: var(--zk-ink);
overflow-wrap: anywhere;
} }

View File

@ -3,31 +3,18 @@ import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx' import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx' import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx' import ChainOfThought from './ChainOfThought.jsx'
import Icon from './Icon.jsx'
import { AGENTS } from '../api/agents.js' import { AGENTS } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import { findingOf, humanize, initials, leadIn, mdRuns, toneFor } from '../api/display.js'
import './Timeline.css' import './Timeline.css'
/* The roster lives in api/agents.js now one short name, one colour and one /* The roster lives in api/agents.js one short name, one colour and one set
set of initials per employee, so the same worker looks the same everywhere 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 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 /* 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 "Reasoned" rung of the chain of thought. The first present one is the
it did what it did rather than loose quote blocks. The first present one finding shown without opening the chain. */
is the finding shown without opening the chain. */
const REASONING = [ const REASONING = [
['attribution_reason', 'Attribution'], ['attribution_reason', 'Attribution'],
['eligibility_reason', 'Eligibility'], ['eligibility_reason', 'Eligibility'],
@ -43,7 +30,7 @@ const REASONING = [
] ]
/* CONVERSATION lines stay as plain quotes they are what was said to and by /* 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 = [ const CONVERSATION = [
['customer_reply', 'The customer said'], ['customer_reply', 'The customer said'],
['customer_answer', 'We replied'], ['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']) const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
/** /** Fields already shown elsewhere in the entry. */
* 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.
*/
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system']) 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 /** A value as one short line. */
* anything here is a field value, not a paragraph. */
function short(f) { function short(f) {
const v = f.value const v = f.value
if (v === null || v === undefined || v === '') return null if (v === null || v === undefined || v === '') return null
@ -70,20 +52,15 @@ function short(f) {
const named = v const named = v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x)) .map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
.filter(Boolean) .filter(Boolean)
return named.length ? named.join(', ') : null return named.length ? named.map(humanize).join(', ') : null
} }
if (typeof v === 'object') return null if (typeof v === 'object') return null
const t = String(v).trim() const t = String(v).trim()
if (!t) return null if (!t) return null
// A value long enough to be prose belongs in the narrative block, not in a if (t.length > 90) return t.slice(0, 88) + '…'
// list of what changed truncating it here would show half a sentence and return humanize(t)
// teach the reader that this list is unreliable.
return t.length > 90 ? t.slice(0, 88) + '…' : 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 = { const DOC_LABELS = {
doc_rc: 'RC', doc_rc: 'RC',
doc_prev_policy: 'Expiring policy', doc_prev_policy: 'Expiring policy',
@ -101,23 +78,20 @@ const DOC_LABELS = {
const FILE_TYPES = new Set(['file', 'ocr']) const FILE_TYPES = new Set(['file', 'ocr'])
function when(ts) { function when(ts) {
if (!ts) return '' if (!ts) return { abs: '', rel: '' }
const d = new Date(ts) const d = new Date(ts)
const mins = Math.round((Date.now() - d.getTime()) / 60000) const mins = Math.round((Date.now() - d.getTime()) / 60000)
const rel = mins < 1 ? 'just now' const rel = mins < 1 ? 'just now'
: mins < 60 ? `${mins}m ago` : mins < 60 ? `${mins}m ago`
: mins < 1440 ? `${Math.round(mins / 60)}h ago` : mins < 1440 ? `${Math.round(mins / 60)}h ago`
: `${Math.round(mins / 1440)}d 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. */
* Last resort for a step whose activity_name did not come back. Without this
* the timeline the screen the whole product is demonstrated on prints a
* raw slug like "zk-act-doc-reminder" in the middle of an otherwise readable
* story. Turning it into "Doc Reminder" is a guess, but it is a guess that
* reads as English.
*/
function prettyUid(uid) { function prettyUid(uid) {
if (!uid) return 'Step' if (!uid) return 'Step'
return String(uid) return String(uid)
@ -134,19 +108,20 @@ function filesIn(value) {
return value.filter((f) => f && typeof f === 'object' && f.uuid) 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. */ /** The two activities that ARE the WhatsApp thread. */
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer']) 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() 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) const [showSys, setShowSys] = useState(false)
if (!rows) return <p className="tl__loading">Loading the timeline</p> 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. // Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))) 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 // ONE SUBMISSION WRITES SEVERAL ROWS, told apart by execution_state. Only the
// submission separately, told apart by execution_state: // row carrying a real state means anything to somebody reading the file.
// // Collapsed on (same activity, within fifteen seconds), because a genuine
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit // repeat has to survive.
// 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.
const isStage = (v) => STAGES.some((s) => s.uid === v) const isStage = (v) => STAGES.some((s) => s.uid === v)
const SAME_SUBMISSION_MS = 15000 const SAME_SUBMISSION_MS = 15000
const merged = [] const merged = []
for (const r of ordered) { 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 let at = -1
for (let i = merged.length - 1; i >= 0; i--) { 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 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] const prev = merged[at]
merged[at] = { merged[at] = {
...prev, ...prev,
// The settle row is the one that names the resulting stage.
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state, 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, data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
fields: (r.fields && r.fields.length) ? r.fields : prev.fields, fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
user_name: prev.user_name || r.user_name, user_name: prev.user_name || r.user_name,
@ -209,22 +164,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
let lastStage = null let lastStage = null
const items = merged.map((r, i) => { const items = merged.map((r, i) => {
const roles = r.user_roles || [] 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 isSystem = r.activity_id === 'DATA_UPDATE'
const aiRole = roles.find((x) => AGENTS[x]) const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human' 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 const fields = Array.isArray(r.fields) && r.fields.length
? r.fields ? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v })) : 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() const byBase = new Map()
for (const f of fields) { for (const f of fields) {
const base = baseFieldId(f.field_id) const base = baseFieldId(f.field_id)
// `_system` is the platform's own marker on every submission.
if (base === '_system') continue if (base === '_system') continue
if (!byBase.has(base)) byBase.set(base, f) 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 = [] const docs = []
for (const [base, f] of byBase) { for (const [base, f] of byBase) {
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue 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) 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 hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state) 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 const moved = stage && stage.uid !== lastStage
if (stage) lastStage = stage.uid 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 { return {
key: r.id ?? i, key: r.id ?? i,
kind: isChat ? 'chat' : kind, kind: isChat ? 'chat' : kind,
@ -273,17 +208,11 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
stage: moved ? stage : null, stage: moved ? stage : null,
when: when(r.created_at), when: when(r.created_at),
docs, docs,
// The reasoning rungs of the chain of thought the employee's own text. reasoning,
reasoning: REASONING finding: kind === 'ai' && reasoning.length ? (findingOf(reasoning[0][1]) ?? leadIn(reasoning[0][1])) : null,
.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.
conversation: CONVERSATION conversation: CONVERSATION
.map(([k, label]) => [label, byBase.get(k)?.value]) .map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''), .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: (() => { confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c) const n = c == null ? null : Number(c)
@ -292,22 +221,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
figures: [...byBase] figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value) .filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]), .map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
// Everything this step wrote, field by field.
/**
* 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.
*/
wrote: [...byBase] wrote: [...byBase]
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_')) .filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)]) .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. A contiguous run of chat turns
* THE CONVERSATION IS ONE THING, NOT NINE. // collapses to one entry that says how many messages, shows the last of
* // them, and opens the thread.
* 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.
*/
const grouped = [] const grouped = []
for (const it of items) { for (const it of items) {
const last = grouped[grouped.length - 1] const last = grouped[grouped.length - 1]
if (it.isChat && last && last.isChat) { if (it.isChat && last && last.isChat) {
last.count += 1 last.count += 1
last.when = it.when 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 if (it.conversation.length) last.conversation = it.conversation
last.docs = last.docs.concat(it.docs) last.docs = last.docs.concat(it.docs)
continue continue
@ -348,65 +246,79 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
} }
const sysCount = grouped.filter((it) => it.kind === 'sys').length 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 ( return (
<> <>
{sysCount ? ( <ol className={'tl' + (newestFirst ? ' tl--newest' : '')}>
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}> {listed.map((it, idx) => (
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'} <li key={it.key} className={'tl__item tl__item--' + it.kind + (idx === 0 && newestFirst ? ' is-latest' : '')}>
</button> {/* THE WORKER IS THE NODE. The face on the rail says who did it
) : null} 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__body">
<div className="tl__head"> <div className="tl__head">
{it.isChat ? ( <div className="tl__title">
<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>
) : (
<strong className="tl__what">{it.what}</strong> <strong className="tl__what">{it.what}</strong>
)} {it.count > 1 ? <span className="pill">{it.count} messages</span> : null}
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null} {it.stage && !it.isChat ? (
{it.stage && !it.isChat ? <span className="tl__stage"> {it.stage.name}</span> : null} <span className="tl__stage"><Icon name="arrowRight" size={12} />{it.stage.name}</span>
) : null}
</div> </div>
{/* The worker, as a face. Recognition down a column beats <span className="tl__when" title={it.when.abs}>{it.when.rel}<em> · {it.when.abs}</em></span>
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}
</div> </div>
{/* What was received, named and openable. The count is stated <div className="tl__meta">
rather than left to be inferred from a list "3 documents {it.kind === 'sys'
received" is the sentence the operator is looking for. */} ? <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 ? ( {it.docs.length ? (
<div className="tl__docs"> <div className="tl__docs">
<span className="tl__docshead"> <span className="tl__docshead">
<Icon name="file" size={13} />
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received {it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
</span> </span>
<div className="tl__doclist"> <div className="tl__doclist">
@ -419,12 +331,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
rel="noreferrer" rel="noreferrer"
title={d.original_name} 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} {d.slot}
</a> </a>
))} ))}
@ -432,39 +338,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div> </div>
) : null} ) : 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 ? ( {it.figures.length ? (
<div className="tl__figs"> <div className="tl__figs">
{it.figures.map(([k, v]) => ( {it.figures.map(([k, v]) => (
@ -473,12 +346,26 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div> </div>
) : null} ) : null}
{/* Folded, because most steps write a lot and the trail has to {/* ONE FOOTER ROW: the chain of thought, what was written, the
stay readable as a story. Open it and the step becomes an call, the thread. These used to stack as three rows of blue
itemised account of what it decided. */} 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 ? ( {it.wrote.length ? (
<details className="tl__wrote"> <details className="tl__wrote">
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary> <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> <dl>
{it.wrote.map(([k, v]) => ( {it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div> <div key={k}><dt>{k}</dt><dd>{v}</dd></div>
@ -486,10 +373,28 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</dl> </dl>
</details> </details>
) : null} ) : 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> </div>
</li> </li>
))} ))}
</ol> </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-tint: #fdeceb;
--zk-danger-line: #f0c2bd; --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 /* Shape, depth, motion
One scale, used everywhere. The palette above is untouched; every shadow 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 below is the brand navy at low alpha, so depth reads as part of the brand
@ -66,6 +83,9 @@
--r-lg: 16px; --r-lg: 16px;
--r-xl: 20px; --r-xl: 20px;
--r-pill: 999px; --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-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); --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 ground the generic admin-template look. The content ground is white
now; the sidebar is the only tinted surface, and sections are separated now; the sidebar is the only tinted surface, and sections are separated
by hairlines and space rather than boxed in shadowed cards. */ by hairlines and space rather than boxed in shadowed cards. */
background: var(--zk-white); background: var(--zk-ground);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -159,7 +179,144 @@ body,
body { body {
margin: 0; 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, 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; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--zk-tint); background: var(--zk-ground);
} }
/* ── Top bar ───────────────────────────────────────────────── */ /* Top bar
56px, white, three zones: brand, search, identity. */
.shell__top { .shell__top {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 30; z-index: 30;
display: flex; display: grid;
grid-template-columns: 232px minmax(0, 1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--sp-6); gap: var(--sp-6);
height: 72px; height: 56px;
padding: 0 var(--sp-7); padding: 0 var(--sp-5) 0 var(--sp-5);
background: var(--zk-white); background: var(--zk-card);
border-bottom: 1px solid var(--zk-line); border-bottom: 1px solid var(--zk-card-line);
} }
.shell__brand { .shell__brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
min-width: 0; min-width: 0;
} }
.shell__brand img { .shell__brand img {
height: 30px; height: 26px;
width: auto; width: auto;
display: block; display: block;
border-radius: 4px;
} }
.shell__brandtext { .shell__brandtext {
display: flex; padding-left: 12px;
flex-direction: column;
gap: 1px;
line-height: 1.2;
padding-left: 16px;
border-left: 1px solid var(--zk-line); 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 { /* ── Global search ───────────────────────────────────────────── */
font-size: var(--fs-md); .gs {
font-weight: var(--fw-semi); position: relative;
letter-spacing: -0.012em; 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); color: var(--zk-ink);
} }
.gs input::placeholder { color: var(--zk-grey); }
.shell__brandtext span { .gs input::-webkit-search-cancel-button { cursor: pointer; }
font-size: var(--fs-3xs); .gs__kbd {
font-weight: 400; flex: none;
letter-spacing: 0.08em; font: inherit;
text-transform: uppercase; 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); 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 { .shell__body {
display: flex; display: flex;
flex: 1; flex: 1;
@ -67,304 +133,218 @@
.shell__nav { .shell__nav {
position: sticky; position: sticky;
top: 72px; top: 56px;
align-self: flex-start; align-self: flex-start;
width: 264px; width: 248px;
flex: none; flex: none;
min-height: calc(100vh - 72px); min-height: calc(100vh - 56px);
max-height: calc(100vh - 72px); max-height: calc(100vh - 56px);
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
background: var(--zk-tint); background: var(--zk-card);
border-right: 1px solid var(--zk-line); border-right: 1px solid var(--zk-card-line);
padding: var(--sp-6) var(--sp-4) var(--sp-8); padding: var(--sp-4) var(--sp-3) var(--sp-5);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--sp-5);
} }
.shell__navtop { display: flex; flex-direction: column; gap: 2px; }
.shell__navlabel { .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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--sp-2); gap: 8px;
padding: var(--sp-3) var(--sp-3) var(--sp-3) 40px; margin: 0 0 6px;
border-radius: var(--r-md); 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; text-decoration: none;
color: var(--zk-muted); color: var(--zk-muted);
font-size: var(--fs-xs); 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 { .shell__stage.is-active,
content: ''; .shell__today.is-active {
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 {
background: var(--zk-tint-blue); background: var(--zk-tint-blue);
color: var(--zk-blue-dark); color: var(--zk-blue-dark);
font-weight: 500;
} }
.shell__stage.is-active .shell__ico,
.shell__stage.is-active::before { .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); 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. */ /* Queues where the machine stops and a person decides. */
.shell__stage.is-needed .shell__stagename { .shell__stage.is-needed { color: var(--zk-ink); }
font-weight: 500; .shell__stage.is-needed .shell__ico { color: var(--zk-amber); }
color: var(--zk-ink); .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); 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 /* ── Closed, folded ──────────────────────────────────────────── */
queue a person has to find, and it is also the longest label. */ .shell__closed > summary {
.shell__stagename { display: flex;
min-width: 0; align-items: center;
line-height: 1.35; 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 { .shell__main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
background: var(--zk-white);
width: 100%; width: 100%;
max-width: 1460px; max-width: 1440px;
margin: 0 auto; 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 ──────── */ /* ── Narrow viewports: the rail lies down and scrolls ────────── */
@media (max-width: 900px) { @media (max-width: 1100px) {
.shell__body { .shell__top { grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-4); }
flex-direction: column; .shell__brandtext { display: none; }
} }
@media (max-width: 900px) {
.shell__top { grid-template-columns: auto auto; }
.gs { display: none; }
.shell__body { flex-direction: column; }
.shell__nav { .shell__nav {
position: static; position: static;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
max-height: none; max-height: none;
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--zk-line); border-bottom: 1px solid var(--zk-card-line);
padding: 14px 16px 16px; padding: 10px 14px;
}
.shell__stages {
flex-direction: row; flex-direction: row;
overflow-x: auto; flex-wrap: wrap;
padding-bottom: 4px; gap: 8px;
} }
.shell__navtop, .shell__group, .shell__closed { display: contents; }
/* Laid out horizontally the stages are a scrolling strip of pills, and a rail .shell__navlabel, .shell__team, .shell__closed > summary { display: none; }
behind them would just be a line struck through the row. */ .shell__stages { flex-direction: row; flex-wrap: wrap; gap: 6px; }
.shell__stages::before { .shell__stage, .shell__today {
display: none; padding: 6px 12px;
}
.shell__stage {
padding: 8px 14px;
white-space: nowrap; white-space: nowrap;
background: var(--zk-white);
border: 1px solid var(--zk-line); border: 1px solid var(--zk-line);
background: var(--zk-card);
} }
.shell__stage.is-active::before, .shell__today.is-active::before { display: none; }
.shell__stage::before { .shell__main { padding: 18px 16px 48px; }
display: none;
} }
.shell__main {
padding: 22px 18px 56px;
}
}
/* The way back to the overview. It sits above "Add a lead" because reading
the book is the commoner act most sessions start by looking, not filing. */
.shell__today {
display: block;
margin: 0 0 8px;
padding: 9px 12px;
border-radius: var(--r-sm);
font-size: 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 { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS } from '../api/config.js' import { NAV_GROUPS } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { rolesOf, visibleStages } from '../api/permissions.js' import { rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx' import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.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 UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp' import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css' import './Shell.css'
@ -13,22 +20,13 @@ import './Shell.css'
* is not the question anyone signing in has; these answer "is anything waiting * 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. * 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 * The counts come from PortfolioProvider one fetch shared with the overview,
* only come from a second full list call that would then disagree with the * so the two cannot disagree. The badge counts leads whose PHASE needs a
* queue's own total. That was right about the cost and wrong about the * person, not leads in the state (see phaseOf); a dot marks a queue holding a
* conclusion with no numbers, the only way to learn whether anything is * renewal inside a week.
* 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 badge counts leads whose PHASE needs a person, not leads in the state * The closed states are folded away: three of the twelve, reporting rather
* see phaseOf. A dot marks a queue holding a renewal inside a week, which is * than work, and at equal weight they made the live queues harder to find.
* 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.
*/ */
export default function Shell() { export default function Shell() {
const { user } = useZino() const { user } = useZino()
@ -37,6 +35,7 @@ export default function Shell() {
// lives on the overview, which counts the whole book for everyone. // lives on the overview, which counts the whole book for everyone.
const stages = visibleStages(roles) const stages = visibleStages(roles)
const { counts, ready } = useStageCounts() const { counts, ready } = useStageCounts()
const [openAgent, setOpenAgent] = useState(null)
// One queue row. Extracted because the live groups and the folded Closed // One queue row. Extracted because the live groups and the folded Closed
// group render the same thing and drifted apart when they did not. // 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' : '') + (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> <span className="shell__stagename">{s.need ?? s.name}</span>
{ready ? ( {ready ? (
<span className="shell__tally"> <span className="shell__tally">
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null} {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 {/* The agents' share is shown apart rather than added in: it is in
than added in: it is in the queue, it is not your work. */} 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} {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> <b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
</span> </span>
@ -73,35 +73,24 @@ export default function Shell() {
<header className="shell__top"> <header className="shell__top">
<div className="shell__brand"> <div className="shell__brand">
<img src={logo} alt="Zurich Kotak General Insurance" /> <img src={logo} alt="Zurich Kotak General Insurance" />
<div className="shell__brandtext"> <span className="shell__brandtext">Lead Desk</span>
<strong>Lead Desk</strong>
<span>Lead to Policy</span>
</div>
</div> </div>
<GlobalSearch />
<UserMenu /> <UserMenu />
</header> </header>
<div className="shell__body"> <div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline"> <nav className="shell__nav" aria-label="Pipeline">
<NavLink <div className="shell__navtop">
to="/" <NavLink to="/" end className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
end <Icon name="home" className="shell__ico" />
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
Overview Overview
</NavLink> </NavLink>
<NavLink <NavLink to="/stage/all" className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
to="/stage/all" <Icon name="list" className="shell__ico" />
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
All leads All leads
</NavLink> </NavLink>
</div>
{/* "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. */}
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => { {NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
const inGroup = stages.filter((s) => s.kind === group.kind) 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 {/* Closed. Folded, because it is reporting rather than work. */}
equal weight with the live queues it made them harder to find. */}
{stages.some((s) => s.kind === 'end') ? ( {stages.some((s) => s.kind === 'end') ? (
<details className="shell__closed"> <details className="shell__closed">
<summary> <summary>
<Icon name="chevronRight" className="shell__caret" />
Closed Closed
{ready ? ( {ready ? (
<b>{stages.filter((s) => s.kind === 'end') <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> <div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
</details> </details>
) : null} ) : 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> </nav>
<main className="shell__main"> <main className="shell__main">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{openAgent ? <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> : null}
</div> </div>
) )
} }

View File

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

View File

@ -1,20 +1,15 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { initials, roleLabel } from '../api/display.js'
import Icon from '../components/Icon.jsx'
import './UserMenu.css' 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 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 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. * 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() { export default function UserMenu() {
const { user, signOut } = useZino() const { user, signOut } = useZino()
@ -43,7 +38,8 @@ export default function UserMenu() {
}, [open]) }, [open])
const name = user?.name || user?.email || 'Signed in' 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 ( return (
<div className="um" ref={wrapRef}> <div className="um" ref={wrapRef}>
@ -55,28 +51,30 @@ export default function UserMenu() {
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} 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"> <span className="um__id">
<strong>{name}</strong> <strong>{name}</strong>
<span>{roles || user?.email}</span> <span>{roles.join(' · ') || user?.email}</span>
</span> </span>
<svg className="um__caret" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"> <Icon name="chevronDown" size={14} className="um__caret" />
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
{open ? ( {open ? (
<div className="um__menu" role="menu"> <div className="um__menu" role="menu">
<div className="um__head"> <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"> <div className="um__headid">
<strong>{name}</strong> <strong>{name}</strong>
{user?.email ? <span>{user.email}</span> : null} {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>
</div> </div>
<button type="button" role="menuitem" className="um__item" onClick={signOut}> <button type="button" role="menuitem" className="um__item" onClick={signOut}>
<Icon name="signOut" />
Sign out Sign out
</button> </button>
</div> </div>

View File

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

View File

@ -1,51 +1,64 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { ORG_ID } from '../api/config.js' 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 logo from '../assets/brand/zurich_logo.webp'
import './Login.css' 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', icon: 'bot',
body: 'No inspection; hassle free policy', title: 'Autonomous by default',
icon: ( body: 'Five AI employees qualify, call, chase, price and check every renewal lead.',
<>
<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" />
</>
),
}, },
{ {
title: '24/7 claim assistance', icon: 'user',
body: 'Making claims simple, transparent, and customer-first', title: 'A person at the gates',
icon: ( body: 'Documents, the underwriting call and the premium sign-off stay with people.',
<>
<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" />
</>
),
}, },
{ {
title: 'A trusted brand', icon: 'brain',
body: 'You can count on Zurich Kotak General Insurance at all times', title: 'Every decision on record',
icon: ( body: 'Each step shows who took it, what it wrote, and the reasoning behind it.',
<>
<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" />
</>
),
}, },
] ]
/**
* 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() { export default function Login() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const { signIn } = useZino() const { signIn } = useZino()
const navigate = useNavigate() const navigate = useNavigate()
const pwRef = useRef(null)
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault() 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 ( return (
<main className="login"> <main className="login">
<section className="login__brand"> <section className="login__brand">
<div className="login__grid" aria-hidden="true" /> <div className="login__grid" aria-hidden="true" />
<div className="login__glow" aria-hidden="true" /> <div className="login__glow" aria-hidden="true" />
<img <img className="login__logo" src={logo} alt="Zurich Kotak General Insurance" />
className="login__logo"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<div className="login__brand-body"> <div className="login__brand-body">
<span className="login__badge"> <span className="login__badge">
<svg <span className="login__badgedot" aria-hidden="true" />
viewBox="0 0 24 24" Lead to Policy · operator console
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> </span>
<h1 className="login__headline"> <h1 className="login__headline">
Protecting what Lead Desk
<span className="login__headline-accent">
matters the most to you
</span>
</h1> </h1>
<p className="login__lede"> <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> </p>
<ul className="login__features"> <ul className="login__features">
{FEATURES.map((feature) => ( {CLAIMS.map((c) => (
<li key={feature.title}> <li key={c.title}>
<span className="login__feature-icon" aria-hidden="true"> <span className="login__feature-icon" aria-hidden="true">
<svg <Icon name={c.icon} size={18} strokeWidth={1.5} />
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{feature.icon}
</svg>
</span> </span>
<span> <span>
<strong>{feature.title}</strong> <strong>{c.title}</strong>
<em>{feature.body}</em> <em>{c.body}</em>
</span> </span>
</li> </li>
))} ))}
</ul> </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> </div>
</section> </section>
<section className="login__panel"> <section className="login__panel">
<div className="login__form-wrap"> <div className="login__card">
<img <img className="login__logo login__logo--mobile" src={logo} alt="Zurich Kotak General Insurance" />
className="login__logo login__logo--mobile"
src={logo}
alt="Zurich Kotak General Insurance"
/>
<h2 className="login__title">Sign in</h2> <h2 className="login__title">Sign in</h2>
<p className="login__subtitle"> <p className="login__subtitle">Use your Lead Desk account for organisation {ORG_ID}.</p>
Enter your credentials to access your dashboard.
</p>
<form className="login__form" onSubmit={handleSubmit} noValidate> <form className="login__form" onSubmit={handleSubmit} noValidate>
<label className="login__field"> <label className="login__field">
@ -157,7 +156,7 @@ export default function Login() {
type="email" type="email"
name="email" name="email"
autoComplete="email" autoComplete="email"
placeholder="you@company.com" placeholder="you@zurichkotak.example"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
/> />
@ -165,44 +164,69 @@ export default function Login() {
<label className="login__field"> <label className="login__field">
<span>Password</span> <span>Password</span>
<div className="login__pw">
<input <input
type="password" ref={pwRef}
type={showPw ? 'text' : 'password'}
name="password" name="password"
autoComplete="current-password" autoComplete="current-password"
placeholder="••••••••" placeholder="••••••••"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} 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> </label>
{error ? ( {error ? (
<p className="login__error" role="alert"> <p className="login__error" role="alert">
<Icon name="alert" size={14} />
{error} {error}
</p> </p>
) : null} ) : null}
<button className="login__submit" type="submit" disabled={submitting}> <button className="login__submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'} {submitting ? 'Signing in…' : picked ? `Sign in as ${picked.name.split(' ')[0]}` : 'Sign in'}
{submitting ? null : ( {submitting ? null : <Icon name="arrowRight" />}
<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>
)}
</button> </button>
</form> </form>
<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"> <p className="login__powered">
Powered by <span>zino</span> Powered by <span>zino</span>
</p> </p>
</div>
</section> </section>
</main> </main>
) )

View File

@ -7,13 +7,15 @@ import AgentChip from '../components/AgentChip.jsx'
import CallTranscript from '../components/CallTranscript.jsx' import CallTranscript from '../components/CallTranscript.jsx'
import ClampText from '../components/ClampText.jsx' import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx' import Conversation from '../components/Conversation.jsx'
import Icon from '../components/Icon.jsx'
import LeadFileDialog from '../components/LeadFileDialog.jsx' import LeadFileDialog from '../components/LeadFileDialog.jsx'
import StageRail from '../components/StageRail.jsx' import StageRail from '../components/StageRail.jsx'
import Timeline from '../components/Timeline.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 { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js' import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import { humanize, initials, toneFor } from '../api/display.js'
import './screens.css' import './screens.css'
/** The countdown, not just the date. On a renewal book this is the number /** 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']], ['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']], ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']], ['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', ['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_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']], 'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
@ -74,22 +72,10 @@ const GROUPS = [
const LONG = 150 const LONG = 150
/** /**
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED. * 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
* Three things are always true of a lead who the customer is, what is being * attached so those are always here. The fourth section depends on where
* insured, and what has been attached so those are always here. The fourth * the lead has got to. Everything else is in the full file.
* 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.
*/ */
const GLANCE_STAGE = { const GLANCE_STAGE = {
'zk-state-new': ['lead_score', 'eligibility_outcome'], 'zk-state-new': ['lead_score', 'eligibility_outcome'],
@ -111,14 +97,7 @@ const GLANCE_RISK = {
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'], sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
} }
/** /** Fallback labels for a field the config does not describe. */
* 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.
*/
const WORDS = { const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW', 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', 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) : [] 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) { function fmt(k, v) {
if (v === null || v === undefined || v === '') return null if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v) if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(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 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) .filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null .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 (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) } 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 * Whose move it is, right now said as a coloured pill in the header.
* 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.
*/ */
function holdsOf(stage) { function holdsOf(stage) {
if (!stage) return null if (!stage) return null
switch (stage.kind) { switch (stage.kind) {
case 'auto': return { tone: 'ai', label: 'With the AI' } case 'auto': return { tone: 'ai', icon: 'bot', label: 'With the AI' }
case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' } case 'customer': return { tone: 'cust', icon: 'chat', label: 'With the customer' }
case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' } case 'needs': return { tone: 'person', icon: 'user', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' } case 'waiting': return { tone: 'grey', icon: 'clock', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' } case 'end': return { tone: 'grey', icon: 'archive', label: 'Closed' }
default: return null 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() { export default function Lead() {
const { instanceId } = useParams() const { instanceId } = useParams()
const { client, user } = useZino() const { client, user } = useZino()
const roles = rolesOf(user) const roles = rolesOf(user)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
// What the workflow said when the last activity was submitted. It is written // What the workflow said when the last activity was submitted shown
// per activity "Documents received capturing the risk" and is the
// workflow telling the operator what it just set in motion, so it is shown
// verbatim rather than replaced with a generic "Saved". // verbatim rather than replaced with a generic "Saved".
const [note, setNote] = useState(location.state?.message ?? null) const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null) const [row, setRow] = useState(null)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const [open, setOpen] = useState(null) const [open, setOpen] = useState(null)
const [labels, setLabels] = useState({}) const [labels, setLabels] = useState({})
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline // The audit rows live HERE, not inside Timeline: one fetch, one poll, and
// fetched them once on mount and never again so a lead worked by five // the conversation view reads the same rows.
// 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.
const [audit, setAudit] = useState(null) const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false) const [showChat, setShowChat] = useState(false)
const [openAgent, setOpenAgent] = useState(null) const [openAgent, setOpenAgent] = useState(null)
const [showCall, setShowCall] = useState(false) const [showCall, setShowCall] = useState(false)
// The full record, on demand see the panel comment below.
const [showFile, setShowFile] = useState(false) const [showFile, setShowFile] = useState(false)
// A clock in state rather than Date.now() in the render body. Reading the // Newest first by default: on a live demo the step that just happened is
// wall clock while rendering is impure React may render twice and get two // the one everybody is looking for. One click reads it as a story instead.
// answers and it also means the "nothing for 6 minutes" counter would only const [newestFirst, setNewestFirst] = useState(true)
// move when something else happened to re-render the page. This ticks it. // A clock in state rather than Date.now() in the render body.
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
const load = useCallback((quiet = false) => { const load = useCallback((quiet = false) => {
@ -215,8 +191,6 @@ export default function Lead() {
return client.detailView(DV_LEAD, instanceId) return client.detailView(DV_LEAD, instanceId)
.then((r) => { .then((r) => {
setRow(r?.data ?? r?.record ?? 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 const fields = r?.config?.fields
if (Array.isArray(fields)) { if (Array.isArray(fields)) {
setLabels(Object.fromEntries( 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 // 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 // becomes an error page.
// operator is reading, or unmount a half-filled form under them.
.catch((e) => { if (!quiet) setErr(e) }) .catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId]) }, [client, instanceId])
// `load` is CALLED here, not handed to useEffect: it returns a promise so the // `load` is CALLED here, not handed to useEffect: it returns a promise.
// 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.
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// The message is handed over on the history entry, so it would show again // The message is handed over on the history entry; consume it once.
// 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.
useEffect(() => { useEffect(() => {
if (location.state?.message) navigate(location.pathname, { replace: true, state: null }) if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
}, [location.state, location.pathname, navigate]) }, [location.state, location.pathname, navigate])
// A lead that no longer exists is not a page to look at. Send the operator // A lead that no longer exists is not a page to look at.
// back to the queue rather than leaving them on an empty shell.
useEffect(() => { useEffect(() => {
if (err && describeError(err).kind === 'gone') { if (err && describeError(err).kind === 'gone') {
const id = setTimeout(() => navigate('/', { replace: true }), 1600) const id = setTimeout(() => navigate('/', { replace: true }), 1600)
@ -252,22 +219,14 @@ export default function Lead() {
} }
}, [err, navigate]) }, [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(() => { useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 20000) const id = setInterval(() => setNow(Date.now()), 20000)
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
useEffect(() => { useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the // Not while a form is open: a successful poll rewrites `actions`, and if
// lead has moved on the open form unmounts taking whatever was typed into // the lead has moved on the open form unmounts with whatever was typed.
// it with no warning. A submission against a stale state is refused by the
// workflow anyway, and that refusal is handled properly below.
if (open) return undefined if (open) return undefined
const id = setInterval(() => { const id = setInterval(() => {
if (document.visibilityState === 'visible') load(true) if (document.visibilityState === 'visible') load(true)
@ -285,7 +244,15 @@ export default function Lead() {
</div> </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. // Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS const groups = GROUPS
@ -296,22 +263,12 @@ export default function Lead() {
.filter(([, shown]) => shown.length) .filter(([, shown]) => shown.length)
const stateName = row.current_state_name || '' 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 stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
const holds = holdsOf(stage) const holds = holdsOf(stage)
// Only what THIS user may run. The workflow refuses the rest server-side // 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.
const actions = actionsFor(roles, stage?.uid) const actions = actionsFor(roles, stage?.uid)
// THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do // THE ONE THING THIS USER MUST DO, promoted to the header.
// 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.
const acceptedAlready = Boolean(row.acceptance_ref) const acceptedAlready = Boolean(row.acceptance_ref)
const primaryAction = actions.find( const primaryAction = actions.find(
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'), (a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
@ -319,16 +276,11 @@ export default function Lead() {
const goToAction = () => { const goToAction = () => {
if (!primaryAction) return if (!primaryAction) return
setOpen(primaryAction.uid) setOpen(primaryAction.uid)
// Let the form mount, then bring the working surface into view.
requestAnimationFrame(() => requestAnimationFrame(() =>
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' })) document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
} }
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0) 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 const pick = (keys) => keys
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]) .map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
.filter(([, v]) => v !== null) .filter(([, v]) => v !== null)
@ -337,25 +289,22 @@ export default function Lead() {
const line = row.product_line === 'sme_package' ? 'sme' : 'motor' const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
const glance = [ const glance = [
['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])], ['Customer', 'user', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
[line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])], [line === 'sme' ? 'The risk' : 'The vehicle', line === 'sme' ? 'building' : 'car', pick(GLANCE_RISK[line])],
['Documents', pick(docKeys)], ['Documents', 'file', pick(docKeys)],
// THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything ['The call', 'phone', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
// else on this lead was typed or derived; this happened out loud, and ['This stage', 'sparkle', pick(GLANCE_STAGE[stage?.uid] || [])],
// until now its outcome sat in the full file with the source channel while ].filter(([, , rows]) => rows.length)
// 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)
// Who worked this lead, and how many steps each took. Derived from the same
// Two different reasons for an empty action list, and they must not read the // audit rows the trail renders, so they cannot drift.
// same. A terminal lead is finished; a live one you cannot act on belongs to const stepsBy = {}
// somebody else, and saying "closed" about it would be a lie. for (const r of audit || []) {
// Derived from the same audit rows the trail renders, so they cannot drift. if (r.activity_id === 'DATA_UPDATE') continue
const worked = [...new Set((audit || []) const k = (r.user_roles || []).find((x) => AGENTS[x])
.flatMap((r) => (r.user_roles || []).filter((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 chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : [] const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => { return n + fs.filter((f) => {
@ -365,74 +314,53 @@ export default function Lead() {
}).length }).length
}, 0) }, 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 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 stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) } ? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
: null : 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 ( return (
<section> <section>
<header className="page__head"> <header className="page__head page__head--lead">
<div className="page__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 <button
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue" type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))} onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
> >
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="chevronLeft" />
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button> </button>
<div> <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> <h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub"> <p className="page__sub page__sub--chips">
{row.lead_ref || `Lead ${instanceId}`} <span className="page__ref">{row.lead_ref || `Lead ${instanceId}`}</span>
{row.entity_name ? ` · ${row.entity_name}` : ''} {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> </p>
</div> </div>
</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 ? ( {primaryAction ? (
/* YOUR MOVE. When this user owns the next step, the header carries it /* YOUR MOVE. When this user owns the next step, the header carries
as the prominent thing amber, because it is waiting on a person it as the prominent thing. */
and pressing it opens the form below and scrolls to it. */
<button type="button" className="cta" onClick={goToAction}> <button type="button" className="cta" onClick={goToAction}>
<span className="cta__txt"> <span className="cta__txt">
<small>Your move</small> <small>Your move</small>
<b>{primaryAction.label}</b> <b>{primaryAction.label}</b>
</span> </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> </button>
) : holds ? ( ) : holds ? (
<div className={'holds holds--' + (holds.pillTone === 'cust' ? 'cust' : holds.tone)}> <div className={'holds holds--' + holds.tone}>
<span className="holds__puck" aria-hidden="true"> <span className="holds__puck" aria-hidden="true"><Icon name={holds.icon} size={16} /></span>
{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>
<span className="holds__txt"> <span className="holds__txt">
<small>Right now</small> <small>Right now</small>
<b>{holds.label}</b> <b>{holds.label}</b>
@ -441,23 +369,9 @@ export default function Lead() {
) : null} ) : null}
</header> </header>
{/* Measures, not fields. Age and dwell time are computed neither is on {/* Measures, not fields. FOUR NUMBERS: money leads, the AI's confidence
the record and they are the two that answer "is this lead moving?", sits beside it, the renewal countdown is the one deadline that matters. */}
which no label/value pair on the page could. */} <div className="lmetrics card">
{(() => {
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"> <div className="lm lm--hero">
<span className="lm__l">Premium</span> <span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong> <strong className="lm__v">{premium ?? '—'}</strong>
@ -479,48 +393,43 @@ export default function Lead() {
) : null} ) : null}
<div className="lm"> <div className="lm">
<span className="lm__l">Renewal</span> <span className="lm__l">Renewal</span>
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong> <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> <span className="lm__s">{e ? e.on : 'no date on file'}</span>
</div> </div>
</div> <div className="lm lm--rail">
)
})()}
<StageRail audit={audit} currentName={stateName} /> <StageRail audit={audit} currentName={stateName} />
</div>
</div>
<div className="lead"> <div className="lead">
<div className="lead__main"> <div className="lead__main">
{note ? ( {note ? (
<div className="said" role="status"> <div className="said" role="status">
<Icon name="check" className="said__ico" />
<p>{note}</p> <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> </div>
) : null} ) : null}
{/* An AI-carried stage has no empty action bar and no idle screen: it {/* 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 says what is happening and who is doing it. */}
stay available ops can run them by hand but they are not the
answer to "why is nothing moving?". */}
{blocked ? ( {blocked ? (
<div className="doing doing--blocked"> <div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" /> <span className="doing__ico"><Icon name="alert" size={18} /></span>
<div> <div>
<strong>Stopped {blocked.what}</strong> <strong>Stopped {blocked.what}</strong>
<span> <span>
{blocked.fix} The agents will pick the lead up again on their own once it {blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime. is there; nothing else is running in the meantime.
</span> </span>
<button <button type="button" className="doing__fix" onClick={() => setOpen(blocked.via)}>
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents Open Collect Documents
</button> </button>
</div> </div>
</div> </div>
) : stalled ? ( ) : stalled ? (
<div className="doing doing--stalled"> <div className="doing doing--stalled">
<span className="doing__stop" aria-hidden="true" /> <span className="doing__ico"><Icon name="clock" size={18} /></span>
<div> <div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong> <strong>{stage.doing} nothing for {stalled.mins} minutes</strong>
<span> <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.'} : ' There is no step to re-run from here; the actions below are the way on.'}
</span> </span>
{stalled.nudge ? ( {stalled.nudge ? (
<button <button type="button" className="doing__fix" onClick={() => setOpen(stalled.nudge.uid)}>
type="button" className="doing__fix"
onClick={() => setOpen(stalled.nudge.uid)}
>
{stalled.nudge.label} {stalled.nudge.label}
</button> </button>
) : null} ) : null}
</div> </div>
</div> </div>
) : stage?.kind === 'customer' && stage.doing ? ( ) : 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"> <div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" /> <span className="doing__ico"><Icon name="chat" size={18} /></span>
<div> <div>
<strong>{stage.doing}</strong> <strong>{stage.doing}</strong>
<span> <span>
@ -558,7 +460,7 @@ export default function Lead() {
</div> </div>
) : stage?.kind === 'auto' ? ( ) : stage?.kind === 'auto' ? (
<div className="doing"> <div className="doing">
<span className="doing__pulse" aria-hidden="true" /> <span className="doing__ico doing__ico--pulse"><Icon name="bot" size={18} /></span>
<div> <div>
<strong>{stage.doing}</strong> <strong>{stage.doing}</strong>
<span> <span>
@ -570,26 +472,12 @@ export default function Lead() {
</div> </div>
) : null} ) : null}
{/* WHAT TO DO NEXT, not what is permitted. {/* WHAT TO DO NEXT, not what is permitted. `do` is the step this
Every action carries a role: `do` is the step this stage is stage is waiting on, `again` a bounded loop, `force` an AI's own
waiting on, `again` is a bounded loop, `force` is an AI's own job job offered only so a stalled lead can be pushed by hand, `exit`
offered only so a stalled lead can be pushed by hand, `exit` is Mark Lost. */}
Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */}
{(() => { {(() => {
// 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' 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 accepted = Boolean(row.acceptance_ref)
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs' const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
@ -605,6 +493,15 @@ export default function Lead() {
const form = open ? ( const form = open ? (
<div className="acts__form"> <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 <ActivityForm
activityUid={open} activityUid={open}
instanceId={Number(instanceId)} instanceId={Number(instanceId)}
@ -623,26 +520,16 @@ export default function Lead() {
if (!step.length && !again.length && !extras.length) return null if (!step.length && !again.length && !extras.length) return null
return ( return (
<div className="panel" id="lead-next"> <div className="card sect" 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. */}
{step.length ? ( {step.length ? (
<div className="panel__head"> <div className="sect__head">
<div> <div>
<h2 className="panel__title">Next step</h2> <h2 className="h-sec">Next step</h2>
<p className="panel__sub">This is what this lead is waiting on.</p> <p className="h-sub">This is what this lead is waiting on.</p>
</div> </div>
</div> </div>
) : null} ) : 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 ? ( {step.length ? (
<div className="step"> <div className="step">
{step.map((a) => ( {step.map((a) => (
@ -652,8 +539,11 @@ export default function Lead() {
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')} + (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)} onClick={() => setOpen(open === a.uid ? null : a.uid)}
> >
<span className="step__ico"><Icon name={a.uid === 'zk-act-uw-decline' ? 'x' : 'check'} /></span>
<span className="step__t">
<strong>{a.label}</strong> <strong>{a.label}</strong>
<span>{a.by}</span> <span>{a.by}</span>
</span>
</button> </button>
))} ))}
</div> </div>
@ -673,12 +563,9 @@ export default function Lead() {
{form} {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 ? ( {extras.length ? (
<details className="stuck"> <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"> <p className="stuck__why">
{force.length {force.length
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.' ? '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. {/* THE STORY IS THE MAIN COLUMN. */}
This sat in a 384px gutter with its own inner scrollbar, beside a <div className="card sect">
panel of reference fields that had the whole width. That was <div className="sect__head">
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">
<div> <div>
<h2 className="panel__title">What happened</h2> <h2 className="h-sec">What happened</h2>
<p className="panel__sub"> <p className="h-sub">Every step on this lead, who took it, and what they wrote.</p>
Every step on this lead, who took it, and what they wrote.
</p>
</div> </div>
<div className="sect__tools">
{chatTurns ? ( {chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}> <button type="button" className="btn btn--sm" onClick={() => setShowChat(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="whatsapp" size={13} />
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Conversation Conversation
<b>{chatTurns}</b> <b className="btn__n">{chatTurns}</b>
</button> </button>
) : null} ) : 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>
</div> </div>
<Timeline <Timeline
rows={audit} rows={audit}
newestFirst={newestFirst}
onOpenAgent={setOpenAgent} onOpenAgent={setOpenAgent}
onOpenChat={() => setShowChat(true)} onOpenChat={() => setShowChat(true)}
onOpenCall={() => setShowCall(true)} onOpenCall={() => setShowCall(true)}
@ -743,44 +623,43 @@ export default function Lead() {
{/* Reference, not narrative. Sticky, so the facts stay put while the {/* Reference, not narrative. Sticky, so the facts stay put while the
story is read against them. */} story is read against them. */}
<aside className="lead__side"> <aside className="lead__side">
{/* Who worked this lead. Five agents carry it and their names appear {/* The team on this lead: who, what it does, how many steps. */}
only inside individual entries, so the team was never visible at
a glance. */}
{worked.length ? ( {worked.length ? (
<div className="panel panel--crew"> <div className="card sect sect--tight">
<span className="crew__l">Worked by</span> <div className="sect__head">
<div className="crew"> <div>
{worked.map((k) => <AgentChip key={k} agentKey={k} onOpen={setOpenAgent} />)} <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>
</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} ) : 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 ? ( {glance.length ? (
<div className="panel"> <div className="card sect sect--tight">
<div className="panel__head"> <div className="sect__head">
<div> <div>
<h2 className="panel__title">At a glance</h2> <h2 className="h-sec">At a glance</h2>
<p className="panel__sub"> <p className="h-sub">The customer, the risk, and what this stage turns on.</p>
The customer, the risk, and what this stage turns on.
</p>
</div> </div>
</div> </div>
<div className="glance"> <div className="glance">
{glance.map(([title, rows]) => ( {glance.map(([title, icon, rows]) => (
<section className="fgroup" key={title}> <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"> <dl className="props">
{rows.map(([k, v, files]) => ( {rows.map(([k, v, files]) => (
<div className="prop" key={k}> <div className="prop" key={k}>
@ -790,19 +669,17 @@ export default function Lead() {
{files.map((f) => ( {files.map((f) => (
<a <a
key={f.uuid} key={f.uuid}
className="prop__file" className="doc"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`} href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer" 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> </a>
))} ))}
</dd> </dd>
) : v.length > LONG ? ( ) : 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><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
) : ( ) : (
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd> <dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
@ -811,11 +688,8 @@ export default function Lead() {
))} ))}
</dl> </dl>
{title === 'The call' && row.call_transcript ? ( {title === 'The call' && row.call_transcript ? (
<button type="button" className="glance__act" onClick={() => setShowCall(true)}> <button type="button" className="btn btn--sm glance__act" onClick={() => setShowCall(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="phone" size={13} />
<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>
Read what was actually said Read what was actually said
</button> </button>
) : null} ) : null}
@ -825,12 +699,10 @@ export default function Lead() {
{fieldCount ? ( {fieldCount ? (
<button type="button" className="glance__all" onClick={() => setShowFile(true)}> <button type="button" className="glance__all" onClick={() => setShowFile(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="list" size={15} />
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor"
strokeWidth="1.4" strokeLinecap="round" />
</svg>
Open the full lead file Open the full lead file
<b>{fieldCount} fields</b> <b>{fieldCount} fields</b>
<Icon name="chevronRight" size={14} />
</button> </button>
) : null} ) : null}
</div> </div>

View File

@ -5,7 +5,9 @@ import { useZino } from '../api/provider.jsx'
import { ENTRY, STAGES, phaseOf } from '../api/config.js' import { ENTRY, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import { initials, toneFor, inrShort, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx' import NewLeadDialog from '../components/NewLeadDialog.jsx'
import Icon from '../components/Icon.jsx'
import './screens.css' 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. */ /** Indian money, short. A dashboard tile has no room for eight digits. */
function inr(n) { function inr(n) {
if (!n) return '₹0' return inrShort(n) ?? '₹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')
} }
function expiryTone(d) { function expiryTone(d) {
@ -55,6 +54,9 @@ function expiryTone(d) {
return 'later' 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() { export default function Overview() {
const { user } = useZino() const { user } = useZino()
const navigate = useNavigate() const navigate = useNavigate()
@ -63,6 +65,9 @@ export default function Overview() {
// Filing a lead happens over this page, not instead of it see // Filing a lead happens over this page, not instead of it see
// NewLeadDialog. The overview behind it keeps its counts and its poll. // NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false) 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 state = usePortfolio()
const m = useMemo(() => { const m = useMemo(() => {
@ -135,8 +140,7 @@ export default function Overview() {
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW. // 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, // 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 // or the customer's.
// is the state, and it is the sentence the whole product exists to say.
const byStageDef = new Map(STAGES.map((s) => [s.name, s])) const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
let withAI = 0, withCustomer = 0, withPerson = 0 let withAI = 0, withCustomer = 0, withPerson = 0
for (const r of open) { for (const r of open) {
@ -148,9 +152,14 @@ export default function Overview() {
else withPerson += 1 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 { return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals, 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, conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)), maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
} }
@ -176,11 +185,16 @@ export default function Overview() {
return ( return (
<section> <section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header> <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> </section>
) )
} }
const shownAttention = bucket
? m.exposure[bucket]
: m.attention
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
@ -188,55 +202,97 @@ export default function Overview() {
<h1 className="page__title">Portfolio overview</h1> <h1 className="page__title">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals · organisation-wide</p> <p className="page__sub">Motor and SME renewals · organisation-wide</p>
</div> </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"> <div className="page__right">
{state.at ? ( {state.at ? (
<span className="stamp"> <span className="live" title="Refreshes every 30 seconds while this tab is visible">
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })} <span className="live__dot" aria-hidden="true" />
Live · {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span> </span>
) : null} ) : null}
{canFile ? ( {canFile ? (
<button type="button" className="newlead" onClick={() => setAdding(true)}> <button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="plus" />
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead New lead
</button> </button>
) : null} ) : null}
</div> </div>
</header> </header>
{/* Who holds the work, right now. Three numbers, one sentence: this many {/* Who holds the work, right now. Three numbers and one risk, as four
are being worked by the AI, this many are waiting on a person, this quiet cards the one that is actually owed warms up. */}
many on a customer. It sits above the money because it is the claim
the money is evidence for. */}
<div className="now"> <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> <span className="now__l">With the AI</span>
<strong className="now__v">{m.withAI}</strong> <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>
<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> <span className="now__l">Waiting on a person</span>
<strong className="now__v">{m.withPerson}</strong> <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> <span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span>
</div> </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> <span className="now__l">With the customer</span>
<strong className="now__v">{m.withCustomer}</strong> <strong className="now__v">{m.withCustomer}</strong>
<span className="now__s">a quote or a payment is theirs to answer</span> <span className="now__s">a quote or a payment is theirs to answer</span>
</div> </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> <span className="now__l">Renewals at risk</span>
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong> <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> <span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span>
</div> </div>
</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"> <div className="kpi">
<span className="kpi__l">Open leads</span> <span className="kpi__l">Open leads</span>
<strong className="kpi__v">{m.open.length}</strong> <strong className="kpi__v">{m.open.length}</strong>
@ -265,11 +321,10 @@ export default function Overview() {
</div> </div>
{/* SIGN-OFF, not work. The only queue that is a person's approval rather {/* 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 than a task, shown to the role that owns it and to nobody else. */}
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. */}
{m.approvals.map((s) => ( {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> <strong className="appr__n">{s.n}</strong>
<span className="appr__t"> <span className="appr__t">
{s.approvalLabel} {s.approvalLabel}
@ -277,52 +332,74 @@ export default function Overview() {
</span> </span>
<span className="appr__go"> <span className="appr__go">
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'} {s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
<Icon name="chevronRight" />
</span> </span>
</Link> </Link>
))} ))}
<div className="split"> <div className="split">
<div className="card sect">
<div className="sect__head">
<div> <div>
<h2 className="sec">Action required</h2> <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"> <div className="qlist">
{m.actionable.map((s) => ( {m.actionable.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}> <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"> <span className="q__t">
{s.need ?? s.name} {s.need ?? s.name}
<em>{s.by}</em> <em>{s.by}{s.working ? ` · +${s.working} with the AI` : ''}</em>
</span> </span>
<span className="q__age"> <span className="q__age">
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''} {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> </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> </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>
</div> </div>
{m.attention.length ? ( <div className="card sect">
<div className="gridwrap"> <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>
{shownAttention.length ? (
<div className="gridwrap gridwrap--flush">
<table className="grid grid--tight"> <table className="grid grid--tight">
<thead> <thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr> <tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead> </thead>
<tbody> <tbody>
{m.attention.slice(0, 8).map((r) => { {shownAttention.slice(0, 8).map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
const st = STAGES.find((s) => s.name === r.current_state_name)
return ( return (
<tr <tr
key={id} key={id}
@ -336,44 +413,35 @@ export default function Overview() {
aria-label={`Open ${r.customer_name || r.lead_ref || id}`} aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
> >
<td> <td>
<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> <span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div> <div className="grid__sub">{r.lead_ref || ''}</div>
</div>
</div>
</td> </td>
<td><span className={'due due--' + expiryTone(r._d)}> <td><span className={'due due--' + expiryTone(r._d)}>
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'} {r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
</span></td> </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> <td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </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> </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>
<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> </div>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null} {adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section> </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 { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js' import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import { initials, toneFor, inrFull, KIND_ICON } from '../api/display.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx' 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 /** Short absolute date plus how long ago a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */ * for "when exactly", the relative for "is this going stale". */
@ -27,21 +30,15 @@ function added(ts) {
* Is an automated stage actually moving? * Is an automated stage actually moving?
* *
* The agents carry six of the nine stages, and a lead sitting in one is in * 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 * exactly one of two states: being worked right now, or stuck. The threshold
* neither, so a lead three hours into "Calling the customer" looked identical * is generous on purpose. An employee wake takes a minute or two and a
* to one thirty seconds in which is the failure this console exists to catch. * scheduled retry can be hours out, so `stalled` means "longer than any
*
* 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". * normal step", not "longer than average".
*/ */
function progress(row, stage) { function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null if (!stage || stage.kind !== 'auto') return null
// `updated_at` ONLY. Falling back to created_at dates the measure from when // `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 // the lead was filed rather than from its last step.
// 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.
const t = Date.parse(row.updated_at) const t = Date.parse(row.updated_at)
if (isNaN(t)) return null if (isNaN(t)) return null
const mins = (Date.now() - t) / 60000 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') } 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 /** Days to expiry, and how alarmed to be about it. */
* the countdown is the most operationally useful number in the row. */
function expiry(dateStr) { function expiry(dateStr) {
if (!dateStr) return null if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z') 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' 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' }) } 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 * 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 * 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 * narrowing in the browser is what keeps a queue honest once there are more
* leads than one page. * 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() { export default function Pipeline() {
const { stageUid } = useParams() const { stageUid } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { client, user } = useZino() const { client, user } = useZino()
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0 const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
// Over the queue rather than away from it: filing a lead used to cost you
// your place in the list you were working through.
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
// "all" is not a stage it is every lead in one list, so an operator // "all" is not a stage it is every lead in one list. IT MEANS ALL: closed
// wanting to find one does not have to guess which queue it is in. // rows are shown, dimmed, and can be hidden by choice.
//
// 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.
const isAll = stageUid === 'all' const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false) const [hideClosed, setHideClosed] = useState(false)
const stage = isAll const stage = isAll
@ -92,33 +94,38 @@ export default function Pipeline() {
: STAGES.find((s) => s.uid === stageUid) : STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null }) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
// `quiet` is what makes polling bearable: the first load may show a skeleton, // `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 // a refresh may not.
// would flash the whole queue away under whoever is reading it.
function fetchRows(quiet) { function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null }) if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client return client
.recordView(RV_LEADS, { .recordView(RV_LEADS, {
limit: isAll ? 200 : 100, 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 ?? '' }] }), ...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
}) })
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? [] 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 // 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. // 100 of it. Counting the page would quietly under-report a busy stage.
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length) const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null }) setState({ status: 'ready', rows, total, error: null })
setAt(new Date())
}) })
.catch((err) => { .catch((err) => {
if (cancelled) return if (cancelled) return
@ -136,24 +143,84 @@ export default function Pipeline() {
return () => { cancelled = true; clearInterval(id) } return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll]) }, [client, stageUid, stage?.name, isAll])
// Split rather than filtered at the query, so the header can say how many const CLOSED = useMemo(() => new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)), [])
// 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 closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0 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)) // Decorate once, then filter and sort on the decorated row.
: state.rows 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> 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 ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
<div className="page__lead">
<span className={'page__kind page__kind--' + stage.kind}><Icon name={KIND_ICON[stage.kind] || 'list'} size={20} /></span>
<div> <div>
<h1 className="page__title">{stage.need ?? stage.name}</h1> <h1 className="page__title">
{stage.need ?? stage.name}
{headCount !== null ? <span className="page__n">{headCount}</span> : null}
</h1>
<p className="page__sub"> <p className="page__sub">
{isAll {isAll
? 'Every lead not yet closed, across all stages.' ? 'Every lead, open and closed, across all stages.'
: stage.kind === 'auto' : stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}` ? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end' : stage.kind === 'end'
@ -161,37 +228,90 @@ export default function Pipeline() {
: stage.kind === 'waiting' : stage.kind === 'waiting'
? 'Held until the renewal window opens. Re-enters outreach automatically.' ? 'Held until the renewal window opens. Re-enters outreach automatically.'
: `Pending action by ${stage.by}.`} : `Pending action by ${stage.by}.`}
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null} {stage.need ? <span className="page__stage"> · Stage: {stage.name}</span> : null}
</p> </p>
</div> </div>
{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> </div>
<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 ? ( {canFile ? (
<button type="button" className="newlead" onClick={() => setAdding(true)}> <button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <Icon name="plus" />
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead New lead
</button> </button>
) : null} ) : null}
</div> </div>
) : null}
</header> </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' ? ( {state.status === 'loading' ? (
<div className="skel" aria-busy="true" aria-label="Loading the queue"> <div className="skel" aria-busy="true" aria-label="Loading the queue">
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)} {Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
@ -206,39 +326,36 @@ export default function Pipeline() {
</div> </div>
) : null} ) : null}
{state.status === 'ready' && shownRows.length === 0 ? ( {state.status === 'ready' && shown.length === 0 ? (
<p className="empty">{isAll ? 'No leads yet.' : 'No records in this stage.'}</p> <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} ) : null}
{state.status === 'ready' && shownRows.length > 0 ? ( {state.status === 'ready' && shown.length > 0 ? (
<div className="gridwrap"> <div className="gridwrap card">
<table className="grid"> <table className="grid">
<thead> <thead>
<tr> <tr>
<th>Customer</th> {sortHead('customer', 'Customer')}
<th>{isAll ? 'Stage' : 'Status'}</th> {sortHead('stage', isAll ? 'Stage' : 'Status')}
<th>Renewal due</th> {sortHead('renewal', 'Renewal due')}
<th className="num">Premium</th> {sortHead('premium', 'Premium', 'num')}
<th>Waiting</th> {sortHead('waiting', 'Added')}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shownRows.map((r) => { {shown.map(({ r, rowStage, prog, exp, add }) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '' }
const exp = expiry(r.renewal_due_date) const closed = CLOSED.has(r.current_state_name)
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)
return ( return (
<tr <tr
key={id} key={id}
className={'grid__row' className={'grid__row'
+ (prog ? ' is-' + prog.state : '') + (prog ? ' is-' + prog.state : '')
+ (CLOSED.has(r.current_state_name) ? ' is-done' : '')} + (closed ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)} onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } 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}`} aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
> >
<td> <td>
<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> <span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub"> <div className="grid__sub">
{r.lead_ref || `#${id}`} <span className="grid__ref">{r.lead_ref || `#${id}`}</span>
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''} {r.product_line ? <span className={'pill ' + (r.product_line === 'motor' ? 'pill--blue' : 'pill--violet')}>{r.product_line === 'motor' ? 'Motor' : 'SME'}</span> : null}
{ch.label ? ` · ${ch.label}` : ''} {ch.label ? <span className="pill pill--line">{ch.label}</span> : null}
</div>
</div>
</div> </div>
</td> </td>
{/* Who holds it, and where an agent does whether it is {/* 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" /> <span className="run__dot" aria-hidden="true" />
{prog.label} {prog.label}
</span> </span>
) : closed ? (
<span className={'pill ' + (END_TONE[r.current_state_name] ? 'pill--' + END_TONE[r.current_state_name] : '')}>{r.current_state_name}</span>
) : isAll ? ( ) : 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"> <span className="grid__sub">
{rowStage?.by ? `with ${rowStage.by}` : '—'} {rowStage?.by ? `with ${rowStage.by}` : '—'}
@ -279,8 +403,8 @@ export default function Pipeline() {
<div className="grid__sub">{exp.on}</div></> <div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>} : <span className="grid__sub"></span>}
</td> </td>
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td> <td className="num">{r.quoted_premium ? inrFull(r.quoted_premium) : <span className="grid__sub"></span>}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td> <td><span className="grid__sub" title={add.abs}>{add.rel || add.abs}</span></td>
</tr> </tr>
) )
})} })}

File diff suppressed because it is too large Load Diff