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:
parent
0df7d12073
commit
79863e2a5f
@ -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
|
||||||
|
|||||||
@ -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
36
src/api/holder.js
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/assets/fonts/source-sans-3-latin-400-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-400-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-500-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-500-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-600-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-600-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-700-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-700-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2
Normal file
Binary file not shown.
@ -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%; }
|
|
||||||
|
|||||||
@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
295
src/index.css
295
src/index.css
@ -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;
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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. */}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user