console: the Lead Desk redesign — one colour rule for who holds the work

Implements the design in zurich-kotak-leaddesk across every screen.

Foundation: Source Sans 3 bundled from src/assets/fonts (no Google Fonts
request), 15px root, navy/blue/amber/teal/red palette where blue is the AI,
amber a person, teal the customer and red is reserved for risk; flat white
10px cards on a grey canvas, no shadows.

Shell: 56px navy masthead (Z wordmark, Lead Desk, user), 232px white nav
grouped Needs you / Scheduled / History with coloured count pills and the
closed stages folded.

Overview: the hero says who holds each open lead (stacked bar + legend)
beside the renewals-at-risk card; five KPIs incl. Closed to date; Waiting
on a person queue with owner dots and "N more with the AI"; Pipeline by
stage with owner-coloured bars; Renewal exposure with tabs, dropdown
filters and a Holding it column.

Lead: back button, id line, holder pill; one attention banner (amber your
turn / blocked, red stalled, blue with the AI, teal with the customer) that
carries its button; four facts with a confidence bar; navy steps rail
whose current step takes its holder's colour; timeline as an avatar
gutter with title, stage pill, by-line + AI tag and text-link actions
(See reasoning · N steps, Hear the call, Open thread); side column flows.

Lists: card with search + hide-closed toggle, columns Customer / Stage /
Renewal due / Holding it / Premium / Last activity, stalled rows get a red
inset, closed rows dim, person-gated queues split into Your turn / With the
AI group rows.

src/api/holder.js is the single rule for holder tone + label so the table
cells, the overview and the lead header cannot disagree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 23:56:29 +05:30
parent 0df7d12073
commit 79863e2a5f
28 changed files with 1350 additions and 3684 deletions

View File

@ -13,12 +13,9 @@
<!--BASE_HREF--> <!--BASE_HREF-->
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" /> <link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
<title>Zurich Kotak | Lead Desk</title> <title>Zurich Kotak | Lead Desk</title>
<!-- Geist — a modern enterprise UI typeface (Vercel). Renders crisp at <!-- Typeface is Source Sans 3, self-hosted and bundled from src/assets
data-density sizes where the previous face went soft. Zurich Sans is (see src/index.css). No CDN request, and it renders under the
kept in the fallback stack, and Geist Mono carries refs and figures. --> sub-path mount because Vite resolves the URLs at build. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Geist+Mono:wght@400;500&display=swap" />
<!-- Written next to the artifact at placement; carries this environment's <!-- Written next to the artifact at placement; carries this environment's
VITE_ZINO_API_URL, which src/runtimeConfig.js reads off VITE_ZINO_API_URL, which src/runtimeConfig.js reads off
window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the

View File

@ -69,11 +69,12 @@ export const STAGES = [
* waiting on me?". * waiting on me?".
*/ */
export const NAV_GROUPS = [ export const NAV_GROUPS = [
{ label: 'Action required', kind: 'needs' }, // The sidebar answers "is anything waiting on me?", so a group is WHO holds
{ label: 'Customer response', kind: 'customer' }, // the work, not where it sits in the process. A person's queues and the
{ label: 'Automated', kind: 'auto' }, // customer's queues both need a human to look, so they share one heading.
{ label: 'Scheduled', kind: 'waiting' }, { label: 'Needs you', kinds: ['needs', 'customer'] },
{ label: 'Closed', kind: 'end' }, { label: 'Scheduled', kinds: ['waiting'] },
{ label: 'History', kinds: ['end'] },
] ]
/** Channel presentation. source_channel is data on the instance, never a branch. */ /** Channel presentation. source_channel is data on the instance, never a branch. */

36
src/api/holder.js Normal file
View File

@ -0,0 +1,36 @@
import { phaseOf } from './config.js'
/**
* Who is holding a lead right now, said the way the colour rule says it:
* blue = the AI, amber = a person, teal = the customer, grey = nobody (closed).
*
* Derived from phaseOf, so it agrees with the sidebar tallies and the lead
* header. `label` is the short form for a table cell: "AI · Intake",
* "Underwriter", "Customer", "Closed".
*/
const AI_NAMES = [
['intake', 'Intake'], ['engage', 'Engage'], ['advisor', 'Advisor'],
['kyc', 'KYC'], ['voice', 'Meera'], ['rating', 'Rating'],
]
function tidy(by) {
const s = String(by || '').replace(/^(the|an|a)\s+/i, '').trim()
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '—'
}
export function holderOf(stageDef, row) {
if (!stageDef) return { tone: 'grey', label: '—' }
const ph = phaseOf(stageDef, row) || stageDef
switch (ph.kind) {
case 'auto': {
const by = String(ph.by || '').toLowerCase()
const hit = AI_NAMES.find(([k]) => by.includes(k))
return { tone: 'blue', label: 'AI · ' + (hit ? hit[1] : 'Engage') }
}
case 'customer': return { tone: 'teal', label: 'Customer' }
case 'needs': return { tone: 'amber', label: tidy(ph.by) }
case 'waiting': return { tone: 'grey', label: 'Calendar' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return { tone: 'grey', label: tidy(ph.by) }
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,96 +1,39 @@
.who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } .who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
/* A soft disc in the worker's colour: blue for the AI at large, amber for KYC,
teal for the Advisor, grey initials for a person. */
.who__disc { .who__disc {
position: relative; position: relative; display: grid; place-items: center; flex: none;
display: grid; width: 22px; height: 22px; border-radius: 50%;
place-items: center; font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
flex: none; background: var(--zk-tint-blue); color: var(--zk-blue);
width: 20px; }
height: 20px; .who--md .who__disc { width: 36px; height: 36px; font-size: 13px; }
border-radius: 50%; .who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }
font-size: var(--fs-2xs);
font-weight: 600; .who__disc--blue, .who__disc--violet, .who__disc--plum { background: var(--zk-tint-blue); color: var(--zk-blue); }
letter-spacing: 0.02em; .who__disc--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
color: #fff; .who__disc--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
user-select: none; .who__disc--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
.who__glyph { width: 52%; height: 52%; }
.who__init { font-weight: 700; }
.who__name { font-size: var(--fs-sm); font-weight: 600; color: var(--zk-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.who__tag {
font-size: 12px; font-weight: 700; letter-spacing: .04em;
color: var(--zk-blue); background: var(--zk-tint-blue);
padding: 1px 6px; border-radius: 4px;
} }
.who--md .who__disc { width: 26px; height: 26px; font-size: var(--fs-2xs); }
.who__name {
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Five agents, five hues, all readable on white at 20px and all distinguishable
from the Zurich blue the interface already uses for its own chrome. */
.who__disc--blue { background: #2167ae; }
.who__disc--teal { background: #0f7b78; }
.who__disc--violet { background: #5b4bb7; }
.who__disc--amber { background: #a8651a; }
.who__disc--plum { background: #8a3d6b; }
.who__disc--grey { background: #6b7280; }
/* The chip becomes a control only where a card exists to open. */
.who--btn { .who--btn {
font: inherit; font: inherit; cursor: pointer; padding: 2px 8px 2px 2px;
cursor: pointer; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent;
padding: 2px 8px 2px 2px; transition: background var(--t-fast), border-color var(--t-fast);
border: 1px solid transparent;
border-radius: var(--r-pill, 999px);
background: transparent;
transition: background .12s, border-color .12s;
} }
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); } .who--btn:hover { background: var(--zk-tint); border-color: var(--zk-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: none; box-shadow: var(--ring); }
.who__i { width: 12px; height: 12px; flex: none; color: var(--zk-grey); opacity: 0; transition: opacity .12s; }
.who__i { .who__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
width: 12px;
height: 12px;
flex: none;
color: var(--zk-grey);
opacity: 0;
transition: opacity .12s;
}
/* The AI mark, on the disc rather than beside the name: it has to survive
showName={false}, and it belongs to the worker, not to the label. Sits
proud of the disc so it stays legible against every one of the five hues,
with a white ring to separate it from the colour underneath. */
.who__ai {
position: absolute;
right: -3px;
bottom: -3px;
width: 11px;
height: 11px;
fill: var(--zk-amber, #b5761f);
stroke: var(--zk-white, #fff);
stroke-width: 0.9;
stroke-linejoin: round;
paint-order: stroke fill;
}
.who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; }
/* Said out loud for a screen reader, which cannot see a sparkle. */
.who__sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* Role icon fills most of the disc, leaving room for the AI spark at the
corner. Sized off the disc so it scales with .who--md. */
.who__glyph { width: 60%; height: 60%; }
.who--md .who__glyph { width: 58%; height: 58%; }

View File

@ -1,4 +1,4 @@
import { AGENTS } from '../api/agents.js' import { AGENTS, initialsOf } from '../api/agents.js'
import './AgentChip.css' import './AgentChip.css'
/** /**
@ -20,16 +20,6 @@ function SparkleGlyph() {
) )
} }
function PersonGlyph() {
return (
<svg className="who__glyph who__glyph--person" viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<circle cx="8" cy="5.6" r="2.7" />
<path d="M2.9 13.8a5.1 5.1 0 0 1 10.2 0" strokeLinecap="round" />
</svg>
)
}
/** /**
* Who did this as a face, not a slug. * Who did this as a face, not a slug.
* *
@ -58,10 +48,11 @@ export default function AgentChip({ agentKey, name, size = 'sm', showName = true
Only agents get it. A trail where a human's disc also carried one Only agents get it. A trail where a human's disc also carried one
would say nothing at all. */} would say nothing at all. */}
<span className={`who__disc who__disc--${tone}`} aria-hidden="true"> <span className={`who__disc who__disc--${tone}`} aria-hidden="true">
{/* A machine wears the sparkle; a person wears the person. */} {/* A machine wears the sparkle; a person wears their initials. */}
{a ? <SparkleGlyph /> : <PersonGlyph />} {a ? <SparkleGlyph /> : <span className="who__init">{initialsOf(name)}</span>}
</span> </span>
{showName ? <span className="who__name">{label}</span> : null} {showName ? <span className="who__name">{label}</span> : null}
{showName && a ? <small className="who__tag">AI</small> : null}
{a ? <span className="who__sr"> (AI)</span> : null} {a ? <span className="who__sr"> (AI)</span> : null}
</> </>
) )

View File

@ -1,102 +1,32 @@
.cot { margin-top: var(--sp-3); } .cot { margin-top: 8px; }
.cot__finding { margin: 0 0 8px; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); max-width: 64ch; }
/* The finding the one sentence that answers "what did it conclude", visible /* The handle is a link, in the row of actions under the entry. */
without opening the reasoning. */
.cot__finding {
margin: 0 0 var(--sp-3);
font-size: var(--fs-sm);
line-height: var(--lh-normal);
font-weight: var(--fw-medium);
color: var(--zk-ink);
}
/* THE THINKING CHIP. A quiet pill, tinted in the accent, that reads as an AI
reasoning affordance rather than a plain link the "how did it decide"
handle. */
.cot__toggle { .cot__toggle {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px; padding: 0;
align-items: center; border: 0; background: none; cursor: pointer;
gap: var(--sp-2); font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
padding: 5px 12px 5px 9px;
border: 1px solid var(--zk-blue-light);
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: var(--fw-semi);
color: var(--zk-blue-dark);
transition: background var(--t-fast), border-color var(--t-fast);
} }
.cot__toggle:hover { background: var(--zk-white); border-color: var(--zk-blue-mid); } .cot__toggle:hover { text-decoration: underline; }
.cot__brain { width: 14px; height: 14px; flex: none; } .cot__toggle:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 4px; }
.cot__ct { color: var(--zk-blue-dark); font-weight: var(--fw-normal); opacity: .72; } .cot__brain { display: none; }
.cot__ct { font-weight: 600; }
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); opacity: .7; } .cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); opacity: .7; }
.cot__caret.is-open { transform: rotate(90deg); } .cot__caret.is-open { transform: rotate(90deg); }
/* THE REASONING, revealed as its own artifact: a soft accent-tinted panel with /* The reasoning, as a flat card with a stepped rail. */
a stepped rail, so the chain reads as a distinct object the record of how
the machine got there not loose text under a link. */
.cot__steps { .cot__steps {
list-style: none; list-style: none; margin: 10px 0 0; padding: 14px 18px 10px;
margin: var(--sp-3) 0 0; border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
padding: var(--sp-4) var(--sp-5) var(--sp-3);
border: 1px solid var(--zk-blue-light);
border-radius: var(--r-md);
background:
linear-gradient(180deg, var(--zk-tint-blue) 0%, transparent 70%),
var(--zk-white);
} }
.cot__step { position: relative; display: grid; grid-template-columns: 84px 1fr; gap: 12px; padding: 8px 0 8px 16px; }
.cot__step {
position: relative;
display: grid;
grid-template-columns: 76px 1fr;
gap: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-2) var(--sp-4);
}
/* the marker dot */
.cot__step::before { .cot__step::before {
content: ""; content: ''; position: absolute; left: 2px; top: 11px; width: 9px; height: 9px;
position: absolute; border-radius: 50%; background: #fff; border: 2px solid var(--zk-blue); z-index: 1;
left: 2px;
top: 9px;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-blue);
z-index: 1;
}
/* the connector between markers */
.cot__step:not(:last-child)::after {
content: "";
position: absolute;
left: 5.5px;
top: 18px;
bottom: -6px;
width: 1.5px;
background: var(--zk-blue-light);
}
.cot__mark {
font-size: var(--fs-3xs);
font-weight: var(--fw-bold);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--zk-blue-dark);
padding-top: 2px;
}
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b b { color: var(--zk-ink); font-weight: var(--fw-semi); }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-2); }
.cot__conf {
font-size: 11.5px;
font-weight: var(--fw-bold);
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
border: 1px solid var(--zk-blue-light);
padding: 1px 10px;
border-radius: var(--r-pill);
} }
.cot__step:not(:last-child)::after { content: ''; position: absolute; left: 5.5px; top: 20px; bottom: -6px; width: 1.5px; background: var(--zk-blue-light); }
.cot__mark { font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-blue); padding-top: 2px; }
.cot__b { font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b b { color: var(--zk-ink); font-weight: 600; }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cot__conf { font-size: 12px; font-weight: 700; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 10px; border-radius: var(--r-pill); }

View File

@ -49,7 +49,7 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
<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" <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" /> fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg> </svg>
Chain of thought See reasoning
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span> <span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true"> <svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6" <path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"

View File

@ -1,104 +1,36 @@
/* The journey, across the top. Eight steps have to fit without wrapping on a /* The journey, across the top of the lead. Eight steps, one row. */
laptop, so the labels are short and the dots carry the state. */
.rail { .rail {
display: flex; display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
align-items: center; margin: 0 0 18px; padding: 20px 28px 18px;
gap: var(--sp-6); border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
flex-wrap: wrap;
margin: 0 0 var(--sp-5);
padding: var(--sp-6) var(--sp-7);
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
background: var(--zk-white);
}
.rail ol {
display: flex;
align-items: flex-start;
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 0;
}
.rail__s {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
flex: 1;
min-width: 0;
text-align: center;
} }
.rail ol { display: flex; align-items: flex-start; list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; }
.rail__s { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; flex: 1; min-width: 0; text-align: center; }
.rail__dot { .rail__dot {
width: 14px; width: 16px; height: 16px; border-radius: 50%; flex: none; z-index: 1;
height: 14px; background: var(--zk-navy); border: 3px solid #fff; box-shadow: 0 0 0 1px var(--zk-navy);
border-radius: 50%; transition: background var(--t-fast), box-shadow var(--t-fast);
flex: none;
background: var(--zk-white);
border: 2px solid var(--zk-line);
z-index: 1;
transition: background var(--t-fast), border-color var(--t-fast);
} }
.rail__l { font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.rail__l { .rail__bar { position: absolute; top: 7px; left: 50%; width: 100%; height: 2px; background: var(--zk-line); }
font-size: var(--fs-2xs); .rail__bar.is-done { background: var(--zk-navy); }
font-weight: var(--fw-medium);
color: var(--zk-grey);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* The connector sits behind the dots, drawn from each step to the next. */ .is-todo .rail__dot { background: #fff; box-shadow: 0 0 0 1px var(--zk-line); }
.rail__bar { .is-todo .rail__l { color: var(--zk-grey); }
position: absolute;
top: 6px;
left: 50%;
width: 100%;
height: 2px;
background: var(--zk-line-soft);
}
.rail__bar.is-done { background: var(--zk-blue-light); }
.is-done .rail__dot { background: var(--zk-blue-light); border-color: var(--zk-blue-light); } .is-here .rail__l { font-weight: 700; color: var(--zk-navy); }
.is-done .rail__l { color: var(--zk-muted); } .is-here.tone-teal .rail__dot { background: var(--zk-teal); box-shadow: 0 0 0 1px var(--zk-teal), 0 0 0 5px var(--zk-teal-tint); }
.is-here.tone-blue .rail__dot { background: var(--zk-blue); box-shadow: 0 0 0 1px var(--zk-blue), 0 0 0 5px var(--zk-tint-blue); }
.is-here.tone-blue .rail__l { color: var(--zk-blue); }
.is-here.tone-navy .rail__dot { box-shadow: 0 0 0 1px var(--zk-navy), 0 0 0 5px var(--zk-tint-blue); }
.is-here .rail__dot {
background: var(--zk-blue);
border-color: var(--zk-blue);
box-shadow: 0 0 0 5px rgba(33, 103, 174, 0.15);
}
.is-here .rail__l {
color: var(--zk-blue-dark);
font-weight: var(--fw-semi);
font-size: var(--fs-xs);
}
/* Off the path Parked, Referred, Lost, Declined. Named rather than drawn
into the row: these are departures, not points every lead passes. */
.rail__off { .rail__off {
flex: none; flex: none; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-amber);
font-size: var(--fs-xs); background: var(--zk-amber-tint); border: 1px solid var(--zk-amber-line);
font-weight: var(--fw-medium); padding: 6px 14px; border-radius: var(--r-pill);
color: var(--zk-amber-ink);
background: var(--zk-amber-tint);
border: 1px solid var(--zk-amber-line);
padding: var(--sp-2) var(--sp-4);
border-radius: var(--r-pill);
}
.rail__off.is-closed {
color: var(--zk-muted);
background: var(--zk-tint);
border-color: var(--zk-line);
} }
.rail__off.is-closed { color: var(--zk-muted); background: var(--zk-line-soft); border-color: var(--zk-line); }
@media (max-width: 900px) { @media (max-width: 900px) { .rail__l { display: none; } .rail { padding: 14px 18px; } }
.rail__l { display: none; }
.rail { padding: 14px 18px; }
}

View File

@ -47,6 +47,9 @@ export default function StageRail({ audit, currentName }) {
if (current) visited.add(current.uid) if (current) visited.add(current.uid)
const currentIndex = PATH.indexOf(current?.uid) const currentIndex = PATH.indexOf(current?.uid)
// The current step takes its holder's colour: teal when the lead is closed
// (issued, onboarded), blue while the AI has it, navy otherwise.
const hereTone = current?.kind === 'end' ? 'teal' : current?.kind === 'auto' ? 'blue' : 'navy'
// 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
@ -60,7 +63,7 @@ export default function StageRail({ audit, currentName }) {
// risk may never see payment so nothing after it is called "to do". // risk may never see payment so nothing after it is called "to do".
const state = here ? 'here' : been ? 'done' : 'todo' const state = here ? 'here' : been ? 'done' : 'todo'
return ( return (
<li key={uid} className={'rail__s is-' + state}> <li key={uid} className={'rail__s is-' + state + (here ? ' tone-' + hereTone : '')}>
<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 ? (

View File

@ -1,351 +1,80 @@
.tl { .tl { list-style: none; margin: 0; padding: 0; }
list-style: none; .tl__loading, .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
margin: 0; .tl__err { color: var(--zk-danger-ink); }
padding: 0 0 0 4px;
/* One event: a 36px avatar gutter, then the body. A thin connector runs down
the gutter between avatars. */
.tl__ev { display: grid; grid-template-columns: 36px 1fr; gap: 14px; position: relative; padding-bottom: 22px; }
.tl__ev::before { content: ''; position: absolute; left: 17px; top: 36px; bottom: 0; width: 2px; background: var(--zk-line-soft); }
.tl__ev:last-child::before { display: none; }
.tl__ev:last-child { padding-bottom: 6px; }
.tl__ev--sys { opacity: .75; }
.tl__gutter { width: 36px; height: 36px; display: grid; place-items: center; position: relative; z-index: 1; }
.tl__gutter .who--btn { padding: 0; border: 0; }
.tl__disc {
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-weight: 700; font-size: 13px;
background: var(--zk-line-soft); color: var(--zk-muted);
} }
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
.tl__disc--cust { background: var(--zk-teal-tint); color: var(--zk-teal); }
.tl__disc--cust svg { width: 16px; height: 16px; }
.tl__loading, .tl__body { min-width: 0; display: flex; flex-direction: column; }
.tl__err { .tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
color: var(--zk-muted); .tl__what { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
font-size: var(--fs-xs); .tl__what--wa { display: inline-flex; align-items: center; gap: 6px; color: var(--zk-teal); }
padding: 14px 2px; .tl__what--wa svg { width: 15px; height: 15px; }
} .tl__ev--ai.is-live .tl__what { color: var(--zk-blue); }
.tl__to { font-size: var(--fs-xs); color: var(--zk-muted); padding: 2px 10px; border-radius: var(--r-pill); background: var(--zk-line-soft); }
.tl__to::before { content: '→ '; color: var(--zk-grey); }
.tl__n { font-size: var(--fs-2xs); color: var(--zk-muted); padding: 1px 8px; border-radius: var(--r-pill); background: var(--zk-line-soft); font-variant-numeric: tabular-nums; }
.tl__when { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
.tl__err { .tl__by { font-size: 14.5px; color: var(--zk-muted); margin-top: -2px; display: flex; align-items: center; gap: 6px; }
color: var(--zk-danger-ink); .tl__byname { font-weight: 600; color: var(--zk-ink); }
} .tl__byname--cust { color: var(--zk-teal); }
.tl__tag { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
.tl__item { /* What was said — a quote block. */
position: relative; .tl__say { margin-top: 8px; padding: 10px 14px; border-left: 2px solid var(--zk-line); border-radius: 0 var(--r-sm) var(--r-sm) 0; background: var(--zk-tint); max-width: 72ch; }
padding: 0 0 var(--sp-6) var(--sp-7); .tl__saylabel { display: block; font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); margin-bottom: 6px; }
border-left: 2px solid var(--zk-line-soft); .tl__say p { margin: 0; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); }
}
.tl__item:last-child { /* Figures the step wrote — money, as chips. */
border-left-color: transparent; .tl__figs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
padding-bottom: 4px; .tl__figs span { display: inline-flex; gap: 8px; align-items: baseline; padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: 14.5px; font-variant-numeric: tabular-nums; }
} .tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; }
.tl__dot {
position: absolute;
left: -8px;
top: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-grey);
box-shadow: 0 0 0 4px var(--zk-white);
}
/* Machine work is blue, people are amber, the platform itself is grey so a
file can be read at a glance for how much of it was done by hand. */
.tl__item--ai .tl__dot {
border-color: var(--zk-blue);
background: var(--zk-blue);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(33, 103, 174, 0.12);
}
.tl__item--human .tl__dot {
border-color: var(--zk-amber);
background: var(--zk-white);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(181, 118, 31, 0.14);
}
.tl__item--sys .tl__dot {
border-color: var(--zk-line);
background: var(--zk-line);
}
.tl__body {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
margin-left: calc(var(--sp-3) * -1);
border-radius: var(--r-md);
transition: background var(--t-fast);
}
.tl__item:hover .tl__body {
background: var(--zk-tint);
}
.tl__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-1) var(--sp-2);
}
/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps
into a ragged block that is harder to scan than two clean rows. */
.tl__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-1) var(--sp-3);
}
.tl__what {
font-size: var(--fs-sm);
font-weight: var(--fw-semi);
letter-spacing: -0.008em;
color: var(--zk-ink);
}
.tl__who {
font-size: var(--fs-2xs);
font-weight: 500;
letter-spacing: 0.03em;
padding: 3px 9px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.tl__who--ai {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
.tl__who--human {
background: var(--zk-amber-tint);
color: var(--zk-amber-ink);
}
.tl__who--sys {
background: var(--zk-tint);
color: var(--zk-muted);
border: 1px solid var(--zk-line);
}
.tl__stage {
font-size: var(--fs-2xs);
color: var(--zk-blue);
}
.tl__when {
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
.tl__say {
margin-top: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-left: 2px solid var(--zk-blue-light);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: var(--zk-tint);
}
.tl__item:hover .tl__say {
background: var(--zk-white);
}
.tl__saylabel {
display: block;
font-size: var(--fs-3xs);
font-weight: var(--fw-semi);
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--zk-grey);
margin-bottom: var(--sp-2);
}
.tl__say p {
margin: 0;
font-size: var(--fs-sm);
line-height: var(--lh-normal);
color: var(--zk-muted);
max-width: 82ch;
}
.tl__figs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.tl__figs span {
font-size: var(--fs-2xs);
color: var(--zk-ink);
font-variant-numeric: tabular-nums;
padding: 4px 11px;
border-radius: var(--r-pill);
background: var(--zk-white);
border: 1px solid var(--zk-line);
}
.tl__figs em {
font-style: normal;
font-size: var(--fs-2xs);
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 5px;
}
/* ---- system noise ---- */
.tl__toggle {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
cursor: pointer;
margin-bottom: 14px;
padding: 5px 12px;
border-radius: var(--r-pill);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.tl__toggle:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
color: var(--zk-blue-dark);
}
/* What was attached
The audit trail's job at this step is to prove receipt. A line saying
"Collect Documents" and nothing else left the operator to open the record
and count for themselves and until the views were fixed, the record could
not see the files either. */
.tl__docs {
margin-top: 8px;
padding: 9px 11px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm, 6px);
background: var(--zk-tint);
}
.tl__docshead {
display: block;
font-size: var(--fs-3xs);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--zk-muted);
margin-bottom: 7px;
}
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
/* Documents received, as chips. */
.tl__docs { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14.5px; }
.tl__docshead { color: var(--zk-grey); }
.tl__doclist { display: flex; flex-wrap: wrap; gap: 8px; }
.tl__doc { .tl__doc {
display: inline-flex; display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px;
align-items: center; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
gap: 5px; font-size: 14.5px; color: var(--zk-ink); text-decoration: none;
font-size: var(--fs-2xs);
text-decoration: none;
padding: 4px 10px 4px 8px;
border-radius: var(--r-pill, 999px);
background: var(--zk-white);
border: 1px solid var(--zk-line);
color: var(--zk-blue-dark);
transition: border-color .12s, background .12s;
} }
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; } .tl__doc svg { width: 13px; height: 13px; color: var(--zk-blue); }
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); } .tl__doc:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* the conversation, as one entry /* The action row: links, in blue. */
Every WhatsApp turn used to be its own entry, so a four-message exchange .tl__acts { display: flex; gap: 18px; margin-top: 8px; font-size: var(--fs-xs); align-items: baseline; flex-wrap: wrap; }
took more of the trail than the whole underwriting chain. */ .tl__link { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; }
.tl__wa { .tl__link:hover { text-decoration: underline; }
display: inline-flex; .tl__wrote > summary { cursor: pointer; list-style: none; font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); }
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
font-weight: 500;
color: #0f7b45;
}
.tl__wa svg { width: 15px; height: 15px; }
.tl__n {
font-size: var(--fs-2xs);
font-variant-numeric: tabular-nums;
color: var(--zk-muted);
padding: 1px 8px;
border-radius: var(--r-pill, 999px);
background: var(--zk-line-soft);
}
/* The channel performs these as the system, but the person who typed is the
actor worth naming. "System · Customer Reply" said the opposite. */
.tl__who--cust { color: #0f7b45; font-weight: 500; }
.tl__open {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
cursor: pointer;
padding: 0;
border: 0;
background: none;
color: var(--zk-blue);
margin-left: auto;
}
.tl__open:hover { color: var(--zk-blue-dark); text-decoration: underline; }
.tl__open:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
/* A chat entry's dot takes the channel's colour, so a scan down the rail shows
where the conversation happened without reading a word. */
.tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; }
/* what this step wrote
Folded by default: most steps write a dozen fields and the trail has to
stay readable as a story. Opened, the entry becomes an itemised account of
what the agent decided which is the closest an app JWT can get to its
reasoning, since ai_reasoning is on the row but not in the API. */
.tl__wrote { margin-top: 8px; }
.tl__wrote > summary {
cursor: pointer;
list-style: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue);
}
.tl__wrote > summary::-webkit-details-marker { display: none; } .tl__wrote > summary::-webkit-details-marker { display: none; }
.tl__wrote > summary::after { .tl__wrote > summary::after { content: ' ▾'; color: var(--zk-grey); }
content: ''; .tl__wrote[open] > summary::after { content: ' ▴'; }
width: 5px; height: 5px; .tl__wrote dl { margin: 8px 0 0; padding: 10px 12px; border: 1px solid var(--zk-line-soft); border-radius: var(--r-sm); background: var(--zk-tint); display: flex; flex-direction: column; gap: 6px; }
border-right: 1.4px solid currentColor;
border-bottom: 1.4px solid currentColor;
transform: rotate(45deg) translate(-1px, -1px);
transition: transform var(--t-fast);
}
.tl__wrote[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.tl__wrote > summary:hover { color: var(--zk-blue-dark); }
.tl__wrote dl {
margin: 8px 0 0;
padding: 10px 12px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm, 6px);
background: var(--zk-tint);
display: flex;
flex-direction: column;
gap: 6px;
}
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; } .tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
.tl__wrote dt { flex: none; width: 42%; font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
.tl__wrote dd { margin: 0; flex: 1; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
.tl__wrote dt { /* System updates, folded behind a pill. */
flex: none; .tl__toggle {
width: 42%; display: inline-block; margin: 6px 0 14px; font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
font-size: var(--fs-2xs); padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); background: #fff; cursor: pointer;
color: var(--zk-grey);
overflow-wrap: anywhere;
}
.tl__wrote dd {
margin: 0;
flex: 1;
min-width: 0;
font-size: var(--fs-2xs);
color: var(--zk-ink);
overflow-wrap: anywhere;
} }
.tl__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); }

View File

@ -5,6 +5,7 @@ import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx' import ChainOfThought from './ChainOfThought.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 { initialsOf } from '../api/agents.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 now one short name, one colour and one
@ -378,12 +379,26 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
<ol className="tl"> <ol className="tl">
{visible.map((it) => ( {visible.map((it) => (
<li key={it.key} className={'tl__item tl__item--' + it.kind}> <li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
<span className="tl__dot" aria-hidden="true" /> {/* The worker, as a face in the gutter. Recognition down a column
beats reading five names to notice it was one agent throughout. */}
<span className="tl__gutter" aria-hidden="true">
{it.kind === 'sys' ? (
<span className="tl__disc tl__disc--sys">·</span>
) : it.isChat && !it.agentKey ? (
<span className="tl__disc tl__disc--cust">
<svg viewBox="0 0 16 16"><path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" /></svg>
</span>
) : it.agentKey ? (
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
) : (
<span className="tl__disc tl__disc--hu">{initialsOf(it.actor)}</span>
)}
</span>
<div className="tl__body"> <div className="tl__body">
<div className="tl__head"> <div className="tl__line1">
{it.isChat ? ( {it.isChat ? (
<span className="tl__wa"> <h4 className="tl__what tl__what--wa">
<svg viewBox="0 0 16 16" aria-hidden="true"> <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" <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" /> fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
@ -391,32 +406,22 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
fill="currentColor" /> fill="currentColor" />
</svg> </svg>
WhatsApp WhatsApp
</span> </h4>
) : ( ) : (
<strong className="tl__what">{it.what}</strong> <h4 className="tl__what">{it.what}</h4>
)} )}
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null} {it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
{it.stage && !it.isChat ? <span className="tl__stage"> {it.stage.name}</span> : null} {it.stage && !it.isChat ? <span className="tl__to">{it.stage.name}</span> : null}
</div>
{/* The worker, as a face. Recognition down a column beats
reading five names to notice it was one agent throughout. */}
<div className="tl__meta">
{it.kind === 'sys'
? <span className="tl__who tl__who--sys">system</span>
: it.isChat && !it.agentKey
? <span className="tl__who tl__who--cust">the customer</span>
: <AgentChip agentKey={it.agentKey} name={it.actor} onOpen={onOpenAgent} />}
<span className="tl__when">{it.when}</span> <span className="tl__when">{it.when}</span>
{it.isChat && onOpenChat ? ( </div>
<button type="button" className="tl__open" onClick={onOpenChat}> <div className="tl__by">
Open thread {it.kind === 'sys'
</button> ? <span className="tl__byname">System</span>
) : null} : it.isChat && !it.agentKey
{it.hasCall && onOpenCall ? ( ? <span className="tl__byname tl__byname--cust">the customer</span>
<button type="button" className="tl__open" onClick={onOpenCall}> : it.agentKey
Hear the call ? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
</button> : <span className="tl__byname">{it.actor}</span>}
) : null}
</div> </div>
{/* What was received, named and openable. The count is stated {/* What was received, named and openable. The count is stated
@ -491,6 +496,13 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</div> </div>
) : null} ) : null}
<div className="tl__acts">
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
) : null}
{it.isChat && onOpenChat ? (
<button type="button" className="tl__link" onClick={onOpenChat}>Open thread</button>
) : null}
{/* Folded, because most steps write a lot and the trail has to {/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an stay readable as a story. Open it and the step becomes an
itemised account of what it decided. */} itemised account of what it decided. */}
@ -505,6 +517,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
</details> </details>
) : null} ) : null}
</div> </div>
</div>
</li> </li>
))} ))}
</ol> </ol>

View File

@ -1,130 +1,112 @@
/* ── Zurich Kotak brand typeface ───────────────────────────── */ /* Typeface: Source Sans 3, self-hosted
@font-face { Bundled from src/assets so Vite resolves the URLs under the sub-path mount;
font-family: 'Zurich Sans'; an absolute /fonts/ path would 404 behind the BASE_HREF rewrite. */
src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype'); @font-face { font-family: 'Source Sans 3'; font-weight: 400; font-display: swap;
font-weight: 300; src: url('./assets/fonts/source-sans-3-latin-400-normal.woff2') format('woff2'),
font-style: normal; url('./assets/fonts/source-sans-3-latin-ext-400-normal.woff2') format('woff2'); }
font-display: swap; @font-face { font-family: 'Source Sans 3'; font-weight: 500; font-display: swap;
} src: url('./assets/fonts/source-sans-3-latin-500-normal.woff2') format('woff2'),
@font-face { url('./assets/fonts/source-sans-3-latin-ext-500-normal.woff2') format('woff2'); }
font-family: 'Zurich Sans'; @font-face { font-family: 'Source Sans 3'; font-weight: 600; font-display: swap;
src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype'); src: url('./assets/fonts/source-sans-3-latin-600-normal.woff2') format('woff2'),
font-weight: 400; url('./assets/fonts/source-sans-3-latin-ext-600-normal.woff2') format('woff2'); }
font-style: normal; @font-face { font-family: 'Source Sans 3'; font-weight: 700; font-display: swap;
font-display: swap; src: url('./assets/fonts/source-sans-3-latin-700-normal.woff2') format('woff2'),
} url('./assets/fonts/source-sans-3-latin-ext-700-normal.woff2') format('woff2'); }
@font-face {
font-family: 'Zurich Sans';
src: url('./assets/fonts/ZurichSans-Medium.otf') format('opentype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
:root { :root {
/* PALETTE assured-inspired /* PALETTE Lead Desk redesign
A clean white ground, warm near-black ink, and ONE confident accent: Colour MEANS something here, and the meaning is fixed on every screen:
a magenta-pink, reserved for action and active state. The token names blue = the AI holds it amber = a person holds it
keep their history (the accent still lives under the --zk-blue-* names teal = the customer holds it red = risk, and nothing else
the whole app references), but the values are the new scheme. Teal is Navy is the masthead and the brand; blue is the one action colour.
the customer, amber a person; the pink is "act / here", nothing else. */ The token NAMES keep their history the whole app references --zk-blue
--zk-blue: #e5227f; /* THE ACCENT (was brand blue) — pink */ and friends so remapping the values here re-themes every surface. */
--zk-blue-dark: #c30f68; --zk-navy: #0B2A5B;
--zk-blue-deep: #c30f68; --zk-navy-2: #12386F;
--zk-navy: #2a1d3d; /* a deep plum-ink for the logo / darkest text */ --zk-blue: #1F55A6; /* primary action; "with the AI" */
--zk-blue-mid: #ee5fa0; --zk-blue-dark: #12386F;
--zk-blue-light: #f6cfe3; /* pink hairline / ring */ --zk-blue-deep: #0B2A5B;
--zk-cyan: #fdebf4; /* pink-soft */ --zk-blue-mid: #4E7FC4;
--zk-teal: #0e9e8f; --zk-blue-light: #C3D3EC; /* hairline / ring */
--zk-teal-ink: #0a6b60; --zk-cyan: #E4ECF9; /* blue-soft */
--zk-teal-tint: #e2f5f2; --zk-tint-blue: #E4ECF9; /* blue-soft */
--zk-teal-line: #bfe6e1;
--zk-good: #18a558;
--zk-ink: #1c1626; /* warm near-black */ --zk-teal: #137A66; /* the customer; issued */
--zk-muted: #645d72; --zk-teal-ink: #0F6353;
--zk-grey: #a49dae; --zk-teal-tint: #DDF1EC;
--zk-line: #e3e1ea; --zk-teal-line: #B8DDD3;
--zk-line-soft: #ececf1; --zk-good: #137A66;
--zk-tint: #faf9fb; /* off-white section ground */
--zk-sunk: #f5f3f8; /* a touch deeper — hover / inset */
--zk-tint-blue: #fdebf4; /* pink-soft (was tint-blue) */
--zk-white: #fff;
--zk-danger: #d64545;
--zk-amber: #c77d1a; --zk-amber: #A86A0B; /* a person holds it */
--zk-amber-ink: #9a5d10; --zk-amber-ink: #8A5609;
--zk-amber-tint: #fbf0dd; --zk-amber-tint: #FBF0DC;
--zk-amber-line: #ecd3a3; --zk-amber-line: #EAD6AE;
--zk-danger-ink: #b23636; --zk-amber-bar: #D99A2B; /* the brighter amber for bars and legends */
--zk-danger-tint: #fbeaea;
--zk-danger-line: #f0c9c9;
/* Shape, depth, motion --zk-danger: #C8102E; /* Kotak red: risk only */
Rounder than before and softer shadows the assured cards read as --zk-danger-ink: #A30D25;
objects that float a little, on a warm plum-ink low alpha rather than --zk-danger-tint: #FBE7EA;
generic grey haze. */ --zk-danger-line: #F1C5CC;
--r-xs: 8px;
--r-sm: 10px; --zk-ink: #152238;
--r-md: 14px; --zk-muted: #4B5A72; /* ink-2 */
--r-lg: 18px; --zk-grey: #7A8699; /* ink-3 */
--r-xl: 22px; --zk-line: #D9DFE9;
--zk-line-soft: #E9EDF3;
--zk-tint: #F2F4F8; /* the canvas */
--zk-sunk: #E9EDF3;
--zk-white: #FFFFFF;
--zk-card: #FFFFFF;
/* Shape
Flat. Cards are white on the grey canvas with a 1px line and a 10px
radius; nothing floats, nothing casts a shadow. */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 10px;
--r-lg: 10px;
--r-xl: 12px;
--r-pill: 999px; --r-pill: 999px;
--sh-xs: 0 1px 2px rgba(28, 22, 38, 0.04); --sh-xs: none;
--sh-sm: 0 1px 2px rgba(28, 22, 38, 0.04), 0 4px 14px -8px rgba(28, 22, 38, 0.10); --sh-sm: none;
--sh-md: 0 1px 2px rgba(28, 22, 38, 0.04), 0 8px 24px -10px rgba(28, 22, 38, 0.12); --sh-md: none;
--sh-lg: 0 2px 6px rgba(28, 22, 38, 0.06), 0 24px 48px -16px rgba(28, 22, 38, 0.22); --sh-lg: 0 12px 32px -12px rgba(21, 34, 56, 0.28); /* dialogs only */
--sh-accent: 0 6px 20px -6px rgba(229, 34, 127, 0.42); --sh-accent: none;
--ring: 0 0 0 3px rgba(229, 34, 127, 0.16); --ring: 0 0 0 3px rgba(31, 85, 166, 0.18);
--ease: cubic-bezier(0.4, 0, 0.2, 1); --ease: cubic-bezier(0.4, 0, 0.2, 1);
--t-fast: 0.14s var(--ease); --t-fast: 0.14s var(--ease);
--t: 0.2s var(--ease); --t: 0.2s var(--ease);
--grad-blue: linear-gradient(135deg, var(--zk-blue) 0%, var(--zk-blue-dark) 100%); --grad-blue: var(--zk-blue);
--grad-blue-hover: linear-gradient(135deg, var(--zk-blue-dark) 0%, #a80c59 100%); --grad-blue-hover: var(--zk-navy-2);
/* TYPE SCALE /* TYPE SCALE on a 15px root, so the rem steps land on the design's
The app had FIFTY-FOUR distinct font sizes, and forty of them sat pixel sizes: 12.5 eyebrows, 13.5 small, 14 labels, 15 body, 17 card
between 0.58rem and 0.95rem a three-pixel band. The most-used size titles, 20 tab figures, 28 page title / KPI, 44 the one risk number. */
was 0.78rem, which is 12.5px of body copy. That is not a hierarchy; --fs-3xs: 0.8333rem; /* 12.5px — uppercase eyebrows, table heads */
it is a fog of small grey text, and it is exactly why the screen read --fs-2xs: 0.9rem; /* 13.5px — meta, pills, secondary lines */
as a decade-old enterprise tool. Nothing could be emphasised, because --fs-xs: 0.9333rem; /* 14px — labels */
everything was already the same size. --fs-sm: 1rem; /* 15px — body */
--fs-md: 1.0667rem; /* 16px — emphasis */
--fs-lg: 1.1333rem; /* 17px — card titles */
--fs-xl: 1.3333rem; /* 20px — tab figures, small headings */
--fs-2xl: 1.8667rem; /* 28px — page title, KPI figures */
--fs-3xl: 2.9333rem; /* 44px — renewals at risk */
Nine steps, with real jumps between them. Body sits at 15px, labels --lh-tight: 1.15;
at 14, and 12px is the FLOOR reserved for uppercase eyebrows, never --lh-snug: 1.35;
for anything a person has to read. Size does the prioritising so the --lh-normal: 1.4;
reader does not have to. */
/* Shifted up one notch after an audit found 124 of ~200 sizings sitting at
12-13px the "meta" tier had quietly become the body size, so the whole
app read tiny and uniform with nothing for the eye to land on. Body is 14
now, values 15, emphasis 16; 12px stays for uppercase eyebrows only. */
--fs-3xs: 0.75rem; /* 12px — uppercase eyebrows only */
--fs-2xs: 0.875rem; /* 14px — was 13; the app's dominant size, now comfortable */
--fs-xs: 0.9375rem; /* 15px — field labels */
--fs-sm: 1rem; /* 16px — body, field values */
--fs-md: 1.0625rem; /* 17px — emphasis */
--fs-lg: 1.125rem; /* 18px — panel titles */
--fs-xl: 1.375rem; /* 22px — section headings */
--fs-2xl: 1.75rem; /* 28px — page title */
--fs-3xl: 2.25rem; /* 36px — the one number that matters */
--lh-tight: 1.25;
--lh-snug: 1.4;
--lh-normal: 1.55;
--fw-normal: 400; --fw-normal: 400;
--fw-medium: 500; --fw-medium: 500;
--fw-semi: 600; --fw-semi: 600;
--fw-bold: 700; --fw-bold: 700;
--fw-x: 800; --fw-x: 700;
/* SPACING
One rhythm. Panels breathe at 24-28px rather than 14-18px, which is
the other half of why this felt cramped. */
--sp-1: 4px; --sp-1: 4px;
--sp-2: 8px; --sp-2: 8px;
--sp-3: 12px; --sp-3: 12px;
@ -135,111 +117,58 @@
--sp-8: 40px; --sp-8: 40px;
--sp-9: 56px; --sp-9: 56px;
/* Display and body are the SAME brand face Zurich Sans has no bold, --font-display: 'Source Sans 3', system-ui, -apple-system, sans-serif;
so headings and figures lean on weight 500 and tighter tracking, set per --font-zurich: 'Source Sans 3', system-ui, -apple-system, sans-serif;
use, not on a second family. */ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-display: 'Plus Jakarta Sans', 'Geist', system-ui, sans-serif;
--font-zurich: 'Plus Jakarta Sans', 'Geist', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-zurich); font-family: var(--font-zurich);
/* The root MUST stay an absolute size every --fs-* token is rem, so /* Absolute on purpose: every --fs-* token is rem and sizing the root from
sizing the root from one of them is circular. */ one of them would be circular. */
font-size: 16px; font-size: 15px;
line-height: var(--lh-normal); line-height: var(--lh-normal);
color: var(--zk-ink); color: var(--zk-ink);
/* ONE CANVAS. The app used to be white cards floating on a grey-blue background: var(--zk-tint);
ground the generic admin-template look. The content ground is white font-feature-settings: 'tnum' 1;
now; the sidebar is the only tinted surface, and sections are separated
by hairlines and space rather than boxed in shadowed cards. */
background: var(--zk-white);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
* { * { box-sizing: border-box; }
box-sizing: border-box;
}
html, html, body, #root { height: 100%; }
body,
#root {
height: 100%;
}
body { body { margin: 0; background: var(--zk-tint); }
margin: 0;
background: var(--zk-white);
}
button, button, input, select, textarea { font: inherit; color: inherit; }
input,
select,
textarea {
font: inherit;
color: inherit;
}
/* One focus treatment for the whole console: a soft brand ring, never the /* One focus treatment for the whole console. */
browser's default outline halfway through a card's rounded corner. */ :focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
:focus-visible {
outline: 2px solid var(--zk-blue);
outline-offset: 2px;
}
::selection { ::selection { background: var(--zk-tint-blue); color: var(--zk-navy); }
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
/* Scrollbars, toned to the palette. Chrome/Safari take the ::-webkit rules,
Firefox the standard properties. */
* {
scrollbar-width: thin;
scrollbar-color: var(--zk-line) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
* { scrollbar-width: thin; scrollbar-color: var(--zk-line) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border: 3px solid transparent; border: 3px solid transparent; border-radius: var(--r-pill);
border-radius: var(--r-pill); background-clip: content-box; background-color: var(--zk-line);
background-clip: content-box;
background-color: var(--zk-line);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--zk-grey);
} }
::-webkit-scrollbar-thumb:hover { background-color: var(--zk-grey); }
@keyframes zk-rise { @keyframes zk-rise {
/* Opacity ONLY. A translateY here promoted every element it touched to a GPU /* Opacity only a transform here composites text onto a GPU layer and it
layer, and text composited on a transformed layer renders blurry in Chrome renders soft in Chrome on Linux. */
on Linux which made the whole app look soft. The fade alone gives the
entrance without ever moving text onto a fractional-pixel layer. */
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes zk-shimmer { @keyframes zk-shimmer {
from { background-position: -420px 0; } from { background-position: -420px 0; }
to { background-position: 420px 0; } to { background-position: 420px 0; }
} }
/* Motion is decoration here every animation is an entrance or a hover, so
dropping them costs nothing. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *, *::before, *::after {
*::before,
*::after {
animation-duration: 0.001ms !important; animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important; transition-duration: 0.001ms !important;

View File

@ -5,341 +5,141 @@
background: var(--zk-tint); background: var(--zk-tint);
} }
/* ── Top bar ───────────────────────────────────────────────── */ /* ── Masthead ─────────────────────────────────────────────────────────────── */
.shell__top { .shell__top {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 30; z-index: 30;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 22px;
gap: var(--sp-6); height: 56px;
height: 72px; padding: 0 24px;
padding: 0 var(--sp-7); background: var(--zk-navy);
background: var(--zk-white); color: #fff;
border-bottom: 1px solid var(--zk-line);
} }
.shell__brand { .mast__logo { display: flex; align-items: center; gap: 10px; }
display: flex; .mast__z {
align-items: center; width: 30px; height: 30px; border-radius: 50%;
gap: 16px; background: #fff; color: var(--zk-navy);
min-width: 0; font-weight: 700; font-size: 17px;
display: grid; place-items: center;
} }
.mast__word b { display: block; font-weight: 700; font-size: 17px; letter-spacing: .06em; line-height: 1.15; }
.mast__word b span { font-weight: 500; letter-spacing: 0; }
.mast__word small { display: block; font-size: 11.5px; opacity: .75; margin-top: -1px; letter-spacing: .02em; }
.shell__brand img { .mast__sep { width: 1px; height: 26px; background: rgba(255, 255, 255, .22); }
height: 30px;
width: auto;
display: block;
}
.shell__brandtext { .mast__app { font-size: 17px; font-weight: 600; }
display: flex; .mast__app span { font-weight: 400; opacity: .7; margin-left: 8px; font-size: 15px; }
flex-direction: column;
gap: 1px;
line-height: 1.2;
padding-left: 16px;
border-left: 1px solid var(--zk-line);
}
.shell__brandtext strong { .mast__user { margin-left: auto; }
font-size: var(--fs-md);
font-weight: var(--fw-semi);
letter-spacing: -0.012em;
color: var(--zk-ink);
}
.shell__brandtext span { /* ── Frame ────────────────────────────────────────────────────────────────── */
font-size: var(--fs-3xs); .shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* ── Body ──────────────────────────────────────────────────── */
.shell__body {
display: flex;
flex: 1;
min-height: 0;
align-items: stretch;
}
.shell__nav { .shell__nav {
position: sticky; position: sticky;
top: 72px; top: 56px;
align-self: flex-start; align-self: flex-start;
width: 264px; width: 232px;
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: #fff;
border-right: 1px solid var(--zk-line); border-right: 1px solid var(--zk-line);
padding: var(--sp-6) var(--sp-4) var(--sp-8); padding: 20px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px;
} }
.shell__navlabel { .nav__h {
margin: 0 0 var(--sp-3); margin: 0;
padding: 0 var(--sp-3); padding: 18px 12px 6px;
font-size: var(--fs-3xs); font-size: var(--fs-3xs);
font-weight: 400; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: .04em;
text-transform: uppercase; text-transform: uppercase;
color: var(--zk-grey); color: var(--zk-grey);
opacity: 0.85;
} }
.nav__group { display: flex; flex-direction: column; gap: 2px; }
.shell__group + .shell__group { .nav__link {
margin-top: var(--sp-5);
}
/* 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__stage {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 8px;
gap: var(--sp-2);
padding: 9px 12px;
border-radius: var(--r-md);
text-decoration: none;
color: var(--zk-muted);
font-size: var(--fs-xs);
transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
}
.shell__stage:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
.shell__stage.is-active {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
font-weight: 500;
}
/* WHICH ONE YOU ARE ON. The node circles used to carry this, but they also sat,
empty, on every inactive row and read as checkboxes. An accent bar that exists
only when active says the same thing and says nothing the rest of the time. */
.shell__stage.is-active::before,
.shell__today.is-active::before {
content: '';
position: absolute;
left: 4px;
top: 50%;
width: 3px;
height: 18px;
margin-top: -9px;
border-radius: var(--r-pill);
background: var(--zk-blue);
}
/* Queues where the machine stops and a person decides. */
.shell__stage.is-needed .shell__stagename {
font-weight: 500;
color: var(--zk-ink);
}
.shell__stage.is-needed.is-active .shell__stagename {
color: var(--zk-blue-dark);
}
/* Stage names wrap rather than truncate: "Referred to Underwriting" is the one
queue a person has to find, and it is also the longest label. */
.shell__stagename {
min-width: 0;
line-height: 1.35;
}
.shell__main {
flex: 1;
min-width: 0;
background: var(--zk-white);
/* Fill the whole width beside the nav. No centring cap: a sidebar dashboard
should use the screen, so the content spans from the rail to the right
edge. The reading columns cap their own line length internally, so text
stays comfortable while the layout itself uses every pixel. */
width: 100%;
padding: 30px 40px 76px;
}
/* ── Narrow viewports: the rail lies down and scrolls ──────── */
@media (max-width: 900px) {
.shell__body {
flex-direction: column;
}
.shell__nav {
position: static;
width: 100%;
min-height: 0;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--zk-line);
padding: 14px 16px 16px;
}
.shell__stages {
flex-direction: row;
overflow-x: auto;
padding-bottom: 4px;
}
/* Laid out horizontally the stages are a scrolling strip of pills, and a rail
behind them would just be a line struck through the row. */
.shell__stages::before {
display: none;
}
.shell__stage {
padding: 8px 14px;
white-space: nowrap;
background: var(--zk-white);
border: 1px solid var(--zk-line);
}
.shell__stage::before {
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 {
position: relative;
display: block;
margin: 0 0 8px;
padding: 9px 12px; padding: 9px 12px;
border-radius: var(--r-sm); border-radius: var(--r-sm);
font-size: var(--fs-xs); font-size: var(--fs-sm);
font-weight: 500; 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-grey);
}
/* 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.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;
}
/* Closed, folded
Three of the twelve queues, opened about once a week. */
.shell__closed { margin-top: var(--sp-4); }
/* Closed matches a queue row same padding, hover and count but stays a
disclosure: a chevron marks it as foldable, closed by default because it is
reporting, not work. */
.shell__closed > summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
list-style: none;
padding: 9px 12px;
border-radius: var(--r-md);
font-size: var(--fs-xs);
color: var(--zk-muted); color: var(--zk-muted);
text-decoration: none;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast); transition: background var(--t-fast), color var(--t-fast);
} }
.shell__closed > summary::-webkit-details-marker { display: none; } .nav__link:hover { background: var(--zk-tint); color: var(--zk-ink); }
.shell__closed > summary:hover { background: var(--zk-tint); color: var(--zk-ink); } .nav__link.is-on {
.shell__closed > summary::before { background: var(--zk-tint-blue);
color: var(--zk-navy);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--zk-blue);
}
.nav__name { min-width: 0; line-height: 1.35; }
/* The count is who is being waited on: red a person urgently, amber the
customer, grey the calendar or history. */
.nav__n {
margin-left: auto;
font-size: var(--fs-2xs);
font-weight: 600;
padding: 1px 8px;
border-radius: var(--r-pill);
font-variant-numeric: tabular-nums;
background: var(--zk-line-soft);
color: var(--zk-muted);
}
.nav__n--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
.nav__n--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.nav__n--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
/* Closed folds. The summary IS a nav row; a chevron says it opens. */
.nav__fold > summary { list-style: none; }
.nav__fold > summary::-webkit-details-marker { display: none; }
.nav__fold > summary .nav__name::before {
content: ''; content: '';
flex: none; display: inline-block;
width: 5px; height: 5px; width: 5px; height: 5px;
margin: 0 8px 1px 0;
border-right: 1.5px solid var(--zk-grey); border-right: 1.5px solid var(--zk-grey);
border-bottom: 1.5px solid var(--zk-grey); border-bottom: 1.5px solid var(--zk-grey);
transform: rotate(-45deg); transform: rotate(-45deg);
transition: transform var(--t-fast); transition: transform var(--t-fast);
} }
.shell__closed[open] > summary::before { transform: rotate(45deg); } .nav__fold[open] > summary .nav__name::before { transform: rotate(45deg); }
.shell__closed > summary b { .nav__sub { display: flex; flex-direction: column; gap: 2px; padding-left: 12px; }
margin-left: auto;
min-width: 20px; /* ── Canvas ───────────────────────────────────────────────────────────────── */
padding: 1px 6px; .shell__main {
border-radius: var(--r-pill); flex: 1;
text-align: center; min-width: 0;
font-size: var(--fs-2xs); width: 100%;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--zk-grey);
background: var(--zk-tint); background: var(--zk-tint);
padding: 28px 32px 40px;
}
@media (max-width: 900px) {
.shell__body { flex-direction: column; }
.shell__nav {
position: static; width: 100%; min-height: 0; max-height: none;
border-right: 0; border-bottom: 1px solid var(--zk-line);
flex-direction: row; flex-wrap: wrap; padding: 10px 12px;
}
.nav__h { display: none; }
.nav__group { flex-direction: row; flex-wrap: wrap; }
.shell__main { padding: 20px 16px 48px; }
} }

View File

@ -1,69 +1,56 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { NAV_GROUPS } from '../api/config.js' import { NAV_GROUPS, STAGES } from '../api/config.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 UserMenu from './UserMenu.jsx' import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css' import './Shell.css'
/** /**
* The sidebar groups the pipeline by WHO IS HOLDING each lead, not by where it * The frame: a navy masthead, a white rail, a grey canvas.
* sits in the process. Fourteen flat stages answer "what is the process?", which
* is not the question anyone signing in has; these answer "is anything waiting
* on me?". Queues that need a person are named by what they want done.
* *
* IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could * The rail groups the pipeline by WHO IS HOLDING each lead a person, the
* only come from a second full list call that would then disagree with the * customer, the calendar because "is anything waiting on me?" is the question
* queue's own total. That was right about the cost and wrong about the * anyone signing in actually has. The count on each row is what needs a person;
* conclusion with no numbers, the only way to learn whether anything is * its colour says how urgently, on the one colour rule the whole console keeps:
* waiting on you is to open all nine queues in turn, which is precisely the * red for risk, amber for a person, teal for the customer, blue for the AI.
* 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 * Closed lives under History and stays folded: it is reporting, not work.
* see phaseOf. A dot marks a queue holding a renewal inside a week, which is
* the only reason to open one queue before another.
*
* The closed states are folded away. They are three of the twelve, they are
* reporting rather than work, and at equal weight they made the live queues
* harder to find.
*/ */
/** The badge colour for a queue: the colour of whoever is being waited on. */
function tone(stage) {
if (stage.kind === 'needs') return 'red'
if (stage.kind === 'customer') return 'amber'
return 'grey'
}
export default function Shell() { export default function Shell() {
const { user } = useZino() const { user } = useZino()
const roles = rolesOf(user) const roles = rolesOf(user)
// The sidebar shows the queues this role WORKS in. Reporting on the rest
// 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()
// One queue row. Extracted because the live groups and the folded Closed const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
// group render the same thing and drifted apart when they did not.
const queue = (s) => { // "All leads" carries the open book, not the whole book.
const c = counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 } const openTotal = STAGES
.filter((s) => s.kind !== 'end')
.reduce((t, s) => t + count(s).total, 0)
const row = (s) => {
const c = count(s)
const n = s.kind === 'end' ? c.total : c.waiting const n = s.kind === 'end' ? c.total : c.waiting
const hint = c.working ? `${c.working} being worked by the AI` : undefined
return ( return (
<NavLink <NavLink
key={s.uid} key={s.uid}
to={`/stage/${s.uid}`} to={`/stage/${s.uid}`}
title={s.need ? s.name : undefined} title={hint}
className={({ isActive }) => className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}
'shell__stage' + (isActive ? ' is-active' : '')
+ (s.kind === 'needs' ? ' is-needed' : '')
+ (ready && !n && !c.working ? ' is-empty' : '')
}
> >
<span className="shell__stagename">{s.need ?? s.name}</span> <span className="nav__name">{s.need ?? s.name}</span>
{ready ? ( {ready ? <span className={`nav__n nav__n--${n ? tone(s) : 'grey'}`}>{n}</span> : null}
<span className="shell__tally">
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null}
{/* The agents' share is shown in the middle dot notation rather
than added in: it is in the queue, it is not your work. */}
{c.working ? <em className="shell__auto" title={`${c.working} being worked by an agent`}>{c.working} with AI</em> : null}
<b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
</span>
) : null}
</NavLink> </NavLink>
) )
} }
@ -71,63 +58,55 @@ export default function Shell() {
return ( return (
<div className="shell"> <div className="shell">
<header className="shell__top"> <header className="shell__top">
<div className="shell__brand"> <div className="mast__logo">
<img src={logo} alt="Zurich Kotak General Insurance" /> <span className="mast__z" aria-hidden="true">Z</span>
<div className="shell__brandtext"> <div className="mast__word">
<strong>Lead Desk</strong> <b>ZURICH <span>kotak</span></b>
<span>Lead to Policy</span> <small>General Insurance</small>
</div> </div>
</div> </div>
<UserMenu /> <span className="mast__sep" aria-hidden="true" />
<div className="mast__app">Lead Desk <span>Lead to policy</span></div>
<div className="mast__user"><UserMenu /></div>
</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 <NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
to="/" <span className="nav__name">Overview</span>
end
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
>
Overview
</NavLink> </NavLink>
<NavLink <NavLink to="/stage/all" className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
to="/stage/all" <span className="nav__name">All leads</span>
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')} {ready ? <span className="nav__n nav__n--grey">{openTotal}</span> : null}
>
All leads
</NavLink> </NavLink>
{/* "New lead" used to sit here. A sidebar is for moving between {NAV_GROUPS.map((group) => {
places; filing a lead is an ACTION, and an action in a list of const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
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) => {
const inGroup = stages.filter((s) => s.kind === group.kind)
if (!inGroup.length) return null if (!inGroup.length) return null
if (group.kinds.includes('end')) {
const closed = inGroup.reduce((t, s) => t + count(s).total, 0)
return ( return (
<div className="shell__group" key={group.kind}> <div className="nav__group" key={group.label}>
<p className="shell__navlabel">{group.label}</p> <h6 className="nav__h">{group.label}</h6>
<div className="shell__stages">{inGroup.map(queue)}</div> <details className="nav__fold">
<summary className="nav__link">
<span className="nav__name">Closed</span>
{ready ? <span className="nav__n nav__n--grey">{closed}</span> : null}
</summary>
<div className="nav__sub">{inGroup.map(row)}</div>
</details>
</div>
)
}
return (
<div className="nav__group" key={group.label}>
<h6 className="nav__h">{group.label}</h6>
{inGroup.map(row)}
</div> </div>
) )
})} })}
{/* Closed. Folded, because it is reporting rather than work and at
equal weight with the live queues it made them harder to find. */}
{stages.some((s) => s.kind === 'end') ? (
<details className="shell__closed">
<summary>
Closed
{ready ? (
<b>{stages.filter((s) => s.kind === 'end')
.reduce((t, s) => t + (counts[s.uid]?.total || 0), 0)}</b>
) : null}
</summary>
<div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
</details>
) : null}
</nav> </nav>
<main className="shell__main"> <main className="shell__main">

View File

@ -1,161 +1,59 @@
.um { .um { position: relative; }
position: relative;
}
/* The trigger sits on the navy masthead, so it is white type on navy and the
avatar inverts: a white disc with navy initials. */
.um__trigger { .um__trigger {
display: flex; display: flex; align-items: center; gap: 10px;
align-items: center;
gap: 10px;
cursor: pointer; cursor: pointer;
padding: 5px 12px 5px 5px; padding: 5px 10px 5px 5px;
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: #fff;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); transition: background var(--t-fast);
}
.um__trigger:hover {
background: var(--zk-tint);
border-color: var(--zk-line);
}
.um__trigger.is-open {
background: var(--zk-tint-blue);
border-color: var(--zk-blue-light);
}
.um__trigger:focus-visible {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
} }
.um__trigger:hover, .um__trigger.is-open { background: rgba(255, 255, 255, .10); }
.um__trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
.um__avatar { .um__avatar {
flex: none; flex: none; width: 32px; height: 32px; border-radius: 50%;
width: 32px; display: grid; place-items: center;
height: 32px; background: #fff; color: var(--zk-navy);
border-radius: 50%; font-size: 13px; font-weight: 700; letter-spacing: .02em;
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: 15px; background: var(--zk-tint-blue); color: var(--zk-navy); }
.um__avatar--lg { .um__id { display: flex; flex-direction: column; text-align: left; line-height: 1.25; }
width: 40px; .um__id strong { font-size: 15px; font-weight: 600; }
height: 40px; .um__id > span { font-size: 12.5px; opacity: .72; }
font-size: var(--fs-xs);
}
.um__id { .um__caret { width: 12px; height: 12px; flex: none; opacity: .6; transition: transform var(--t); }
display: flex; .um__trigger.is-open .um__caret { transform: rotate(180deg); }
flex-direction: column;
text-align: left;
line-height: 1.3;
}
.um__id strong {
font-size: var(--fs-xs);
font-weight: 500;
}
.um__id > span {
font-size: var(--fs-2xs);
color: var(--zk-muted);
}
.um__caret {
width: 12px;
height: 12px;
flex: none;
color: var(--zk-grey);
transition: transform var(--t);
}
.um__trigger.is-open .um__caret {
transform: rotate(180deg);
}
/* The menu drops onto the white canvas below the bar. */
.um__menu { .um__menu {
position: absolute; position: absolute; top: calc(100% + 10px); right: 0; z-index: 40;
top: calc(100% + 10px); min-width: 300px; padding: 7px;
right: 0; border-radius: var(--r-md);
z-index: 40; background: #fff; color: var(--zk-ink);
min-width: 300px;
padding: 7px;
border-radius: var(--r-lg);
background: var(--zk-white);
border: 1px solid var(--zk-line); border: 1px solid var(--zk-line);
box-shadow: var(--sh-lg); box-shadow: var(--sh-lg);
transform-origin: top right; animation: zk-rise .16s var(--ease) both;
animation: zk-rise 0.16s var(--ease) both;
} }
.um__head { display: flex; align-items: flex-start; gap: 12px; padding: 12px 11px 14px; }
.um__head { .um__headid { display: flex; flex-direction: column; gap: 1px; line-height: 1.35; min-width: 0; }
display: flex; .um__headid strong { font-size: var(--fs-sm); font-weight: 600; }
align-items: flex-start; .um__headid span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
gap: 12px;
padding: 12px 11px 14px;
}
.um__headid {
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.35;
min-width: 0;
}
.um__headid strong {
font-size: var(--fs-xs);
font-weight: 500;
}
.um__headid span {
font-size: var(--fs-2xs);
color: var(--zk-muted);
overflow-wrap: anywhere;
}
.um__headid .um__roles { .um__headid .um__roles {
margin-top: 4px; margin-top: 4px; align-self: flex-start;
align-self: flex-start; font-size: var(--fs-2xs); font-weight: 600;
font-size: var(--fs-2xs); color: var(--zk-navy); background: var(--zk-tint-blue);
font-weight: 500; border-radius: var(--r-pill); padding: 2px 9px;
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: block; width: 100%; text-align: left; cursor: pointer;
width: 100%; font: inherit; font-size: var(--fs-sm); padding: 10px 11px;
text-align: left; border-radius: var(--r-sm); background: none; border: 0;
cursor: pointer; border-top: 1px solid var(--zk-line-soft); color: var(--zk-ink);
font: inherit;
font-size: var(--fs-2xs);
padding: 10px 11px;
border-radius: var(--r-sm);
background: none;
border: 0;
border-top: 1px solid var(--zk-line-soft);
color: var(--zk-ink);
transition: background var(--t-fast), color var(--t-fast);
}
.um__item:hover {
background: var(--zk-tint);
color: var(--zk-blue-dark);
}
.um__item:focus-visible {
outline: none;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
} }
.um__item:hover, .um__item:focus-visible { outline: none; background: var(--zk-tint); color: var(--zk-navy); }

View File

@ -43,7 +43,12 @@ 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(', ') // Role slugs read as titles: partner_agent -> Partner agent.
const roles = (user?.roles || [])
// `sme_underwriter` "SME underwriter": the acronyms the roles here use stay upper.
.map((r) => String(r).replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
.replace(/\b(sme|uw|rm|posp|csr|ops|kyc)\b/gi, (m) => m.toUpperCase()))
.join(', ')
return ( return (
<div className="um" ref={wrapRef}> <div className="um" ref={wrapRef}>

View File

@ -164,9 +164,9 @@ function fmt(k, v) {
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: 'blue', label: 'With the AI' }
case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' } case 'customer': return { tone: 'teal', label: 'With the customer' }
case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' } case 'needs': return { tone: 'amber', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
case 'waiting': return { tone: 'grey', label: 'In nurture' } case 'waiting': return { tone: 'grey', label: 'In nurture' }
case 'end': return { tone: 'grey', label: 'Closed' } case 'end': return { tone: 'grey', label: 'Closed' }
default: return null default: return null
@ -395,78 +395,75 @@ export default function Lead() {
the premium" so the status strip (an alert when blocked or stalled, the the premium" so the status strip (an alert when blocked or stalled, the
live state otherwise) sits directly under the header, before the metrics. live state otherwise) sits directly under the header, before the metrics.
Rendered full-width there rather than inside the narrow work column. */ Rendered full-width there rather than inside the narrow work column. */
// THE BANNER. One sentence about what this lead needs from a person, with
// the button that does it amber for a task, red for a stall, blue while
// the AI has it. Nothing else on the page competes with it.
const statusStrip = ( const statusStrip = (
blocked ? ( blocked ? (
<div className="doing doing--blocked"> <div className="attn" role="status">
<span className="doing__stop" aria-hidden="true" />
<div> <div>
<strong>Stopped {blocked.what}</strong> <h3><span className="dot dot--amber" />Stopped: {blocked.what}</h3>
<span> <p>
{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
is there; nothing else is running in the meantime. there; nothing else is running in the meantime.
</span> </p>
<button </div>
type="button" className="doing__fix" <div className="attn__acts">
onClick={() => setOpen(blocked.via)} <button type="button" className="btn btn--navy" onClick={() => setOpen(blocked.via)}>Open Collect Documents</button>
>
Open Collect Documents
</button>
</div> </div>
</div> </div>
) : stalled ? ( ) : stalled ? (
<div className="doing doing--stalled"> <div className="attn attn--red" role="status">
<span className="doing__stop" aria-hidden="true" />
<div> <div>
<strong>{stage.doing} nothing for {stalled.mins} minutes</strong> <h3><span className="dot dot--red" />{stage.doing}: nothing for {stalled.mins} minutes</h3>
<span> <p>
{stage.by} has not come back. That is usually the model provider being {stage.by} has not come back. That is usually the model provider being slow or
slow or dropping a request, not a problem with this lead the work so far dropping a request, not a problem with this lead; the work so far is safe.
is safe and nothing has been lost.
{stalled.nudge {stalled.nudge
? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.` ? ` Running ${stalled.nudge.label.toLowerCase()} wakes ${stalled.nudge.by} to try again.`
: ' 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> </p>
</div>
{stalled.nudge ? ( {stalled.nudge ? (
<button <div className="attn__acts">
type="button" className="doing__fix" <button type="button" className="btn btn--navy" onClick={() => setOpen(stalled.nudge.uid)}>{stalled.nudge.label}</button>
onClick={() => setOpen(stalled.nudge.uid)} </div>
>
{stalled.nudge.label}
</button>
) : null} ) : null}
</div> </div>
) : primaryAction ? (
<div className="attn" role="status">
<div>
<h3><span className="dot dot--amber" />Your turn: {primaryAction.label}</h3>
<p>{stage?.need ? `${stage.need}. ` : ''}This lead is waiting on you; the AI carries on as soon as it is done.</p>
</div>
<div className="attn__acts">
<button type="button" className="btn btn--navy" onClick={goToAction}>{primaryAction.label}</button>
</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 <div className="attn attn--teal" role="status" style={{ borderColor: 'var(--zk-teal-line)', background: 'var(--zk-teal-tint)' }}>
is with someone outside the business. Without this the screen
went blank at exactly the stage where an operator is most likely
to wonder whether something has broken. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<div> <div>
<strong>{stage.doing}</strong> <h3><span className="dot dot--teal" />{stage.doing}</h3>
<span> <p>
Their answer comes back to this lead on its own Engage reads it and Their answer comes back to this lead on its own; Engage reads it and records the
records the acceptance. Nothing is waiting on you. acceptance. Nothing is waiting on you.
</span> </p>
</div> </div>
</div> </div>
) : stage?.kind === 'auto' ? ( ) : stage?.kind === 'auto' ? (
<div className="doing"> <div className="attn attn--blue" role="status">
<span className="doing__pulse" aria-hidden="true" />
<div> <div>
<strong>{stage.doing}</strong> <h3><span className="dot dot--blue" />{stage.doing}</h3>
<span> <p>
Handled by {stage.by}. Handled by {stage.by}.
{stage.after ? ` The lead stays in ${stateName} until a quote exists nothing is waiting on you.` : ''} {stage.after ? ` The lead stays in ${stateName} until a quote exists; nothing is waiting on you.` : ''}
{' '}Typically completes within two minutes; this view refreshes automatically. {' '}Typically completes within two minutes; this view refreshes automatically.
</span> </p>
</div> </div>
</div> </div>
) : null ) : null
) )
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
@ -475,7 +472,7 @@ blocked ? (
so a lead opened from its own URL has nothing to go back TO and so a lead opened from its own URL has nothing to go back TO and
lands on the queue instead of leaving the app. */} 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="back" 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"> <svg viewBox="0 0 16 16" aria-hidden="true">
@ -485,89 +482,70 @@ blocked ? (
</button> </button>
<div> <div>
<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="idline">
{row.lead_ref || `Lead ${instanceId}`} {row.lead_ref || `Lead ${instanceId}`}
{row.entity_name ? ` · ${row.entity_name}` : ''} {row.entity_name ? ` · ${row.entity_name}` : ''}
{row.product_line ? ` · ${row.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{row.vehicle_reg ? ` · ${row.vehicle_reg}` : ''}
</p> </p>
</div> </div>
</div> </div>
{/* WHOSE MOVE IT IS. The stage name is in the rail below; this says {/* 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 who holds the lead right now, in that holder's colour. */}
really being asked. The customer tone reuses the teal cust palette. */} <div className="page__right">
{primaryAction ? ( <div className="state">
/* YOUR MOVE. When this user owns the next step, the header carries it <span>{stateName}</span>
as the prominent thing amber, because it is waiting on a person {holds ? <span className={'pill pill--' + holds.tone}><span className={'dot dot--' + holds.tone} />{holds.label}</span> : null}
and pressing it opens the form below and scrolls to it. */ </div>
<button type="button" className="cta" onClick={goToAction}>
<span className="cta__txt">
<small>Your move</small>
<b>{primaryAction.label}</b>
</span>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
) : holds ? (
<div className={'holds holds--' + (holds.pillTone === 'cust' ? 'cust' : holds.tone)}>
<span className="holds__puck" aria-hidden="true">
{holds.tone === 'ai' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 3a2 2 0 0 0-2 2 2 2 0 0 0-1 3.6A2 2 0 0 0 4 12a2 2 0 0 0 2 2M14 3a2 2 0 0 1 2 2 2 2 0 0 1 1 3.6A2 2 0 0 1 16 12a2 2 0 0 1-2 2M10 3.5v12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
) : holds.pillTone === 'cust' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M4 5h12v8h-6l-3 2.5V13H4z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
) : holds.tone === 'person' ? (
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="6.5" r="2.8" fill="none" stroke="currentColor" strokeWidth="1.5"/><path d="M4.5 15.5a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M10 5.5v4.5l3 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5"/></svg>
)}
</span>
<span className="holds__txt">
<small>Right now</small>
<b>{holds.label}</b>
</span>
</div> </div>
) : null}
</header> </header>
{statusStrip} {statusStrip}
{/* Measures, not fields. Age and dwell time are computed neither is on {/* FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal
the record and they are the two that answer "is this lead moving?", is about; the AI's confidence in the cover sits beside it; the
which no label/value pair on the page could. */} renewal countdown is the one deadline that matters. */}
{(() => { {(() => {
const e = expiryOf(row.renewal_due_date) const e = expiryOf(row.renewal_due_date)
const premium = inrShort(row.quoted_premium) const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount) const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence) const conf = Number(row.ai_recommendation_confidence)
// FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal const dueTone = e ? (e.tone === 'lapsed' ? 'red' : e.tone === 'urgent' ? 'amber' : '') : ''
// 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 ( return (
<div className="lmetrics"> <div className="card facts">
<div className="lm lm--hero"> <div className="fact">
<span className="lm__l">Premium</span> <span className="fact__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong> <strong className={'fact__v' + (premium ? '' : ' is-none')}>{premium ?? 'Not yet rated'}</strong>
<span className="lm__s">{premium ? 'quoted, incl. GST' : 'not yet rated'}</span> <span className="fact__s">{premium ? 'quoted, incl. GST' : 'a quote appears once Rating has run'}</span>
</div> </div>
<div className="lm lm--hero"> <div className="fact">
<span className="lm__l">Commission</span> <span className="fact__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong> <strong className={'fact__v' + (commission ? '' : ' is-none')}>{commission ?? '—'}</strong>
<span className="lm__s"> <span className="fact__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'} {row.commission_rate_pct ? row.commission_rate_pct + '% · on issue' : 'on placement'}
</span> </span>
</div> </div>
<div className="fact">
<span className="fact__l">AI confidence</span>
{Number.isFinite(conf) && conf > 0 ? ( {Number.isFinite(conf) && conf > 0 ? (
<div className="lm"> <>
<span className="lm__l">AI confidence</span> <strong className="fact__v">{conf}%</strong>
<strong className="lm__v">{conf}%</strong> <div className="fact__conf">
<span className="lm__s">on the cover advice</span> <span className="tr" aria-hidden="true"><i style={{ width: Math.min(100, conf) + '%' }} /></span>
<span>on the cover advice</span>
</div> </div>
) : null} </>
<div className="lm"> ) : (
<span className="lm__l">Renewal</span> <>
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong> <strong className="fact__v is-none"></strong>
<span className="lm__s">{e ? e.on : 'no date on file'}</span> <span className="fact__s">once the Advisor has spoken</span>
</>
)}
</div>
<div className="fact">
<span className="fact__l">Renewal due</span>
<strong className={'fact__v' + (dueTone ? ' due--' + dueTone : '')}>{e ? e.label : '—'}</strong>
<span className="fact__s">{e ? e.on : 'no date on file'}</span>
</div> </div>
</div> </div>
) )
@ -575,8 +553,8 @@ blocked ? (
<StageRail audit={audit} currentName={stateName} /> <StageRail audit={audit} currentName={stateName} />
<div className="lead"> <div className="detail">
<div className="lead__main"> <div className="detail__main">
{note ? ( {note ? (
<div className="said" role="status"> <div className="said" role="status">
<p>{note}</p> <p>{note}</p>
@ -761,7 +739,7 @@ blocked ? (
{/* 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="detail__side">
{/* Who worked this lead. Five agents carry it and their names appear {/* Who worked this lead. Five agents carry it and their names appear
only inside individual entries, so the team was never visible at only inside individual entries, so the team was never visible at
a glance. */} a glance. */}

View File

@ -6,6 +6,7 @@ import { ENTRY, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx' import NewLeadDialog from '../components/NewLeadDialog.jsx'
import { holderOf } from '../api/holder.js'
import './screens.css' import './screens.css'
/** /**
@ -65,13 +66,6 @@ const PREM_RANGES = [
] ]
const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn])) const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn]))
function expiryTone(d) {
if (d === null) return 'later'
if (d < 0) return 'lapsed'
if (d <= 7) return 'urgent'
if (d <= 30) return 'soon'
return 'later'
}
export default function Overview() { export default function Overview() {
const { user } = useZino() const { user } = useZino()
@ -218,7 +212,7 @@ export default function Overview() {
<header className="page__head"> <header className="page__head">
<div> <div>
<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, {/* The action sits with the thing it acts on. It was in the sidebar,
which is for moving between places an action among destinations which is for moving between places an action among destinations
@ -245,35 +239,53 @@ export default function Overview() {
are being worked by the AI, this many are waiting on a person, this are being worked by the AI, this many are waiting on a person, this
many on a customer. It sits above the money because it is the claim many on a customer. It sits above the money because it is the claim
the money is evidence for. */} the money is evidence for. */}
<div className="now"> {/* WHO HOLDS THE WORK, RIGHT NOW. Every open lead is in exactly one of
<div className="now__cell now__cell--ai"> three hands the AI's, a person's, the customer's and that is the
<span className="now__l">With the AI</span> sentence this console exists to say. The money below is evidence for
<strong className="now__v">{m.withAI}</strong> it. Risk stands apart, in the one colour reserved for it. */}
<span className="now__s">being qualified, rated, checked or chased no one waiting</span> {(() => {
const total = m.open.length || 1
const pct = (n) => Math.max(n ? 2 : 0, Math.round((n / total) * 100))
const risk = m.exposure.lapsed.length + m.exposure.week.length
return (
<section className="hero">
<div className="card holders">
<div className="holders__top">
<b>{m.open.length}</b>
<span>open leads. Who is holding each one right now:</span>
</div> </div>
<div className={'now__cell now__cell--person' + (m.withPerson ? ' is-live' : '')}> <div className="bar" aria-hidden="true">
<span className="now__l">Waiting on a person</span> <div className="bar__blue" style={{ width: pct(m.withAI) + '%' }} />
<strong className="now__v">{m.withPerson}</strong> <div className="bar__amber" style={{ width: pct(m.withPerson) + '%' }} />
<span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span> <div className="bar__teal" style={{ width: pct(m.withCustomer) + '%' }} />
</div> </div>
<div className="now__cell now__cell--cust"> <div className="legend">
<span className="now__l">With the customer</span> <div className="legend__item legend__item--blue">
<strong className="now__v">{m.withCustomer}</strong> <b>{m.withAI}</b><strong>With the AI</strong>
<span className="now__s">a quote or a payment is theirs to answer</span> <span>Being qualified, rated, checked or chased. Nothing for anyone to do.</span>
</div> </div>
<div className={'now__cell now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}> <div className="legend__item legend__item--amber">
<span className="now__l">Renewals at risk</span> <b>{m.withPerson}</b><strong>Waiting on a person</strong>
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong> <span>A decision or an upload is owed by an agent, underwriter or ops.</span>
<span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span> </div>
<div className="legend__item legend__item--teal">
<b>{m.withCustomer}</b><strong>With the customer</strong>
<span>A quote or a payment is theirs to answer.</span>
</div> </div>
</div> </div>
</div>
<div className={'card risk' + (risk ? '' : ' is-quiet')}>
<b>{risk}</b><strong>{risk === 1 ? 'renewal at risk' : 'renewals at risk'}</strong>
<div className="risk__rows">
<div><span>Already lapsed</span><em>{m.exposure.lapsed.length}</em></div>
<div><span>Expire within 7 days</span><em>{m.exposure.week.length}</em></div>
</div>
</div>
</section>
)
})()}
<div className="kpis"> <section className="card kpis">
<div className="kpi">
<span className="kpi__l">Open leads</span>
<strong className="kpi__v">{m.open.length}</strong>
<span className="kpi__s">{m.closed} closed to date</span>
</div>
<div className="kpi"> <div className="kpi">
<span className="kpi__l">Pipeline value</span> <span className="kpi__l">Pipeline value</span>
<strong className="kpi__v">{inr(m.pipeline)}</strong> <strong className="kpi__v">{inr(m.pipeline)}</strong>
@ -287,121 +299,150 @@ export default function Overview() {
<div className="kpi"> <div className="kpi">
<span className="kpi__l">Conversion</span> <span className="kpi__l">Conversion</span>
<strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong> <strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong>
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span> <span className="kpi__s">of leads that closed</span>
</div> </div>
<div className="kpi"> <div className="kpi">
<span className="kpi__l">Commission</span> <span className="kpi__l">Commission</span>
<strong className="kpi__v">{inr(m.commission)}</strong> <strong className="kpi__v">{inr(m.commission)}</strong>
<span className="kpi__s">payable to partners</span> <span className="kpi__s">payable to partners</span>
</div> </div>
<div className="kpi">
<span className="kpi__l">Closed to date</span>
<strong className="kpi__v">{m.closed}</strong>
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span>
</div> </div>
</section>
{/* SIGN-OFF, not work. The only queue that is a person's approval rather {/* SIGN-OFF, not work: the one 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' + (s.n ? ' is-live' : '')}>
<strong className="appr__n">{s.n}</strong> <strong className="appr__n">{s.n}</strong>
<span className="appr__t"> <span className="appr__t">{s.approvalLabel}<em>{s.approvalNote}</em></span>
{s.approvalLabel} <span className="appr__go">{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest} days` : 'review') : 'nothing waiting'}</span>
<em>{s.approvalNote}</em>
</span>
<span className="appr__go">
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
</span>
</Link> </Link>
))} ))}
<div className="split"> <section className="cols">
<div className="card">
<div className="card__hd">
<h3>Waiting on a person</h3>
<p>{m.actionable.reduce((t, s) => t + s.n, 0)} leads, oldest first</p>
</div>
<div className="queue">
{[...m.actionable].sort((a, b) => (b.oldest ?? -1) - (a.oldest ?? -1)).map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'queue__row' + (s.n ? '' : ' is-clear')}>
<b className="queue__n">{s.n}</b>
<div> <div>
<h2 className="sec">Action required</h2> <div className="queue__t">{s.need ?? s.name}</div>
<div className="qlist"> <div className="queue__who">
{m.actionable.map((s) => ( <span className={'dot dot--' + (s.kind === 'customer' ? 'teal' : 'amber')} />
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}> {String(s.by).replace(/^(the|an|a)\s+/i, '').replace(/^./, (c) => c.toUpperCase())}
<strong className="q__n">{s.n}</strong> {!mine.has(s.uid) ? <span className="queue__ro" title="View only for your role">view</span> : null}
<span className="q__t"> </div>
{s.need ?? s.name} </div>
<em>{s.by}</em> <div className="queue__age">
</span> {s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest} day${s.oldest === 1 ? '' : 's'}` : ''}
<span className="q__age"> {s.working ? <em>{s.working} more with the AI</em> : null}
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''} </div>
{s.working ? <b className="q__auto">+{s.working} with the AI</b> : null} <span className="btn btn--ghost btn--small">Open</span>
</span>
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
</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>
{/* Plain dropdown filters over the columns the table shows. Empty <div className="card">
option = no filter on that field; they narrow together. */} <div className="card__hd"><h3>Pipeline by stage</h3><p>{m.open.length} open</p></div>
<div className="xfd"> <div className="dist">
{STAGES.filter((s) => s.kind !== 'end').map((s) => {
const n = m.byStage[s.name] || 0
const tone = s.kind === 'auto' ? 'blue' : s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'amber' : 'grey'
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--' + tone} />
</span>
<b className="dist__n">{n}</b>
</Link>
)
})}
<div className="dist__sec">Closed · {m.closed}</div>
{STAGES.filter((s) => s.kind === 'end').map((s) => {
const n = m.byStage[s.name] || 0
const max = Math.max(1, ...STAGES.filter((x) => x.kind === 'end').map((x) => m.byStage[x.name] || 0))
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className="dist__r is-dim">
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / max) * 100 + '%' }} className="dist__f dist__f--grey" />
</span>
<b className="dist__n">{n}</b>
</Link>
)
})}
</div>
</div>
</section>
<section className="card exp">
<div className="card__hd"><h3>Renewal exposure</h3><p>open leads by time to expiry</p></div>
<div className="exp__tabs">
<div className="tab tab--red"><b>{m.exposure.lapsed.length}</b>lapsed</div>
<div className="tab tab--amber"><b>{m.exposure.week.length}</b>within 7 days</div>
<div className="tab"><b>{m.exposure.month.length}</b>8 to 30 days</div>
<div className="exp__filters">
<label className="xfd__f"> <label className="xfd__f">
<span>Stage</span> <select value={fStage} onChange={(e) => setFStage(e.target.value)} aria-label="Stage">
<select value={fStage} onChange={(e) => setFStage(e.target.value)}>
<option value="">All stages</option> <option value="">All stages</option>
{expoStages.map((st) => <option key={st} value={st}>{st}</option>)} {expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
</select> </select>
</label> </label>
<label className="xfd__f"> <label className="xfd__f">
<span>Time to expiry</span> <select value={fExpiry} onChange={(e) => setFExpiry(e.target.value)} aria-label="Time to expiry">
<select value={fExpiry} onChange={(e) => setFExpiry(e.target.value)}>
<option value="">Any time</option> <option value="">Any time</option>
{Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)} {Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select> </select>
</label> </label>
<label className="xfd__f"> <label className="xfd__f">
<span>Premium</span> <select value={fPrem} onChange={(e) => setFPrem(e.target.value)} aria-label="Premium">
<select value={fPrem} onChange={(e) => setFPrem(e.target.value)}>
<option value="">Any premium</option> <option value="">Any premium</option>
{PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)} {PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select> </select>
</label> </label>
{anyFilter ? ( {anyFilter ? (
<button type="button" className="xfd__clear" <button type="button" className="xfd__clear" onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>Clear</button>
onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>
Clear
</button>
) : null} ) : null}
</div> </div>
</div>
{shown.length ? ( {shown.length ? (
<div className="gridwrap"> <div className="gridwrap">
<table className="grid grid--tight"> <table className="grid">
<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>Holding it</th><th className="num">Premium</th></tr>
</thead> </thead>
<tbody> <tbody>
{shown.slice(0, anyFilter ? 100 : 8).map((r) => { {shown.slice(0, anyFilter ? 100 : 8).map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
const def = STAGES.find((s) => s.name === r.current_state_name)
const h = holderOf(def, r)
const tone = r._d < 0 ? 'red' : r._d <= 7 ? 'amber' : 'grey'
return ( return (
<tr <tr key={id} className="grid__row" tabIndex={0} role="link"
key={id}
className="grid__row"
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}`) } aria-label={`Open ${r.customer_name || r.lead_ref || id}`}>
}}
tabIndex={0}
role="link"
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td> <td>
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span> <span className="grid__name" style={{ fontSize: 'var(--fs-sm)' }}>{r.customer_name || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div> <div className="grid__sub">{r.lead_ref || ''}</div>
</td> </td>
<td><span className={'due due--' + expiryTone(r._d)}> <td><span className={'pill pill--' + tone}>
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'} {r._d < 0 ? Math.abs(r._d) + (r._d === -1 ? ' day' : ' days') + ' overdue' : r._d === 0 ? 'today' : r._d + (r._d === 1 ? ' day' : ' days')}
</span></td> </span></td>
<td><span className="grid__sub">{r.current_state_name}</span></td> <td>{r.current_state_name}</td>
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td> <td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
<td className="num">{r.quoted_premium ? '₹' + Math.round(num(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
</tr> </tr>
) )
})} })}
@ -409,32 +450,11 @@ export default function Overview() {
</table> </table>
</div> </div>
) : ( ) : (
<p className="empty"> <p className="empty" style={{ margin: '0 20px 20px' }}>
{anyFilter ? 'No open leads match these filters.' : 'No renewals due within 30 days.'} {anyFilter ? 'No open leads match these filters.' : 'No renewals due within 30 days.'}
</p> </p>
)} )}
</div> </section>
<div>
<h2 className="sec">Pipeline distribution</h2>
{/* Every stage, zeroes included. A stage that has quietly stopped
receiving leads is only visible if its zero is on the page. */}
<div className="dist">
{STAGES.map((s) => {
const n = m.byStage[s.name] || 0
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + s.kind} />
</span>
<span className="dist__n">{n}</span>
</Link>
)
})}
</div>
</div>
</div>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null} {adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section> </section>
) )

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, 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 { ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js' import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx' import NewLeadDialog from '../components/NewLeadDialog.jsx'
import { holderOf } from '../api/holder.js'
/** 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". */
@ -87,6 +88,8 @@ export default function Pipeline() {
// answer. Closed rows are shown, and dimmed, and can be hidden by choice. // 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)
// Find-a-lead. Client-side, over the page already fetched.
const [q, setQ] = useState('')
const stage = isAll const stage = isAll
? { uid: 'all', name: 'All leads', kind: 'all', by: 'everyone' } ? { uid: 'all', name: 'All leads', kind: 'all', by: 'everyone' }
: STAGES.find((s) => s.uid === stageUid) : STAGES.find((s) => s.uid === stageUid)
@ -140,12 +143,82 @@ export default function Pipeline() {
// are closed instead of silently showing fewer than the page claims. // are closed instead of silently showing fewer than the page claims.
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)) const CLOSED = 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 const needle = q.trim().toLowerCase()
const shownRows = (isAll && hideClosed
? state.rows.filter((r) => !CLOSED.has(r.current_state_name)) ? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
: state.rows : state.rows
).filter((r) => !needle || [r.customer_name, r.lead_ref, r.vehicle_reg, r.entity_name, r.customer_phone]
.some((v) => String(v || '').toLowerCase().includes(needle)))
// Inside a person-gated queue the rows split in two: the ones that are that
// person's turn, and the ones the AI is still working inside the same state.
// Two group rows say so; a flat list read as one queue when it was two.
const grouped = !isAll && (stage.kind === 'needs' || stage.kind === 'customer')
const yourTurn = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind !== 'auto') : shownRows
const withAI = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind === 'auto') : []
if (!stage) return <p className="empty">Unknown stage.</p> if (!stage) return <p className="empty">Unknown stage.</p>
function renderRows(rows) {
return (
<>
{rows.map((r) => {
const id = r.instance_id ?? r.id
const exp = expiry(r.renewal_due_date)
const add = added(r.updated_at || 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 def = STAGES.find((x) => x.name === r.current_state_name)
const rowStage = phaseOf(def, r)
const prog = progress(r, rowStage)
const h = holderOf(def, r)
const done = CLOSED.has(r.current_state_name)
const dueTone = exp ? (exp.tone === 'lapsed' ? 'red' : exp.tone === 'urgent' ? 'amber' : '') : ''
return (
<tr
key={id}
className={'grid__row'
+ (prog?.state === 'stalled' ? ' is-stalled' : '')
+ (done ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
}}
tabIndex={0}
role="link"
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
{r.lead_ref || `#${id}`}
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{r.vehicle_reg ? ` · ${r.vehicle_reg}` : r.entity_name ? ` · ${r.entity_name}` : ''}
</div>
</td>
<td>
{prog?.state === 'stalled' ? (
<><span className="stall">Stalled</span><div className="grid__sub">{prog.label}</div></>
) : (
<>{r.current_state_name || '—'}{prog ? <div className="grid__sub">{prog.label}</div> : null}</>
)}
</td>
<td>
{exp
? <span className={'due' + (dueTone ? ' due--' + dueTone : '')}><b>{exp.label}</b><small>{exp.on}</small></span>
: <span className="grid__sub"></span>}
</td>
<td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
<td className="num">{r.quoted_premium ? '₹' + Math.round(Number(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
</tr>
)
})}
</>
)
}
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
@ -164,22 +237,8 @@ export default function Pipeline() {
{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>
{canFile ? ( {canFile ? (
<div className="page__right">
<button type="button" className="newlead" onClick={() => setAdding(true)}> <button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" <path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
@ -187,7 +246,6 @@ export default function Pipeline() {
</svg> </svg>
New lead New lead
</button> </button>
) : null}
</div> </div>
) : null} ) : null}
</header> </header>
@ -206,87 +264,69 @@ export default function Pipeline() {
</div> </div>
) : null} ) : null}
{state.status === 'ready' && shownRows.length === 0 ? ( {state.status === 'ready' ? (
<p className="empty">{isAll ? 'No leads yet.' : 'No records in this stage.'}</p> <div className="card">
<div className="tools">
<label className="search">
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a lead, a registration, a reference" aria-label="Find a lead" />
</label>
{/* 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={'tools__toggle' + (hideClosed ? ' is-on' : '')}>
<input type="checkbox" checked={hideClosed} onChange={(ev) => setHideClosed(ev.target.checked)} />
<i aria-hidden="true" />
Hide {closedCount} closed
</label>
) : null} ) : null}
<span className="tools__count">
{shownRows.length} {shownRows.length === 1 ? 'lead' : 'leads'}
{!isAll && state.total > shownRows.length && !needle ? ` of ${state.total}` : ''}
</span>
</div>
{state.status === 'ready' && shownRows.length > 0 ? ( {shownRows.length === 0 ? (
<p className="empty" style={{ margin: 20 }}>
{needle ? 'No lead matches that.' : isAll ? 'No leads yet.' : 'No records in this stage.'}
</p>
) : (
<div className="gridwrap"> <div className="gridwrap">
<table className="grid"> <table className="grid">
<thead> <thead>
<tr> <tr>
<th>Customer</th> <th>Customer</th>
<th>{isAll ? 'Stage' : 'Status'}</th> <th>Stage</th>
<th>Renewal due</th> <th>Renewal due</th>
<th>Holding it</th>
<th className="num">Premium</th> <th className="num">Premium</th>
<th>Waiting</th> <th>Last activity</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{shownRows.map((r) => { {grouped && withAI.length ? (
const id = r.instance_id ?? r.id <tr className="grp-row"><td colSpan={6}>
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } <span className={'dot dot--' + (stage.kind === 'customer' ? 'teal' : 'amber')} />
const exp = expiry(r.renewal_due_date) {stage.kind === 'customer' ? 'With the customer' : 'Your turn'}<span>{yourTurn.length}</span>
const add = added(r.created_at) </td></tr>
// What is really happening in the state, not just its name ) : null}
// a lead whose documents are in is being worked by the AI, not {renderRows(yourTurn)}
// waiting on the agent whose queue it is sitting in. {grouped && withAI.length ? (
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r) <>
const prog = progress(r, rowStage) <tr className="grp-row"><td colSpan={6}>
return ( <span className="dot dot--blue" />With the AI<span>{withAI.length} · nothing for anyone to do</span>
<tr </td></tr>
key={id} {renderRows(withAI)}
className={'grid__row' </>
+ (prog ? ' is-' + prog.state : '') ) : null}
+ (CLOSED.has(r.current_state_name) ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
}}
tabIndex={0}
role="link"
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
{r.lead_ref || `#${id}`}
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{ch.label ? ` · ${ch.label}` : ''}
</div>
</td>
{/* Who holds it, and where an agent does whether it is
moving. On the all-leads view the stage name is the
useful column; inside a queue every row shares it, so
the useful thing is progress. */}
<td>
{prog ? (
<span className={'run run--' + prog.state}>
<span className="run__dot" aria-hidden="true" />
{prog.label}
</span>
) : isAll ? (
<span className="grid__sub">{r.current_state_name || '—'}</span>
) : (
<span className="grid__sub">
{rowStage?.by ? `with ${rowStage.by}` : '—'}
</span>
)}
</td>
<td>
{exp
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
<div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>}
</td>
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)}
</div>
) : null} ) : null}
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null} {adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section> </section>

File diff suppressed because it is too large Load Diff