console: redesign the UI — one card language, icons, filters, live trail
Visual and information-architecture pass across every screen. No API call, payload, poll, permission rule or workflow mirror changes; forms still render from the live schema and the timeline's merge/grouping is unchanged (only the display order is reversible). Design language - Ground + white cards with one hairline and one radius; sentence-case headings; shared primitives in index.css (.card .pill .btn .avatar .live). - One inline icon set (components/Icon.jsx); no more filled navy/red tiles. - Read-side labels in api/display.js: roles and enum tokens humanized, customer initials/tones, money and date formatters. Nothing sent changes. Shell - 56px header with brand, global lead search (portfolio rows, "/" to focus) and identity chip with a human role label. - Sidebar rows carry icons and a clear active bar; AI roster at the foot, each opening its remit card. Login - Operator copy instead of retail copy; centred sign-in card with show/hide password; demo persona picker that fills the EMAIL only. Overview - "Who holds the work" as four quiet cards that warm only when owed. - Pipeline flow bar with every stage named beneath it; KPI strip in a card. - Action rows with chevron / view-only eye; expiry bands filter the table. Queues - Filter bar (search, product, channel, expiry band, stalled, hide closed) and sortable columns, all client-side on the fetched page. - Avatars, product/channel chips, stage chips for closed leads, tinted stalled rows, count inline with the title. Lead - Avatar + chips in the header; metrics card with the stage rail inside, and a branch node for Referred / Parked / Lost / Declined. - Timeline newest-first by default (toggle), the worker's face as the node, one footer row per entry, AI markdown rendered as bold. - "AI team on this lead" panel with per-agent step counts; PDF tags on docs. Forms & dialogs - Required marked with an asterisk + legend; "Select…" placeholder; dd Mon yyyy hint under date inputs; open activity form in a titled panel; one-line dialog footnotes. Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens (sme_underwriter, verbal_call, Rsa), findings cut at "1.". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2965c47b71
commit
e90e4204ec
39
CLAUDE.md
39
CLAUDE.md
@ -14,10 +14,10 @@ npm run preview
|
|||||||
|
|
||||||
There are no tests and no test runner in this project.
|
There are no tests and no test runner in this project.
|
||||||
|
|
||||||
`npm run lint` currently reports 12 pre-existing errors — 8 `react-hooks/refs`, 3
|
`npm run lint` currently reports 14 pre-existing errors — 8 `react-hooks/refs`, 3
|
||||||
`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider
|
`react-hooks/set-state-in-effect`, 3 `react-refresh/only-export-components` (the provider
|
||||||
file exports both a component and a hook). A clean run is not the baseline; compare against
|
and portfolio files export both a component and hooks). A clean run is not the baseline;
|
||||||
this count rather than against zero.
|
compare against this count rather than against zero.
|
||||||
|
|
||||||
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
|
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
|
||||||
build does **not** — see "Runtime config" below.
|
build does **not** — see "Runtime config" below.
|
||||||
@ -50,12 +50,33 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/
|
|||||||
- `src/api/errors.js` — `describeError()` turns the gateway's five error shapes into a
|
- `src/api/errors.js` — `describeError()` turns the gateway's five error shapes into a
|
||||||
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
|
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
|
||||||
list).
|
list).
|
||||||
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding
|
- `src/layout/Shell.jsx` — 56px header (brand, `GlobalSearch`, `UserMenu`) + the sidebar,
|
||||||
the lead) rather than by pipeline position, with queue counts from one tallied list call.
|
grouped by `NAV_GROUPS` (who is holding the lead) rather than by pipeline position, with
|
||||||
|
queue counts from one tallied list call and the AI roster at its foot.
|
||||||
|
- `src/layout/GlobalSearch.jsx` — finds a lead from anywhere. Filters the rows
|
||||||
|
`PortfolioProvider` already holds (name, business, ref, mobile, registration) — no request
|
||||||
|
of its own. `/` focuses it.
|
||||||
|
- `src/api/display.js` — read-side labels only: `roleLabel`, `humanize` (snake_case tokens →
|
||||||
|
English; names, refs, emails pass through untouched), `initials`/`toneFor` for customer
|
||||||
|
avatars, money and date formatters, `findingOf`/`leadIn`/`mdRuns` for AI prose. Nothing in
|
||||||
|
here changes a value that is SENT anywhere.
|
||||||
|
- `src/components/Icon.jsx` — the one monoline icon set. Use the NAME, never a path.
|
||||||
|
- `src/index.css` — the tokens plus the shared primitives every screen builds from:
|
||||||
|
`.card`, `.pill`, `.btn`, `.avatar`, `.live`, `.h-sec`/`.h-sub`. Screen stylesheets style
|
||||||
|
content; these style containers. Page ground is `--zk-ground`, bounded regions are white
|
||||||
|
cards.
|
||||||
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
|
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
|
||||||
`current_state_name`.
|
`current_state_name`. The filter bar (search, product, channel, expiry band, stalled,
|
||||||
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
|
hide closed) and the column sort narrow the PAGE that came back, client-side; they never
|
||||||
field groups (`GROUPS`, in the order the lead is worked).
|
change the request.
|
||||||
|
- `src/screens/Lead.jsx` — one lead: header (avatar, chips, Your move / whose move), a
|
||||||
|
metrics card with the `StageRail` inside it, the status strip, Next step, the timeline
|
||||||
|
(newest first by default, toggle to read as a story), and a sticky reference rail: the AI
|
||||||
|
team with per-agent step counts, then At a glance. The full record is `LeadFileDialog`
|
||||||
|
(`GROUPS`, in the order the lead is worked).
|
||||||
|
- `src/pages/Login.jsx` — sign-in plus a demo persona picker (the README accounts for org
|
||||||
|
84). Picking one fills the EMAIL only and focuses the password box — no credential lives
|
||||||
|
in the bundle. What the persona may do is still the workflow's decision.
|
||||||
- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
|
- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
|
||||||
one is surfaced.
|
one is surfaced.
|
||||||
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
|
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
|
||||||
|
|||||||
214
src/api/display.js
Normal file
214
src/api/display.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* How raw workflow values read on screen.
|
||||||
|
*
|
||||||
|
* The platform stores roles and enums as snake_case tokens — `sme_underwriter`,
|
||||||
|
* `verbal_call`, `due_now` — and the console was printing them as-is in the
|
||||||
|
* header chip, the lead file and the trail. Nothing here changes a value that
|
||||||
|
* is SENT anywhere; these are read-side labels only. A token missing from the
|
||||||
|
* maps still reads as English through `humanize`, so a value added to the
|
||||||
|
* workflow tomorrow does not come back as a slug.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROLE_LABELS = {
|
||||||
|
ops_admin: 'Operations',
|
||||||
|
partner_agent: 'Partner agent',
|
||||||
|
bank_rm: 'Bancassurance RM',
|
||||||
|
csr_direct: 'Direct sales',
|
||||||
|
sme_underwriter: 'SME underwriter',
|
||||||
|
ai_intake: 'Intake AI',
|
||||||
|
ai_engage: 'Engage AI',
|
||||||
|
ai_advisor: 'Advisor AI',
|
||||||
|
ai_kyc: 'KYC AI',
|
||||||
|
ai_uw_referral: 'Referral AI',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleLabel(role) {
|
||||||
|
if (!role) return ''
|
||||||
|
return ROLE_LABELS[role] ?? humanize(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enum values the workflow writes, in the words a person would use. */
|
||||||
|
const VALUE_LABELS = {
|
||||||
|
// channels & lines
|
||||||
|
direct: 'Direct',
|
||||||
|
bancassurance: 'Bancassurance',
|
||||||
|
agency: 'Agency',
|
||||||
|
motor: 'Motor',
|
||||||
|
sme_package: 'SME Package',
|
||||||
|
// consent / contact
|
||||||
|
verbal_call: 'Verbal, on the call',
|
||||||
|
written: 'Written',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
email: 'Email',
|
||||||
|
connected: 'Connected',
|
||||||
|
no_answer: 'No answer',
|
||||||
|
callback: 'Callback requested',
|
||||||
|
not_interested: 'Not interested',
|
||||||
|
wrong_number: 'Wrong number',
|
||||||
|
due_now: 'Due now',
|
||||||
|
too_early: 'Too early',
|
||||||
|
lapsed: 'Lapsed',
|
||||||
|
// documents / outcomes
|
||||||
|
complete: 'Complete',
|
||||||
|
partial: 'Partial',
|
||||||
|
pending: 'Pending',
|
||||||
|
received: 'Received',
|
||||||
|
proceed: 'Proceed',
|
||||||
|
clear: 'Clear',
|
||||||
|
cleared: 'Cleared',
|
||||||
|
refer: 'Refer',
|
||||||
|
referred: 'Referred',
|
||||||
|
decline: 'Decline',
|
||||||
|
declined: 'Declined',
|
||||||
|
accepted: 'Accepted',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
paid: 'Paid',
|
||||||
|
unpaid: 'Unpaid',
|
||||||
|
issued: 'Issued',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
// vehicle
|
||||||
|
petrol: 'Petrol',
|
||||||
|
diesel: 'Diesel',
|
||||||
|
cng: 'CNG',
|
||||||
|
electric: 'Electric',
|
||||||
|
hybrid: 'Hybrid',
|
||||||
|
// kyc
|
||||||
|
ckyc: 'CKYC',
|
||||||
|
okyc: 'Aadhaar OTP',
|
||||||
|
vkyc: 'Video KYC',
|
||||||
|
match: 'Match',
|
||||||
|
mismatch: 'Mismatch',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Words that keep their case when a slug is spelt out. */
|
||||||
|
const KEEP = {
|
||||||
|
pan: 'PAN', gstin: 'GSTIN', gst: 'GST', kyc: 'KYC', ai: 'AI', sme: 'SME',
|
||||||
|
uw: 'UW', od: 'OD', tp: 'TP', idv: 'IDV', ncb: 'NCB', rm: 'RM', posp: 'POSP',
|
||||||
|
rc: 'RC', otp: 'OTP', id: 'ID', bi: 'BI', ee: 'EE', wip: 'WIP', amc: 'AMC',
|
||||||
|
irdai: 'IRDAI', msme: 'MSME', rsa: 'RSA', pa: 'PA', cpa: 'CPA', ckyc: 'CKYC', sms: 'SMS',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks like a stored token rather than free text: lowercase words joined by
|
||||||
|
* underscores, nothing else. Names, references, emails and numbers all fail
|
||||||
|
* this test and pass through untouched. */
|
||||||
|
const TOKEN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/
|
||||||
|
|
||||||
|
export function isToken(v) {
|
||||||
|
return typeof v === 'string' && v.length <= 40 && TOKEN.test(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snake_case token as English. Anything that is not a token — a name, an
|
||||||
|
* email, a registration number, a sentence — comes back exactly as given.
|
||||||
|
*/
|
||||||
|
export function humanize(v) {
|
||||||
|
if (!isToken(v)) return v
|
||||||
|
if (VALUE_LABELS[v]) return VALUE_LABELS[v]
|
||||||
|
const words = v.split('_').map((w) => KEEP[w] ?? w)
|
||||||
|
const s = words.join(' ')
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initials for a customer or a person, two letters at most. */
|
||||||
|
export function initials(name) {
|
||||||
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (!parts.length) return '·'
|
||||||
|
const a = parts[0][0] || ''
|
||||||
|
const b = parts.length > 1 ? parts[parts.length - 1][0] : (parts[0][1] || '')
|
||||||
|
return (a + b).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable tone for a name, so the same customer wears the same colour on
|
||||||
|
* every screen. Six hues, all readable with white text. */
|
||||||
|
const TONES = ['blue', 'teal', 'violet', 'amber', 'plum', 'slate']
|
||||||
|
export function toneFor(name) {
|
||||||
|
const s = String(name || '')
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
|
||||||
|
return TONES[h % TONES.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indian money, short. */
|
||||||
|
export function inrShort(v) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n) || !n) return null
|
||||||
|
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
|
||||||
|
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
|
||||||
|
return '₹' + Math.round(n).toLocaleString('en-IN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Indian money, in full. */
|
||||||
|
export function inrFull(v) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
return '₹' + n.toLocaleString('en-IN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dd Mon yyyy, the way an Indian insurer writes a date. */
|
||||||
|
export function dateLong(v) {
|
||||||
|
if (!v) return ''
|
||||||
|
const d = new Date(String(v).length === 10 ? String(v) + 'T00:00:00' : v)
|
||||||
|
if (isNaN(d)) return String(v)
|
||||||
|
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long ago, short. */
|
||||||
|
export function ago(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (isNaN(d)) return ''
|
||||||
|
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return mins + 'm ago'
|
||||||
|
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
|
||||||
|
return Math.round(mins / 1440) + 'd ago'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first sentence of an AI paragraph — the employees write the conclusion
|
||||||
|
* first, so this IS the finding. Null when the split would be useless.
|
||||||
|
*/
|
||||||
|
export function findingOf(text) {
|
||||||
|
const m = String(text).match(/^(.{24,200}?[^\d\s][.!?])(\s|$)/)
|
||||||
|
return m ? m[1].trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first ~180 characters as a finding when no clean first sentence exists,
|
||||||
|
* cut at a word so it never ends mid-token.
|
||||||
|
*/
|
||||||
|
export function leadIn(text, max = 180) {
|
||||||
|
const t = String(text || '').replace(/\s+/g, ' ').trim()
|
||||||
|
if (t.length <= max) return t
|
||||||
|
const cut = t.slice(0, max)
|
||||||
|
return cut.slice(0, Math.max(cut.lastIndexOf(' '), 100)) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The employees write light markdown — **bold** and list markers. Rendered as
|
||||||
|
* plain runs with the bold kept, so a paragraph never shows its asterisks.
|
||||||
|
*/
|
||||||
|
export function mdRuns(text) {
|
||||||
|
const t = String(text || '')
|
||||||
|
const out = []
|
||||||
|
const re = /\*\*(.+?)\*\*/g
|
||||||
|
let last = 0, m
|
||||||
|
while ((m = re.exec(t))) {
|
||||||
|
if (m.index > last) out.push({ b: false, t: t.slice(last, m.index) })
|
||||||
|
out.push({ b: true, t: m[1] })
|
||||||
|
last = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (last < t.length) out.push({ b: false, t: t.slice(last) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The icon each queue kind wears in the sidebar and on the overview. */
|
||||||
|
export const KIND_ICON = {
|
||||||
|
needs: 'inbox',
|
||||||
|
customer: 'chat',
|
||||||
|
auto: 'bot',
|
||||||
|
waiting: 'clock',
|
||||||
|
end: 'archive',
|
||||||
|
all: 'list',
|
||||||
|
}
|
||||||
@ -28,32 +28,37 @@
|
|||||||
.af__field > span {
|
.af__field > span {
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.03em;
|
color: var(--zk-ink);
|
||||||
color: var(--zk-muted);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.af__req {
|
.af__req {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: var(--fs-3xs);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
color: var(--zk-danger);
|
||||||
color: var(--zk-blue);
|
margin-left: -4px;
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.af__legend {
|
||||||
|
margin: -8px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
.af__legend .af__req { margin: 0 3px 0 0; font-size: var(--fs-xs); }
|
||||||
|
|
||||||
|
.af__hint { font-size: 11.5px; color: var(--zk-muted); margin-top: -2px; }
|
||||||
|
|
||||||
.af input,
|
.af input,
|
||||||
.af select,
|
.af select,
|
||||||
.af textarea {
|
.af textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-sm);
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-line);
|
||||||
background: var(--zk-white);
|
background: var(--zk-white);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
@ -117,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.af__chip.is-on {
|
.af__chip.is-on {
|
||||||
background: var(--grad-blue);
|
background: var(--zk-blue);
|
||||||
border-color: var(--zk-blue);
|
border-color: var(--zk-blue);
|
||||||
color: var(--zk-white);
|
color: var(--zk-white);
|
||||||
box-shadow: var(--sh-xs);
|
box-shadow: var(--sh-xs);
|
||||||
@ -136,17 +141,17 @@
|
|||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 10px 26px;
|
padding: 10px 26px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--grad-blue);
|
background: var(--zk-blue);
|
||||||
color: var(--zk-white);
|
color: var(--zk-white);
|
||||||
box-shadow: var(--sh-sm);
|
box-shadow: var(--sh-sm);
|
||||||
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
|
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.af__submit:hover:not(:disabled) {
|
.af__submit:hover:not(:disabled) {
|
||||||
background: var(--grad-blue-hover);
|
background: var(--zk-blue-dark);
|
||||||
box-shadow: var(--sh-md);
|
box-shadow: var(--sh-md);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
@ -166,7 +171,7 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-line);
|
||||||
background: var(--zk-white);
|
background: var(--zk-white);
|
||||||
@ -211,7 +216,7 @@
|
|||||||
.af__auto {
|
.af__auto {
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-sm);
|
||||||
min-height: 41px;
|
min-height: 41px;
|
||||||
border: 1px dashed var(--zk-line);
|
border: 1px dashed var(--zk-line);
|
||||||
background: var(--zk-tint);
|
background: var(--zk-tint);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useZino } from '../api/provider.jsx'
|
|||||||
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
|
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
|
||||||
import { describeError, describeValidation } from '../api/errors.js'
|
import { describeError, describeValidation } from '../api/errors.js'
|
||||||
import FileField from './FileField.jsx'
|
import FileField from './FileField.jsx'
|
||||||
|
import { dateLong } from '../api/display.js'
|
||||||
import './ActivityForm.css'
|
import './ActivityForm.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -288,7 +289,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
|||||||
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
|
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
|
||||||
+ (missing.includes(f.id) ? ' is-missing' : '')}
|
+ (missing.includes(f.id) ? ' is-missing' : '')}
|
||||||
>
|
>
|
||||||
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
<span>{f.name}{f.mandatory ? <em className="af__req" title="Required" aria-label="required">*</em> : null}</span>
|
||||||
|
|
||||||
{f.data_type === 'file' || f.data_type === 'ocr' ? (
|
{f.data_type === 'file' || f.data_type === 'ocr' ? (
|
||||||
<FileField
|
<FileField
|
||||||
@ -303,7 +304,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
|||||||
</div>
|
</div>
|
||||||
) : f.data_type === 'select' ? (
|
) : f.data_type === 'select' ? (
|
||||||
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
|
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">Select…</option>
|
||||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : f.data_type === 'multiselect' ? (
|
) : f.data_type === 'multiselect' ? (
|
||||||
@ -318,6 +319,13 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
|||||||
</div>
|
</div>
|
||||||
) : f.data_type === 'longtext' ? (
|
) : f.data_type === 'longtext' ? (
|
||||||
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
||||||
|
) : f.data_type === 'date' ? (
|
||||||
|
<>
|
||||||
|
<input type="date" value={v} onChange={(e) => set(f.id, e.target.value)} />
|
||||||
|
{/* The native picker shows the browser's locale order; the
|
||||||
|
hint says the date the way an Indian insurer writes it. */}
|
||||||
|
{v ? <small className="af__hint">{dateLong(v)}</small> : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={INPUT_TYPES[f.data_type] ?? 'text'}
|
type={INPUT_TYPES[f.data_type] ?? 'text'}
|
||||||
@ -329,6 +337,10 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fields.some((f) => f.mandatory && f.data_type !== 'id_gen') ? (
|
||||||
|
<p className="af__legend"><em className="af__req">*</em> required</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* The form's own complaint, before anything is sent. */}
|
{/* The form's own complaint, before anything is sent. */}
|
||||||
{missing.length ? (
|
{missing.length ? (
|
||||||
<div className="af__err af__err--soft" role="alert">
|
<div className="af__err af__err--soft" role="alert">
|
||||||
|
|||||||
@ -1,33 +1,33 @@
|
|||||||
.who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
|
.who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
|
||||||
|
|
||||||
.who__disc {
|
.who__disc {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 20px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: var(--fs-2xs);
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: var(--fw-semi);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.who--md .who__disc { width: 26px; height: 26px; font-size: var(--fs-2xs); }
|
.who--md .who__disc { width: 30px; height: 30px; font-size: 11.5px; }
|
||||||
|
|
||||||
.who__name {
|
.who__name {
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 500;
|
font-weight: var(--fw-medium);
|
||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Five agents, five hues, all readable on white at 20px and all distinguishable
|
/* Five agents, five hues, all readable on white and all distinguishable
|
||||||
from the Zurich blue the interface already uses for its own chrome. */
|
from the Zurich blue the interface uses for its own chrome. */
|
||||||
.who__disc--blue { background: #2167ae; }
|
.who__disc--blue { background: #2167ae; }
|
||||||
.who__disc--teal { background: #0f7b78; }
|
.who__disc--teal { background: #0f7b78; }
|
||||||
.who__disc--violet { background: #5b4bb7; }
|
.who__disc--violet { background: #5b4bb7; }
|
||||||
@ -41,13 +41,16 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 8px 2px 2px;
|
padding: 2px 8px 2px 2px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--r-pill, 999px);
|
border-radius: var(--r-pill);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background .12s, border-color .12s;
|
transition: background .12s, border-color .12s, transform .12s;
|
||||||
}
|
}
|
||||||
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
|
.who--btn:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
|
||||||
.who--btn:hover .who__i { opacity: 1; }
|
.who--btn:hover .who__i { opacity: 1; }
|
||||||
.who--btn:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
.who--btn:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
/* A name-less chip is a bare disc; give it a hover lift instead of a border. */
|
||||||
|
.who--btn:not(:has(.who__name)) { padding: 2px; }
|
||||||
|
.who--btn:not(:has(.who__name)):hover { background: transparent; border-color: transparent; transform: translateY(-1px); }
|
||||||
|
|
||||||
.who__i {
|
.who__i {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@ -57,11 +60,9 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity .12s;
|
transition: opacity .12s;
|
||||||
}
|
}
|
||||||
|
.who--btn:not(:has(.who__name)) .who__i { display: none; }
|
||||||
|
|
||||||
/* The AI mark, on the disc rather than beside the name: it has to survive
|
/* The AI mark, on the disc rather than beside the name. */
|
||||||
showName={false}, and it belongs to the worker, not to the label. Sits
|
|
||||||
proud of the disc so it stays legible against every one of the five hues,
|
|
||||||
with a white ring to separate it from the colour underneath. */
|
|
||||||
.who__ai {
|
.who__ai {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -3px;
|
right: -3px;
|
||||||
@ -69,12 +70,11 @@
|
|||||||
width: 11px;
|
width: 11px;
|
||||||
height: 11px;
|
height: 11px;
|
||||||
fill: var(--zk-amber, #b5761f);
|
fill: var(--zk-amber, #b5761f);
|
||||||
stroke: var(--zk-white, #fff);
|
stroke: var(--zk-card, #fff);
|
||||||
stroke-width: 0.9;
|
stroke-width: 0.9;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
paint-order: stroke fill;
|
paint-order: stroke fill;
|
||||||
}
|
}
|
||||||
|
|
||||||
.who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; }
|
.who--md .who__ai { width: 13px; height: 13px; right: -3px; bottom: -3px; }
|
||||||
|
|
||||||
/* Said out loud for a screen reader, which cannot see a sparkle. */
|
/* Said out loud for a screen reader, which cannot see a sparkle. */
|
||||||
@ -90,7 +90,6 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Role icon fills most of the disc, leaving room for the AI spark at the
|
/* Role icon fills most of the disc, leaving room for the AI spark. */
|
||||||
corner. Sized off the disc so it scales with .who--md. */
|
|
||||||
.who__glyph { width: 60%; height: 60%; }
|
.who__glyph { width: 60%; height: 60%; }
|
||||||
.who--md .who__glyph { width: 58%; height: 58%; }
|
.who--md .who__glyph { width: 56%; height: 56%; }
|
||||||
|
|||||||
@ -96,9 +96,8 @@ export default function CallTranscript({ text, name, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="conv__note">
|
<p className="conv__note">
|
||||||
Recorded by the voice agent as the call happened. This is what was said —
|
Recorded by the voice agent as the call happened. The call notes in the trail
|
||||||
the call notes in the trail are Engage's reading of it, which is a
|
are Engage's summary of this.
|
||||||
different thing.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,59 +1,102 @@
|
|||||||
.cot { margin-top: var(--sp-2); }
|
/* The finding, when this component is asked to show it. */
|
||||||
|
|
||||||
/* The finding, always visible — the one sentence that answers "what did it
|
|
||||||
conclude" without opening anything. */
|
|
||||||
.cot__finding {
|
.cot__finding {
|
||||||
margin: 0 0 var(--sp-2);
|
margin: 0 0 var(--sp-2);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
line-height: var(--lh-normal);
|
line-height: var(--lh-normal);
|
||||||
font-weight: var(--fw-medium);
|
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Default toggle look, for anywhere the Timeline's footer row is not
|
||||||
|
restyling it. */
|
||||||
.cot__toggle {
|
.cot__toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: 6px;
|
||||||
padding: var(--sp-1) 0;
|
padding: 4px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: var(--fw-semi);
|
font-weight: var(--fw-medium);
|
||||||
color: var(--zk-blue-dark);
|
color: var(--zk-blue-dark);
|
||||||
}
|
}
|
||||||
.cot__brain { width: 15px; height: 15px; flex: none; }
|
.cot__brain { flex: none; }
|
||||||
.cot__ct { color: var(--zk-grey); font-weight: var(--fw-normal); }
|
.cot__ct {
|
||||||
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); }
|
font-size: 11px;
|
||||||
.cot__caret.is-open { transform: rotate(90deg); }
|
font-weight: var(--fw-semi);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.cot__toggle.is-open .cot__ct { background: var(--zk-blue-light); color: var(--zk-navy); }
|
||||||
|
.cot__caret { transition: transform .18s var(--ease); }
|
||||||
|
.cot__caret.is-open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* The chain itself: a rail of rungs. */
|
||||||
.cot__steps {
|
.cot__steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: var(--sp-3) 0 0;
|
margin: 4px 0 0;
|
||||||
padding: var(--sp-3) 0 var(--sp-2);
|
padding: 12px 16px 10px 18px;
|
||||||
border-left: 2px solid var(--zk-blue-light);
|
border: 1px solid var(--zk-card-line);
|
||||||
padding-left: var(--sp-4);
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--zk-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cot__step { position: relative; display: grid; grid-template-columns: 82px 1fr; gap: var(--sp-3); padding: var(--sp-2) 0; }
|
.cot__step {
|
||||||
.cot__step::before {
|
position: relative;
|
||||||
content: ""; position: absolute; left: calc(var(--sp-4) * -1 - 6px); top: 9px;
|
display: grid;
|
||||||
width: 9px; height: 9px; border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-blue);
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 6px 0 6px 18px;
|
||||||
}
|
}
|
||||||
|
.cot__step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 11px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--zk-card);
|
||||||
|
border: 2px solid var(--zk-blue-light);
|
||||||
|
}
|
||||||
|
.cot__step::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5.5px;
|
||||||
|
top: 22px;
|
||||||
|
bottom: -6px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.cot__step:last-child::after { display: none; }
|
||||||
|
.cot__step--decided::before { background: var(--zk-blue); border-color: var(--zk-blue); }
|
||||||
|
|
||||||
.cot__mark {
|
.cot__mark {
|
||||||
font-size: var(--fs-3xs);
|
font-size: 11px;
|
||||||
font-weight: var(--fw-semi);
|
font-weight: var(--fw-semi);
|
||||||
letter-spacing: .06em;
|
letter-spacing: .04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--zk-blue-dark);
|
color: var(--zk-blue-dark);
|
||||||
padding-top: 1px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; }
|
.cot__b { font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-muted); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||||
.cot__b b { color: var(--zk-ink); font-weight: var(--fw-semi); }
|
.cot__b b { color: var(--zk-ink); font-weight: var(--fw-semi); }
|
||||||
|
|
||||||
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-2); }
|
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
.cot__conf { font-size: 11.5px; font-weight: var(--fw-semi); color: var(--zk-blue-dark); background: var(--zk-tint-blue); border: 1px solid var(--zk-blue-light); padding: 1px 9px; border-radius: var(--r-pill); }
|
.cot__conf {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
padding: 1px 9px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cot__step { grid-template-columns: 1fr; gap: 2px; }
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import Icon from './Icon.jsx'
|
||||||
|
import { findingOf, mdRuns } from '../api/display.js'
|
||||||
import './ChainOfThought.css'
|
import './ChainOfThought.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -6,28 +8,17 @@ import './ChainOfThought.css'
|
|||||||
*
|
*
|
||||||
* This is the trust surface. A person signing off on — or overriding — a
|
* This is the trust surface. A person signing off on — or overriding — a
|
||||||
* machine's decision needs the answer to one question first: WHY did it do
|
* machine's decision needs the answer to one question first: WHY did it do
|
||||||
* that. Before this, the reasoning existed (the employees write it as workflow
|
* that. The chain reads the way the employee's own record reads: what it
|
||||||
* fields, so it is already in the audit trail) but the console showed only the
|
* REASONED and what it DECIDED, assembled from real fields already on the step
|
||||||
* conclusion. An operator either trusted it blind or went digging. Neither is
|
* — never narrated after the fact. A step may carry more than one reasoning
|
||||||
* oversight.
|
* field (attribution AND eligibility, say); each is its own rung.
|
||||||
*
|
|
||||||
* The chain reads the way the employee's own record reads: what it REASONED and
|
|
||||||
* what it DECIDED. It is assembled from real fields already on the step — the
|
|
||||||
* employee's own words for the reasoning, the activity it committed and the
|
|
||||||
* state it moved the lead to for the decision — never narrated after the fact.
|
|
||||||
* A step may carry more than one reasoning field (attribution AND eligibility,
|
|
||||||
* say); each is its own rung.
|
|
||||||
*
|
*
|
||||||
* The finding — the first sentence, since the employees write the conclusion
|
* The finding — the first sentence, since the employees write the conclusion
|
||||||
* first — is lifted out and stays visible, so the "why" can be read without
|
* first — is lifted out so the "why" can be read without opening the chain.
|
||||||
* opening the chain.
|
* The Timeline renders that finding itself and passes `showFinding={false}`,
|
||||||
|
* so the toggle can sit in the entry's footer row beside its neighbours.
|
||||||
*/
|
*/
|
||||||
function findingOf(text) {
|
export default function ChainOfThought({ reasoning, decided, confidence, showFinding = true }) {
|
||||||
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
|
|
||||||
return m ? m[1].trim() : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChainOfThought({ reasoning, decided, confidence }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const primary = reasoning.length ? reasoning[0][1] : ''
|
const primary = reasoning.length ? reasoning[0][1] : ''
|
||||||
@ -36,25 +27,19 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
|
|||||||
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
|
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cot">
|
<>
|
||||||
{finding ? <p className="cot__finding">{finding}</p> : null}
|
{showFinding && finding ? <p className="cot__finding">{finding}</p> : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="cot__toggle"
|
className={'cot__toggle' + (open ? ' is-open' : '')}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="brain" size={14} className="cot__brain" />
|
||||||
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
Chain of thought
|
Chain of thought
|
||||||
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
|
<span className="cot__ct">{steps}</span>
|
||||||
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
|
<Icon name="chevronDown" size={12} className={'cot__caret' + (open ? ' is-open' : '')} />
|
||||||
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
@ -62,11 +47,13 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
|
|||||||
{reasoning.map(([label, text], i) => (
|
{reasoning.map(([label, text], i) => (
|
||||||
<li className="cot__step" key={label}>
|
<li className="cot__step" key={label}>
|
||||||
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
|
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
|
||||||
<div className="cot__b">{text}</div>
|
<div className="cot__b">
|
||||||
|
{mdRuns(text).map((r, j) => (r.b ? <b key={j}>{r.t}</b> : <span key={j}>{r.t}</span>))}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<li className="cot__step">
|
<li className="cot__step cot__step--decided">
|
||||||
<span className="cot__mark">Decided</span>
|
<span className="cot__mark">Decided</span>
|
||||||
<div className="cot__b">
|
<div className="cot__b">
|
||||||
<b>{decided.what}</b>
|
<b>{decided.what}</b>
|
||||||
@ -82,6 +69,6 @@ export default function ChainOfThought({ reasoning, decided, confidence }) {
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,8 +96,8 @@
|
|||||||
max-height: min(76vh, 720px);
|
max-height: min(76vh, 720px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--zk-white);
|
background: var(--zk-card);
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-card);
|
||||||
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
|
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
|
||||||
outline: none;
|
outline: none;
|
||||||
animation: zk-rise 0.18s var(--ease) both;
|
animation: zk-rise 0.18s var(--ease) both;
|
||||||
@ -114,8 +114,9 @@
|
|||||||
|
|
||||||
.prose__head h3 {
|
.prose__head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-md);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,17 +41,19 @@
|
|||||||
|
|
||||||
.bub--them {
|
.bub--them {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
background: var(--zk-line-soft);
|
background: var(--zk-ground);
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
border-bottom-left-radius: 4px;
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bub--us {
|
.bub--us {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
background: var(--zk-tint-blue);
|
background: var(--zk-blue);
|
||||||
color: var(--zk-blue-dark);
|
color: #fff;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
.bub--us span { opacity: .8; }
|
||||||
|
|
||||||
.conv__note {
|
.conv__note {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@ -122,10 +122,8 @@ export default function Conversation({ rows, name, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="conv__note">
|
<p className="conv__note">
|
||||||
Shows the customer's messages and everything written back to them,
|
Everything the customer sent and everything written back. The quote itself
|
||||||
including the acceptance confirmation. One automatic send is not here:
|
goes out as a WhatsApp template and is not shown here.
|
||||||
the quote, which goes out as a registered WhatsApp template rather than
|
|
||||||
text we compose.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
70
src/components/Icon.jsx
Normal file
70
src/components/Icon.jsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* One monoline icon set for the whole console — 16-unit grid, 1.6 stroke,
|
||||||
|
* round caps. Drawn inline so nothing is fetched and the colour is always
|
||||||
|
* `currentColor`. Use the NAME, never the path, so an icon changed here
|
||||||
|
* changes everywhere it appears.
|
||||||
|
*/
|
||||||
|
const PATHS = {
|
||||||
|
home: <><path d="M2.5 8.2 8 3.4l5.5 4.8" /><path d="M4 7.4V13h8V7.4" /></>,
|
||||||
|
list: <><path d="M5.5 4.2h8M5.5 8h8M5.5 11.8h8" /><circle cx="2.8" cy="4.2" r=".9" fill="currentColor" stroke="none" /><circle cx="2.8" cy="8" r=".9" fill="currentColor" stroke="none" /><circle cx="2.8" cy="11.8" r=".9" fill="currentColor" stroke="none" /></>,
|
||||||
|
inbox: <><path d="M2.5 9.5V4.8a1.3 1.3 0 0 1 1.3-1.3h8.4a1.3 1.3 0 0 1 1.3 1.3v4.7" /><path d="M2.5 9.5h3.2l.8 1.6h3l.8-1.6h3.2v2.2a1.3 1.3 0 0 1-1.3 1.3H3.8a1.3 1.3 0 0 1-1.3-1.3z" /></>,
|
||||||
|
chat: <path d="M2.8 3.6h10.4v6.4H7.4l-3 2.4v-2.4H2.8z" strokeLinejoin="round" />,
|
||||||
|
bot: <><rect x="3" y="5" width="10" height="7.5" rx="2" /><path d="M8 2.6V5M6 8.4h.01M10 8.4h.01" /><path d="M6 10.6h4" /></>,
|
||||||
|
clock: <><circle cx="8" cy="8" r="5.6" /><path d="M8 4.8V8l2.2 1.4" /></>,
|
||||||
|
archive: <><rect x="2.5" y="3" width="11" height="3" rx=".8" /><path d="M3.5 6v6.2a.8.8 0 0 0 .8.8h7.4a.8.8 0 0 0 .8-.8V6M6.5 8.8h3" /></>,
|
||||||
|
search: <><circle cx="7" cy="7" r="4.3" /><path d="M10.3 10.3 13.5 13.5" /></>,
|
||||||
|
plus: <path d="M8 3.2v9.6M3.2 8h9.6" />,
|
||||||
|
chevronRight: <path d="M6 3.5 10.5 8 6 12.5" />,
|
||||||
|
chevronLeft: <path d="M10 3.5 5.5 8 10 12.5" />,
|
||||||
|
chevronDown: <path d="M3.5 6 8 10.5 12.5 6" />,
|
||||||
|
arrowRight: <path d="M3 8h9.5M8.8 4.3 12.5 8l-3.7 3.7" />,
|
||||||
|
lock: <><rect x="3.5" y="7" width="9" height="6.5" rx="1.3" /><path d="M5.5 7V5.3a2.5 2.5 0 0 1 5 0V7" /></>,
|
||||||
|
eye: <><path d="M1.8 8s2.3-4 6.2-4 6.2 4 6.2 4-2.3 4-6.2 4S1.8 8 1.8 8z" /><circle cx="8" cy="8" r="1.8" /></>,
|
||||||
|
file: <><path d="M4 1.8h5.2L12.5 5v9.2H4z" strokeLinejoin="round" /><path d="M9 1.9v3.3h3.4" strokeLinejoin="round" /></>,
|
||||||
|
phone: <path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z" strokeLinejoin="round" />,
|
||||||
|
whatsapp: <><path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z" strokeLinejoin="round" /><path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z" fill="currentColor" stroke="none" /></>,
|
||||||
|
shield: <path d="M8 1.8 13 3.8v3.6c0 3.1-2.1 5.2-5 6.8-2.9-1.6-5-3.7-5-6.8V3.8z" strokeLinejoin="round" />,
|
||||||
|
alert: <><path d="M8 2.2 14 12.8H2z" strokeLinejoin="round" /><path d="M8 6.4v3M8 11.2h.01" /></>,
|
||||||
|
refresh: <><path d="M13 8a5 5 0 0 1-8.7 3.4M3 8a5 5 0 0 1 8.7-3.4" /><path d="M11.8 1.8v3h-3M4.2 14.2v-3h3" /></>,
|
||||||
|
signOut: <><path d="M6.5 2.8H3.6a1 1 0 0 0-1 1v8.4a1 1 0 0 0 1 1h2.9" /><path d="M10 5.2 12.8 8 10 10.8M6.3 8h6.4" /></>,
|
||||||
|
check: <path d="M3 8.4l3.2 3.2L13 4.8" />,
|
||||||
|
x: <path d="M4 4l8 8M12 4l-8 8" />,
|
||||||
|
user: <><circle cx="8" cy="5.5" r="2.7" /><path d="M2.8 13.4a5.2 5.2 0 0 1 10.4 0" /></>,
|
||||||
|
users: <><circle cx="6" cy="5.6" r="2.4" /><path d="M1.8 13a4.2 4.2 0 0 1 8.4 0" /><path d="M10.4 3.6a2.4 2.4 0 0 1 0 4M12 9.2a4.2 4.2 0 0 1 2.2 3.8" /></>,
|
||||||
|
sparkle: <path d="M8 1.6 9.6 6.4l4.8 1.6-4.8 1.6L8 14.4 6.4 9.6 1.6 8l4.8-1.6z" strokeLinejoin="round" />,
|
||||||
|
money: <><rect x="1.8" y="4" width="12.4" height="8" rx="1.3" /><circle cx="8" cy="8" r="1.9" /><path d="M4.2 6.4h.01M11.8 9.6h.01" /></>,
|
||||||
|
trend: <path d="M2 11.5 6 7.5l2.6 2.6L14 4.6M10.5 4.6H14v3.5" />,
|
||||||
|
filter: <path d="M2.5 3.5h11L9.4 8.6v4.2l-2.8-1.4V8.6z" strokeLinejoin="round" />,
|
||||||
|
sortAsc: <path d="M4.5 12.5v-9M2.2 5.8l2.3-2.3 2.3 2.3M9 4h4.5M9 8h3.5M9 12h2.5" />,
|
||||||
|
sortDesc: <path d="M4.5 3.5v9M2.2 10.2l2.3 2.3 2.3-2.3M9 4h2.5M9 8h3.5M9 12h4.5" />,
|
||||||
|
brain: <path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10" />,
|
||||||
|
calendar: <><rect x="2.5" y="3.5" width="11" height="10" rx="1.3" /><path d="M2.5 7h11M5.5 2v3M10.5 2v3" /></>,
|
||||||
|
car: <><path d="M3 9.5 4.2 5.8A1.2 1.2 0 0 1 5.3 5h5.4a1.2 1.2 0 0 1 1.1.8L13 9.5v3H3z" strokeLinejoin="round" /><circle cx="5.3" cy="10.8" r=".9" fill="currentColor" stroke="none" /><circle cx="10.7" cy="10.8" r=".9" fill="currentColor" stroke="none" /></>,
|
||||||
|
building: <><rect x="3" y="2.5" width="10" height="11" rx="1" /><path d="M6 5.5h1M9 5.5h1M6 8h1M9 8h1M7 13.5v-3h2v3" /></>,
|
||||||
|
mail: <><rect x="2" y="3.5" width="12" height="9" rx="1.3" /><path d="m2.5 4.5 5.5 4.3 5.5-4.3" /></>,
|
||||||
|
id: <><rect x="2" y="3.4" width="12" height="9.2" rx="1.4" /><circle cx="5.8" cy="7" r="1.5" /><path d="M9 6.4h3M9 8.6h3M4 10.4h5" /></>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Icon({ name, size = 16, className = '', strokeWidth = 1.6, ...rest }) {
|
||||||
|
const body = PATHS[name]
|
||||||
|
if (!body) return null
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={'ico' + (className ? ' ' + className : '')}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,16 +1,6 @@
|
|||||||
/* The journey, across the top. Eight steps have to fit without wrapping on a
|
/* The journey, across the width of the metrics card. Eight steps have to fit
|
||||||
laptop, so the labels are short and the dots carry the state. */
|
on a laptop, so the labels are short and the dots carry the state. */
|
||||||
.rail {
|
.rail { width: 100%; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-6);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: var(--sp-6) 0;
|
|
||||||
padding: var(--sp-2) 0 var(--sp-5);
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid var(--zk-line);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail ol {
|
.rail ol {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -18,8 +8,6 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail__s {
|
.rail__s {
|
||||||
@ -27,18 +15,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-3);
|
gap: 8px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail__dot {
|
.rail__dot {
|
||||||
width: 14px;
|
width: 12px;
|
||||||
height: 14px;
|
height: 12px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
flex: none;
|
flex: none;
|
||||||
background: var(--zk-white);
|
background: var(--zk-card);
|
||||||
border: 2px solid var(--zk-line);
|
border: 2px solid var(--zk-line);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
@ -57,7 +45,7 @@
|
|||||||
/* The connector sits behind the dots, drawn from each step to the next. */
|
/* The connector sits behind the dots, drawn from each step to the next. */
|
||||||
.rail__bar {
|
.rail__bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 5px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
@ -71,33 +59,62 @@
|
|||||||
.is-here .rail__dot {
|
.is-here .rail__dot {
|
||||||
background: var(--zk-blue);
|
background: var(--zk-blue);
|
||||||
border-color: var(--zk-blue);
|
border-color: var(--zk-blue);
|
||||||
box-shadow: 0 0 0 5px rgba(33, 103, 174, 0.15);
|
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.15);
|
||||||
}
|
|
||||||
.is-here .rail__l {
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
font-weight: var(--fw-semi);
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
}
|
}
|
||||||
|
.is-here .rail__l { color: var(--zk-blue-dark); font-weight: var(--fw-semi); }
|
||||||
|
|
||||||
/* Off the path — Parked, Referred, Lost, Declined. Named rather than drawn
|
/* Beyond a branch the path is unknowable, so it is drawn faint. */
|
||||||
into the row: these are departures, not points every lead passes. */
|
.is-faint .rail__dot { border-color: var(--zk-line-soft); }
|
||||||
.rail__off {
|
.is-faint .rail__l { color: var(--zk-line); }
|
||||||
flex: none;
|
|
||||||
font-size: var(--fs-xs);
|
/* The branch: a node hanging beneath the stage the lead left the line at. */
|
||||||
font-weight: var(--fw-medium);
|
.rail__branch {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: 6px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.rail__branch::before {
|
||||||
|
content: '';
|
||||||
|
width: 2px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--zk-amber-line);
|
||||||
|
}
|
||||||
|
.rail__bdot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background: var(--zk-amber);
|
||||||
|
box-shadow: 0 0 0 4px var(--zk-amber-tint);
|
||||||
|
}
|
||||||
|
.rail__bl {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
color: var(--zk-amber-ink);
|
color: var(--zk-amber-ink);
|
||||||
background: var(--zk-amber-tint);
|
white-space: nowrap;
|
||||||
border: 1px solid var(--zk-amber-line);
|
padding: 2px 10px;
|
||||||
padding: var(--sp-2) var(--sp-4);
|
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-amber-tint);
|
||||||
}
|
}
|
||||||
.rail__off.is-closed {
|
.rail__bl small { font-size: 10.5px; font-weight: var(--fw-medium); opacity: .8; }
|
||||||
color: var(--zk-muted);
|
.rail__branch.is-closed::before { background: var(--zk-line); }
|
||||||
background: var(--zk-tint);
|
.rail__branch.is-closed .rail__bdot { background: var(--zk-grey); box-shadow: 0 0 0 4px var(--zk-line-soft); }
|
||||||
border-color: var(--zk-line);
|
.rail__branch.is-closed .rail__bl { color: var(--zk-muted); background: var(--zk-line-soft); }
|
||||||
}
|
|
||||||
|
/* Room for the branch beneath the rail when one is drawn. */
|
||||||
|
.rail:has(.rail__branch) { padding-bottom: 52px; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.rail__l { display: none; }
|
.rail__l { display: none; }
|
||||||
.rail { padding: 14px 18px; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,28 +4,23 @@ import './StageRail.css'
|
|||||||
/**
|
/**
|
||||||
* Where this lead has been, where it is, and what is left.
|
* Where this lead has been, where it is, and what is left.
|
||||||
*
|
*
|
||||||
* The page said the stage in one chip in the corner and nothing about the
|
|
||||||
* journey, so "how far along is this?" — the first question anyone asks about
|
|
||||||
* a lead — could only be answered by knowing the workflow by heart.
|
|
||||||
*
|
|
||||||
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
|
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
|
||||||
* (documents uploaded from Qualified carried one straight past Contacted) and
|
* and can go backwards, so colouring everything left of the current stage
|
||||||
* can go backwards (a callback returns it to Awaiting Contact). Colouring
|
* would claim steps that never happened. The trail knows which states this
|
||||||
* everything left of the current stage would have claimed steps that never
|
* lead actually entered; this reads that.
|
||||||
* happened. The trail knows which states this lead actually entered; this
|
|
||||||
* reads that.
|
|
||||||
*
|
*
|
||||||
* The side states — Parked, Referred, Lost, Declined — are deliberately NOT on
|
* The side states — Parked, Referred, Lost, Declined — are not points every
|
||||||
* the rail. They are departures from the path rather than points along it, and
|
* lead passes through, so they are not on the main rail. When the lead is in
|
||||||
* putting them in a row would suggest every lead passes through. When the lead
|
* one, it is drawn as a BRANCH: a node hanging off the last stage the lead
|
||||||
* is in one, the rail says so at the end instead.
|
* reached on the path, so the picture says "it left the line here", rather
|
||||||
|
* than a pill floating at the end of the row.
|
||||||
*/
|
*/
|
||||||
const PATH = [
|
const PATH = [
|
||||||
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
||||||
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
|
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Short enough to fit eight across; the queue names are written for a sidebar. */
|
/** Short enough to fit eight across. */
|
||||||
const SHORT = {
|
const SHORT = {
|
||||||
'zk-state-new': 'Filed',
|
'zk-state-new': 'Filed',
|
||||||
'zk-state-qualified': 'Called',
|
'zk-state-qualified': 'Called',
|
||||||
@ -35,47 +30,60 @@ const SHORT = {
|
|||||||
'zk-state-payment': 'Payment',
|
'zk-state-payment': 'Payment',
|
||||||
'zk-state-issued': 'Issued',
|
'zk-state-issued': 'Issued',
|
||||||
'zk-state-onboarded': 'Onboarded',
|
'zk-state-onboarded': 'Onboarded',
|
||||||
|
'zk-state-referred': 'Referred',
|
||||||
|
'zk-state-parked': 'Parked',
|
||||||
|
'zk-state-lost': 'Lost',
|
||||||
|
'zk-state-declined': 'Declined',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StageRail({ audit, currentName }) {
|
export default function StageRail({ audit, currentName }) {
|
||||||
const current = STAGES.find((s) => s.name === currentName)
|
const current = STAGES.find((s) => s.name === currentName)
|
||||||
const visited = new Set(
|
const visitedList = (audit || [])
|
||||||
(audit || [])
|
|
||||||
.map((r) => r.execution_state)
|
.map((r) => r.execution_state)
|
||||||
.filter((v) => PATH.includes(v)),
|
.filter((v) => PATH.includes(v))
|
||||||
)
|
const visited = new Set(visitedList)
|
||||||
if (current) visited.add(current.uid)
|
if (current) visited.add(current.uid)
|
||||||
|
|
||||||
const currentIndex = PATH.indexOf(current?.uid)
|
const currentIndex = PATH.indexOf(current?.uid)
|
||||||
// A lead sitting somewhere off the path — Parked, Referred, Lost, Declined.
|
// A lead sitting somewhere off the path — Parked, Referred, Lost, Declined.
|
||||||
const aside = current && currentIndex === -1 ? current : null
|
const aside = current && currentIndex === -1 ? current : null
|
||||||
|
|
||||||
|
// Where the branch hangs: the furthest point along the path the lead
|
||||||
|
// reached before it left.
|
||||||
|
const reached = Math.max(-1, ...PATH.map((uid, i) => (visited.has(uid) ? i : -1)))
|
||||||
|
const branchAfter = aside ? Math.max(0, reached) : -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rail" aria-label="Progress">
|
<div className="rail" aria-label="Progress">
|
||||||
<ol>
|
<ol>
|
||||||
{PATH.map((uid, i) => {
|
{PATH.map((uid, i) => {
|
||||||
const been = visited.has(uid)
|
const been = visited.has(uid)
|
||||||
const here = uid === current?.uid
|
const here = uid === current?.uid
|
||||||
// Ahead of a lead that has left the path is unknowable — a referred
|
// Ahead of a lead that has left the path is unknowable, so nothing
|
||||||
// risk may never see payment — so nothing after it is called "to do".
|
// after the branch is called "to do" with any confidence — it is
|
||||||
const state = here ? 'here' : been ? 'done' : 'todo'
|
// drawn faint.
|
||||||
|
const state = here ? 'here' : been ? 'done' : aside && i > branchAfter ? 'faint' : 'todo'
|
||||||
|
const nextDone = visited.has(PATH[i + 1])
|
||||||
return (
|
return (
|
||||||
<li key={uid} className={'rail__s is-' + state}>
|
<li key={uid} className={'rail__s is-' + state + (i === branchAfter && aside ? ' has-branch' : '')}>
|
||||||
<span className="rail__dot" aria-hidden="true" />
|
<span className="rail__dot" aria-hidden="true" />
|
||||||
<span className="rail__l">{SHORT[uid]}</span>
|
<span className="rail__l">{SHORT[uid]}</span>
|
||||||
{i < PATH.length - 1 ? (
|
{i < PATH.length - 1 ? (
|
||||||
<span className={'rail__bar' + (visited.has(PATH[i + 1]) ? ' is-done' : '')} aria-hidden="true" />
|
<span className={'rail__bar' + (nextDone ? ' is-done' : '')} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
{i === branchAfter && aside ? (
|
||||||
|
<span className={'rail__branch' + (aside.kind === 'end' ? ' is-closed' : '')}>
|
||||||
|
<span className="rail__bdot" aria-hidden="true" />
|
||||||
|
<span className="rail__bl">
|
||||||
|
<small>{aside.kind === 'end' ? 'Ended' : 'Now'}</small>
|
||||||
|
{SHORT[aside.uid] || aside.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{aside ? (
|
|
||||||
<div className={'rail__off' + (aside.kind === 'end' ? ' is-closed' : '')}>
|
|
||||||
{aside.kind === 'end' ? 'Ended in' : 'Now in'} <strong>{aside.name}</strong>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,351 +1,259 @@
|
|||||||
|
/* The trail. The worker's face is the node on the rail; the entry is a title,
|
||||||
|
a finding, and one footer row of controls. */
|
||||||
.tl {
|
.tl {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 4px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl__loading,
|
.tl__loading,
|
||||||
.tl__err {
|
.tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
|
||||||
color: var(--zk-muted);
|
.tl__err { color: var(--zk-danger-ink); }
|
||||||
font-size: var(--fs-xs);
|
|
||||||
padding: 14px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__err {
|
|
||||||
color: var(--zk-danger-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__item {
|
.tl__item {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 0 var(--sp-6) var(--sp-7);
|
display: grid;
|
||||||
border-left: 2px solid var(--zk-line-soft);
|
grid-template-columns: 36px minmax(0, 1fr);
|
||||||
|
gap: 0 14px;
|
||||||
|
padding-bottom: var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl__item:last-child {
|
/* The connecting line, drawn from node to node. */
|
||||||
border-left-color: transparent;
|
.tl__item::before {
|
||||||
padding-bottom: 4px;
|
content: '';
|
||||||
}
|
|
||||||
|
|
||||||
.tl__dot {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -8px;
|
left: 17px;
|
||||||
top: 3px;
|
top: 40px;
|
||||||
width: 14px;
|
bottom: 0;
|
||||||
height: 14px;
|
width: 2px;
|
||||||
border-radius: 50%;
|
background: var(--zk-line-soft);
|
||||||
background: var(--zk-white);
|
}
|
||||||
border: 2px solid var(--zk-grey);
|
.tl__item:last-child::before { display: none; }
|
||||||
box-shadow: 0 0 0 4px var(--zk-white);
|
.tl__item:last-child { padding-bottom: 4px; }
|
||||||
|
|
||||||
|
.tl__node {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
/* The agent disc at md is 26px; centre it and ring it in white so the line
|
||||||
|
passes cleanly behind. */
|
||||||
|
.tl__node .who--md .who__disc { width: 32px; height: 32px; box-shadow: 0 0 0 3px var(--zk-card); }
|
||||||
|
.tl__node .who--btn { padding: 0; border: 0; }
|
||||||
|
.tl__node .who__i { display: none; }
|
||||||
|
.tl__node .avatar { width: 32px; height: 32px; font-size: 12px; box-shadow: 0 0 0 3px var(--zk-card); }
|
||||||
|
.tl__sysdot { width: 12px; height: 12px; border-radius: 50%; background: var(--zk-line); box-shadow: 0 0 0 3px var(--zk-card); }
|
||||||
|
.tl__wanode {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
color: #fff; background: var(--zk-wa);
|
||||||
|
box-shadow: 0 0 0 3px var(--zk-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Machine work is blue, people are amber, the platform itself is grey — so a
|
/* The newest entry is marked — it is what the page is refreshed for. */
|
||||||
file can be read at a glance for how much of it was done by hand. */
|
.tl--newest .tl__item.is-latest .tl__body { background: var(--zk-ground); }
|
||||||
.tl__item--ai .tl__dot {
|
|
||||||
border-color: var(--zk-blue);
|
|
||||||
background: var(--zk-blue);
|
|
||||||
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(33, 103, 174, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__item--human .tl__dot {
|
|
||||||
border-color: var(--zk-amber);
|
|
||||||
background: var(--zk-white);
|
|
||||||
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(181, 118, 31, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__item--sys .tl__dot {
|
|
||||||
border-color: var(--zk-line);
|
|
||||||
background: var(--zk-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__body {
|
.tl__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-2);
|
gap: 6px;
|
||||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
min-width: 0;
|
||||||
margin-left: calc(var(--sp-3) * -1);
|
padding: 6px 12px 10px;
|
||||||
|
margin: 0 0 0 -4px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
transition: background var(--t-fast);
|
transition: background var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.tl__item:hover .tl__body { background: var(--zk-ground); }
|
||||||
.tl__item:hover .tl__body {
|
|
||||||
background: var(--zk-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__head {
|
.tl__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: var(--sp-1) var(--sp-2);
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.tl__title { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; }
|
||||||
/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps
|
.tl__what { font-size: var(--fs-sm); font-weight: var(--fw-semi); letter-spacing: -0.008em; color: var(--zk-ink); }
|
||||||
into a ragged block that is harder to scan than two clean rows. */
|
.tl__item--sys .tl__what { color: var(--zk-muted); font-weight: var(--fw-medium); }
|
||||||
.tl__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--sp-1) var(--sp-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__what {
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: var(--fw-semi);
|
|
||||||
letter-spacing: -0.008em;
|
|
||||||
color: var(--zk-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__who {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
padding: 3px 9px;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__who--ai {
|
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__who--human {
|
|
||||||
background: var(--zk-amber-tint);
|
|
||||||
color: var(--zk-amber-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__who--sys {
|
|
||||||
background: var(--zk-tint);
|
|
||||||
color: var(--zk-muted);
|
|
||||||
border: 1px solid var(--zk-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__stage {
|
.tl__stage {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
color: var(--zk-blue);
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
padding: 1px 8px 1px 6px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
}
|
}
|
||||||
|
.tl__when { flex: none; font-size: var(--fs-2xs); color: var(--zk-grey); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.tl__when em { font-style: normal; }
|
||||||
|
|
||||||
.tl__when {
|
.tl__meta { display: flex; align-items: center; gap: 8px; margin-top: -2px; }
|
||||||
font-size: var(--fs-2xs);
|
.tl__who { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-2xs); color: var(--zk-muted); }
|
||||||
color: var(--zk-grey);
|
.tl__who--cust { color: var(--zk-wa); font-weight: var(--fw-medium); }
|
||||||
}
|
.tl__aitag {
|
||||||
|
font-size: 9.5px;
|
||||||
.tl__say {
|
|
||||||
margin-top: var(--sp-2);
|
|
||||||
padding: var(--sp-3) var(--sp-4);
|
|
||||||
border-left: 2px solid var(--zk-blue-light);
|
|
||||||
border-radius: 0 var(--r-md) var(--r-md) 0;
|
|
||||||
background: var(--zk-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__item:hover .tl__say {
|
|
||||||
background: var(--zk-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__saylabel {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--fs-3xs);
|
|
||||||
font-weight: var(--fw-semi);
|
font-weight: var(--fw-semi);
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: .06em;
|
||||||
text-transform: uppercase;
|
padding: 1px 5px;
|
||||||
color: var(--zk-grey);
|
border-radius: 4px;
|
||||||
margin-bottom: var(--sp-2);
|
color: var(--zk-amber-ink);
|
||||||
|
background: var(--zk-amber-tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl__say p {
|
/* The finding — body copy, not a quote box. */
|
||||||
margin: 0;
|
.tl__finding {
|
||||||
|
margin: 2px 0 0;
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
line-height: var(--lh-normal);
|
line-height: var(--lh-normal);
|
||||||
color: var(--zk-muted);
|
color: var(--zk-ink);
|
||||||
max-width: 76ch;
|
max-width: 78ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl__figs {
|
/* A quoted line: a person's reason, or what was said to/by the customer. */
|
||||||
display: flex;
|
.tl__say {
|
||||||
flex-wrap: wrap;
|
margin-top: 2px;
|
||||||
gap: 8px;
|
padding: 10px 14px;
|
||||||
margin-top: 9px;
|
border-left: 3px solid var(--zk-blue-light);
|
||||||
|
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
background: var(--zk-card);
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
border-right: 1px solid var(--zk-line-soft);
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
}
|
}
|
||||||
|
.tl__say--chat { border-left-color: var(--zk-wa); }
|
||||||
|
.tl__saylabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.tl__say p { margin: 0; font-size: var(--fs-xs); line-height: var(--lh-normal); color: var(--zk-ink); max-width: 76ch; }
|
||||||
|
|
||||||
|
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
|
||||||
.tl__figs span {
|
.tl__figs span {
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
padding: 4px 11px;
|
padding: 3px 10px;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
background: var(--zk-white);
|
background: var(--zk-card);
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-card-line);
|
||||||
}
|
}
|
||||||
|
.tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; }
|
||||||
|
|
||||||
.tl__figs em {
|
/* Documents received */
|
||||||
font-style: normal;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
color: var(--zk-grey);
|
|
||||||
text-transform: capitalize;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- system noise ---- */
|
|
||||||
.tl__toggle {
|
|
||||||
font: inherit;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
border: 1px solid var(--zk-line);
|
|
||||||
background: var(--zk-white);
|
|
||||||
color: var(--zk-muted);
|
|
||||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__toggle:hover {
|
|
||||||
border-color: var(--zk-blue-light);
|
|
||||||
background: var(--zk-tint);
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── What was attached ────────────────────────────────────────────────────
|
|
||||||
The audit trail's job at this step is to prove receipt. A line saying
|
|
||||||
"Collect Documents" and nothing else left the operator to open the record
|
|
||||||
and count for themselves — and until the views were fixed, the record could
|
|
||||||
not see the files either. */
|
|
||||||
.tl__docs {
|
.tl__docs {
|
||||||
margin-top: 8px;
|
margin-top: 2px;
|
||||||
padding: 9px 11px;
|
padding: 9px 12px;
|
||||||
border: 1px solid var(--zk-line-soft);
|
border: 1px solid var(--zk-card-line);
|
||||||
border-radius: var(--r-sm, 6px);
|
border-radius: var(--r-sm);
|
||||||
background: var(--zk-tint);
|
background: var(--zk-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl__docshead {
|
.tl__docshead {
|
||||||
display: block;
|
display: flex;
|
||||||
font-size: var(--fs-3xs);
|
align-items: center;
|
||||||
font-weight: 600;
|
gap: 6px;
|
||||||
letter-spacing: 0.02em;
|
font-size: var(--fs-2xs);
|
||||||
text-transform: uppercase;
|
font-weight: var(--fw-medium);
|
||||||
color: var(--zk-muted);
|
color: var(--zk-ink);
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
.tl__docshead svg { color: var(--zk-blue); }
|
||||||
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
|
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|
||||||
.tl__doc {
|
.tl__doc {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 4px 10px 4px 8px;
|
padding: 3px 10px;
|
||||||
border-radius: var(--r-pill, 999px);
|
border-radius: var(--r-pill);
|
||||||
background: var(--zk-white);
|
background: var(--zk-tint-blue);
|
||||||
border: 1px solid var(--zk-line);
|
|
||||||
color: var(--zk-blue-dark);
|
color: var(--zk-blue-dark);
|
||||||
transition: border-color .12s, background .12s;
|
transition: background .12s;
|
||||||
}
|
}
|
||||||
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; }
|
.tl__doc:hover { background: var(--zk-blue-light); color: var(--zk-navy); }
|
||||||
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
|
||||||
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
/* ── the conversation, as one entry ───────────────────────────────────────
|
/* ONE FOOTER ROW. Chain of thought, fields written, the call, the thread —
|
||||||
Every WhatsApp turn used to be its own entry, so a four-message exchange
|
ghost buttons side by side, and whichever is opened unfolds beneath the
|
||||||
took more of the trail than the whole underwriting chain. */
|
whole row. `details` is display:contents so its summary sits in the row
|
||||||
.tl__wa {
|
and its list drops below it. */
|
||||||
|
.tl__foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.tl__foot .cot__toggle,
|
||||||
|
.tl__wrote > summary,
|
||||||
|
.tl__open {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: #0f7b45;
|
|
||||||
}
|
|
||||||
.tl__wa svg { width: 15px; height: 15px; }
|
|
||||||
|
|
||||||
.tl__n {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--zk-muted);
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: var(--r-pill, 999px);
|
|
||||||
background: var(--zk-line-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The channel performs these as the system, but the person who typed is the
|
|
||||||
actor worth naming. "System · Customer Reply" said the opposite. */
|
|
||||||
.tl__who--cust { color: #0f7b45; font-weight: 500; }
|
|
||||||
|
|
||||||
.tl__open {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 500;
|
font-weight: var(--fw-medium);
|
||||||
cursor: pointer;
|
color: var(--zk-muted);
|
||||||
padding: 0;
|
padding: 4px 10px;
|
||||||
border: 0;
|
border-radius: var(--r-sm);
|
||||||
background: none;
|
border: 1px solid var(--zk-card-line);
|
||||||
color: var(--zk-blue);
|
background: var(--zk-card);
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.tl__open:hover { color: var(--zk-blue-dark); text-decoration: underline; }
|
|
||||||
.tl__open:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
|
|
||||||
|
|
||||||
/* A chat entry's dot takes the channel's colour, so a scan down the rail shows
|
|
||||||
where the conversation happened without reading a word. */
|
|
||||||
.tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; }
|
|
||||||
|
|
||||||
/* ── what this step wrote ────────────────────────────────────────────────
|
|
||||||
Folded by default: most steps write a dozen fields and the trail has to
|
|
||||||
stay readable as a story. Opened, the entry becomes an itemised account of
|
|
||||||
what the agent decided — which is the closest an app JWT can get to its
|
|
||||||
reasoning, since ai_reasoning is on the row but not in the API. */
|
|
||||||
.tl__wrote { margin-top: 8px; }
|
|
||||||
|
|
||||||
.tl__wrote > summary {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: inline-flex;
|
white-space: nowrap;
|
||||||
align-items: center;
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
gap: 6px;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--zk-blue);
|
|
||||||
}
|
}
|
||||||
|
.tl__foot .cot__toggle:hover,
|
||||||
|
.tl__wrote > summary:hover,
|
||||||
|
.tl__open:hover { border-color: var(--zk-blue-light); color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
|
||||||
|
.tl__foot .cot__toggle.is-open,
|
||||||
|
.tl__wrote[open] > summary { border-color: var(--zk-blue-light); color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
|
||||||
|
.tl__foot .cot__toggle { order: 1; }
|
||||||
|
.tl__wrote { display: contents; }
|
||||||
|
.tl__wrote > summary { order: 2; }
|
||||||
.tl__wrote > summary::-webkit-details-marker { display: none; }
|
.tl__wrote > summary::-webkit-details-marker { display: none; }
|
||||||
.tl__wrote > summary::after {
|
.tl__open { order: 3; }
|
||||||
content: '';
|
.tl__foot .cot__steps { order: 10; flex-basis: 100%; }
|
||||||
width: 5px; height: 5px;
|
.tl__wrote > dl { order: 11; flex-basis: 100%; }
|
||||||
border-right: 1.4px solid currentColor;
|
.tl__wcaret { transition: transform var(--t-fast); }
|
||||||
border-bottom: 1.4px solid currentColor;
|
.tl__wrote[open] .tl__wcaret { transform: rotate(180deg); }
|
||||||
transform: rotate(45deg) translate(-1px, -1px);
|
|
||||||
transition: transform var(--t-fast);
|
.tl__wrote > dl {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--zk-card);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 4px 24px;
|
||||||
}
|
}
|
||||||
.tl__wrote[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
|
.tl__wrote dl > div { display: grid; grid-template-columns: minmax(0, 45%) minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 3px 0; }
|
||||||
.tl__wrote > summary:hover { color: var(--zk-blue-dark); }
|
.tl__wrote dt { font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
|
||||||
|
.tl__wrote dd { margin: 0; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.tl__wrote dl {
|
/* Platform noise, folded — offered at the foot rather than the head. */
|
||||||
margin: 8px 0 0;
|
.tl__toggle {
|
||||||
padding: 10px 12px;
|
font: inherit;
|
||||||
border: 1px solid var(--zk-line-soft);
|
|
||||||
border-radius: var(--r-sm, 6px);
|
|
||||||
background: var(--zk-tint);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
|
|
||||||
|
|
||||||
.tl__wrote dt {
|
|
||||||
flex: none;
|
|
||||||
width: 42%;
|
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
color: var(--zk-grey);
|
font-weight: var(--fw-medium);
|
||||||
overflow-wrap: anywhere;
|
cursor: pointer;
|
||||||
|
margin: 8px 0 0 50px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
border: 1px dashed var(--zk-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.tl__toggle:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
|
||||||
|
|
||||||
.tl__wrote dd {
|
@media (max-width: 720px) {
|
||||||
margin: 0;
|
.tl__head { flex-direction: column; align-items: flex-start; gap: 2px; }
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
color: var(--zk-ink);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,31 +3,18 @@ import { useZino } from '../api/provider.jsx'
|
|||||||
import AgentChip from './AgentChip.jsx'
|
import AgentChip from './AgentChip.jsx'
|
||||||
import ClampText from './ClampText.jsx'
|
import ClampText from './ClampText.jsx'
|
||||||
import ChainOfThought from './ChainOfThought.jsx'
|
import ChainOfThought from './ChainOfThought.jsx'
|
||||||
|
import Icon from './Icon.jsx'
|
||||||
import { AGENTS } from '../api/agents.js'
|
import { AGENTS } from '../api/agents.js'
|
||||||
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
|
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
|
||||||
|
import { findingOf, humanize, initials, leadIn, mdRuns, toneFor } from '../api/display.js'
|
||||||
import './Timeline.css'
|
import './Timeline.css'
|
||||||
|
|
||||||
/* The roster lives in api/agents.js now — one short name, one colour and one
|
/* The roster lives in api/agents.js — one short name, one colour and one set
|
||||||
set of initials per employee, so the same worker looks the same everywhere
|
of initials per employee, so the same worker looks the same everywhere. */
|
||||||
it appears. This file only needs to know which roles are agents. */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fields worth surfacing per activity — an agent's reasoning, a rule's
|
|
||||||
* output, a call's notes. Everything else stays in the file below; a timeline
|
|
||||||
* that shows every field is a table, not a story.
|
|
||||||
*
|
|
||||||
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
|
|
||||||
* field ids, which carry a per-form suffix — the call notes arrive as
|
|
||||||
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
|
|
||||||
* global ids directly, as this did, meant almost nothing ever matched: the
|
|
||||||
* timeline showed a bare list of activity names, and the one narrative line
|
|
||||||
* that did appear was an accident (a DATA_UPDATE row happens to write the
|
|
||||||
* unsuffixed key).
|
|
||||||
*/
|
|
||||||
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
|
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
|
||||||
"Reasoned" rung of the chain of thought — the employee's own words for why
|
"Reasoned" rung of the chain of thought. The first present one is the
|
||||||
it did what it did — rather than loose quote blocks. The first present one
|
finding shown without opening the chain. */
|
||||||
is the finding shown without opening the chain. */
|
|
||||||
const REASONING = [
|
const REASONING = [
|
||||||
['attribution_reason', 'Attribution'],
|
['attribution_reason', 'Attribution'],
|
||||||
['eligibility_reason', 'Eligibility'],
|
['eligibility_reason', 'Eligibility'],
|
||||||
@ -43,7 +30,7 @@ const REASONING = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/* CONVERSATION lines stay as plain quotes — they are what was said to and by
|
/* CONVERSATION lines stay as plain quotes — they are what was said to and by
|
||||||
the customer, not the machine's reasoning, and belong beside the thread. */
|
the customer, not the machine's reasoning. */
|
||||||
const CONVERSATION = [
|
const CONVERSATION = [
|
||||||
['customer_reply', 'The customer said'],
|
['customer_reply', 'The customer said'],
|
||||||
['customer_answer', 'We replied'],
|
['customer_answer', 'We replied'],
|
||||||
@ -54,15 +41,10 @@ const NARRATIVE = [...REASONING, ...CONVERSATION]
|
|||||||
|
|
||||||
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
||||||
|
|
||||||
/**
|
/** Fields already shown elsewhere in the entry. */
|
||||||
* Fields already shown elsewhere in the entry, so the "what it wrote" list
|
|
||||||
* does not repeat them: the narrative prose above it, the money figures below
|
|
||||||
* it, the document chips, and the platform's own bookkeeping.
|
|
||||||
*/
|
|
||||||
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system'])
|
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system'])
|
||||||
|
|
||||||
/** A value as one short line. Long prose is already in the narrative block, so
|
/** A value as one short line. */
|
||||||
* anything here is a field value, not a paragraph. */
|
|
||||||
function short(f) {
|
function short(f) {
|
||||||
const v = f.value
|
const v = f.value
|
||||||
if (v === null || v === undefined || v === '') return null
|
if (v === null || v === undefined || v === '') return null
|
||||||
@ -70,20 +52,15 @@ function short(f) {
|
|||||||
const named = v
|
const named = v
|
||||||
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
|
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
return named.length ? named.join(', ') : null
|
return named.length ? named.map(humanize).join(', ') : null
|
||||||
}
|
}
|
||||||
if (typeof v === 'object') return null
|
if (typeof v === 'object') return null
|
||||||
const t = String(v).trim()
|
const t = String(v).trim()
|
||||||
if (!t) return null
|
if (!t) return null
|
||||||
// A value long enough to be prose belongs in the narrative block, not in a
|
if (t.length > 90) return t.slice(0, 88) + '…'
|
||||||
// list of what changed — truncating it here would show half a sentence and
|
return humanize(t)
|
||||||
// teach the reader that this list is unreliable.
|
|
||||||
return t.length > 90 ? t.slice(0, 88) + '…' : t
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document slots, in the order they are worth reading. Labels are shorter than
|
|
||||||
* the workflow's own — "Registration Certificate (RC)" is a chip, not a form
|
|
||||||
* label — and a slot missing from here still renders under its server label. */
|
|
||||||
const DOC_LABELS = {
|
const DOC_LABELS = {
|
||||||
doc_rc: 'RC',
|
doc_rc: 'RC',
|
||||||
doc_prev_policy: 'Expiring policy',
|
doc_prev_policy: 'Expiring policy',
|
||||||
@ -101,23 +78,20 @@ const DOC_LABELS = {
|
|||||||
const FILE_TYPES = new Set(['file', 'ocr'])
|
const FILE_TYPES = new Set(['file', 'ocr'])
|
||||||
|
|
||||||
function when(ts) {
|
function when(ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return { abs: '', rel: '' }
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
||||||
const rel = mins < 1 ? 'just now'
|
const rel = mins < 1 ? 'just now'
|
||||||
: mins < 60 ? `${mins}m ago`
|
: mins < 60 ? `${mins}m ago`
|
||||||
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
||||||
: `${Math.round(mins / 1440)}d ago`
|
: `${Math.round(mins / 1440)}d ago`
|
||||||
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
|
return {
|
||||||
|
abs: d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
|
||||||
|
rel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Last resort for a step whose activity_name did not come back. */
|
||||||
* Last resort for a step whose activity_name did not come back. Without this
|
|
||||||
* the timeline — the screen the whole product is demonstrated on — prints a
|
|
||||||
* raw slug like "zk-act-doc-reminder" in the middle of an otherwise readable
|
|
||||||
* story. Turning it into "Doc Reminder" is a guess, but it is a guess that
|
|
||||||
* reads as English.
|
|
||||||
*/
|
|
||||||
function prettyUid(uid) {
|
function prettyUid(uid) {
|
||||||
if (!uid) return 'Step'
|
if (!uid) return 'Step'
|
||||||
return String(uid)
|
return String(uid)
|
||||||
@ -134,19 +108,20 @@ function filesIn(value) {
|
|||||||
return value.filter((f) => f && typeof f === 'object' && f.uuid)
|
return value.filter((f) => f && typeof f === 'object' && f.uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `rows` is owned by the lead page and refreshed on its poll, so the trail
|
|
||||||
* keeps up with a lead that five agents are working through in three minutes.
|
|
||||||
* This component fetched them itself once on mount and never again.
|
|
||||||
*/
|
|
||||||
/** The two activities that ARE the WhatsApp thread. */
|
/** The two activities that ARE the WhatsApp thread. */
|
||||||
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
||||||
|
|
||||||
export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall }) {
|
/**
|
||||||
|
* `rows` is owned by the lead page and refreshed on its poll, so the trail
|
||||||
|
* keeps up with a lead that five agents are working through in three minutes.
|
||||||
|
*
|
||||||
|
* `newestFirst` flips the reading order. The story reads forwards; a live
|
||||||
|
* demo wants the latest step at the top, where the eye lands. The merge and
|
||||||
|
* the grouping below are always done oldest-first and the finished list is
|
||||||
|
* reversed at the end, so the order never changes what is merged.
|
||||||
|
*/
|
||||||
|
export default function Timeline({ rows, newestFirst = false, onOpenAgent, onOpenChat, onOpenCall }) {
|
||||||
const { client } = useZino()
|
const { client } = useZino()
|
||||||
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
|
|
||||||
// anything. They are the bulk of a busy trail and they are hidden until asked
|
|
||||||
// for — the story is what people and agents did.
|
|
||||||
const [showSys, setShowSys] = useState(false)
|
const [showSys, setShowSys] = useState(false)
|
||||||
|
|
||||||
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
||||||
@ -155,33 +130,15 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
// Oldest first: a timeline reads forwards.
|
// Oldest first: a timeline reads forwards.
|
||||||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||||||
|
|
||||||
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
|
// ONE SUBMISSION WRITES SEVERAL ROWS, told apart by execution_state. Only the
|
||||||
// submission separately, told apart by execution_state:
|
// row carrying a real state means anything to somebody reading the file.
|
||||||
//
|
// Collapsed on (same activity, within fifteen seconds), because a genuine
|
||||||
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
|
// repeat has to survive.
|
||||||
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
|
|
||||||
// zk-act-qualify zk-state-qualified the one that moved the lead
|
|
||||||
//
|
|
||||||
// Rendered literally that is "Qualify Lead" three times in a row, which
|
|
||||||
// reads as the AI having done the same thing three times. Only the row
|
|
||||||
// carrying a real state means anything to somebody reading the file.
|
|
||||||
//
|
|
||||||
// Collapsed on (same activity, within fifteen seconds) rather than on the
|
|
||||||
// execution_state alone, because a genuine repeat has to survive: Collect
|
|
||||||
// Documents really is performed twice on a lead whose first upload was
|
|
||||||
// incomplete, and those are minutes apart, not milliseconds.
|
|
||||||
//
|
|
||||||
// The kept row takes the EARLIEST timestamp — when the operator acted — and
|
|
||||||
// whichever state and payload is actually populated.
|
|
||||||
const isStage = (v) => STAGES.some((s) => s.uid === v)
|
const isStage = (v) => STAGES.some((s) => s.uid === v)
|
||||||
const SAME_SUBMISSION_MS = 15000
|
const SAME_SUBMISSION_MS = 15000
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
for (const r of ordered) {
|
for (const r of ordered) {
|
||||||
// Look BACK for a match rather than only at the previous entry: the
|
|
||||||
// platform interleaves a DATA_UPDATE row between the two halves of one
|
|
||||||
// submission, so the rows to merge are near each other in time but not
|
|
||||||
// adjacent in the list.
|
|
||||||
let at = -1
|
let at = -1
|
||||||
for (let i = merged.length - 1; i >= 0; i--) {
|
for (let i = merged.length - 1; i >= 0; i--) {
|
||||||
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
|
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
|
||||||
@ -192,9 +149,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
const prev = merged[at]
|
const prev = merged[at]
|
||||||
merged[at] = {
|
merged[at] = {
|
||||||
...prev,
|
...prev,
|
||||||
// The settle row is the one that names the resulting stage.
|
|
||||||
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
|
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
|
||||||
// Whichever row actually carries the submission and the AI's working.
|
|
||||||
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
|
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
|
||||||
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
|
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
|
||||||
user_name: prev.user_name || r.user_name,
|
user_name: prev.user_name || r.user_name,
|
||||||
@ -209,22 +164,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
let lastStage = null
|
let lastStage = null
|
||||||
const items = merged.map((r, i) => {
|
const items = merged.map((r, i) => {
|
||||||
const roles = r.user_roles || []
|
const roles = r.user_roles || []
|
||||||
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
|
|
||||||
// so an AI's own field write matched the agent roster, was classed as its work,
|
|
||||||
// and appeared in the trail as an entry titled "Data updated" — twice,
|
|
||||||
// while the toggle below still offered to reveal two others. The row's
|
|
||||||
// activity id is what says it is bookkeeping; the roles say who triggered
|
|
||||||
// the bookkeeping, which is a different question.
|
|
||||||
const isSystem = r.activity_id === 'DATA_UPDATE'
|
const isSystem = r.activity_id === 'DATA_UPDATE'
|
||||||
const aiRole = roles.find((x) => AGENTS[x])
|
const aiRole = roles.find((x) => AGENTS[x])
|
||||||
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
|
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
|
||||||
|
|
||||||
/**
|
|
||||||
* The value view. `fields[]` is the platform's own typed rendering of the
|
|
||||||
* submission — one entry per configured field with its label, data type and
|
|
||||||
* value — and it is what makes a file field knowable as a file. `data` is
|
|
||||||
* the raw untyped fallback for a row the workflow could not resolve.
|
|
||||||
*/
|
|
||||||
const fields = Array.isArray(r.fields) && r.fields.length
|
const fields = Array.isArray(r.fields) && r.fields.length
|
||||||
? r.fields
|
? r.fields
|
||||||
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
|
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
|
||||||
@ -232,14 +175,10 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
const byBase = new Map()
|
const byBase = new Map()
|
||||||
for (const f of fields) {
|
for (const f of fields) {
|
||||||
const base = baseFieldId(f.field_id)
|
const base = baseFieldId(f.field_id)
|
||||||
// `_system` is the platform's own marker on every submission.
|
|
||||||
if (base === '_system') continue
|
if (base === '_system') continue
|
||||||
if (!byBase.has(base)) byBase.set(base, f)
|
if (!byBase.has(base)) byBase.set(base, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// What was attached. This is the answer to "show me that the documents
|
|
||||||
// were captured": the names, from the submission that carried them, each
|
|
||||||
// one openable.
|
|
||||||
const docs = []
|
const docs = []
|
||||||
for (const [base, f] of byBase) {
|
for (const [base, f] of byBase) {
|
||||||
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
|
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
|
||||||
@ -248,20 +187,16 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The channel performs these as the system, but the meaningful actor is
|
|
||||||
// the person who typed. "System · Customer Reply" told the reader the
|
|
||||||
// opposite of what happened.
|
|
||||||
const isChat = CHAT_ACTS.has(r.activity_id)
|
const isChat = CHAT_ACTS.has(r.activity_id)
|
||||||
// Log Contact is where the call lands. Offer the words themselves beside
|
|
||||||
// the summary, because they are not the same thing.
|
|
||||||
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
|
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
|
||||||
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
||||||
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
|
|
||||||
// back into it — is not a move, and printing "→ Document Pending" against
|
|
||||||
// four consecutive entries reads as the lead bouncing.
|
|
||||||
const moved = stage && stage.uid !== lastStage
|
const moved = stage && stage.uid !== lastStage
|
||||||
if (stage) lastStage = stage.uid
|
if (stage) lastStage = stage.uid
|
||||||
|
|
||||||
|
const reasoning = REASONING
|
||||||
|
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||||||
|
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: r.id ?? i,
|
key: r.id ?? i,
|
||||||
kind: isChat ? 'chat' : kind,
|
kind: isChat ? 'chat' : kind,
|
||||||
@ -273,17 +208,11 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
stage: moved ? stage : null,
|
stage: moved ? stage : null,
|
||||||
when: when(r.created_at),
|
when: when(r.created_at),
|
||||||
docs,
|
docs,
|
||||||
// The reasoning rungs of the chain of thought — the employee's own text.
|
reasoning,
|
||||||
reasoning: REASONING
|
finding: kind === 'ai' && reasoning.length ? (findingOf(reasoning[0][1]) ?? leadIn(reasoning[0][1])) : null,
|
||||||
.map(([k, label]) => [label, byBase.get(k)?.value])
|
|
||||||
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
|
||||||
// Conversation lines stay as plain quotes.
|
|
||||||
conversation: CONVERSATION
|
conversation: CONVERSATION
|
||||||
.map(([k, label]) => [label, byBase.get(k)?.value])
|
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||||||
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
||||||
// The decision's confidence, when the step recorded it — ai_recommendation_confidence
|
|
||||||
// is a real field the advice step writes into the audit data, so it needs
|
|
||||||
// no backend change to reach here.
|
|
||||||
confidence: (() => {
|
confidence: (() => {
|
||||||
const c = byBase.get('ai_recommendation_confidence')?.value
|
const c = byBase.get('ai_recommendation_confidence')?.value
|
||||||
const n = c == null ? null : Number(c)
|
const n = c == null ? null : Number(c)
|
||||||
@ -292,22 +221,7 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
figures: [...byBase]
|
figures: [...byBase]
|
||||||
.filter(([base, f]) => MONEY.has(base) && f.value)
|
.filter(([base, f]) => MONEY.has(base) && f.value)
|
||||||
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
|
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
|
||||||
|
// Everything this step wrote, field by field.
|
||||||
/**
|
|
||||||
* EVERYTHING THIS STEP WROTE.
|
|
||||||
*
|
|
||||||
* Twelve fields were rendered as prose and four as money; the other
|
|
||||||
* hundred-odd were invisible. So Capture Motor Risk — which writes engine
|
|
||||||
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
|
|
||||||
* add-ons — showed as a heading and a timestamp, and the only way to see
|
|
||||||
* what an agent had actually decided was to open the Lead file and guess
|
|
||||||
* which values came from that step.
|
|
||||||
*
|
|
||||||
* This is not the model's internal reasoning; that is recorded on the row
|
|
||||||
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
|
|
||||||
* It is the next best and arguably more useful thing: the decision it
|
|
||||||
* committed, field by field, against the step that made it.
|
|
||||||
*/
|
|
||||||
wrote: [...byBase]
|
wrote: [...byBase]
|
||||||
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
|
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
|
||||||
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
||||||
@ -315,31 +229,15 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// THE CONVERSATION IS ONE THING, NOT NINE. A contiguous run of chat turns
|
||||||
* THE CONVERSATION IS ONE THING, NOT NINE.
|
// collapses to one entry that says how many messages, shows the last of
|
||||||
*
|
// them, and opens the thread.
|
||||||
* Every WhatsApp turn was its own entry — "Customer Reply", "Customer
|
|
||||||
* Reply", "Customer Reply", "Answer the Customer" — each with a heading, an
|
|
||||||
* actor and a quote box, so a four-message exchange occupied more of the
|
|
||||||
* trail than the entire underwriting chain. A resend made it worse: the same
|
|
||||||
* sentence printed four times because the customer sent it four times.
|
|
||||||
*
|
|
||||||
* A contiguous run of chat turns now collapses to one entry that says how
|
|
||||||
* many messages, shows the last of them, and opens the thread. The trail goes
|
|
||||||
* back to being a list of decisions, and the conversation goes back to being
|
|
||||||
* a conversation.
|
|
||||||
*
|
|
||||||
* Contiguous, not global: a second exchange after underwriting is a separate
|
|
||||||
* episode in this lead's story and should read as one.
|
|
||||||
*/
|
|
||||||
const grouped = []
|
const grouped = []
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const last = grouped[grouped.length - 1]
|
const last = grouped[grouped.length - 1]
|
||||||
if (it.isChat && last && last.isChat) {
|
if (it.isChat && last && last.isChat) {
|
||||||
last.count += 1
|
last.count += 1
|
||||||
last.when = it.when
|
last.when = it.when
|
||||||
// Keep the newest line as the preview — an operator scanning the trail
|
|
||||||
// wants where the conversation GOT to, not where it started.
|
|
||||||
if (it.conversation.length) last.conversation = it.conversation
|
if (it.conversation.length) last.conversation = it.conversation
|
||||||
last.docs = last.docs.concat(it.docs)
|
last.docs = last.docs.concat(it.docs)
|
||||||
continue
|
continue
|
||||||
@ -348,65 +246,79 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sysCount = grouped.filter((it) => it.kind === 'sys').length
|
const sysCount = grouped.filter((it) => it.kind === 'sys').length
|
||||||
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
|
const visible = (showSys ? grouped : grouped.filter((it) => it.kind !== 'sys'))
|
||||||
|
const listed = newestFirst ? [...visible].reverse() : visible
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sysCount ? (
|
<ol className={'tl' + (newestFirst ? ' tl--newest' : '')}>
|
||||||
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
{listed.map((it, idx) => (
|
||||||
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
<li key={it.key} className={'tl__item tl__item--' + it.kind + (idx === 0 && newestFirst ? ' is-latest' : '')}>
|
||||||
</button>
|
{/* THE WORKER IS THE NODE. The face on the rail says who did it
|
||||||
) : null}
|
before a word is read: a machine wears its role icon, a person
|
||||||
|
their initials, the customer the WhatsApp mark. */}
|
||||||
|
<div className="tl__node">
|
||||||
|
{it.kind === 'sys' ? (
|
||||||
|
<span className="tl__sysdot" aria-hidden="true" />
|
||||||
|
) : it.isChat && !it.agentKey ? (
|
||||||
|
<span className="tl__wanode" aria-hidden="true"><Icon name="whatsapp" size={16} /></span>
|
||||||
|
) : it.agentKey ? (
|
||||||
|
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
|
||||||
|
) : (
|
||||||
|
<span className={'avatar avatar--' + toneFor(it.actor)} aria-hidden="true">{initials(it.actor)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ol className="tl">
|
|
||||||
{visible.map((it) => (
|
|
||||||
<li key={it.key} className={'tl__item tl__item--' + it.kind}>
|
|
||||||
<span className="tl__dot" aria-hidden="true" />
|
|
||||||
<div className="tl__body">
|
<div className="tl__body">
|
||||||
<div className="tl__head">
|
<div className="tl__head">
|
||||||
{it.isChat ? (
|
<div className="tl__title">
|
||||||
<span className="tl__wa">
|
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
|
||||||
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
|
|
||||||
fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
WhatsApp
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<strong className="tl__what">{it.what}</strong>
|
<strong className="tl__what">{it.what}</strong>
|
||||||
)}
|
{it.count > 1 ? <span className="pill">{it.count} messages</span> : null}
|
||||||
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
|
{it.stage && !it.isChat ? (
|
||||||
{it.stage && !it.isChat ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
<span className="tl__stage"><Icon name="arrowRight" size={12} />{it.stage.name}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{/* The worker, as a face. Recognition down a column beats
|
<span className="tl__when" title={it.when.abs}>{it.when.rel}<em> · {it.when.abs}</em></span>
|
||||||
reading five names to notice it was one agent throughout. */}
|
|
||||||
<div className="tl__meta">
|
|
||||||
{it.kind === 'sys'
|
|
||||||
? <span className="tl__who tl__who--sys">system</span>
|
|
||||||
: it.isChat && !it.agentKey
|
|
||||||
? <span className="tl__who tl__who--cust">the customer</span>
|
|
||||||
: <AgentChip agentKey={it.agentKey} name={it.actor} onOpen={onOpenAgent} />}
|
|
||||||
<span className="tl__when">{it.when}</span>
|
|
||||||
{it.isChat && onOpenChat ? (
|
|
||||||
<button type="button" className="tl__open" onClick={onOpenChat}>
|
|
||||||
Open thread
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{it.hasCall && onOpenCall ? (
|
|
||||||
<button type="button" className="tl__open" onClick={onOpenCall}>
|
|
||||||
Hear the call
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What was received, named and openable. The count is stated
|
<div className="tl__meta">
|
||||||
rather than left to be inferred from a list — "3 documents
|
{it.kind === 'sys'
|
||||||
received" is the sentence the operator is looking for. */}
|
? <span className="tl__who">platform</span>
|
||||||
|
: it.isChat && !it.agentKey
|
||||||
|
? <span className="tl__who tl__who--cust">the customer</span>
|
||||||
|
: <span className="tl__who">{it.actor}{it.agentKey ? <span className="tl__aitag">AI</span> : null}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The finding — the one sentence that answers "what did it
|
||||||
|
conclude" — as the entry's body. */}
|
||||||
|
{it.finding ? (
|
||||||
|
<p className="tl__finding">
|
||||||
|
{mdRuns(it.finding).map((r, j) => (r.b ? <b key={j}>{r.t}</b> : <span key={j}>{r.t}</span>))}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* A human step's reason, and an AI step whose reasoning has
|
||||||
|
no clean first sentence, stay as a quote. */}
|
||||||
|
{it.kind !== 'ai'
|
||||||
|
? it.reasoning.map(([label, v]) => (
|
||||||
|
<div className="tl__say" key={label}>
|
||||||
|
<span className="tl__saylabel">{label}</span>
|
||||||
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{it.conversation.map(([label, v]) => (
|
||||||
|
<div className="tl__say tl__say--chat" key={label}>
|
||||||
|
<span className="tl__saylabel">{label}</span>
|
||||||
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{it.docs.length ? (
|
{it.docs.length ? (
|
||||||
<div className="tl__docs">
|
<div className="tl__docs">
|
||||||
<span className="tl__docshead">
|
<span className="tl__docshead">
|
||||||
|
<Icon name="file" size={13} />
|
||||||
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
||||||
</span>
|
</span>
|
||||||
<div className="tl__doclist">
|
<div className="tl__doclist">
|
||||||
@ -419,12 +331,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title={d.original_name}
|
title={d.original_name}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
||||||
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
|
|
||||||
strokeLinejoin="round" />
|
|
||||||
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
|
|
||||||
strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
{d.slot}
|
{d.slot}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@ -432,39 +338,6 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
|
|
||||||
loose quote — it is how the employee reached the decision, so
|
|
||||||
it renders as the chain: what it checked, what it reasoned,
|
|
||||||
what it decided. This is the answer to "why did it do that",
|
|
||||||
which is the first thing a person needs before trusting or
|
|
||||||
overriding a machine. */}
|
|
||||||
{it.kind === 'ai' && it.reasoning.length ? (
|
|
||||||
<ChainOfThought
|
|
||||||
reasoning={it.reasoning}
|
|
||||||
confidence={it.confidence}
|
|
||||||
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* A human step's reason, and the AI step's reasoning when it is
|
|
||||||
not the story's actor (rare), stay as a plain quote. */}
|
|
||||||
{it.kind !== 'ai'
|
|
||||||
? it.reasoning.map(([label, v]) => (
|
|
||||||
<div className="tl__say" key={label}>
|
|
||||||
<span className="tl__saylabel">{label}</span>
|
|
||||||
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: null}
|
|
||||||
|
|
||||||
{/* What was said to and by the customer — always a plain quote. */}
|
|
||||||
{it.conversation.map(([label, v]) => (
|
|
||||||
<div className="tl__say" key={label}>
|
|
||||||
<span className="tl__saylabel">{label}</span>
|
|
||||||
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{it.figures.length ? (
|
{it.figures.length ? (
|
||||||
<div className="tl__figs">
|
<div className="tl__figs">
|
||||||
{it.figures.map(([k, v]) => (
|
{it.figures.map(([k, v]) => (
|
||||||
@ -473,12 +346,26 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Folded, because most steps write a lot and the trail has to
|
{/* ONE FOOTER ROW: the chain of thought, what was written, the
|
||||||
stay readable as a story. Open it and the step becomes an
|
call, the thread. These used to stack as three rows of blue
|
||||||
itemised account of what it decided. */}
|
links under every entry. */}
|
||||||
|
{(it.kind === 'ai' && it.reasoning.length) || it.wrote.length || (it.isChat && onOpenChat) || (it.hasCall && onOpenCall) ? (
|
||||||
|
<div className="tl__foot">
|
||||||
|
{it.kind === 'ai' && it.reasoning.length ? (
|
||||||
|
<ChainOfThought
|
||||||
|
reasoning={it.reasoning}
|
||||||
|
confidence={it.confidence}
|
||||||
|
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
|
||||||
|
showFinding={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{it.wrote.length ? (
|
{it.wrote.length ? (
|
||||||
<details className="tl__wrote">
|
<details className="tl__wrote">
|
||||||
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
|
<summary>
|
||||||
|
<Icon name="list" size={13} />
|
||||||
|
{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written
|
||||||
|
<Icon name="chevronDown" size={12} className="tl__wcaret" />
|
||||||
|
</summary>
|
||||||
<dl>
|
<dl>
|
||||||
{it.wrote.map(([k, v]) => (
|
{it.wrote.map(([k, v]) => (
|
||||||
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
||||||
@ -486,10 +373,28 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat, onOpenCall })
|
|||||||
</dl>
|
</dl>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
{it.isChat && onOpenChat ? (
|
||||||
|
<button type="button" className="tl__open" onClick={onOpenChat}>
|
||||||
|
<Icon name="whatsapp" size={13} /> Open thread
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{it.hasCall && onOpenCall ? (
|
||||||
|
<button type="button" className="tl__open" onClick={onOpenCall}>
|
||||||
|
<Icon name="phone" size={13} /> Read the call
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
{sysCount ? (
|
||||||
|
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
||||||
|
{showSys ? 'Hide' : 'Show'} {sysCount} platform update{sysCount === 1 ? '' : 's'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/index.css
161
src/index.css
@ -56,6 +56,23 @@
|
|||||||
--zk-danger-tint: #fdeceb;
|
--zk-danger-tint: #fdeceb;
|
||||||
--zk-danger-line: #f0c2bd;
|
--zk-danger-line: #f0c2bd;
|
||||||
|
|
||||||
|
/* ── Surfaces ───────────────────────────────────────────────
|
||||||
|
One ground, one card. The page sits on a cool off-white and every
|
||||||
|
bounded region is a white card with a hairline and a whisper of shadow,
|
||||||
|
so the eye reads groups before it reads text. */
|
||||||
|
--zk-ground: #f4f6f9;
|
||||||
|
--zk-card: #ffffff;
|
||||||
|
--zk-card-line: #e4e9ef;
|
||||||
|
--zk-hover: #f7f9fc;
|
||||||
|
--zk-green: #1f9d63;
|
||||||
|
--zk-green-ink: #157347;
|
||||||
|
--zk-green-tint: #e6f6ee;
|
||||||
|
--zk-green-line: #b4e3c9;
|
||||||
|
--zk-violet: #5b4bb7;
|
||||||
|
--zk-violet-tint: #eeebfa;
|
||||||
|
--zk-slate: #5b6b7f;
|
||||||
|
--zk-wa: #0f7b45;
|
||||||
|
|
||||||
/* ── Shape, depth, motion ────────────────────────────────────
|
/* ── Shape, depth, motion ────────────────────────────────────
|
||||||
One scale, used everywhere. The palette above is untouched; every shadow
|
One scale, used everywhere. The palette above is untouched; every shadow
|
||||||
below is the brand navy at low alpha, so depth reads as part of the brand
|
below is the brand navy at low alpha, so depth reads as part of the brand
|
||||||
@ -66,6 +83,9 @@
|
|||||||
--r-lg: 16px;
|
--r-lg: 16px;
|
||||||
--r-xl: 20px;
|
--r-xl: 20px;
|
||||||
--r-pill: 999px;
|
--r-pill: 999px;
|
||||||
|
--r-card: 14px;
|
||||||
|
--sh-card: 0 1px 2px rgba(35, 54, 111, 0.04), 0 1px 3px rgba(35, 54, 111, 0.06);
|
||||||
|
--sh-card-hover: 0 1px 2px rgba(35, 54, 111, 0.05), 0 8px 20px -10px rgba(35, 54, 111, 0.18);
|
||||||
|
|
||||||
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05);
|
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05);
|
||||||
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07);
|
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07);
|
||||||
@ -141,7 +161,7 @@
|
|||||||
ground — the generic admin-template look. The content ground is white
|
ground — the generic admin-template look. The content ground is white
|
||||||
now; the sidebar is the only tinted surface, and sections are separated
|
now; the sidebar is the only tinted surface, and sections are separated
|
||||||
by hairlines and space rather than boxed in shadowed cards. */
|
by hairlines and space rather than boxed in shadowed cards. */
|
||||||
background: var(--zk-white);
|
background: var(--zk-ground);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@ -159,7 +179,144 @@ body,
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--zk-white);
|
background: var(--zk-ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared primitives ───────────────────────────────────────
|
||||||
|
Used by every screen so a card, an eyebrow or an avatar looks the same
|
||||||
|
wherever it appears. Screen stylesheets style CONTENT; these style the
|
||||||
|
containers. */
|
||||||
|
.card {
|
||||||
|
background: var(--zk-card);
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--sh-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A small label above a value. Sentence case, quiet, never letter-spaced
|
||||||
|
into a shout. */
|
||||||
|
.eyebrow {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section heading: a real heading at 16-18px, with an optional one-line
|
||||||
|
subtitle. */
|
||||||
|
.h-sec {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-lg);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.h-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A round face for a person or a customer. */
|
||||||
|
.avatar {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--zk-slate);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.avatar--sm { width: 26px; height: 26px; font-size: 11px; }
|
||||||
|
.avatar--lg { width: 44px; height: 44px; font-size: var(--fs-sm); }
|
||||||
|
.avatar--blue { background: var(--zk-blue); }
|
||||||
|
.avatar--teal { background: var(--zk-teal); }
|
||||||
|
.avatar--violet { background: var(--zk-violet); }
|
||||||
|
.avatar--amber { background: var(--zk-amber); }
|
||||||
|
.avatar--plum { background: #8a3d6b; }
|
||||||
|
.avatar--slate { background: var(--zk-slate); }
|
||||||
|
|
||||||
|
/* Small status pills: product line, channel, stage, tone. */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.pill--blue { color: var(--zk-blue-dark); background: var(--zk-tint-blue); }
|
||||||
|
.pill--teal { color: var(--zk-teal-ink); background: var(--zk-teal-tint); }
|
||||||
|
.pill--amber { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
|
||||||
|
.pill--red { color: var(--zk-danger-ink); background: var(--zk-danger-tint); }
|
||||||
|
.pill--green { color: var(--zk-green-ink); background: var(--zk-green-tint); }
|
||||||
|
.pill--violet { color: var(--zk-violet); background: var(--zk-violet-tint); }
|
||||||
|
.pill--line { background: transparent; border: 1px solid var(--zk-line); }
|
||||||
|
|
||||||
|
/* Buttons. Three weights, one shape. */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-card);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: 1.3;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--zk-hover); border-color: var(--zk-grey); }
|
||||||
|
.btn:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
.btn:disabled { opacity: .55; cursor: default; }
|
||||||
|
.btn--primary { background: var(--zk-blue); border-color: var(--zk-blue); color: #fff; }
|
||||||
|
.btn--primary:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
|
||||||
|
.btn--ghost { border-color: transparent; background: transparent; color: var(--zk-blue-dark); }
|
||||||
|
.btn--ghost:hover { background: var(--zk-tint-blue); border-color: transparent; }
|
||||||
|
.btn--sm { padding: 5px 10px; font-size: var(--fs-2xs); border-radius: var(--r-xs); }
|
||||||
|
.btn--danger { color: var(--zk-danger-ink); border-color: var(--zk-danger-line); }
|
||||||
|
.btn--danger:hover { background: var(--zk-danger-tint); border-color: var(--zk-danger); }
|
||||||
|
|
||||||
|
/* A live dot: something is refreshing or in motion. */
|
||||||
|
.live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.live__dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--zk-green);
|
||||||
|
box-shadow: 0 0 0 0 rgba(31, 157, 99, .5);
|
||||||
|
animation: zk-live 2.2s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
@keyframes zk-live {
|
||||||
|
70% { box-shadow: 0 0 0 7px rgba(31, 157, 99, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(31, 157, 99, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
|
|||||||
121
src/layout/GlobalSearch.jsx
Normal file
121
src/layout/GlobalSearch.jsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { usePortfolio } from '../api/portfolio.jsx'
|
||||||
|
import { STAGES } from '../api/config.js'
|
||||||
|
import { initials, toneFor } from '../api/display.js'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a lead from anywhere.
|
||||||
|
*
|
||||||
|
* Reads the book the PortfolioProvider already holds — the same rows the
|
||||||
|
* sidebar tallies and the overview count — so this costs no request and can
|
||||||
|
* never disagree with them. Matches on the customer, the business, the
|
||||||
|
* reference and the mobile number, because those are the four things a caller
|
||||||
|
* gives you.
|
||||||
|
*/
|
||||||
|
export default function GlobalSearch() {
|
||||||
|
const { rows } = usePortfolio()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [cursor, setCursor] = useState(0)
|
||||||
|
const wrap = useRef(null)
|
||||||
|
const input = useRef(null)
|
||||||
|
|
||||||
|
const hits = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
if (term.length < 2) return []
|
||||||
|
const out = []
|
||||||
|
for (const r of rows) {
|
||||||
|
const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no]
|
||||||
|
.filter(Boolean).map((x) => String(x).toLowerCase())
|
||||||
|
if (hay.some((h) => h.includes(term))) out.push(r)
|
||||||
|
if (out.length >= 8) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [rows, q])
|
||||||
|
|
||||||
|
// Close on an outside click, and jump to the box on "/".
|
||||||
|
useEffect(() => {
|
||||||
|
function onDown(e) { if (!wrap.current?.contains(e.target)) setOpen(false) }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === '/' && !/input|textarea|select/i.test(document.activeElement?.tagName || '')) {
|
||||||
|
e.preventDefault()
|
||||||
|
input.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function go(r) {
|
||||||
|
const id = r.instance_id ?? r.id
|
||||||
|
setQ(''); setOpen(false)
|
||||||
|
navigate(`/lead/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e) {
|
||||||
|
if (e.key === 'Escape') { setOpen(false); input.current?.blur(); return }
|
||||||
|
if (!hits.length) return
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setCursor((c) => (c + 1) % hits.length) }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setCursor((c) => (c - 1 + hits.length) % hits.length) }
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); go(hits[cursor]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const showList = open && q.trim().length >= 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'gs' + (open ? ' is-open' : '')} ref={wrap} role="search">
|
||||||
|
<Icon name="search" className="gs__icon" />
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
placeholder="Search a lead — name, reference, mobile…"
|
||||||
|
aria-label="Search leads"
|
||||||
|
onChange={(e) => { setQ(e.target.value); setCursor(0); setOpen(true) }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
<kbd className="gs__kbd" aria-hidden="true">/</kbd>
|
||||||
|
|
||||||
|
{showList ? (
|
||||||
|
<div className="gs__menu" role="listbox">
|
||||||
|
{hits.length ? hits.map((r, i) => {
|
||||||
|
const id = r.instance_id ?? r.id
|
||||||
|
const stage = STAGES.find((s) => s.name === r.current_state_name)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === cursor}
|
||||||
|
className={'gs__hit' + (i === cursor ? ' is-cursor' : '')}
|
||||||
|
onMouseEnter={() => setCursor(i)}
|
||||||
|
onClick={() => go(r)}
|
||||||
|
>
|
||||||
|
<span className={'avatar avatar--sm avatar--' + toneFor(r.customer_name)}>
|
||||||
|
{initials(r.customer_name)}
|
||||||
|
</span>
|
||||||
|
<span className="gs__t">
|
||||||
|
<strong>{r.customer_name || r.lead_ref || `#${id}`}</strong>
|
||||||
|
<span>{r.lead_ref}{r.mobile ? ` · ${r.mobile}` : ''}</span>
|
||||||
|
</span>
|
||||||
|
<span className={'pill ' + (stage?.kind === 'end' ? '' : stage?.kind === 'needs' ? 'pill--amber' : 'pill--blue')}>
|
||||||
|
{r.current_state_name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}) : (
|
||||||
|
<p className="gs__none">No lead matches “{q.trim()}”.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,62 +2,128 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--zk-tint);
|
background: var(--zk-ground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Top bar ───────────────────────────────────────────────── */
|
/* ── Top bar ───────────────────────────────────────────────────
|
||||||
|
56px, white, three zones: brand, search, identity. */
|
||||||
.shell__top {
|
.shell__top {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 232px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--sp-6);
|
gap: var(--sp-6);
|
||||||
height: 72px;
|
height: 56px;
|
||||||
padding: 0 var(--sp-7);
|
padding: 0 var(--sp-5) 0 var(--sp-5);
|
||||||
background: var(--zk-white);
|
background: var(--zk-card);
|
||||||
border-bottom: 1px solid var(--zk-line);
|
border-bottom: 1px solid var(--zk-card-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell__brand {
|
.shell__brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell__brand img {
|
.shell__brand img {
|
||||||
height: 30px;
|
height: 26px;
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell__brandtext {
|
.shell__brandtext {
|
||||||
display: flex;
|
padding-left: 12px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
line-height: 1.2;
|
|
||||||
padding-left: 16px;
|
|
||||||
border-left: 1px solid var(--zk-line);
|
border-left: 1px solid var(--zk-line);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell__brandtext strong {
|
/* ── Global search ───────────────────────────────────────────── */
|
||||||
font-size: var(--fs-md);
|
.gs {
|
||||||
font-weight: var(--fw-semi);
|
position: relative;
|
||||||
letter-spacing: -0.012em;
|
justify-self: center;
|
||||||
|
width: min(100%, 520px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
|
background: var(--zk-ground);
|
||||||
|
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
.gs:hover { border-color: var(--zk-line); }
|
||||||
|
.gs:focus-within { background: var(--zk-card); border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
.gs__icon { flex: none; color: var(--zk-grey); }
|
||||||
|
.gs:focus-within .gs__icon { color: var(--zk-blue); }
|
||||||
|
.gs input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
}
|
}
|
||||||
|
.gs input::placeholder { color: var(--zk-grey); }
|
||||||
.shell__brandtext span {
|
.gs input::-webkit-search-cancel-button { cursor: pointer; }
|
||||||
font-size: var(--fs-3xs);
|
.gs__kbd {
|
||||||
font-weight: 400;
|
flex: none;
|
||||||
letter-spacing: 0.08em;
|
font: inherit;
|
||||||
text-transform: uppercase;
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-card);
|
||||||
color: var(--zk-grey);
|
color: var(--zk-grey);
|
||||||
}
|
}
|
||||||
|
.gs:focus-within .gs__kbd { display: none; }
|
||||||
|
|
||||||
/* ── Body ──────────────────────────────────────────────────── */
|
.gs__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-card);
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
animation: zk-rise 0.14s var(--ease) both;
|
||||||
|
}
|
||||||
|
.gs__hit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gs__hit.is-cursor { background: var(--zk-tint-blue); }
|
||||||
|
.gs__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
|
||||||
|
.gs__t strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gs__t span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.gs__none { margin: 0; padding: 14px 12px; font-size: var(--fs-2xs); color: var(--zk-muted); }
|
||||||
|
|
||||||
|
/* ── Body ────────────────────────────────────────────────────── */
|
||||||
.shell__body {
|
.shell__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -67,136 +133,196 @@
|
|||||||
|
|
||||||
.shell__nav {
|
.shell__nav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 72px;
|
top: 56px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
width: 264px;
|
width: 248px;
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: calc(100vh - 72px);
|
min-height: calc(100vh - 56px);
|
||||||
max-height: calc(100vh - 72px);
|
max-height: calc(100vh - 56px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
background: var(--zk-tint);
|
background: var(--zk-card);
|
||||||
border-right: 1px solid var(--zk-line);
|
border-right: 1px solid var(--zk-card-line);
|
||||||
padding: var(--sp-6) var(--sp-4) var(--sp-8);
|
padding: var(--sp-4) var(--sp-3) var(--sp-5);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shell__navtop { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
.shell__navlabel {
|
.shell__navlabel {
|
||||||
margin: 0 0 var(--sp-3);
|
|
||||||
padding: 0 var(--sp-3);
|
|
||||||
font-size: var(--fs-3xs);
|
|
||||||
font-weight: var(--fw-semi);
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zk-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__group + .shell__group {
|
|
||||||
margin-top: var(--sp-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The stage list is drawn as a rail with a node per state: the sidebar should
|
|
||||||
look like the pipeline it represents, not like a list of links. */
|
|
||||||
.shell__stages {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--sp-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stages::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: 18px;
|
|
||||||
top: 20px;
|
|
||||||
bottom: 20px;
|
|
||||||
width: 2px;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
background: linear-gradient(180deg, var(--zk-line) 0%, var(--zk-line-soft) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--sp-2);
|
gap: 8px;
|
||||||
padding: var(--sp-3) var(--sp-3) var(--sp-3) 40px;
|
margin: 0 0 6px;
|
||||||
border-radius: var(--r-md);
|
padding: 0 var(--sp-3);
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__group { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.shell__stages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One shape for every nav row: icon, label, count. */
|
||||||
|
.shell__stage,
|
||||||
|
.shell__today {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px var(--sp-3);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
|
font-weight: var(--fw-medium);
|
||||||
|
line-height: 1.35;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.shell__ico { flex: none; color: var(--zk-grey); transition: color var(--t-fast); }
|
||||||
|
.shell__stage:hover,
|
||||||
|
.shell__today:hover { background: var(--zk-hover); color: var(--zk-ink); }
|
||||||
|
.shell__stage:hover .shell__ico,
|
||||||
|
.shell__today:hover .shell__ico { color: var(--zk-muted); }
|
||||||
|
|
||||||
.shell__stage::before {
|
.shell__stage.is-active,
|
||||||
content: '';
|
.shell__today.is-active {
|
||||||
position: absolute;
|
|
||||||
left: 13px;
|
|
||||||
top: 50%;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
margin-top: -6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--zk-white);
|
|
||||||
border: 2px solid var(--zk-line);
|
|
||||||
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage:hover {
|
|
||||||
background: var(--zk-tint);
|
|
||||||
color: var(--zk-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage:hover::before {
|
|
||||||
border-color: var(--zk-blue-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage.is-active {
|
|
||||||
background: var(--zk-tint-blue);
|
background: var(--zk-tint-blue);
|
||||||
color: var(--zk-blue-dark);
|
color: var(--zk-blue-dark);
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
.shell__stage.is-active .shell__ico,
|
||||||
.shell__stage.is-active::before {
|
.shell__today.is-active .shell__ico { color: var(--zk-blue); }
|
||||||
|
.shell__stage.is-active::before,
|
||||||
|
.shell__today.is-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: 7px;
|
||||||
|
bottom: 7px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
background: var(--zk-blue);
|
background: var(--zk-blue);
|
||||||
border-color: var(--zk-blue);
|
|
||||||
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Queues where the machine stops and a person decides. */
|
/* Queues where the machine stops and a person decides. */
|
||||||
.shell__stage.is-needed .shell__stagename {
|
.shell__stage.is-needed { color: var(--zk-ink); }
|
||||||
font-weight: 500;
|
.shell__stage.is-needed .shell__ico { color: var(--zk-amber); }
|
||||||
color: var(--zk-ink);
|
.shell__stage.is-needed.is-active .shell__ico { color: var(--zk-blue); }
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage.is-needed.is-active .shell__stagename {
|
.shell__stagename { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.shell__stage.is-empty { color: var(--zk-grey); font-weight: var(--fw-normal); }
|
||||||
|
.shell__stage.is-empty .shell__ico { color: var(--zk-line); }
|
||||||
|
|
||||||
|
/* ── Queue tallies ───────────────────────────────────────────── */
|
||||||
|
.shell__tally { display: flex; align-items: center; gap: 6px; flex: none; }
|
||||||
|
|
||||||
|
.shell__n {
|
||||||
|
min-width: 22px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--zk-blue-dark);
|
color: var(--zk-blue-dark);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
}
|
||||||
|
.shell__stage.is-needed .shell__n { color: var(--zk-amber-ink); background: var(--zk-amber-tint); }
|
||||||
|
.shell__stage.is-active .shell__n { color: #fff; background: var(--zk-blue); }
|
||||||
|
.shell__n.is-zero { color: var(--zk-grey); background: transparent; font-weight: var(--fw-normal); }
|
||||||
|
|
||||||
|
.shell__auto {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--zk-blue-mid);
|
||||||
|
}
|
||||||
|
.shell__auto::after { content: '·'; margin-left: 5px; color: var(--zk-line); }
|
||||||
|
|
||||||
|
.shell__urgent { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-danger); flex: none; }
|
||||||
|
|
||||||
|
.shell__grouptally {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--fw-semi);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--zk-amber);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 1px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stage names wrap rather than truncate: "Referred to Underwriting" is the one
|
/* ── Closed, folded ──────────────────────────────────────────── */
|
||||||
queue a person has to find, and it is also the longest label. */
|
.shell__closed > summary {
|
||||||
.shell__stagename {
|
display: flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
line-height: 1.35;
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
padding: 6px var(--sp-3);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
.shell__closed > summary::-webkit-details-marker { display: none; }
|
||||||
|
.shell__closed > summary:hover { color: var(--zk-muted); background: var(--zk-hover); }
|
||||||
|
.shell__closed > summary b {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
.shell__caret { transition: transform var(--t-fast); color: var(--zk-grey); }
|
||||||
|
.shell__closed[open] .shell__caret { transform: rotate(90deg); }
|
||||||
|
.shell__closed .shell__stages { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── AI team, at the foot ─────────────────────────────────────── */
|
||||||
|
.shell__team {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--sp-4) var(--sp-3) 0;
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.shell__team .shell__navlabel { padding: 0; }
|
||||||
|
.shell__teamrow { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.shell__teamnote {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--zk-grey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Main ────────────────────────────────────────────────────── */
|
||||||
.shell__main {
|
.shell__main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--zk-white);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1460px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 30px 34px 76px;
|
padding: var(--sp-6) var(--sp-7) var(--sp-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Narrow viewports: the rail lies down and scrolls ────────── */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.shell__top { grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--sp-4); }
|
||||||
|
.shell__brandtext { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Narrow viewports: the rail lies down and scrolls ──────── */
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.shell__body {
|
.shell__top { grid-template-columns: auto auto; }
|
||||||
flex-direction: column;
|
.gs { display: none; }
|
||||||
}
|
|
||||||
|
.shell__body { flex-direction: column; }
|
||||||
|
|
||||||
.shell__nav {
|
.shell__nav {
|
||||||
position: static;
|
position: static;
|
||||||
@ -204,167 +330,21 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--zk-line);
|
border-bottom: 1px solid var(--zk-card-line);
|
||||||
padding: 14px 16px 16px;
|
padding: 10px 14px;
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stages {
|
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
overflow-x: auto;
|
flex-wrap: wrap;
|
||||||
padding-bottom: 4px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.shell__navtop, .shell__group, .shell__closed { display: contents; }
|
||||||
/* Laid out horizontally the stages are a scrolling strip of pills, and a rail
|
.shell__navlabel, .shell__team, .shell__closed > summary { display: none; }
|
||||||
behind them would just be a line struck through the row. */
|
.shell__stages { flex-direction: row; flex-wrap: wrap; gap: 6px; }
|
||||||
.shell__stages::before {
|
.shell__stage, .shell__today {
|
||||||
display: none;
|
padding: 6px 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage {
|
|
||||||
padding: 8px 14px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: var(--zk-white);
|
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-card);
|
||||||
}
|
}
|
||||||
|
.shell__stage.is-active::before, .shell__today.is-active::before { display: none; }
|
||||||
.shell__stage::before {
|
.shell__main { padding: 18px 16px 48px; }
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__main {
|
|
||||||
padding: 22px 18px 56px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The way back to the overview. It sits above "Add a lead" because reading
|
|
||||||
the book is the commoner act — most sessions start by looking, not filing. */
|
|
||||||
.shell__today {
|
|
||||||
display: block;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-radius: var(--r-sm);
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--zk-ink);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background .12s, color .12s;
|
|
||||||
}
|
|
||||||
.shell__today:hover { background: var(--zk-tint); }
|
|
||||||
.shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
|
|
||||||
|
|
||||||
/* ── Queue tallies ─────────────────────────────────────────────────────────
|
|
||||||
Three numbers, deliberately unequal in weight, because they are unequal in
|
|
||||||
importance: what needs YOU, what an agent holds, and whether anything in
|
|
||||||
there is about to lapse. */
|
|
||||||
.shell__tally {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What is waiting on a person. The only figure with full contrast. */
|
|
||||||
.shell__n {
|
|
||||||
min-width: 20px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 600;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zero is shown, not hidden. "Nothing here" is an answer, and a queue that
|
|
||||||
disappears when it empties makes the sidebar move under the cursor. */
|
|
||||||
.shell__n.is-zero {
|
|
||||||
color: var(--zk-grey);
|
|
||||||
background: transparent;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What an agent is carrying. Present, quiet, and never added to the badge —
|
|
||||||
it is in the queue, it is not your work. */
|
|
||||||
.shell__auto {
|
|
||||||
font-style: normal;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--zk-blue-mid);
|
|
||||||
}
|
|
||||||
.shell__auto::after {
|
|
||||||
content: '·';
|
|
||||||
margin-left: 5px;
|
|
||||||
color: var(--zk-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A renewal inside a week sitting in this queue. */
|
|
||||||
.shell__urgent {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--zk-amber);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__stage.is-empty { color: var(--zk-grey); }
|
|
||||||
.shell__stage.is-empty::before { border-color: var(--zk-line-soft); }
|
|
||||||
.shell__stage.is-empty.is-needed .shell__stagename { color: var(--zk-muted); font-weight: 400; }
|
|
||||||
|
|
||||||
/* The one number worth carrying on a group heading: everything this role has
|
|
||||||
to clear, across its queues. */
|
|
||||||
.shell__navlabel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell__grouptally {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--zk-white);
|
|
||||||
background: var(--zk-blue);
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
padding: 1px 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Closed, folded ───────────────────────────────────────────────────────
|
|
||||||
Three of the twelve queues, opened about once a week. */
|
|
||||||
.shell__closed { margin-top: 22px; }
|
|
||||||
|
|
||||||
.shell__closed > summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
padding: 0 10px 10px;
|
|
||||||
font-size: var(--fs-3xs);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--zk-grey);
|
|
||||||
}
|
|
||||||
.shell__closed > summary::-webkit-details-marker { display: none; }
|
|
||||||
.shell__closed > summary:hover { color: var(--zk-muted); }
|
|
||||||
.shell__closed > summary b {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--zk-grey);
|
|
||||||
}
|
|
||||||
.shell__closed > summary::after {
|
|
||||||
content: '';
|
|
||||||
order: -1;
|
|
||||||
width: 5px; height: 5px;
|
|
||||||
border-right: 1.5px solid currentColor;
|
|
||||||
border-bottom: 1.5px solid currentColor;
|
|
||||||
transform: rotate(-45deg);
|
|
||||||
transition: transform var(--t-fast);
|
|
||||||
}
|
|
||||||
.shell__closed[open] > summary::after { transform: rotate(45deg); }
|
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { NAV_GROUPS } from '../api/config.js'
|
import { NAV_GROUPS } from '../api/config.js'
|
||||||
|
import { AGENTS } from '../api/agents.js'
|
||||||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
import { useStageCounts } from '../api/portfolio.jsx'
|
import { useStageCounts } from '../api/portfolio.jsx'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import AgentCard from '../components/AgentCard.jsx'
|
||||||
|
import AgentChip from '../components/AgentChip.jsx'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
|
import { KIND_ICON } from '../api/display.js'
|
||||||
|
import GlobalSearch from './GlobalSearch.jsx'
|
||||||
import UserMenu from './UserMenu.jsx'
|
import UserMenu from './UserMenu.jsx'
|
||||||
import logo from '../assets/brand/zurich_logo.webp'
|
import logo from '../assets/brand/zurich_logo.webp'
|
||||||
import './Shell.css'
|
import './Shell.css'
|
||||||
@ -13,22 +20,13 @@ import './Shell.css'
|
|||||||
* is not the question anyone signing in has; these answer "is anything waiting
|
* is not the question anyone signing in has; these answer "is anything waiting
|
||||||
* on me?". Queues that need a person are named by what they want done.
|
* on me?". Queues that need a person are named by what they want done.
|
||||||
*
|
*
|
||||||
* IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could
|
* The counts come from PortfolioProvider — one fetch shared with the overview,
|
||||||
* only come from a second full list call that would then disagree with the
|
* so the two cannot disagree. The badge counts leads whose PHASE needs a
|
||||||
* queue's own total. That was right about the cost and wrong about the
|
* person, not leads in the state (see phaseOf); a dot marks a queue holding a
|
||||||
* conclusion — with no numbers, the only way to learn whether anything is
|
* renewal inside a week.
|
||||||
* waiting on you is to open all nine queues in turn, which is precisely the
|
|
||||||
* question navigation exists to answer. The overview's existing fetch moved
|
|
||||||
* into PortfolioProvider and both surfaces read it, so this is one call fewer
|
|
||||||
* than before rather than one more.
|
|
||||||
*
|
*
|
||||||
* The badge counts leads whose PHASE needs a person, not leads in the state —
|
* The closed states are folded away: three of the twelve, reporting rather
|
||||||
* see phaseOf. A dot marks a queue holding a renewal inside a week, which is
|
* than work, and at equal weight they made the live queues harder to find.
|
||||||
* the only reason to open one queue before another.
|
|
||||||
*
|
|
||||||
* The closed states are folded away. They are three of the twelve, they are
|
|
||||||
* reporting rather than work, and at equal weight they made the live queues
|
|
||||||
* harder to find.
|
|
||||||
*/
|
*/
|
||||||
export default function Shell() {
|
export default function Shell() {
|
||||||
const { user } = useZino()
|
const { user } = useZino()
|
||||||
@ -37,6 +35,7 @@ export default function Shell() {
|
|||||||
// lives on the overview, which counts the whole book for everyone.
|
// lives on the overview, which counts the whole book for everyone.
|
||||||
const stages = visibleStages(roles)
|
const stages = visibleStages(roles)
|
||||||
const { counts, ready } = useStageCounts()
|
const { counts, ready } = useStageCounts()
|
||||||
|
const [openAgent, setOpenAgent] = useState(null)
|
||||||
|
|
||||||
// One queue row. Extracted because the live groups and the folded Closed
|
// One queue row. Extracted because the live groups and the folded Closed
|
||||||
// group render the same thing and drifted apart when they did not.
|
// group render the same thing and drifted apart when they did not.
|
||||||
@ -54,12 +53,13 @@ export default function Shell() {
|
|||||||
+ (ready && !n && !c.working ? ' is-empty' : '')
|
+ (ready && !n && !c.working ? ' is-empty' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Icon name={KIND_ICON[s.kind] || 'list'} className="shell__ico" />
|
||||||
<span className="shell__stagename">{s.need ?? s.name}</span>
|
<span className="shell__stagename">{s.need ?? s.name}</span>
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<span className="shell__tally">
|
<span className="shell__tally">
|
||||||
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null}
|
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null}
|
||||||
{/* The agents' share is shown in the middle dot notation rather
|
{/* The agents' share is shown apart rather than added in: it is in
|
||||||
than added in: it is in the queue, it is not your work. */}
|
the queue, it is not your work. */}
|
||||||
{c.working ? <em className="shell__auto" title={`${c.working} being worked by an agent`}>{c.working}</em> : null}
|
{c.working ? <em className="shell__auto" title={`${c.working} being worked by an agent`}>{c.working}</em> : null}
|
||||||
<b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
|
<b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
|
||||||
</span>
|
</span>
|
||||||
@ -73,35 +73,24 @@ export default function Shell() {
|
|||||||
<header className="shell__top">
|
<header className="shell__top">
|
||||||
<div className="shell__brand">
|
<div className="shell__brand">
|
||||||
<img src={logo} alt="Zurich Kotak General Insurance" />
|
<img src={logo} alt="Zurich Kotak General Insurance" />
|
||||||
<div className="shell__brandtext">
|
<span className="shell__brandtext">Lead Desk</span>
|
||||||
<strong>Lead Desk</strong>
|
|
||||||
<span>Lead to Policy</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<GlobalSearch />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell__body">
|
<div className="shell__body">
|
||||||
<nav className="shell__nav" aria-label="Pipeline">
|
<nav className="shell__nav" aria-label="Pipeline">
|
||||||
<NavLink
|
<div className="shell__navtop">
|
||||||
to="/"
|
<NavLink to="/" end className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
|
||||||
end
|
<Icon name="home" className="shell__ico" />
|
||||||
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
|
|
||||||
>
|
|
||||||
Overview
|
Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink to="/stage/all" className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}>
|
||||||
to="/stage/all"
|
<Icon name="list" className="shell__ico" />
|
||||||
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
|
|
||||||
>
|
|
||||||
All leads
|
All leads
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</div>
|
||||||
{/* "New lead" used to sit here. A sidebar is for moving between
|
|
||||||
places; filing a lead is an ACTION, and an action in a list of
|
|
||||||
destinations is the one item that does not behave like its
|
|
||||||
neighbours. It lives on the screens where leads are looked at —
|
|
||||||
the overview and the queues — beside the thing it adds to. */}
|
|
||||||
|
|
||||||
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
|
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
|
||||||
const inGroup = stages.filter((s) => s.kind === group.kind)
|
const inGroup = stages.filter((s) => s.kind === group.kind)
|
||||||
@ -120,11 +109,11 @@ export default function Shell() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Closed. Folded, because it is reporting rather than work — and at
|
{/* Closed. Folded, because it is reporting rather than work. */}
|
||||||
equal weight with the live queues it made them harder to find. */}
|
|
||||||
{stages.some((s) => s.kind === 'end') ? (
|
{stages.some((s) => s.kind === 'end') ? (
|
||||||
<details className="shell__closed">
|
<details className="shell__closed">
|
||||||
<summary>
|
<summary>
|
||||||
|
<Icon name="chevronRight" className="shell__caret" />
|
||||||
Closed
|
Closed
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<b>{stages.filter((s) => s.kind === 'end')
|
<b>{stages.filter((s) => s.kind === 'end')
|
||||||
@ -134,12 +123,26 @@ export default function Shell() {
|
|||||||
<div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
|
<div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
|
||||||
</details>
|
</details>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* The team that carries the work. Five employees, each one click
|
||||||
|
from what it does and what it may touch. */}
|
||||||
|
<div className="shell__team">
|
||||||
|
<p className="shell__navlabel">AI employees</p>
|
||||||
|
<div className="shell__teamrow">
|
||||||
|
{Object.keys(AGENTS).map((k) => (
|
||||||
|
<AgentChip key={k} agentKey={k} size="md" showName={false} onOpen={setOpenAgent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="shell__teamnote">Working every open lead. Click one to see its remit.</span>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="shell__main">
|
<main className="shell__main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{openAgent ? <AgentCard agentKey={openAgent} onClose={() => setOpenAgent(null)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,96 +1,43 @@
|
|||||||
.um {
|
.um { position: relative; justify-self: end; }
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__trigger {
|
.um__trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 5px 12px 5px 5px;
|
padding: 4px 10px 4px 4px;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
|
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.um__trigger:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
|
||||||
.um__trigger:hover {
|
.um__trigger.is-open { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||||
background: var(--zk-tint);
|
.um__trigger:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
border-color: var(--zk-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__trigger.is-open {
|
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
border-color: var(--zk-blue-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__trigger:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--zk-blue);
|
|
||||||
box-shadow: var(--ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__avatar {
|
|
||||||
flex: none;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: var(--grad-blue);
|
|
||||||
color: var(--zk-white);
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
box-shadow: var(--sh-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__avatar--lg {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__id {
|
.um__id {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1.3;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
.um__id strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
|
||||||
|
.um__id > span { font-size: 11.5px; color: var(--zk-muted); }
|
||||||
|
|
||||||
.um__id strong {
|
.um__caret { flex: none; color: var(--zk-grey); transition: transform var(--t); }
|
||||||
font-size: var(--fs-xs);
|
.um__trigger.is-open .um__caret { transform: rotate(180deg); }
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__id > span {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
color: var(--zk-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__caret {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
flex: none;
|
|
||||||
color: var(--zk-grey);
|
|
||||||
transition: transform var(--t);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__trigger.is-open .um__caret {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__menu {
|
.um__menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 8px);
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
padding: 7px;
|
padding: 6px;
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-md);
|
||||||
background: var(--zk-white);
|
background: var(--zk-card);
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-card-line);
|
||||||
box-shadow: var(--sh-lg);
|
box-shadow: var(--sh-lg);
|
||||||
transform-origin: top right;
|
transform-origin: top right;
|
||||||
animation: zk-rise 0.16s var(--ease) both;
|
animation: zk-rise 0.16s var(--ease) both;
|
||||||
@ -106,56 +53,31 @@
|
|||||||
.um__headid {
|
.um__headid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.um__headid strong { font-size: var(--fs-xs); font-weight: var(--fw-medium); }
|
||||||
.um__headid strong {
|
.um__headid > span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
|
||||||
font-size: var(--fs-xs);
|
.um__roles { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__headid span {
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
color: var(--zk-muted);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__headid .um__roles {
|
|
||||||
margin-top: 4px;
|
|
||||||
align-self: flex-start;
|
|
||||||
font-size: var(--fs-2xs);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
border-radius: var(--r-pill);
|
|
||||||
padding: 2px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__item {
|
.um__item {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-xs);
|
||||||
padding: 10px 11px;
|
padding: 9px 11px;
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-top: 1px solid var(--zk-line-soft);
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
border-radius: 0 0 var(--r-sm) var(--r-sm);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
transition: background var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.um__item:hover { background: var(--zk-hover); color: var(--zk-blue-dark); }
|
||||||
.um__item:hover {
|
.um__item:focus-visible { outline: none; background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
|
||||||
background: var(--zk-tint);
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.um__item:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
background: var(--zk-tint-blue);
|
|
||||||
color: var(--zk-blue-dark);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import { initials, roleLabel } from '../api/display.js'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
import './UserMenu.css'
|
import './UserMenu.css'
|
||||||
|
|
||||||
/** First letters of the first two words — a name is all we have for an avatar. */
|
|
||||||
function initials(user) {
|
|
||||||
const from = user?.name || user?.email || ''
|
|
||||||
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
|
|
||||||
if (!parts.length) return '?'
|
|
||||||
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The signed-in identity, with sign-out folded inside it.
|
* The signed-in identity, with sign-out folded inside it.
|
||||||
*
|
*
|
||||||
* The role stays visible on the trigger rather than only inside the menu: RBAC is
|
* The role stays visible on the trigger rather than only inside the menu: RBAC is
|
||||||
* the demo, so who you are signed in as should never take a click to find out.
|
* the demo, so who you are signed in as should never take a click to find out.
|
||||||
|
* It reads as a label — "SME underwriter" — never as the stored slug.
|
||||||
*/
|
*/
|
||||||
export default function UserMenu() {
|
export default function UserMenu() {
|
||||||
const { user, signOut } = useZino()
|
const { user, signOut } = useZino()
|
||||||
@ -43,7 +38,8 @@ export default function UserMenu() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const name = user?.name || user?.email || 'Signed in'
|
const name = user?.name || user?.email || 'Signed in'
|
||||||
const roles = (user?.roles || []).join(', ')
|
const roles = (user?.roles || []).map(roleLabel).filter(Boolean)
|
||||||
|
const who = initials(user?.name || (user?.email || '').replace(/@.*$/, '').replace(/[._-]+/g, ' '))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="um" ref={wrapRef}>
|
<div className="um" ref={wrapRef}>
|
||||||
@ -55,28 +51,30 @@ export default function UserMenu() {
|
|||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
>
|
>
|
||||||
<span className="um__avatar" aria-hidden="true">{initials(user)}</span>
|
<span className="avatar avatar--sm avatar--blue" aria-hidden="true">{who}</span>
|
||||||
<span className="um__id">
|
<span className="um__id">
|
||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
<span>{roles || user?.email}</span>
|
<span>{roles.join(' · ') || user?.email}</span>
|
||||||
</span>
|
</span>
|
||||||
<svg className="um__caret" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
<Icon name="chevronDown" size={14} className="um__caret" />
|
||||||
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.4"
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="um__menu" role="menu">
|
<div className="um__menu" role="menu">
|
||||||
<div className="um__head">
|
<div className="um__head">
|
||||||
<span className="um__avatar um__avatar--lg" aria-hidden="true">{initials(user)}</span>
|
<span className="avatar avatar--lg avatar--blue" aria-hidden="true">{who}</span>
|
||||||
<div className="um__headid">
|
<div className="um__headid">
|
||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
{user?.email ? <span>{user.email}</span> : null}
|
{user?.email ? <span>{user.email}</span> : null}
|
||||||
{roles ? <span className="um__roles">{roles}</span> : null}
|
{roles.length ? (
|
||||||
|
<span className="um__roles">
|
||||||
|
{roles.map((r) => <span key={r} className="pill pill--blue">{r}</span>)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
|
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
|
||||||
|
<Icon name="signOut" />
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
.login {
|
.login {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 45% 55%;
|
grid-template-columns: minmax(380px, 44%) minmax(0, 1fr);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9,12 +9,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 48px;
|
padding: 40px 48px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--zk-white);
|
color: var(--zk-white);
|
||||||
background:
|
background: linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
|
||||||
linear-gradient(160deg, var(--zk-navy) 0%, #1d4c86 55%, var(--zk-blue) 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__grid {
|
.login__grid {
|
||||||
@ -31,22 +29,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(
|
radial-gradient(520px circle at 12% 12%, rgba(84, 149, 207, 0.38), transparent 62%),
|
||||||
520px circle at 12% 12%,
|
radial-gradient(620px circle at 78% 96%, rgba(199, 235, 249, 0.16), transparent 60%);
|
||||||
rgba(84, 149, 207, 0.38),
|
|
||||||
transparent 62%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
620px circle at 78% 96%,
|
|
||||||
rgba(199, 235, 249, 0.16),
|
|
||||||
transparent 60%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__logo {
|
.login__logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 186px;
|
width: 170px;
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@ -54,153 +44,146 @@
|
|||||||
.login__brand-body {
|
.login__brand-body {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin-top: auto;
|
margin: auto 0;
|
||||||
margin-bottom: auto;
|
padding: 48px 0 24px;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__badge {
|
.login__badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
padding: 8px 16px;
|
padding: 6px 14px 6px 10px;
|
||||||
border: 1px solid rgba(199, 235, 249, 0.34);
|
border: 1px solid rgba(199, 235, 249, 0.3);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--zk-cyan);
|
color: var(--zk-cyan);
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 400;
|
font-weight: var(--fw-medium);
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
}
|
||||||
|
.login__badgedot {
|
||||||
.login__badge svg {
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
width: 15px;
|
background: #7ee2b8;
|
||||||
height: 15px;
|
box-shadow: 0 0 0 0 rgba(126, 226, 184, .6);
|
||||||
|
animation: zk-live 2.2s var(--ease) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__headline {
|
.login__headline {
|
||||||
/* Zurich Kotak set their hero in Zurich Sans Light */
|
margin: 22px 0 0;
|
||||||
margin: 26px 0 0;
|
font-size: 44px;
|
||||||
font-size: var(--fs-3xl);
|
font-weight: 500;
|
||||||
font-weight: 300;
|
line-height: 1.05;
|
||||||
line-height: 1.16;
|
letter-spacing: -0.03em;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__headline-accent {
|
|
||||||
display: block;
|
|
||||||
background: linear-gradient(
|
|
||||||
92deg,
|
|
||||||
#a9dcf6 0%,
|
|
||||||
var(--zk-cyan) 55%,
|
|
||||||
#e9f8ff 100%
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__lede {
|
.login__lede {
|
||||||
margin: 16px 0 0;
|
margin: 14px 0 0;
|
||||||
max-width: 430px;
|
max-width: 440px;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.5;
|
line-height: 1.55;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__features {
|
.login__features {
|
||||||
margin: 36px 0 0;
|
margin: 34px 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
}
|
|
||||||
|
|
||||||
.login__features li {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
}
|
||||||
|
.login__features li { display: flex; align-items: flex-start; gap: 14px; }
|
||||||
|
|
||||||
.login__feature-icon {
|
.login__feature-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 34px;
|
width: 36px;
|
||||||
height: 34px;
|
height: 36px;
|
||||||
border: 1px solid rgba(199, 235, 249, 0.26);
|
border: 1px solid rgba(199, 235, 249, 0.26);
|
||||||
border-radius: 9px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: var(--zk-cyan);
|
color: var(--zk-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__feature-icon svg {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__features strong {
|
.login__features strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.005em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__features em {
|
.login__features em {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
|
line-height: 1.5;
|
||||||
color: rgba(255, 255, 255, 0.74);
|
color: rgba(255, 255, 255, 0.74);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login__team {
|
||||||
|
margin-top: 36px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
.login__team > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.login__teamrow { display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
||||||
|
.login__teamrow .who__name { color: rgba(255, 255, 255, 0.86); }
|
||||||
|
|
||||||
/* ── Right form panel ──────────────────────────────────────── */
|
/* ── Right form panel ──────────────────────────────────────── */
|
||||||
.login__panel {
|
.login__panel {
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 22px;
|
||||||
padding: 40px 24px;
|
padding: 40px 24px;
|
||||||
background: var(--zk-tint);
|
background: var(--zk-ground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__form-wrap {
|
.login__card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 440px;
|
||||||
|
padding: 34px 36px 30px;
|
||||||
|
background: var(--zk-card);
|
||||||
|
border: 1px solid var(--zk-card-line);
|
||||||
|
border-radius: var(--r-xl);
|
||||||
|
box-shadow: var(--sh-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__logo--mobile {
|
.login__logo--mobile { display: none; margin-bottom: 24px; }
|
||||||
display: none;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__title {
|
.login__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--fs-2xl);
|
font-size: var(--fs-2xl);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.02em;
|
||||||
color: var(--zk-navy);
|
color: var(--zk-navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__subtitle {
|
.login__subtitle {
|
||||||
margin: 8px 0 0;
|
margin: 6px 0 0;
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 300;
|
|
||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__form {
|
.login__form {
|
||||||
margin-top: 30px;
|
margin-top: 26px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__field span {
|
.login__field span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 6px;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
@ -210,31 +193,45 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 14px;
|
padding: 11px 14px;
|
||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-line);
|
||||||
border-radius: 8px;
|
border-radius: var(--r-sm);
|
||||||
background: var(--zk-white);
|
background: var(--zk-white);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 400;
|
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
transition:
|
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||||
border-color 0.16s ease,
|
|
||||||
box-shadow 0.16s ease;
|
|
||||||
}
|
}
|
||||||
|
.login__field input::placeholder { color: var(--zk-grey); }
|
||||||
|
.login__field input:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
|
||||||
.login__field input::placeholder {
|
.login__pw { position: relative; }
|
||||||
|
.login__pw input { padding-right: 44px; }
|
||||||
|
.login__eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border: 0; border-radius: 6px;
|
||||||
|
background: none;
|
||||||
color: var(--zk-grey);
|
color: var(--zk-grey);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.login__eye:hover { background: var(--zk-hover); color: var(--zk-ink); }
|
||||||
.login__field input:focus {
|
.login__eye[aria-pressed="true"] { color: var(--zk-blue); }
|
||||||
outline: none;
|
|
||||||
border-color: var(--zk-blue);
|
|
||||||
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__error {
|
.login__error {
|
||||||
margin: -4px 0 0;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -2px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--zk-danger-tint);
|
||||||
|
border: 1px solid var(--zk-danger-line);
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
color: var(--zk-danger);
|
line-height: 1.45;
|
||||||
|
color: var(--zk-danger-ink);
|
||||||
}
|
}
|
||||||
|
.login__error svg { flex: none; margin-top: 2px; }
|
||||||
|
|
||||||
.login__submit {
|
.login__submit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -244,44 +241,67 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: var(--r-sm);
|
||||||
background: var(--zk-blue);
|
background: var(--zk-blue);
|
||||||
color: var(--zk-white);
|
color: var(--zk-white);
|
||||||
font-size: var(--fs-xs);
|
font-size: var(--fs-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition: background 0.16s ease, box-shadow 0.16s ease;
|
||||||
background 0.16s ease,
|
|
||||||
box-shadow 0.16s ease;
|
|
||||||
}
|
}
|
||||||
|
.login__submit:hover:not(:disabled) { background: var(--zk-blue-dark); }
|
||||||
|
.login__submit:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28); }
|
||||||
|
.login__submit:disabled { opacity: 0.72; cursor: default; }
|
||||||
|
|
||||||
.login__submit svg {
|
/* ── Personas ─────────────────────────────────────────────── */
|
||||||
width: 16px;
|
.login__personas {
|
||||||
height: 16px;
|
margin-top: 26px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
}
|
}
|
||||||
|
.login__personas-l {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.login__personas-l span { font-size: var(--fs-2xs); font-weight: 500; color: var(--zk-ink); }
|
||||||
|
.login__personas-l em { font-size: 11.5px; font-style: normal; color: var(--zk-grey); }
|
||||||
|
|
||||||
.login__submit:hover:not(:disabled) {
|
.login__personagrid { display: flex; flex-direction: column; gap: 4px; }
|
||||||
background: var(--zk-blue-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__submit:focus-visible {
|
.persona {
|
||||||
outline: none;
|
display: grid;
|
||||||
box-shadow: 0 0 0 3px rgba(33, 103, 174, 0.28);
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
}
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
.login__submit:disabled {
|
width: 100%;
|
||||||
opacity: 0.72;
|
padding: 7px 10px 7px 7px;
|
||||||
cursor: default;
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
}
|
}
|
||||||
|
.persona:hover { background: var(--zk-hover); border-color: var(--zk-card-line); }
|
||||||
|
.persona.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||||
|
.persona:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
.persona__t { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
|
||||||
|
.persona__t strong { font-size: var(--fs-2xs); font-weight: 500; }
|
||||||
|
.persona__t span { font-size: 11.5px; color: var(--zk-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.persona__ok { color: var(--zk-blue); }
|
||||||
|
|
||||||
.login__powered {
|
.login__powered {
|
||||||
margin: 26px 0 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: var(--fs-2xs);
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--zk-grey);
|
color: var(--zk-grey);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__powered span {
|
.login__powered span {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@ -291,34 +311,13 @@
|
|||||||
|
|
||||||
/* ── Responsive ────────────────────────────────────────────── */
|
/* ── Responsive ────────────────────────────────────────────── */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.login__brand {
|
.login__brand { padding: 32px; }
|
||||||
padding: 32px;
|
.login__headline { font-size: 38px; }
|
||||||
}
|
|
||||||
|
|
||||||
.login__headline {
|
|
||||||
font-size: var(--fs-3xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__lede {
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.login {
|
.login { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
.login__brand { display: none; }
|
||||||
}
|
.login__logo--mobile { display: block; }
|
||||||
|
.login__card { padding: 26px 22px; }
|
||||||
.login__brand {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__panel {
|
|
||||||
align-content: center;
|
|
||||||
background: var(--zk-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login__logo--mobile {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,51 +1,64 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { ORG_ID } from '../api/config.js'
|
import { ORG_ID } from '../api/config.js'
|
||||||
|
import { AGENTS } from '../api/agents.js'
|
||||||
|
import AgentChip from '../components/AgentChip.jsx'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
import logo from '../assets/brand/zurich_logo.webp'
|
import logo from '../assets/brand/zurich_logo.webp'
|
||||||
import './Login.css'
|
import './Login.css'
|
||||||
|
|
||||||
const FEATURES = [
|
/**
|
||||||
|
* What the console is, said to the person about to use it — not what the
|
||||||
|
* insurer sells. The old panel carried retail copy ("we cover it all"); this is
|
||||||
|
* an operator's tool, and the three lines are the three claims the demo makes.
|
||||||
|
*/
|
||||||
|
const CLAIMS = [
|
||||||
{
|
{
|
||||||
title: 'Instant policy issuance',
|
icon: 'bot',
|
||||||
body: 'No inspection; hassle free policy',
|
title: 'Autonomous by default',
|
||||||
icon: (
|
body: 'Five AI employees qualify, call, chase, price and check every renewal lead.',
|
||||||
<>
|
|
||||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" />
|
|
||||||
<path d="M14 3v5h5" />
|
|
||||||
<path d="m9.2 14.2 1.9 1.9 3.7-3.7" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '24/7 claim assistance',
|
icon: 'user',
|
||||||
body: 'Making claims simple, transparent, and customer-first',
|
title: 'A person at the gates',
|
||||||
icon: (
|
body: 'Documents, the underwriting call and the premium sign-off stay with people.',
|
||||||
<>
|
|
||||||
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
|
|
||||||
<path d="M12 7.2V12l3.2 1.9" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'A trusted brand',
|
icon: 'brain',
|
||||||
body: 'You can count on Zurich Kotak General Insurance at all times',
|
title: 'Every decision on record',
|
||||||
icon: (
|
body: 'Each step shows who took it, what it wrote, and the reasoning behind it.',
|
||||||
<>
|
|
||||||
<path d="M12 3 4.8 5.9v5.4c0 4.3 3 8.3 7.2 9.4 4.2-1.1 7.2-5.1 7.2-9.4V5.9L12 3Z" />
|
|
||||||
<path d="m9.2 12.1 2 2 3.6-3.8" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo sign-ins. These are the accounts listed in README.md for org 84; the
|
||||||
|
* picker only saves typing the address during a walkthrough. No password is
|
||||||
|
* held here — the gateway authenticates every sign-in as it always did, and
|
||||||
|
* anything a persona can do is still decided by the workflow, server-side.
|
||||||
|
*/
|
||||||
|
const PERSONAS = [
|
||||||
|
{ email: 'sanjay.uw@zurichkotak.example', name: 'Sanjay Bhat', role: 'SME underwriter', does: 'Clears or declines referrals', tone: 'blue' },
|
||||||
|
{ email: 'arjun.posp@zurichkotak.example', name: 'Arjun Shetty', role: 'Partner agent', does: 'Files leads, uploads documents', tone: 'teal' },
|
||||||
|
{ email: 'meera.rm@zurichkotak.example', name: 'Meera Rao', role: 'Bancassurance RM', does: 'Files partner-bank leads', tone: 'violet' },
|
||||||
|
{ email: 'kavya.csr@zurichkotak.example', name: 'Kavya Nair', role: 'Direct sales', does: 'Files self-serve leads', tone: 'amber' },
|
||||||
|
{ email: 'deepa.ops@zurichkotak.example', name: 'Deepa Iyer', role: 'Operations', does: 'Sees every queue, confirms premiums', tone: 'plum' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function initialsOf(name) {
|
||||||
|
const p = name.split(' ')
|
||||||
|
return (p[0][0] + (p[1]?.[0] || '')).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPw, setShowPw] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const { signIn } = useZino()
|
const { signIn } = useZino()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const pwRef = useRef(null)
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
async function handleSubmit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -72,83 +85,69 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pick(p) {
|
||||||
|
setEmail(p.email)
|
||||||
|
setError('')
|
||||||
|
// The address is filled; the password is still theirs to type.
|
||||||
|
pwRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = PERSONAS.find((p) => p.email === email)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login">
|
<main className="login">
|
||||||
<section className="login__brand">
|
<section className="login__brand">
|
||||||
<div className="login__grid" aria-hidden="true" />
|
<div className="login__grid" aria-hidden="true" />
|
||||||
<div className="login__glow" aria-hidden="true" />
|
<div className="login__glow" aria-hidden="true" />
|
||||||
|
|
||||||
<img
|
<img className="login__logo" src={logo} alt="Zurich Kotak General Insurance" />
|
||||||
className="login__logo"
|
|
||||||
src={logo}
|
|
||||||
alt="Zurich Kotak General Insurance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="login__brand-body">
|
<div className="login__brand-body">
|
||||||
<span className="login__badge">
|
<span className="login__badge">
|
||||||
<svg
|
<span className="login__badgedot" aria-hidden="true" />
|
||||||
viewBox="0 0 24 24"
|
Lead to Policy · operator console
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M3.2 12.4a8.8 8.8 0 0 1 17.6 0H3.2Z" />
|
|
||||||
<path d="M12 12.4v5.9a2.4 2.4 0 0 0 4.8 0" />
|
|
||||||
</svg>
|
|
||||||
General insurance that has it all
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<h1 className="login__headline">
|
<h1 className="login__headline">
|
||||||
Protecting what
|
Lead Desk
|
||||||
<span className="login__headline-accent">
|
|
||||||
matters the most to you
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="login__lede">
|
<p className="login__lede">
|
||||||
Get tailored insurance for all your needs, cause we cover it all
|
Every motor and SME renewal lead, from the first call to the issued policy —
|
||||||
|
carried by AI employees, with people at the decisions that need one.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="login__features">
|
<ul className="login__features">
|
||||||
{FEATURES.map((feature) => (
|
{CLAIMS.map((c) => (
|
||||||
<li key={feature.title}>
|
<li key={c.title}>
|
||||||
<span className="login__feature-icon" aria-hidden="true">
|
<span className="login__feature-icon" aria-hidden="true">
|
||||||
<svg
|
<Icon name={c.icon} size={18} strokeWidth={1.5} />
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.7"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
{feature.icon}
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{feature.title}</strong>
|
<strong>{c.title}</strong>
|
||||||
<em>{feature.body}</em>
|
<em>{c.body}</em>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div className="login__team">
|
||||||
|
<span>The team</span>
|
||||||
|
<div className="login__teamrow">
|
||||||
|
{Object.keys(AGENTS).map((k) => (
|
||||||
|
<AgentChip key={k} agentKey={k} size="md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="login__panel">
|
<section className="login__panel">
|
||||||
<div className="login__form-wrap">
|
<div className="login__card">
|
||||||
<img
|
<img className="login__logo login__logo--mobile" src={logo} alt="Zurich Kotak General Insurance" />
|
||||||
className="login__logo login__logo--mobile"
|
|
||||||
src={logo}
|
|
||||||
alt="Zurich Kotak General Insurance"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h2 className="login__title">Sign in</h2>
|
<h2 className="login__title">Sign in</h2>
|
||||||
<p className="login__subtitle">
|
<p className="login__subtitle">Use your Lead Desk account for organisation {ORG_ID}.</p>
|
||||||
Enter your credentials to access your dashboard.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form className="login__form" onSubmit={handleSubmit} noValidate>
|
<form className="login__form" onSubmit={handleSubmit} noValidate>
|
||||||
<label className="login__field">
|
<label className="login__field">
|
||||||
@ -157,7 +156,7 @@ export default function Login() {
|
|||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="you@company.com"
|
placeholder="you@zurichkotak.example"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -165,44 +164,69 @@ export default function Login() {
|
|||||||
|
|
||||||
<label className="login__field">
|
<label className="login__field">
|
||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
|
<div className="login__pw">
|
||||||
<input
|
<input
|
||||||
type="password"
|
ref={pwRef}
|
||||||
|
type={showPw ? 'text' : 'password'}
|
||||||
name="password"
|
name="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login__eye"
|
||||||
|
onClick={() => setShowPw((v) => !v)}
|
||||||
|
aria-label={showPw ? 'Hide password' : 'Show password'}
|
||||||
|
aria-pressed={showPw}
|
||||||
|
>
|
||||||
|
<Icon name="eye" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="login__error" role="alert">
|
<p className="login__error" role="alert">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<button className="login__submit" type="submit" disabled={submitting}>
|
<button className="login__submit" type="submit" disabled={submitting}>
|
||||||
{submitting ? 'Signing in…' : 'Sign in'}
|
{submitting ? 'Signing in…' : picked ? `Sign in as ${picked.name.split(' ')[0]}` : 'Sign in'}
|
||||||
{submitting ? null : (
|
{submitting ? null : <Icon name="arrowRight" />}
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
aria-hidden="true"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.9"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 12h15m0 0-5.5-5.5M19 12l-5.5 5.5" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="login__personas">
|
||||||
|
<p className="login__personas-l">
|
||||||
|
<span>Demo personas</span>
|
||||||
|
<em>pick one to fill the email</em>
|
||||||
|
</p>
|
||||||
|
<div className="login__personagrid">
|
||||||
|
{PERSONAS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.email}
|
||||||
|
type="button"
|
||||||
|
className={'persona' + (email === p.email ? ' is-on' : '')}
|
||||||
|
onClick={() => pick(p)}
|
||||||
|
>
|
||||||
|
<span className={'avatar avatar--' + p.tone} aria-hidden="true">{initialsOf(p.name)}</span>
|
||||||
|
<span className="persona__t">
|
||||||
|
<strong>{p.name}</strong>
|
||||||
|
<span>{p.role} · {p.does}</span>
|
||||||
|
</span>
|
||||||
|
{email === p.email ? <Icon name="check" className="persona__ok" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="login__powered">
|
<p className="login__powered">
|
||||||
Powered by <span>zino</span>
|
Powered by <span>zino</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,13 +7,15 @@ import AgentChip from '../components/AgentChip.jsx'
|
|||||||
import CallTranscript from '../components/CallTranscript.jsx'
|
import CallTranscript from '../components/CallTranscript.jsx'
|
||||||
import ClampText from '../components/ClampText.jsx'
|
import ClampText from '../components/ClampText.jsx'
|
||||||
import Conversation from '../components/Conversation.jsx'
|
import Conversation from '../components/Conversation.jsx'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
import LeadFileDialog from '../components/LeadFileDialog.jsx'
|
import LeadFileDialog from '../components/LeadFileDialog.jsx'
|
||||||
import StageRail from '../components/StageRail.jsx'
|
import StageRail from '../components/StageRail.jsx'
|
||||||
import Timeline from '../components/Timeline.jsx'
|
import Timeline from '../components/Timeline.jsx'
|
||||||
import { APP_ID, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
|
import { APP_ID, CHANNELS, DOC_SLOTS, DV_LEAD, PRODUCTS, STAGES, STALL_AFTER_MS, blockedOn, nudgeFor, phaseOf } from '../api/config.js'
|
||||||
import { AGENTS } from '../api/agents.js'
|
import { AGENTS } from '../api/agents.js'
|
||||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
|
import { humanize, initials, toneFor } from '../api/display.js'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
|
|
||||||
/** The countdown, not just the date. On a renewal book this is the number
|
/** The countdown, not just the date. On a renewal book this is the number
|
||||||
@ -50,10 +52,6 @@ const GROUPS = [
|
|||||||
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
|
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
|
||||||
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
|
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
|
||||||
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
|
['Contact', ['contact_outcome','outreach_window','contact_notes','call_transcript']],
|
||||||
// The five OCR slots led this list and were absent from it, which is how a
|
|
||||||
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
|
|
||||||
// — the status and the notes. They were also absent from the view itself
|
|
||||||
// until 76; adding them here without that would have changed nothing.
|
|
||||||
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
|
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
|
||||||
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
|
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
|
||||||
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
|
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
|
||||||
@ -74,22 +72,10 @@ const GROUPS = [
|
|||||||
const LONG = 150
|
const LONG = 150
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED.
|
* WHAT THE PAGE SHOWS WITHOUT BEING ASKED. Three things are always true of a
|
||||||
*
|
* lead — who the customer is, what is being insured, and what has been
|
||||||
* Three things are always true of a lead — who the customer is, what is being
|
* attached — so those are always here. The fourth section depends on where
|
||||||
* insured, and what has been attached — so those are always here. The fourth
|
* the lead has got to. Everything else is in the full file.
|
||||||
* section depends on where the lead has got to: a lead in Quoted needs the
|
|
||||||
* cover and the quote's expiry; the same lead in Onboarded needs the policy
|
|
||||||
* number and when it renews. Everything else is in the full file.
|
|
||||||
*
|
|
||||||
* THIS IS A MIRROR, in the same sense as STAGES, ACTIONS and DOC_SLOTS, and it
|
|
||||||
* carries the same hazard: a stage not listed here simply gets no fourth
|
|
||||||
* section, and a field renamed in the workflow quietly stops appearing. That is
|
|
||||||
* the safe direction to fail — the full file is generated from the data and
|
|
||||||
* still holds everything — but it is a mirror, not a source.
|
|
||||||
*
|
|
||||||
* The metrics strip at the top of the page already carries premium, commission,
|
|
||||||
* product, renewal date, lead age and AI confidence. Nothing is repeated here.
|
|
||||||
*/
|
*/
|
||||||
const GLANCE_STAGE = {
|
const GLANCE_STAGE = {
|
||||||
'zk-state-new': ['lead_score', 'eligibility_outcome'],
|
'zk-state-new': ['lead_score', 'eligibility_outcome'],
|
||||||
@ -111,14 +97,7 @@ const GLANCE_RISK = {
|
|||||||
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
|
sme: ['sme_product_variant', 'sme_occupancy', 'sme_value_at_risk'],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Fallback labels for a field the config does not describe. */
|
||||||
* Field ids are snake_case and several carry acronyms, which sentence-casing
|
|
||||||
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
|
|
||||||
* other word passes through.
|
|
||||||
*
|
|
||||||
* This is the FALLBACK. The detail view ships an output_label for every field
|
|
||||||
* and that is preferred — this covers a field the config does not describe.
|
|
||||||
*/
|
|
||||||
const WORDS = {
|
const WORDS = {
|
||||||
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
|
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
|
||||||
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
|
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
|
||||||
@ -136,73 +115,70 @@ function filesOf(v) {
|
|||||||
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
|
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fields whose value is a code that must NOT be spelt out. */
|
||||||
|
const VERBATIM = new Set(['lead_ref', 'pan', 'gstin', 'udyam_no', 'motor_reg_no', 'partner_code', 'policy_no',
|
||||||
|
'payment_ref', 'kyc_ref', 'acceptance_ref', 'dedupe_match_ref', 'payout_ref', 'product_code', 'motor_prev_policy_no', 'email', 'mobile'])
|
||||||
|
|
||||||
function fmt(k, v) {
|
function fmt(k, v) {
|
||||||
if (v === null || v === undefined || v === '') return null
|
if (v === null || v === undefined || v === '') return null
|
||||||
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
|
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
// A file field holds an array of upload references — {uuid, original_name,
|
|
||||||
// blob_path, …} — so joining it raw prints [object Object].
|
|
||||||
return v
|
return v
|
||||||
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
|
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : humanize(x)))
|
||||||
.filter((x) => x !== '' && x !== null && x !== undefined)
|
.filter((x) => x !== '' && x !== null && x !== undefined)
|
||||||
.join(', ') || null
|
.join(', ') || null
|
||||||
}
|
}
|
||||||
// No field carries a bare object today, but one arriving as JSON should not
|
|
||||||
// print as [object Object].
|
|
||||||
if (typeof v === 'object') return JSON.stringify(v)
|
if (typeof v === 'object') return JSON.stringify(v)
|
||||||
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
|
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
|
||||||
return String(v)
|
// Stored tokens read as English; references, names and prose pass through.
|
||||||
|
return VERBATIM.has(k) ? String(v) : humanize(String(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whose move it is, right now — the question an operator opens a lead to
|
* Whose move it is, right now — said as a coloured pill in the header.
|
||||||
* answer, said as a coloured pill in the header. Derived from the stage's own
|
|
||||||
* kind, so it stays in step with the workflow: an AI-run stage is with the AI,
|
|
||||||
* a customer-gated one is with the customer, a person-gated one is waiting on
|
|
||||||
* someone, and a closed lead holds nobody.
|
|
||||||
*/
|
*/
|
||||||
function holdsOf(stage) {
|
function holdsOf(stage) {
|
||||||
if (!stage) return null
|
if (!stage) return null
|
||||||
switch (stage.kind) {
|
switch (stage.kind) {
|
||||||
case 'auto': return { tone: 'ai', label: 'With the AI' }
|
case 'auto': return { tone: 'ai', icon: 'bot', label: 'With the AI' }
|
||||||
case 'customer': return { tone: 'grey', pillTone: 'cust', label: 'With the customer' }
|
case 'customer': return { tone: 'cust', icon: 'chat', label: 'With the customer' }
|
||||||
case 'needs': return { tone: 'person', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
|
case 'needs': return { tone: 'person', icon: 'user', label: stage.approval ? 'Awaiting your approval' : 'Waiting on a person' }
|
||||||
case 'waiting': return { tone: 'grey', label: 'In nurture' }
|
case 'waiting': return { tone: 'grey', icon: 'clock', label: 'In nurture' }
|
||||||
case 'end': return { tone: 'grey', label: 'Closed' }
|
case 'end': return { tone: 'grey', icon: 'archive', label: 'Closed' }
|
||||||
default: return null
|
default: return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A file's extension, for the little type tag on a document chip. */
|
||||||
|
function extOf(name) {
|
||||||
|
const m = String(name || '').match(/\.([a-z0-9]{2,5})$/i)
|
||||||
|
return m ? m[1].toUpperCase() : 'FILE'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Lead() {
|
export default function Lead() {
|
||||||
const { instanceId } = useParams()
|
const { instanceId } = useParams()
|
||||||
const { client, user } = useZino()
|
const { client, user } = useZino()
|
||||||
const roles = rolesOf(user)
|
const roles = rolesOf(user)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// What the workflow said when the last activity was submitted. It is written
|
// What the workflow said when the last activity was submitted — shown
|
||||||
// per activity — "Documents received — capturing the risk" — and is the
|
|
||||||
// workflow telling the operator what it just set in motion, so it is shown
|
|
||||||
// verbatim rather than replaced with a generic "Saved".
|
// verbatim rather than replaced with a generic "Saved".
|
||||||
const [note, setNote] = useState(location.state?.message ?? null)
|
const [note, setNote] = useState(location.state?.message ?? null)
|
||||||
const [row, setRow] = useState(null)
|
const [row, setRow] = useState(null)
|
||||||
const [err, setErr] = useState(null)
|
const [err, setErr] = useState(null)
|
||||||
const [open, setOpen] = useState(null)
|
const [open, setOpen] = useState(null)
|
||||||
const [labels, setLabels] = useState({})
|
const [labels, setLabels] = useState({})
|
||||||
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline
|
// The audit rows live HERE, not inside Timeline: one fetch, one poll, and
|
||||||
// fetched them once on mount and never again — so a lead worked by five
|
// the conversation view reads the same rows.
|
||||||
// agents in three minutes showed the trail as it was when you opened the
|
|
||||||
// page — and the conversation view needs the same rows. One fetch, one
|
|
||||||
// poll, two readers that cannot disagree.
|
|
||||||
const [audit, setAudit] = useState(null)
|
const [audit, setAudit] = useState(null)
|
||||||
const [showChat, setShowChat] = useState(false)
|
const [showChat, setShowChat] = useState(false)
|
||||||
const [openAgent, setOpenAgent] = useState(null)
|
const [openAgent, setOpenAgent] = useState(null)
|
||||||
const [showCall, setShowCall] = useState(false)
|
const [showCall, setShowCall] = useState(false)
|
||||||
// The full record, on demand — see the panel comment below.
|
|
||||||
const [showFile, setShowFile] = useState(false)
|
const [showFile, setShowFile] = useState(false)
|
||||||
// A clock in state rather than Date.now() in the render body. Reading the
|
// Newest first by default: on a live demo the step that just happened is
|
||||||
// wall clock while rendering is impure — React may render twice and get two
|
// the one everybody is looking for. One click reads it as a story instead.
|
||||||
// answers — and it also means the "nothing for 6 minutes" counter would only
|
const [newestFirst, setNewestFirst] = useState(true)
|
||||||
// move when something else happened to re-render the page. This ticks it.
|
// A clock in state rather than Date.now() in the render body.
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
const load = useCallback((quiet = false) => {
|
const load = useCallback((quiet = false) => {
|
||||||
@ -215,8 +191,6 @@ export default function Lead() {
|
|||||||
return client.detailView(DV_LEAD, instanceId)
|
return client.detailView(DV_LEAD, instanceId)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setRow(r?.data ?? r?.record ?? r)
|
setRow(r?.data ?? r?.record ?? r)
|
||||||
// The detail view ships an output_label for every field. Using the
|
|
||||||
// server's names means a field renamed in Studio is renamed here.
|
|
||||||
const fields = r?.config?.fields
|
const fields = r?.config?.fields
|
||||||
if (Array.isArray(fields)) {
|
if (Array.isArray(fields)) {
|
||||||
setLabels(Object.fromEntries(
|
setLabels(Object.fromEntries(
|
||||||
@ -225,26 +199,19 @@ export default function Lead() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
// A poll that fails leaves the screen exactly as it is. Only a first load
|
// A poll that fails leaves the screen exactly as it is. Only a first load
|
||||||
// becomes an error page: a gateway blip must not throw away a lead the
|
// becomes an error page.
|
||||||
// operator is reading, or unmount a half-filled form under them.
|
|
||||||
.catch((e) => { if (!quiet) setErr(e) })
|
.catch((e) => { if (!quiet) setErr(e) })
|
||||||
}, [client, instanceId])
|
}, [client, instanceId])
|
||||||
|
|
||||||
// `load` is CALLED here, not handed to useEffect: it returns a promise so the
|
// `load` is CALLED here, not handed to useEffect: it returns a promise.
|
||||||
// poll below and onDone can chain on it, and an effect that returns anything
|
|
||||||
// but a function has that value called as cleanup — React invokes the promise
|
|
||||||
// as destroy(), throws, and unmounts the whole tree to a blank screen.
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
// The message is handed over on the history entry, so it would show again
|
// The message is handed over on the history entry; consume it once.
|
||||||
// every time this lead is reached by Back — on a lead that has since moved on.
|
|
||||||
// Consume it once, then strike it from the entry.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
|
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
|
||||||
}, [location.state, location.pathname, navigate])
|
}, [location.state, location.pathname, navigate])
|
||||||
|
|
||||||
// A lead that no longer exists is not a page to look at. Send the operator
|
// A lead that no longer exists is not a page to look at.
|
||||||
// back to the queue rather than leaving them on an empty shell.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (err && describeError(err).kind === 'gone') {
|
if (err && describeError(err).kind === 'gone') {
|
||||||
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
|
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
|
||||||
@ -252,22 +219,14 @@ export default function Lead() {
|
|||||||
}
|
}
|
||||||
}, [err, navigate])
|
}, [err, navigate])
|
||||||
|
|
||||||
/**
|
|
||||||
* Nothing is pushed — no socket, no SSE — and most of this workflow is carried
|
|
||||||
* by AI employees that take one to three minutes a step, so a lead genuinely
|
|
||||||
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
|
|
||||||
* at all while the tab is in the background.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(Date.now()), 20000)
|
const id = setInterval(() => setNow(Date.now()), 20000)
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Not while a form is open. A successful poll rewrites `actions`, and if the
|
// Not while a form is open: a successful poll rewrites `actions`, and if
|
||||||
// lead has moved on the open form unmounts — taking whatever was typed into
|
// the lead has moved on the open form unmounts with whatever was typed.
|
||||||
// it with no warning. A submission against a stale state is refused by the
|
|
||||||
// workflow anyway, and that refusal is handled properly below.
|
|
||||||
if (open) return undefined
|
if (open) return undefined
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
if (document.visibilityState === 'visible') load(true)
|
if (document.visibilityState === 'visible') load(true)
|
||||||
@ -285,7 +244,15 @@ export default function Lead() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!row) return <p className="empty">Loading…</p>
|
if (!row) {
|
||||||
|
return (
|
||||||
|
<section aria-busy="true">
|
||||||
|
<header className="page__head"><div className="page__lead"><span className="skel__disc" /><div><div className="skel__line skel__line--title" /><div className="skel__line" /></div></div></header>
|
||||||
|
<div className="skel skel--tiles"><div /><div /><div /><div /></div>
|
||||||
|
<div className="skel"><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /></div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Only groups that actually hold something; an empty tab is a dead end.
|
// Only groups that actually hold something; an empty tab is a dead end.
|
||||||
const groups = GROUPS
|
const groups = GROUPS
|
||||||
@ -296,22 +263,12 @@ export default function Lead() {
|
|||||||
.filter(([, shown]) => shown.length)
|
.filter(([, shown]) => shown.length)
|
||||||
|
|
||||||
const stateName = row.current_state_name || ''
|
const stateName = row.current_state_name || ''
|
||||||
// The state, then what is really happening inside it — see phaseOf. Actions
|
|
||||||
// are still looked up on the state's own uid; only the presentation moves.
|
|
||||||
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
|
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
|
||||||
const holds = holdsOf(stage)
|
const holds = holdsOf(stage)
|
||||||
// Only what THIS user may run. The workflow refuses the rest server-side
|
// Only what THIS user may run. The workflow refuses the rest server-side.
|
||||||
// anyway; showing them a row of buttons that all 403 reads as a broken app
|
|
||||||
// rather than as a control.
|
|
||||||
const actions = actionsFor(roles, stage?.uid)
|
const actions = actionsFor(roles, stage?.uid)
|
||||||
|
|
||||||
// THE ONE THING THIS USER MUST DO, promoted to the header. "What do I do
|
// THE ONE THING THIS USER MUST DO, promoted to the header.
|
||||||
// now" is the first question an operator asks of a lead, and it was answered
|
|
||||||
// only by scrolling past the metrics and the stage rail to the Next step
|
|
||||||
// panel. When this user has an action, it belongs top-right, where the eye
|
|
||||||
// goes for the next move; when they do not, the passive status stays there
|
|
||||||
// instead. `role: 'do'` is the committing step — never a re-run or a
|
|
||||||
// recovery lever, which must not be dressed as the thing to do.
|
|
||||||
const acceptedAlready = Boolean(row.acceptance_ref)
|
const acceptedAlready = Boolean(row.acceptance_ref)
|
||||||
const primaryAction = actions.find(
|
const primaryAction = actions.find(
|
||||||
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
|
(a) => a.role === 'do' && !(acceptedAlready && a.uid === 'zk-act-accept'),
|
||||||
@ -319,16 +276,11 @@ export default function Lead() {
|
|||||||
const goToAction = () => {
|
const goToAction = () => {
|
||||||
if (!primaryAction) return
|
if (!primaryAction) return
|
||||||
setOpen(primaryAction.uid)
|
setOpen(primaryAction.uid)
|
||||||
// Let the form mount, then bring the working surface into view.
|
|
||||||
requestAnimationFrame(() =>
|
requestAnimationFrame(() =>
|
||||||
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
|
document.getElementById('lead-next')?.scrollIntoView({ behavior: 'smooth', block: 'center' }))
|
||||||
}
|
}
|
||||||
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
|
const fieldCount = groups.reduce((n, [, shown]) => n + shown.length, 0)
|
||||||
|
|
||||||
// Built from the SAME rows the full file renders, so the two can never
|
|
||||||
// disagree about a value. A section with nothing in it is dropped rather
|
|
||||||
// than rendered empty — an SME lead has no registration number, and a
|
|
||||||
// heading over a blank box reads as a bug.
|
|
||||||
const pick = (keys) => keys
|
const pick = (keys) => keys
|
||||||
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
|
.map((k) => [k, fmt(k, row[k]), filesOf(row[k])])
|
||||||
.filter(([, v]) => v !== null)
|
.filter(([, v]) => v !== null)
|
||||||
@ -337,25 +289,22 @@ export default function Lead() {
|
|||||||
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
|
const line = row.product_line === 'sme_package' ? 'sme' : 'motor'
|
||||||
|
|
||||||
const glance = [
|
const glance = [
|
||||||
['Customer', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
|
['Customer', 'user', pick(['customer_name', 'entity_name', 'mobile', 'email'])],
|
||||||
[line === 'sme' ? 'The risk' : 'The vehicle', pick(GLANCE_RISK[line])],
|
[line === 'sme' ? 'The risk' : 'The vehicle', line === 'sme' ? 'building' : 'car', pick(GLANCE_RISK[line])],
|
||||||
['Documents', pick(docKeys)],
|
['Documents', 'file', pick(docKeys)],
|
||||||
// THE CALL IS THE ONE STEP NOBODY CAN REPLAY FROM THE FIELDS. Everything
|
['The call', 'phone', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
|
||||||
// else on this lead was typed or derived; this happened out loud, and
|
['This stage', 'sparkle', pick(GLANCE_STAGE[stage?.uid] || [])],
|
||||||
// until now its outcome sat in the full file with the source channel while
|
].filter(([, , rows]) => rows.length)
|
||||||
// the words themselves were reachable only from a button halfway down the
|
|
||||||
// trail. It gets its own card whenever a call has actually happened.
|
|
||||||
['The call', pick(['contact_outcome', 'outreach_window', 'contact_notes'])],
|
|
||||||
['This stage', pick(GLANCE_STAGE[stage?.uid] || [])],
|
|
||||||
].filter(([, rows]) => rows.length)
|
|
||||||
|
|
||||||
|
// Who worked this lead, and how many steps each took. Derived from the same
|
||||||
// Two different reasons for an empty action list, and they must not read the
|
// audit rows the trail renders, so they cannot drift.
|
||||||
// same. A terminal lead is finished; a live one you cannot act on belongs to
|
const stepsBy = {}
|
||||||
// somebody else, and saying "closed" about it would be a lie.
|
for (const r of audit || []) {
|
||||||
// Derived from the same audit rows the trail renders, so they cannot drift.
|
if (r.activity_id === 'DATA_UPDATE') continue
|
||||||
const worked = [...new Set((audit || [])
|
const k = (r.user_roles || []).find((x) => AGENTS[x])
|
||||||
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
|
if (k) stepsBy[k] = (stepsBy[k] || 0) + 1
|
||||||
|
}
|
||||||
|
const worked = Object.keys(AGENTS).filter((k) => stepsBy[k])
|
||||||
const chatTurns = (audit || []).reduce((n, r) => {
|
const chatTurns = (audit || []).reduce((n, r) => {
|
||||||
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
|
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
|
||||||
return n + fs.filter((f) => {
|
return n + fs.filter((f) => {
|
||||||
@ -365,74 +314,53 @@ export default function Lead() {
|
|||||||
}).length
|
}).length
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// An automated stage that has stopped for a stated reason. Checked before
|
|
||||||
// the "in progress" strip below, which would otherwise keep promising that
|
|
||||||
// something is happening for as long as the lead is left alone.
|
|
||||||
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
|
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
|
||||||
// An automated step that has sat too long. Distinct from `blocked`, which is
|
|
||||||
// the workflow refusing for a stated reason — this is the agent not having
|
|
||||||
// come back, and the cause is usually outside this app entirely: a slow or
|
|
||||||
// failing model provider, a thinking budget that ran out mid-sentence, a
|
|
||||||
// wake that never arrived. The console cannot see any of that, so it reports
|
|
||||||
// the only thing it can know — nothing has happened for a while — and offers
|
|
||||||
// the way out rather than going on promising two minutes.
|
|
||||||
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
|
const stillFor = row.updated_at ? now - Date.parse(row.updated_at) : 0
|
||||||
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
|
const stalled = !blocked && stage?.kind === 'auto' && stillFor > STALL_AFTER_MS
|
||||||
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
|
? { mins: Math.round(stillFor / 60000), nudge: nudgeFor(stage, row) }
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const e = expiryOf(row.renewal_due_date)
|
||||||
|
const premium = inrShort(row.quoted_premium)
|
||||||
|
const commission = inrShort(row.commission_amount)
|
||||||
|
const conf = Number(row.ai_recommendation_confidence)
|
||||||
|
const ch = CHANNELS[row.source_channel]
|
||||||
|
const openAction = open ? [...actions, { uid: 'zk-act-collect-docs', label: 'Replace or add a document', by: 'you' }].find((a) => a.uid === open) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head page__head--lead">
|
||||||
<div className="page__lead">
|
<div className="page__lead">
|
||||||
{/* React Router stamps the first entry in a session with key 'default',
|
|
||||||
so a lead opened from its own URL has nothing to go back TO and
|
|
||||||
lands on the queue instead of leaving the app. */}
|
|
||||||
<button
|
<button
|
||||||
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
|
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
|
||||||
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
|
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="chevronLeft" />
|
||||||
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<span className={'avatar avatar--lg avatar--' + toneFor(row.customer_name)} aria-hidden="true">{initials(row.customer_name)}</span>
|
||||||
|
<div className="page__idblock">
|
||||||
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
|
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
|
||||||
<p className="page__sub">
|
<p className="page__sub page__sub--chips">
|
||||||
{row.lead_ref || `Lead ${instanceId}`}
|
<span className="page__ref">{row.lead_ref || `Lead ${instanceId}`}</span>
|
||||||
{row.entity_name ? ` · ${row.entity_name}` : ''}
|
{row.product_line ? <span className={'pill ' + (line === 'motor' ? 'pill--blue' : 'pill--violet')}>{PRODUCTS[row.product_line] ?? row.product_line}</span> : null}
|
||||||
|
{ch ? <span className="pill pill--line">{ch.label}</span> : null}
|
||||||
|
{row.entity_name ? <span className="page__entity">{row.entity_name}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* WHOSE MOVE IT IS. The stage name is in the rail below; this says
|
|
||||||
who holds the lead right now, which is the question the header is
|
|
||||||
really being asked. The customer tone reuses the teal cust palette. */}
|
|
||||||
{primaryAction ? (
|
{primaryAction ? (
|
||||||
/* YOUR MOVE. When this user owns the next step, the header carries it
|
/* YOUR MOVE. When this user owns the next step, the header carries
|
||||||
as the prominent thing — amber, because it is waiting on a person —
|
it as the prominent thing. */
|
||||||
and pressing it opens the form below and scrolls to it. */
|
|
||||||
<button type="button" className="cta" onClick={goToAction}>
|
<button type="button" className="cta" onClick={goToAction}>
|
||||||
<span className="cta__txt">
|
<span className="cta__txt">
|
||||||
<small>Your move</small>
|
<small>Your move</small>
|
||||||
<b>{primaryAction.label}</b>
|
<b>{primaryAction.label}</b>
|
||||||
</span>
|
</span>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
<Icon name="arrowRight" />
|
||||||
</button>
|
</button>
|
||||||
) : holds ? (
|
) : holds ? (
|
||||||
<div className={'holds holds--' + (holds.pillTone === 'cust' ? 'cust' : holds.tone)}>
|
<div className={'holds holds--' + holds.tone}>
|
||||||
<span className="holds__puck" aria-hidden="true">
|
<span className="holds__puck" aria-hidden="true"><Icon name={holds.icon} size={16} /></span>
|
||||||
{holds.tone === 'ai' ? (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 3a2 2 0 0 0-2 2 2 2 0 0 0-1 3.6A2 2 0 0 0 4 12a2 2 0 0 0 2 2M14 3a2 2 0 0 1 2 2 2 2 0 0 1 1 3.6A2 2 0 0 1 16 12a2 2 0 0 1-2 2M10 3.5v12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>
|
|
||||||
) : holds.pillTone === 'cust' ? (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M4 5h12v8h-6l-3 2.5V13H4z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/></svg>
|
|
||||||
) : holds.tone === 'person' ? (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="6.5" r="2.8" fill="none" stroke="currentColor" strokeWidth="1.5"/><path d="M4.5 15.5a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
|
||||||
) : (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M10 5.5v4.5l3 2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/><circle cx="10" cy="10" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5"/></svg>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="holds__txt">
|
<span className="holds__txt">
|
||||||
<small>Right now</small>
|
<small>Right now</small>
|
||||||
<b>{holds.label}</b>
|
<b>{holds.label}</b>
|
||||||
@ -441,23 +369,9 @@ export default function Lead() {
|
|||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Measures, not fields. Age and dwell time are computed — neither is on
|
{/* Measures, not fields. FOUR NUMBERS: money leads, the AI's confidence
|
||||||
the record — and they are the two that answer "is this lead moving?",
|
sits beside it, the renewal countdown is the one deadline that matters. */}
|
||||||
which no label/value pair on the page could. */}
|
<div className="lmetrics card">
|
||||||
{(() => {
|
|
||||||
const e = expiryOf(row.renewal_due_date)
|
|
||||||
const premium = inrShort(row.quoted_premium)
|
|
||||||
const commission = inrShort(row.commission_amount)
|
|
||||||
const conf = Number(row.ai_recommendation_confidence)
|
|
||||||
// FOUR NUMBERS, NOT SEVEN. Money leads, because that is what a renewal
|
|
||||||
// is about; the AI's confidence in the cover sits beside it; the
|
|
||||||
// renewal countdown is the one deadline that matters. Dropped: lead age
|
|
||||||
// and time-in-stage (they overlapped each other and the feed carries
|
|
||||||
// timing), and product/vehicle (the rail's "The vehicle" holds them).
|
|
||||||
// Nothing is lost — every removed measure is still on the page once,
|
|
||||||
// where it belongs.
|
|
||||||
return (
|
|
||||||
<div className="lmetrics">
|
|
||||||
<div className="lm lm--hero">
|
<div className="lm lm--hero">
|
||||||
<span className="lm__l">Premium</span>
|
<span className="lm__l">Premium</span>
|
||||||
<strong className="lm__v">{premium ?? '—'}</strong>
|
<strong className="lm__v">{premium ?? '—'}</strong>
|
||||||
@ -479,48 +393,43 @@ export default function Lead() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="lm">
|
<div className="lm">
|
||||||
<span className="lm__l">Renewal</span>
|
<span className="lm__l">Renewal</span>
|
||||||
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong>
|
<strong className={'lm__v' + (e ? ' lm__v--' + e.tone : '')}>{e ? e.label : '—'}</strong>
|
||||||
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
|
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="lm lm--rail">
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<StageRail audit={audit} currentName={stateName} />
|
<StageRail audit={audit} currentName={stateName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="lead">
|
<div className="lead">
|
||||||
<div className="lead__main">
|
<div className="lead__main">
|
||||||
{note ? (
|
{note ? (
|
||||||
<div className="said" role="status">
|
<div className="said" role="status">
|
||||||
|
<Icon name="check" className="said__ico" />
|
||||||
<p>{note}</p>
|
<p>{note}</p>
|
||||||
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
|
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss"><Icon name="x" size={14} /></button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* An AI-carried stage has no empty action bar and no idle screen: it
|
{/* An AI-carried stage has no empty action bar and no idle screen: it
|
||||||
says what is happening and who is doing it. The activities below it
|
says what is happening and who is doing it. */}
|
||||||
stay available — ops can run them by hand — but they are not the
|
|
||||||
answer to "why is nothing moving?". */}
|
|
||||||
{blocked ? (
|
{blocked ? (
|
||||||
<div className="doing doing--blocked">
|
<div className="doing doing--blocked">
|
||||||
<span className="doing__stop" aria-hidden="true" />
|
<span className="doing__ico"><Icon name="alert" size={18} /></span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Stopped — {blocked.what}</strong>
|
<strong>Stopped — {blocked.what}</strong>
|
||||||
<span>
|
<span>
|
||||||
{blocked.fix} The agents will pick the lead up again on their own once it
|
{blocked.fix} The agents will pick the lead up again on their own once it
|
||||||
is there; nothing else is running in the meantime.
|
is there; nothing else is running in the meantime.
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button type="button" className="doing__fix" onClick={() => setOpen(blocked.via)}>
|
||||||
type="button" className="doing__fix"
|
|
||||||
onClick={() => setOpen(blocked.via)}
|
|
||||||
>
|
|
||||||
Open Collect Documents
|
Open Collect Documents
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : stalled ? (
|
) : stalled ? (
|
||||||
<div className="doing doing--stalled">
|
<div className="doing doing--stalled">
|
||||||
<span className="doing__stop" aria-hidden="true" />
|
<span className="doing__ico"><Icon name="clock" size={18} /></span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{stage.doing} — nothing for {stalled.mins} minutes</strong>
|
<strong>{stage.doing} — nothing for {stalled.mins} minutes</strong>
|
||||||
<span>
|
<span>
|
||||||
@ -532,22 +441,15 @@ export default function Lead() {
|
|||||||
: ' There is no step to re-run from here; the actions below are the way on.'}
|
: ' There is no step to re-run from here; the actions below are the way on.'}
|
||||||
</span>
|
</span>
|
||||||
{stalled.nudge ? (
|
{stalled.nudge ? (
|
||||||
<button
|
<button type="button" className="doing__fix" onClick={() => setOpen(stalled.nudge.uid)}>
|
||||||
type="button" className="doing__fix"
|
|
||||||
onClick={() => setOpen(stalled.nudge.uid)}
|
|
||||||
>
|
|
||||||
{stalled.nudge.label}
|
{stalled.nudge.label}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : stage?.kind === 'customer' && stage.doing ? (
|
) : stage?.kind === 'customer' && stage.doing ? (
|
||||||
/* Not an agent working, and not a task of yours either — the lead
|
|
||||||
is with someone outside the business. Without this the screen
|
|
||||||
went blank at exactly the stage where an operator is most likely
|
|
||||||
to wonder whether something has broken. */
|
|
||||||
<div className="doing doing--waiting">
|
<div className="doing doing--waiting">
|
||||||
<span className="doing__wait" aria-hidden="true" />
|
<span className="doing__ico"><Icon name="chat" size={18} /></span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{stage.doing}</strong>
|
<strong>{stage.doing}</strong>
|
||||||
<span>
|
<span>
|
||||||
@ -558,7 +460,7 @@ export default function Lead() {
|
|||||||
</div>
|
</div>
|
||||||
) : stage?.kind === 'auto' ? (
|
) : stage?.kind === 'auto' ? (
|
||||||
<div className="doing">
|
<div className="doing">
|
||||||
<span className="doing__pulse" aria-hidden="true" />
|
<span className="doing__ico doing__ico--pulse"><Icon name="bot" size={18} /></span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{stage.doing}</strong>
|
<strong>{stage.doing}</strong>
|
||||||
<span>
|
<span>
|
||||||
@ -570,26 +472,12 @@ export default function Lead() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* WHAT TO DO NEXT, not what is permitted.
|
{/* WHAT TO DO NEXT, not what is permitted. `do` is the step this
|
||||||
Every action carries a role: `do` is the step this stage is
|
stage is waiting on, `again` a bounded loop, `force` an AI's own
|
||||||
waiting on, `again` is a bounded loop, `force` is an AI's own job
|
job offered only so a stalled lead can be pushed by hand, `exit`
|
||||||
offered only so a stalled lead can be pushed by hand, `exit` is
|
Mark Lost. */}
|
||||||
Mark Lost. Rendering them as one flat row of equals is what made
|
|
||||||
six buttons appear where one was the answer. */}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Once the documents are in, the upload is not the step, not an
|
|
||||||
// alternative to the step, and not a loop worth offering: it is
|
|
||||||
// recovery. Demoting it only to 'again' still printed an Upload
|
|
||||||
// documents button under a panel saying the documents had been
|
|
||||||
// received, which is the contradiction that was reported. It goes
|
|
||||||
// into the fold with the other recovery levers, relabelled for what
|
|
||||||
// it is actually for — a wrong file, or one more.
|
|
||||||
const docsDone = stage?.after === 'documents received'
|
const docsDone = stage?.after === 'documents received'
|
||||||
// Recording an acceptance a second time is not a loop, a recovery
|
|
||||||
// or an alternative — it is meaningless. The customer accepted on
|
|
||||||
// WhatsApp at a stated time and the lead carries the reference;
|
|
||||||
// offering the button again invites somebody to overwrite that
|
|
||||||
// with a worse record of the same event.
|
|
||||||
const accepted = Boolean(row.acceptance_ref)
|
const accepted = Boolean(row.acceptance_ref)
|
||||||
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
|
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
|
||||||
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
|
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
|
||||||
@ -605,6 +493,15 @@ export default function Lead() {
|
|||||||
|
|
||||||
const form = open ? (
|
const form = open ? (
|
||||||
<div className="acts__form">
|
<div className="acts__form">
|
||||||
|
<div className="acts__formhead">
|
||||||
|
<div>
|
||||||
|
<h3>{openAction?.label ?? 'Complete the step'}</h3>
|
||||||
|
{openAction?.by ? <p>Performed by {openAction.by}</p> : null}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => setOpen(null)}>
|
||||||
|
<Icon name="x" size={13} /> Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ActivityForm
|
<ActivityForm
|
||||||
activityUid={open}
|
activityUid={open}
|
||||||
instanceId={Number(instanceId)}
|
instanceId={Number(instanceId)}
|
||||||
@ -623,26 +520,16 @@ export default function Lead() {
|
|||||||
if (!step.length && !again.length && !extras.length) return null
|
if (!step.length && !again.length && !extras.length) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel" id="lead-next">
|
<div className="card sect" id="lead-next">
|
||||||
{/* THE HEADING IS FOR A REAL STEP ONLY. When there is no `do`
|
|
||||||
action, the "what is happening / nothing owed" story is
|
|
||||||
already told once — by the status strip above and the
|
|
||||||
header — so repeating it here as "Next step: nothing is
|
|
||||||
waiting on you" was the same sentence a third time. The
|
|
||||||
secondary actions (add a document, mark lost) still render
|
|
||||||
below, without the framing. */}
|
|
||||||
{step.length ? (
|
{step.length ? (
|
||||||
<div className="panel__head">
|
<div className="sect__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">Next step</h2>
|
<h2 className="h-sec">Next step</h2>
|
||||||
<p className="panel__sub">This is what this lead is waiting on.</p>
|
<p className="h-sub">This is what this lead is waiting on.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* The step. One button, sized like a decision. Referred has
|
|
||||||
two because clear and decline are a pair, not a choice
|
|
||||||
between doing something and doing nothing. */}
|
|
||||||
{step.length ? (
|
{step.length ? (
|
||||||
<div className="step">
|
<div className="step">
|
||||||
{step.map((a) => (
|
{step.map((a) => (
|
||||||
@ -652,8 +539,11 @@ export default function Lead() {
|
|||||||
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
|
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
|
||||||
onClick={() => setOpen(open === a.uid ? null : a.uid)}
|
onClick={() => setOpen(open === a.uid ? null : a.uid)}
|
||||||
>
|
>
|
||||||
|
<span className="step__ico"><Icon name={a.uid === 'zk-act-uw-decline' ? 'x' : 'check'} /></span>
|
||||||
|
<span className="step__t">
|
||||||
<strong>{a.label}</strong>
|
<strong>{a.label}</strong>
|
||||||
<span>{a.by}</span>
|
<span>{a.by}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -673,12 +563,9 @@ export default function Lead() {
|
|||||||
|
|
||||||
{form}
|
{form}
|
||||||
|
|
||||||
{/* Recovery, folded. An operator needs these on the day an
|
|
||||||
agent stalls and never otherwise, and putting them in the
|
|
||||||
open makes an AI's own work look like an outstanding task. */}
|
|
||||||
{extras.length ? (
|
{extras.length ? (
|
||||||
<details className="stuck">
|
<details className="stuck">
|
||||||
<summary>Lead not moving?</summary>
|
<summary><Icon name="chevronRight" size={12} className="stuck__caret" />Lead not moving?</summary>
|
||||||
<p className="stuck__why">
|
<p className="stuck__why">
|
||||||
{force.length
|
{force.length
|
||||||
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
|
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
|
||||||
@ -701,38 +588,31 @@ export default function Lead() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* THE STORY IS THE MAIN COLUMN NOW.
|
{/* THE STORY IS THE MAIN COLUMN. */}
|
||||||
This sat in a 384px gutter with its own inner scrollbar, beside a
|
<div className="card sect">
|
||||||
panel of reference fields that had the whole width. That was
|
<div className="sect__head">
|
||||||
exactly backwards. The trail is what this product is FOR — it is
|
|
||||||
the record of work nobody watched happen — and putting it in a
|
|
||||||
sidebar said it was a footnote to the form fields.
|
|
||||||
|
|
||||||
It gets the wide column and the page's own scroll. The reference
|
|
||||||
data moved to the rail, where short key/value pairs belong and
|
|
||||||
where a narrow measure costs nothing. */}
|
|
||||||
<div className="panel">
|
|
||||||
<div className="panel__head">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">What happened</h2>
|
<h2 className="h-sec">What happened</h2>
|
||||||
<p className="panel__sub">
|
<p className="h-sub">Every step on this lead, who took it, and what they wrote.</p>
|
||||||
Every step on this lead, who took it, and what they wrote.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sect__tools">
|
||||||
{chatTurns ? (
|
{chatTurns ? (
|
||||||
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
|
<button type="button" className="btn btn--sm" onClick={() => setShowChat(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="whatsapp" size={13} />
|
||||||
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
|
|
||||||
strokeWidth="1.3" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
Conversation
|
Conversation
|
||||||
<b>{chatTurns}</b>
|
<b className="btn__n">{chatTurns}</b>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="seg" role="group" aria-label="Order">
|
||||||
|
<button type="button" className={'seg__b' + (newestFirst ? ' is-on' : '')} aria-pressed={newestFirst} onClick={() => setNewestFirst(true)}>Newest first</button>
|
||||||
|
<button type="button" className={'seg__b' + (!newestFirst ? ' is-on' : '')} aria-pressed={!newestFirst} onClick={() => setNewestFirst(false)}>As it happened</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Timeline
|
<Timeline
|
||||||
rows={audit}
|
rows={audit}
|
||||||
|
newestFirst={newestFirst}
|
||||||
onOpenAgent={setOpenAgent}
|
onOpenAgent={setOpenAgent}
|
||||||
onOpenChat={() => setShowChat(true)}
|
onOpenChat={() => setShowChat(true)}
|
||||||
onOpenCall={() => setShowCall(true)}
|
onOpenCall={() => setShowCall(true)}
|
||||||
@ -743,44 +623,43 @@ export default function Lead() {
|
|||||||
{/* Reference, not narrative. Sticky, so the facts stay put while the
|
{/* Reference, not narrative. Sticky, so the facts stay put while the
|
||||||
story is read against them. */}
|
story is read against them. */}
|
||||||
<aside className="lead__side">
|
<aside className="lead__side">
|
||||||
{/* Who worked this lead. Five agents carry it and their names appear
|
{/* The team on this lead: who, what it does, how many steps. */}
|
||||||
only inside individual entries, so the team was never visible at
|
|
||||||
a glance. */}
|
|
||||||
{worked.length ? (
|
{worked.length ? (
|
||||||
<div className="panel panel--crew">
|
<div className="card sect sect--tight">
|
||||||
<span className="crew__l">Worked by</span>
|
<div className="sect__head">
|
||||||
<div className="crew">
|
<div>
|
||||||
{worked.map((k) => <AgentChip key={k} agentKey={k} onOpen={setOpenAgent} />)}
|
<h2 className="h-sec">AI team on this lead</h2>
|
||||||
|
<p className="h-sub">{Object.values(stepsBy).reduce((a, b) => a + b, 0)} steps taken without a person.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ul className="crew">
|
||||||
|
{worked.map((k) => (
|
||||||
|
<li key={k} className="crew__row">
|
||||||
|
<AgentChip agentKey={k} size="md" showName={false} onOpen={setOpenAgent} />
|
||||||
|
<div className="crew__t">
|
||||||
|
<button type="button" className="crew__name" onClick={() => setOpenAgent(k)}>{AGENTS[k].full}</button>
|
||||||
|
<span>{AGENTS[k].does}</span>
|
||||||
|
</div>
|
||||||
|
<b className="crew__n">{stepsBy[k]}</b>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{/* WHAT MATTERS HERE, NOT EVERYTHING THERE IS.
|
|
||||||
The whole record used to sit open on the page: fifty-seven
|
|
||||||
fields over thirteen groups, which pushed the audit trail — the
|
|
||||||
part people actually read — a screen and a half down. Most of it
|
|
||||||
is reference material. Nobody opens a lead to find its source
|
|
||||||
channel; they open it to see who the customer is, what is being
|
|
||||||
insured, what has been attached, and what this stage turns on.
|
|
||||||
|
|
||||||
So the page carries that, and the complete file is one click
|
|
||||||
away with a filter on it. Hiding is only safe when it stays
|
|
||||||
findable, which is why the trigger below names the number it is
|
|
||||||
holding back rather than saying "more". */}
|
|
||||||
{glance.length ? (
|
{glance.length ? (
|
||||||
<div className="panel">
|
<div className="card sect sect--tight">
|
||||||
<div className="panel__head">
|
<div className="sect__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">At a glance</h2>
|
<h2 className="h-sec">At a glance</h2>
|
||||||
<p className="panel__sub">
|
<p className="h-sub">The customer, the risk, and what this stage turns on.</p>
|
||||||
The customer, the risk, and what this stage turns on.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="glance">
|
<div className="glance">
|
||||||
{glance.map(([title, rows]) => (
|
{glance.map(([title, icon, rows]) => (
|
||||||
<section className="fgroup" key={title}>
|
<section className="fgroup" key={title}>
|
||||||
<h3>{title}<span>{rows.length}</span></h3>
|
<h3><Icon name={icon} size={14} />{title}<span>{rows.length}</span></h3>
|
||||||
<dl className="props">
|
<dl className="props">
|
||||||
{rows.map(([k, v, files]) => (
|
{rows.map(([k, v, files]) => (
|
||||||
<div className="prop" key={k}>
|
<div className="prop" key={k}>
|
||||||
@ -790,19 +669,17 @@ export default function Lead() {
|
|||||||
{files.map((f) => (
|
{files.map((f) => (
|
||||||
<a
|
<a
|
||||||
key={f.uuid}
|
key={f.uuid}
|
||||||
className="prop__file"
|
className="doc"
|
||||||
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
|
||||||
target="_blank" rel="noreferrer"
|
target="_blank" rel="noreferrer"
|
||||||
|
title={f.original_name}
|
||||||
>
|
>
|
||||||
{f.original_name || 'Document'}
|
<span className="doc__ext">{extOf(f.original_name)}</span>
|
||||||
|
<span className="doc__name">{f.original_name || 'Document'}</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</dd>
|
</dd>
|
||||||
) : v.length > LONG ? (
|
) : v.length > LONG ? (
|
||||||
/* A stage reason can run to a paragraph —
|
|
||||||
uw_referral_reason and lost_reason both do — and
|
|
||||||
a paragraph printed raw here would rebuild the
|
|
||||||
wall this panel exists to remove. */
|
|
||||||
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
|
<dd><ClampText text={v} title={labels[k] ?? label(k)} lines={2} /></dd>
|
||||||
) : (
|
) : (
|
||||||
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||||
@ -811,11 +688,8 @@ export default function Lead() {
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
{title === 'The call' && row.call_transcript ? (
|
{title === 'The call' && row.call_transcript ? (
|
||||||
<button type="button" className="glance__act" onClick={() => setShowCall(true)}>
|
<button type="button" className="btn btn--sm glance__act" onClick={() => setShowCall(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="phone" size={13} />
|
||||||
<path d="M3.4 2.8h2.4l1.2 3-1.5 1a7.5 7.5 0 0 0 3.7 3.7l1-1.5 3 1.2v2.4a1 1 0 0 1-1.1 1A10.6 10.6 0 0 1 2.4 3.9a1 1 0 0 1 1-1.1Z"
|
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
Read what was actually said
|
Read what was actually said
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@ -825,12 +699,10 @@ export default function Lead() {
|
|||||||
|
|
||||||
{fieldCount ? (
|
{fieldCount ? (
|
||||||
<button type="button" className="glance__all" onClick={() => setShowFile(true)}>
|
<button type="button" className="glance__all" onClick={() => setShowFile(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="list" size={15} />
|
||||||
<path d="M3 3.5h10M3 8h10M3 12.5h6" fill="none" stroke="currentColor"
|
|
||||||
strokeWidth="1.4" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
Open the full lead file
|
Open the full lead file
|
||||||
<b>{fieldCount} fields</b>
|
<b>{fieldCount} fields</b>
|
||||||
|
<Icon name="chevronRight" size={14} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import { useZino } from '../api/provider.jsx'
|
|||||||
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
||||||
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
|
import { initials, toneFor, inrShort, KIND_ICON } from '../api/display.js'
|
||||||
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,10 +43,7 @@ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
|
|||||||
|
|
||||||
/** Indian money, short. A dashboard tile has no room for eight digits. */
|
/** Indian money, short. A dashboard tile has no room for eight digits. */
|
||||||
function inr(n) {
|
function inr(n) {
|
||||||
if (!n) return '₹0'
|
return inrShort(n) ?? '₹0'
|
||||||
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
|
|
||||||
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
|
|
||||||
return '₹' + Math.round(n).toLocaleString('en-IN')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiryTone(d) {
|
function expiryTone(d) {
|
||||||
@ -55,6 +54,9 @@ function expiryTone(d) {
|
|||||||
return 'later'
|
return 'later'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The colour a stage's share of the book wears in the flow bar. */
|
||||||
|
const FLOW_TONE = { needs: 'amber', customer: 'teal', auto: 'blue', waiting: 'grey', end: 'line' }
|
||||||
|
|
||||||
export default function Overview() {
|
export default function Overview() {
|
||||||
const { user } = useZino()
|
const { user } = useZino()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -63,6 +65,9 @@ export default function Overview() {
|
|||||||
// Filing a lead happens over this page, not instead of it — see
|
// Filing a lead happens over this page, not instead of it — see
|
||||||
// NewLeadDialog. The overview behind it keeps its counts and its poll.
|
// NewLeadDialog. The overview behind it keeps its counts and its poll.
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
// Which renewal bucket the table below is narrowed to. Null is "all due
|
||||||
|
// within 30 days", as before.
|
||||||
|
const [bucket, setBucket] = useState(null)
|
||||||
const state = usePortfolio()
|
const state = usePortfolio()
|
||||||
|
|
||||||
const m = useMemo(() => {
|
const m = useMemo(() => {
|
||||||
@ -135,8 +140,7 @@ export default function Overview() {
|
|||||||
|
|
||||||
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
|
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
|
||||||
// Every open lead is in exactly one of three hands: a person's, the AI's,
|
// Every open lead is in exactly one of three hands: a person's, the AI's,
|
||||||
// or the customer's. The page led with money, which is the outcome; this
|
// or the customer's.
|
||||||
// is the state, and it is the sentence the whole product exists to say.
|
|
||||||
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
|
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
|
||||||
let withAI = 0, withCustomer = 0, withPerson = 0
|
let withAI = 0, withCustomer = 0, withPerson = 0
|
||||||
for (const r of open) {
|
for (const r of open) {
|
||||||
@ -148,9 +152,14 @@ export default function Overview() {
|
|||||||
else withPerson += 1
|
else withPerson += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The flow: every stage in pipeline order with its share of the book.
|
||||||
|
const flow = STAGES.map((s) => ({ ...s, n: byStage[s.name] || 0 }))
|
||||||
|
const flowOpen = flow.filter((s) => s.kind !== 'end')
|
||||||
|
const flowTotal = flowOpen.reduce((t, s) => t + s.n, 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
|
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
|
||||||
withAI, withCustomer, withPerson,
|
withAI, withCustomer, withPerson, flow, flowOpen, flowTotal,
|
||||||
conversion: closed ? Math.round((won.length / closed) * 100) : null,
|
conversion: closed ? Math.round((won.length / closed) * 100) : null,
|
||||||
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
|
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
|
||||||
}
|
}
|
||||||
@ -176,11 +185,16 @@ export default function Overview() {
|
|||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
||||||
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
|
<div className="skel skel--tiles" aria-busy="true"><div /><div /><div /><div /></div>
|
||||||
|
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /><div className="skel__row" /></div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shownAttention = bucket
|
||||||
|
? m.exposure[bucket]
|
||||||
|
: m.attention
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head">
|
||||||
@ -188,55 +202,97 @@ export default function Overview() {
|
|||||||
<h1 className="page__title">Portfolio overview</h1>
|
<h1 className="page__title">Portfolio overview</h1>
|
||||||
<p className="page__sub">Motor and SME renewals · organisation-wide</p>
|
<p className="page__sub">Motor and SME renewals · organisation-wide</p>
|
||||||
</div>
|
</div>
|
||||||
{/* The action sits with the thing it acts on. It was in the sidebar,
|
|
||||||
which is for moving between places — an action among destinations
|
|
||||||
is the one item that does not behave like its neighbours. */}
|
|
||||||
<div className="page__right">
|
<div className="page__right">
|
||||||
{state.at ? (
|
{state.at ? (
|
||||||
<span className="stamp">
|
<span className="live" title="Refreshes every 30 seconds while this tab is visible">
|
||||||
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
<span className="live__dot" aria-hidden="true" />
|
||||||
|
Live · {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{canFile ? (
|
{canFile ? (
|
||||||
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
<button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="plus" />
|
||||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
|
||||||
strokeWidth="1.7" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
New lead
|
New lead
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Who holds the work, right now. Three numbers, one sentence: this many
|
{/* Who holds the work, right now. Three numbers and one risk, as four
|
||||||
are being worked by the AI, this many are waiting on a person, this
|
quiet cards — the one that is actually owed warms up. */}
|
||||||
many on a customer. It sits above the money because it is the claim
|
|
||||||
the money is evidence for. */}
|
|
||||||
<div className="now">
|
<div className="now">
|
||||||
<div className="now__cell now__cell--ai">
|
<div className="now__cell card now__cell--ai">
|
||||||
|
<span className="now__ico"><Icon name="bot" size={18} /></span>
|
||||||
<span className="now__l">With the AI</span>
|
<span className="now__l">With the AI</span>
|
||||||
<strong className="now__v">{m.withAI}</strong>
|
<strong className="now__v">{m.withAI}</strong>
|
||||||
<span className="now__s">being qualified, rated, checked or chased — no one waiting</span>
|
<span className="now__s">being qualified, rated, checked or chased</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'now__cell now__cell--person' + (m.withPerson ? ' is-live' : '')}>
|
<div className={'now__cell card now__cell--person' + (m.withPerson ? ' is-live' : '')}>
|
||||||
|
<span className="now__ico"><Icon name="user" size={18} /></span>
|
||||||
<span className="now__l">Waiting on a person</span>
|
<span className="now__l">Waiting on a person</span>
|
||||||
<strong className="now__v">{m.withPerson}</strong>
|
<strong className="now__v">{m.withPerson}</strong>
|
||||||
<span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span>
|
<span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="now__cell now__cell--cust">
|
<div className="now__cell card now__cell--cust">
|
||||||
|
<span className="now__ico"><Icon name="chat" size={18} /></span>
|
||||||
<span className="now__l">With the customer</span>
|
<span className="now__l">With the customer</span>
|
||||||
<strong className="now__v">{m.withCustomer}</strong>
|
<strong className="now__v">{m.withCustomer}</strong>
|
||||||
<span className="now__s">a quote or a payment is theirs to answer</span>
|
<span className="now__s">a quote or a payment is theirs to answer</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'now__cell now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}>
|
<div className={'now__cell card now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}>
|
||||||
|
<span className="now__ico"><Icon name="alert" size={18} /></span>
|
||||||
<span className="now__l">Renewals at risk</span>
|
<span className="now__l">Renewals at risk</span>
|
||||||
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong>
|
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong>
|
||||||
<span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span>
|
<span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="kpis">
|
{/* The pipeline as one bar. Every open stage in order, sized by its
|
||||||
|
share, coloured by who holds it — and every stage, zeroes included,
|
||||||
|
named beneath so a stage that has quietly stopped receiving leads is
|
||||||
|
visible as a zero. */}
|
||||||
|
<div className="card flow">
|
||||||
|
<div className="flow__head">
|
||||||
|
<div>
|
||||||
|
<h2 className="h-sec">Pipeline</h2>
|
||||||
|
<p className="h-sub">{m.open.length} open leads across {m.flowOpen.filter((s) => s.n).length} stages · {m.closed} closed</p>
|
||||||
|
</div>
|
||||||
|
<div className="flow__legend">
|
||||||
|
<span><i className="flow__sw flow__sw--blue" />AI</span>
|
||||||
|
<span><i className="flow__sw flow__sw--amber" />Person</span>
|
||||||
|
<span><i className="flow__sw flow__sw--teal" />Customer</span>
|
||||||
|
<span><i className="flow__sw flow__sw--grey" />Scheduled</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flow__bar" aria-hidden="true">
|
||||||
|
{m.flowOpen.filter((s) => s.n).map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.uid}
|
||||||
|
to={`/stage/${s.uid}`}
|
||||||
|
className={'flow__seg flow__seg--' + FLOW_TONE[s.kind]}
|
||||||
|
style={{ flexGrow: s.n }}
|
||||||
|
title={`${s.name} · ${s.n}`}
|
||||||
|
>
|
||||||
|
<span>{s.n}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!m.flowTotal ? <span className="flow__seg flow__seg--line" style={{ flexGrow: 1 }} /> : null}
|
||||||
|
</div>
|
||||||
|
<ol className="flow__stages">
|
||||||
|
{m.flow.map((s) => (
|
||||||
|
<li key={s.uid}>
|
||||||
|
<Link to={`/stage/${s.uid}`} className={'flow__stage' + (s.n ? '' : ' is-zero') + (s.kind === 'end' ? ' is-end' : '')}>
|
||||||
|
<Icon name={KIND_ICON[s.kind]} size={13} className={'flow__ki flow__ki--' + FLOW_TONE[s.kind]} />
|
||||||
|
<span className="flow__sname">{s.name}</span>
|
||||||
|
<b>{s.n}</b>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Money and outcome, one row. */}
|
||||||
|
<div className="card kpis">
|
||||||
<div className="kpi">
|
<div className="kpi">
|
||||||
<span className="kpi__l">Open leads</span>
|
<span className="kpi__l">Open leads</span>
|
||||||
<strong className="kpi__v">{m.open.length}</strong>
|
<strong className="kpi__v">{m.open.length}</strong>
|
||||||
@ -265,11 +321,10 @@ export default function Overview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
|
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
|
||||||
than a task, shown to the role that owns it and to nobody else. It sat
|
than a task, shown to the role that owns it and to nobody else. */}
|
||||||
fourth in a list of six and read like any other item, which is the
|
|
||||||
wrong weight for the step that decides whether cover begins. */}
|
|
||||||
{m.approvals.map((s) => (
|
{m.approvals.map((s) => (
|
||||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr card' + (s.n ? ' is-live' : '')}>
|
||||||
|
<span className="appr__ico"><Icon name="lock" size={18} /></span>
|
||||||
<strong className="appr__n">{s.n}</strong>
|
<strong className="appr__n">{s.n}</strong>
|
||||||
<span className="appr__t">
|
<span className="appr__t">
|
||||||
{s.approvalLabel}
|
{s.approvalLabel}
|
||||||
@ -277,52 +332,74 @@ export default function Overview() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="appr__go">
|
<span className="appr__go">
|
||||||
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
|
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
|
||||||
|
<Icon name="chevronRight" />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="split">
|
<div className="split">
|
||||||
|
<div className="card sect">
|
||||||
|
<div className="sect__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="sec">Action required</h2>
|
<h2 className="h-sec">Action required</h2>
|
||||||
|
<p className="h-sub">Queues where a person has to move a lead on.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="qlist">
|
<div className="qlist">
|
||||||
{m.actionable.map((s) => (
|
{m.actionable.map((s) => (
|
||||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
|
||||||
<strong className="q__n">{s.n}</strong>
|
<span className={'q__ico q__ico--' + s.kind}><Icon name={KIND_ICON[s.kind]} size={16} /></span>
|
||||||
<span className="q__t">
|
<span className="q__t">
|
||||||
{s.need ?? s.name}
|
{s.need ?? s.name}
|
||||||
<em>{s.by}</em>
|
<em>{s.by}{s.working ? ` · +${s.working} with the AI` : ''}</em>
|
||||||
</span>
|
</span>
|
||||||
<span className="q__age">
|
<span className="q__age">
|
||||||
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
|
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
|
||||||
{s.working ? <b className="q__auto">+{s.working} with the AI</b> : null}
|
|
||||||
</span>
|
</span>
|
||||||
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
|
<strong className="q__n">{s.n}</strong>
|
||||||
|
{!mine.has(s.uid)
|
||||||
|
? <span className="q__go q__go--ro" title="View only for your role"><Icon name="eye" size={14} /></span>
|
||||||
|
: <span className="q__go"><Icon name="chevronRight" size={14} /></span>}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
|
|
||||||
<div className="buckets">
|
|
||||||
<div className="bucket bucket--lapsed">
|
|
||||||
<strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span>
|
|
||||||
</div>
|
|
||||||
<div className="bucket bucket--urgent">
|
|
||||||
<strong>{m.exposure.week.length}</strong><span>Within 7 days</span>
|
|
||||||
</div>
|
|
||||||
<div className="bucket bucket--soon">
|
|
||||||
<strong>{m.exposure.month.length}</strong><span>8 to 30 days</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.attention.length ? (
|
<div className="card sect">
|
||||||
<div className="gridwrap">
|
<div className="sect__head">
|
||||||
|
<div>
|
||||||
|
<h2 className="h-sec">Renewal exposure</h2>
|
||||||
|
<p className="h-sub">Open leads by time to expiry. Pick a band to narrow the list.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="buckets" role="group" aria-label="Expiry band">
|
||||||
|
{[
|
||||||
|
['lapsed', 'Lapsed', m.exposure.lapsed.length],
|
||||||
|
['week', 'Within 7 days', m.exposure.week.length],
|
||||||
|
['month', '8 to 30 days', m.exposure.month.length],
|
||||||
|
].map(([k, label, n]) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={'bucket bucket--' + k + (bucket === k ? ' is-on' : '')}
|
||||||
|
aria-pressed={bucket === k}
|
||||||
|
onClick={() => setBucket(bucket === k ? null : k)}
|
||||||
|
>
|
||||||
|
<strong>{n}</strong><span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shownAttention.length ? (
|
||||||
|
<div className="gridwrap gridwrap--flush">
|
||||||
<table className="grid grid--tight">
|
<table className="grid grid--tight">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
|
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{m.attention.slice(0, 8).map((r) => {
|
{shownAttention.slice(0, 8).map((r) => {
|
||||||
const id = r.instance_id ?? r.id
|
const id = r.instance_id ?? r.id
|
||||||
|
const st = STAGES.find((s) => s.name === r.current_state_name)
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={id}
|
key={id}
|
||||||
@ -336,44 +413,35 @@ export default function Overview() {
|
|||||||
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
|
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
<div className="grid__who">
|
||||||
|
<span className={'avatar avatar--sm avatar--' + toneFor(r.customer_name)}>{initials(r.customer_name)}</span>
|
||||||
|
<div>
|
||||||
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
|
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
|
||||||
<div className="grid__sub">{r.lead_ref || ''}</div>
|
<div className="grid__sub">{r.lead_ref || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span className={'due due--' + expiryTone(r._d)}>
|
<td><span className={'due due--' + expiryTone(r._d)}>
|
||||||
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
|
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
|
||||||
</span></td>
|
</span></td>
|
||||||
<td><span className="grid__sub">{r.current_state_name}</span></td>
|
<td><span className={'pill ' + (st?.kind === 'needs' ? 'pill--amber' : st?.kind === 'customer' ? 'pill--teal' : 'pill--blue')}>{r.current_state_name}</span></td>
|
||||||
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
|
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{shownAttention.length > 8 ? (
|
||||||
|
<Link to="/stage/all" className="grid__more">See all {shownAttention.length} in the lead list <Icon name="arrowRight" size={13} /></Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="empty">No renewals due within 30 days.</p>
|
<p className="empty empty--flush">
|
||||||
|
<Icon name="check" size={18} />
|
||||||
|
{bucket ? 'No renewals in this band.' : 'No renewals due within 30 days.'}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="sec">Pipeline distribution</h2>
|
|
||||||
{/* Every stage, zeroes included. A stage that has quietly stopped
|
|
||||||
receiving leads is only visible if its zero is on the page. */}
|
|
||||||
<div className="dist">
|
|
||||||
{STAGES.map((s) => {
|
|
||||||
const n = m.byStage[s.name] || 0
|
|
||||||
return (
|
|
||||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
|
|
||||||
<span className="dist__l">{s.name}</span>
|
|
||||||
<span className="dist__bar" aria-hidden="true">
|
|
||||||
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + s.kind} />
|
|
||||||
</span>
|
|
||||||
<span className="dist__n">{n}</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
import { CHANNELS, ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||||
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
|
import { initials, toneFor, inrFull, KIND_ICON } from '../api/display.js'
|
||||||
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||||
|
import Icon from '../components/Icon.jsx'
|
||||||
|
import './screens.css'
|
||||||
|
|
||||||
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
||||||
* for "when exactly", the relative for "is this going stale". */
|
* for "when exactly", the relative for "is this going stale". */
|
||||||
@ -27,21 +30,15 @@ function added(ts) {
|
|||||||
* Is an automated stage actually moving?
|
* Is an automated stage actually moving?
|
||||||
*
|
*
|
||||||
* The agents carry six of the nine stages, and a lead sitting in one is in
|
* The agents carry six of the nine stages, and a lead sitting in one is in
|
||||||
* exactly one of two states: being worked right now, or stuck. The row showed
|
* exactly one of two states: being worked right now, or stuck. The threshold
|
||||||
* neither, so a lead three hours into "Calling the customer" looked identical
|
* is generous on purpose. An employee wake takes a minute or two and a
|
||||||
* to one thirty seconds in — which is the failure this console exists to catch.
|
* scheduled retry can be hours out, so `stalled` means "longer than any
|
||||||
*
|
|
||||||
* The threshold is generous on purpose. An employee wake takes a minute or two
|
|
||||||
* and a scheduled retry can be hours out, so `stalled` means "longer than any
|
|
||||||
* normal step", not "longer than average".
|
* normal step", not "longer than average".
|
||||||
*/
|
*/
|
||||||
function progress(row, stage) {
|
function progress(row, stage) {
|
||||||
if (!stage || stage.kind !== 'auto') return null
|
if (!stage || stage.kind !== 'auto') return null
|
||||||
// `updated_at` ONLY. Falling back to created_at dates the measure from when
|
// `updated_at` ONLY. Falling back to created_at dates the measure from when
|
||||||
// the lead was filed rather than from its last step, so every lead older than
|
// the lead was filed rather than from its last step.
|
||||||
// half an hour reports "stalled" — an alarm on every row is an alarm on none.
|
|
||||||
// The view returns the column since 76; before that it was silently absent,
|
|
||||||
// which is exactly how that fallback got there.
|
|
||||||
const t = Date.parse(row.updated_at)
|
const t = Date.parse(row.updated_at)
|
||||||
if (isNaN(t)) return null
|
if (isNaN(t)) return null
|
||||||
const mins = (Date.now() - t) / 60000
|
const mins = (Date.now() - t) / 60000
|
||||||
@ -50,8 +47,7 @@ function progress(row, stage) {
|
|||||||
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
|
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Days to expiry, and how alarmed to be about it. This is a renewal book —
|
/** Days to expiry, and how alarmed to be about it. */
|
||||||
* the countdown is the most operationally useful number in the row. */
|
|
||||||
function expiry(dateStr) {
|
function expiry(dateStr) {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
|
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
|
||||||
@ -61,30 +57,36 @@ function expiry(dateStr) {
|
|||||||
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
|
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
|
||||||
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
|
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
|
||||||
}
|
}
|
||||||
import './screens.css'
|
|
||||||
|
/** The tone a closed state's chip wears. */
|
||||||
|
const END_TONE = { 'Onboarded': 'green', 'Policy Issued': 'green', 'Lost / Dropped': '', 'Declined': 'red' }
|
||||||
|
|
||||||
|
const SORTS = {
|
||||||
|
customer: (r) => String(r.customer_name || '').toLowerCase(),
|
||||||
|
stage: (r) => STAGES.findIndex((s) => s.name === r.current_state_name),
|
||||||
|
renewal: (r) => { const t = Date.parse(String(r.renewal_due_date || '').substring(0, 10)); return isNaN(t) ? Infinity : t },
|
||||||
|
premium: (r) => Number(r.quoted_premium) || 0,
|
||||||
|
waiting: (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? 0 : t },
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One record view drives every queue; the stage is a server-side filter on
|
* One record view drives every queue; the stage is a server-side filter on
|
||||||
* current_state_name. Filtering server-side rather than fetching everything and
|
* current_state_name. Filtering server-side rather than fetching everything and
|
||||||
* narrowing in the browser is what keeps a queue honest once there are more
|
* narrowing in the browser is what keeps a queue honest once there are more
|
||||||
* leads than one page.
|
* leads than one page.
|
||||||
|
*
|
||||||
|
* The filter bar below narrows the PAGE that came back — product, channel,
|
||||||
|
* expiry band, stalled — client-side, on data already fetched. It never
|
||||||
|
* changes the request.
|
||||||
*/
|
*/
|
||||||
export default function Pipeline() {
|
export default function Pipeline() {
|
||||||
const { stageUid } = useParams()
|
const { stageUid } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { client, user } = useZino()
|
const { client, user } = useZino()
|
||||||
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
|
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
|
||||||
// Over the queue rather than away from it: filing a lead used to cost you
|
|
||||||
// your place in the list you were working through.
|
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
// "all" is not a stage — it is every lead in one list, so an operator
|
// "all" is not a stage — it is every lead in one list. IT MEANS ALL: closed
|
||||||
// wanting to find one does not have to guess which queue it is in.
|
// rows are shown, dimmed, and can be hidden by choice.
|
||||||
//
|
|
||||||
// IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
|
|
||||||
// or onboarded disappeared from the page called "All leads" — the one place
|
|
||||||
// somebody goes when they cannot find something. A lead that ended is still
|
|
||||||
// a lead, and "where did it go?" is exactly the question this page exists to
|
|
||||||
// answer. Closed rows are shown, and dimmed, and can be hidden by choice.
|
|
||||||
const isAll = stageUid === 'all'
|
const isAll = stageUid === 'all'
|
||||||
const [hideClosed, setHideClosed] = useState(false)
|
const [hideClosed, setHideClosed] = useState(false)
|
||||||
const stage = isAll
|
const stage = isAll
|
||||||
@ -92,33 +94,38 @@ export default function Pipeline() {
|
|||||||
: STAGES.find((s) => s.uid === stageUid)
|
: STAGES.find((s) => s.uid === stageUid)
|
||||||
|
|
||||||
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
|
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
|
||||||
|
const [at, setAt] = useState(null)
|
||||||
|
|
||||||
|
// Filters. Reset when the queue changes so a product filter does not follow
|
||||||
|
// the operator into a queue where it hides everything.
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [product, setProduct] = useState('')
|
||||||
|
const [channel, setChannel] = useState('')
|
||||||
|
const [band, setBand] = useState('')
|
||||||
|
const [stalledOnly, setStalledOnly] = useState(false)
|
||||||
|
const [sort, setSort] = useState({ key: 'waiting', dir: 'desc' })
|
||||||
|
useEffect(() => { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false) }, [stageUid])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
// `quiet` is what makes polling bearable: the first load may show a skeleton,
|
// `quiet` is what makes polling bearable: the first load may show a skeleton,
|
||||||
// a refresh may not — dropping back to the loading state every 30 seconds
|
// a refresh may not.
|
||||||
// would flash the whole queue away under whoever is reading it.
|
|
||||||
function fetchRows(quiet) {
|
function fetchRows(quiet) {
|
||||||
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
|
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
|
||||||
return client
|
return client
|
||||||
.recordView(RV_LEADS, {
|
.recordView(RV_LEADS, {
|
||||||
limit: isAll ? 200 : 100,
|
limit: isAll ? 200 : 100,
|
||||||
// No stage filter on the all view. Closed leads are dropped below
|
|
||||||
// rather than in the query: one request beats three negations, and
|
|
||||||
// 200 covers a demo book comfortably.
|
|
||||||
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
|
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
let rows = res?.data ?? res?.rows ?? res?.records ?? []
|
let rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||||
// Nothing is dropped here any more. Whether closed leads are shown
|
|
||||||
// is a choice made below, on data already fetched — a filter that
|
|
||||||
// throws rows away at the query cannot be undone by a toggle.
|
|
||||||
// total_count is the size of the QUEUE; rows is one page of at most
|
// total_count is the size of the QUEUE; rows is one page of at most
|
||||||
// 100 of it. Counting the page would quietly under-report a busy stage.
|
// 100 of it. Counting the page would quietly under-report a busy stage.
|
||||||
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
|
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
|
||||||
setState({ status: 'ready', rows, total, error: null })
|
setState({ status: 'ready', rows, total, error: null })
|
||||||
|
setAt(new Date())
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@ -136,24 +143,84 @@ export default function Pipeline() {
|
|||||||
return () => { cancelled = true; clearInterval(id) }
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
}, [client, stageUid, stage?.name, isAll])
|
}, [client, stageUid, stage?.name, isAll])
|
||||||
|
|
||||||
// Split rather than filtered at the query, so the header can say how many
|
const CLOSED = useMemo(() => new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)), [])
|
||||||
// are closed instead of silently showing fewer than the page claims.
|
|
||||||
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
|
|
||||||
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
|
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
|
||||||
const shownRows = isAll && hideClosed
|
|
||||||
? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
|
// Decorate once, then filter and sort on the decorated row.
|
||||||
: state.rows
|
const decorated = useMemo(() => state.rows.map((r) => {
|
||||||
|
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
|
||||||
|
return { r, rowStage, prog: progress(r, rowStage), exp: expiry(r.renewal_due_date), add: added(r.created_at) }
|
||||||
|
}), [state.rows])
|
||||||
|
|
||||||
|
const canStall = decorated.some((d) => d.prog)
|
||||||
|
const channelsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.source_channel).filter(Boolean))], [state.rows])
|
||||||
|
const productsPresent = useMemo(() => [...new Set(state.rows.map((r) => r.product_line).filter(Boolean))], [state.rows])
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
let out = decorated.filter(({ r, prog, exp }) => {
|
||||||
|
if (isAll && hideClosed && CLOSED.has(r.current_state_name)) return false
|
||||||
|
if (product && r.product_line !== product) return false
|
||||||
|
if (channel && r.source_channel !== channel) return false
|
||||||
|
if (stalledOnly && prog?.state !== 'stalled') return false
|
||||||
|
if (band) {
|
||||||
|
if (!exp) return false
|
||||||
|
if (band === 'lapsed' && exp.days >= 0) return false
|
||||||
|
if (band === 'week' && (exp.days < 0 || exp.days > 7)) return false
|
||||||
|
if (band === 'month' && (exp.days <= 7 || exp.days > 30)) return false
|
||||||
|
}
|
||||||
|
if (term) {
|
||||||
|
const hay = [r.customer_name, r.entity_name, r.lead_ref, r.mobile, r.motor_reg_no, r.current_state_name]
|
||||||
|
.filter(Boolean).map((x) => String(x).toLowerCase())
|
||||||
|
if (!hay.some((h) => h.includes(term))) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const f = SORTS[sort.key]
|
||||||
|
if (f) {
|
||||||
|
out = [...out].sort((a, b) => {
|
||||||
|
const x = f(a.r), y = f(b.r)
|
||||||
|
const c = x < y ? -1 : x > y ? 1 : 0
|
||||||
|
return sort.dir === 'asc' ? c : -c
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [decorated, q, product, channel, band, stalledOnly, sort, isAll, hideClosed, CLOSED])
|
||||||
|
|
||||||
|
const filtering = Boolean(q.trim() || product || channel || band || stalledOnly || (isAll && hideClosed))
|
||||||
|
const stalledCount = decorated.filter((d) => d.prog?.state === 'stalled').length
|
||||||
|
|
||||||
|
function toggleSort(key) {
|
||||||
|
setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: key === 'customer' || key === 'stage' ? 'asc' : 'desc' }))
|
||||||
|
}
|
||||||
|
// A sortable header cell. A plain function, not a component: it is called
|
||||||
|
// in place, so React never sees a new component type per render.
|
||||||
|
const sortHead = (k, text, className) => (
|
||||||
|
<th key={k} className={className} aria-sort={sort.key === k ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
||||||
|
<button type="button" className={'grid__sort' + (sort.key === k ? ' is-on' : '')} onClick={() => toggleSort(k)}>
|
||||||
|
{text}
|
||||||
|
<Icon name={sort.key === k && sort.dir === 'asc' ? 'sortAsc' : 'sortDesc'} size={13} />
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
|
||||||
if (!stage) return <p className="empty">Unknown stage.</p>
|
if (!stage) return <p className="empty">Unknown stage.</p>
|
||||||
|
|
||||||
|
const headCount = state.status === 'ready' ? (isAll ? state.rows.length - (hideClosed ? closedCount : 0) : state.total) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head">
|
||||||
|
<div className="page__lead">
|
||||||
|
<span className={'page__kind page__kind--' + stage.kind}><Icon name={KIND_ICON[stage.kind] || 'list'} size={20} /></span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
<h1 className="page__title">
|
||||||
|
{stage.need ?? stage.name}
|
||||||
|
{headCount !== null ? <span className="page__n">{headCount}</span> : null}
|
||||||
|
</h1>
|
||||||
<p className="page__sub">
|
<p className="page__sub">
|
||||||
{isAll
|
{isAll
|
||||||
? 'Every lead not yet closed, across all stages.'
|
? 'Every lead, open and closed, across all stages.'
|
||||||
: stage.kind === 'auto'
|
: stage.kind === 'auto'
|
||||||
? `Automated · ${stage.doing.toLowerCase()}`
|
? `Automated · ${stage.doing.toLowerCase()}`
|
||||||
: stage.kind === 'end'
|
: stage.kind === 'end'
|
||||||
@ -161,37 +228,90 @@ export default function Pipeline() {
|
|||||||
: stage.kind === 'waiting'
|
: stage.kind === 'waiting'
|
||||||
? 'Held until the renewal window opens. Re-enters outreach automatically.'
|
? 'Held until the renewal window opens. Re-enters outreach automatically.'
|
||||||
: `Pending action by ${stage.by}.`}
|
: `Pending action by ${stage.by}.`}
|
||||||
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
|
{stage.need ? <span className="page__stage"> · Stage: {stage.name}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{state.status === 'ready' ? (
|
|
||||||
<div className="page__right">
|
|
||||||
{/* Offered only where there is something to hide, and OFF by
|
|
||||||
default: this page's job is that nothing disappears from it. */}
|
|
||||||
{isAll && closedCount ? (
|
|
||||||
<label className="toggle">
|
|
||||||
<input type="checkbox" checked={hideClosed}
|
|
||||||
onChange={(ev) => setHideClosed(ev.target.checked)} />
|
|
||||||
Hide {closedCount} closed
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
<div className="page__count">
|
|
||||||
<strong>{isAll ? shownRows.length : state.total}</strong>
|
|
||||||
<span>{(isAll ? shownRows.length : state.total) === 1 ? 'lead' : 'leads'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="page__right">
|
||||||
|
{at ? (
|
||||||
|
<span className="live" title="Refreshes every 30 seconds while this tab is visible">
|
||||||
|
<span className="live__dot" aria-hidden="true" />
|
||||||
|
Live · {at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{canFile ? (
|
{canFile ? (
|
||||||
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
<button type="button" className="btn btn--primary" onClick={() => setAdding(true)}>
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<Icon name="plus" />
|
||||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
|
||||||
strokeWidth="1.7" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
New lead
|
New lead
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{state.status === 'ready' && state.rows.length ? (
|
||||||
|
<div className="filters card">
|
||||||
|
<label className="filters__q">
|
||||||
|
<Icon name="search" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Filter by name, reference, mobile, registration…"
|
||||||
|
aria-label="Filter this list"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{productsPresent.length > 1 ? (
|
||||||
|
<select className="filters__sel" value={product} onChange={(e) => setProduct(e.target.value)} aria-label="Product line">
|
||||||
|
<option value="">All products</option>
|
||||||
|
<option value="motor">Motor</option>
|
||||||
|
<option value="sme_package">SME Package</option>
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{channelsPresent.length > 1 ? (
|
||||||
|
<select className="filters__sel" value={channel} onChange={(e) => setChannel(e.target.value)} aria-label="Channel">
|
||||||
|
<option value="">All channels</option>
|
||||||
|
{channelsPresent.map((c) => <option key={c} value={c}>{CHANNELS[c]?.label || c}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<select className="filters__sel" value={band} onChange={(e) => setBand(e.target.value)} aria-label="Renewal band">
|
||||||
|
<option value="">Any expiry</option>
|
||||||
|
<option value="lapsed">Lapsed</option>
|
||||||
|
<option value="week">Within 7 days</option>
|
||||||
|
<option value="month">8 to 30 days</option>
|
||||||
|
</select>
|
||||||
|
{canStall ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={'filters__tog' + (stalledOnly ? ' is-on' : '')}
|
||||||
|
aria-pressed={stalledOnly}
|
||||||
|
onClick={() => setStalledOnly((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className="run__dot run__dot--stalled" aria-hidden="true" />
|
||||||
|
Stalled{stalledCount ? ` · ${stalledCount}` : ''}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{isAll && closedCount ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={'filters__tog' + (hideClosed ? ' is-on' : '')}
|
||||||
|
aria-pressed={hideClosed}
|
||||||
|
onClick={() => setHideClosed((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon name="archive" size={13} />
|
||||||
|
Hide {closedCount} closed
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{filtering ? (
|
||||||
|
<span className="filters__count">
|
||||||
|
{shown.length} of {state.rows.length}
|
||||||
|
<button type="button" className="btn btn--ghost btn--sm" onClick={() => { setQ(''); setProduct(''); setChannel(''); setBand(''); setStalledOnly(false); setHideClosed(false) }}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{state.status === 'loading' ? (
|
{state.status === 'loading' ? (
|
||||||
<div className="skel" aria-busy="true" aria-label="Loading the queue">
|
<div className="skel" aria-busy="true" aria-label="Loading the queue">
|
||||||
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
|
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
|
||||||
@ -206,39 +326,36 @@ export default function Pipeline() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && shownRows.length === 0 ? (
|
{state.status === 'ready' && shown.length === 0 ? (
|
||||||
<p className="empty">{isAll ? 'No leads yet.' : 'No records in this stage.'}</p>
|
<p className="empty">
|
||||||
|
<Icon name={filtering ? 'filter' : 'check'} size={18} />
|
||||||
|
{filtering ? 'Nothing matches these filters.' : isAll ? 'No leads yet.' : 'Nothing in this queue.'}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && shownRows.length > 0 ? (
|
{state.status === 'ready' && shown.length > 0 ? (
|
||||||
<div className="gridwrap">
|
<div className="gridwrap card">
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Customer</th>
|
{sortHead('customer', 'Customer')}
|
||||||
<th>{isAll ? 'Stage' : 'Status'}</th>
|
{sortHead('stage', isAll ? 'Stage' : 'Status')}
|
||||||
<th>Renewal due</th>
|
{sortHead('renewal', 'Renewal due')}
|
||||||
<th className="num">Premium</th>
|
{sortHead('premium', 'Premium', 'num')}
|
||||||
<th>Waiting</th>
|
{sortHead('waiting', 'Added')}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{shownRows.map((r) => {
|
{shown.map(({ r, rowStage, prog, exp, add }) => {
|
||||||
const id = r.instance_id ?? r.id
|
const id = r.instance_id ?? r.id
|
||||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '' }
|
||||||
const exp = expiry(r.renewal_due_date)
|
const closed = CLOSED.has(r.current_state_name)
|
||||||
const add = added(r.created_at)
|
|
||||||
// What is really happening in the state, not just its name —
|
|
||||||
// a lead whose documents are in is being worked by the AI, not
|
|
||||||
// waiting on the agent whose queue it is sitting in.
|
|
||||||
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
|
|
||||||
const prog = progress(r, rowStage)
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={id}
|
key={id}
|
||||||
className={'grid__row'
|
className={'grid__row'
|
||||||
+ (prog ? ' is-' + prog.state : '')
|
+ (prog ? ' is-' + prog.state : '')
|
||||||
+ (CLOSED.has(r.current_state_name) ? ' is-done' : '')}
|
+ (closed ? ' is-done' : '')}
|
||||||
onClick={() => navigate(`/lead/${id}`)}
|
onClick={() => navigate(`/lead/${id}`)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
|
||||||
@ -248,11 +365,16 @@ export default function Pipeline() {
|
|||||||
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
|
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
|
<div className="grid__who">
|
||||||
|
<span className={'avatar avatar--' + toneFor(r.customer_name)}>{initials(r.customer_name)}</span>
|
||||||
|
<div className="grid__id">
|
||||||
<span className="grid__name">{r.customer_name || '—'}</span>
|
<span className="grid__name">{r.customer_name || '—'}</span>
|
||||||
<div className="grid__sub">
|
<div className="grid__sub">
|
||||||
{r.lead_ref || `#${id}`}
|
<span className="grid__ref">{r.lead_ref || `#${id}`}</span>
|
||||||
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
|
{r.product_line ? <span className={'pill ' + (r.product_line === 'motor' ? 'pill--blue' : 'pill--violet')}>{r.product_line === 'motor' ? 'Motor' : 'SME'}</span> : null}
|
||||||
{ch.label ? ` · ${ch.label}` : ''}
|
{ch.label ? <span className="pill pill--line">{ch.label}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{/* Who holds it, and — where an agent does — whether it is
|
{/* Who holds it, and — where an agent does — whether it is
|
||||||
@ -265,8 +387,10 @@ export default function Pipeline() {
|
|||||||
<span className="run__dot" aria-hidden="true" />
|
<span className="run__dot" aria-hidden="true" />
|
||||||
{prog.label}
|
{prog.label}
|
||||||
</span>
|
</span>
|
||||||
|
) : closed ? (
|
||||||
|
<span className={'pill ' + (END_TONE[r.current_state_name] ? 'pill--' + END_TONE[r.current_state_name] : '')}>{r.current_state_name}</span>
|
||||||
) : isAll ? (
|
) : isAll ? (
|
||||||
<span className="grid__sub">{r.current_state_name || '—'}</span>
|
<span className={'pill ' + (rowStage?.kind === 'needs' ? 'pill--amber' : rowStage?.kind === 'customer' ? 'pill--teal' : 'pill--blue')}>{r.current_state_name || '—'}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="grid__sub">
|
<span className="grid__sub">
|
||||||
{rowStage?.by ? `with ${rowStage.by}` : '—'}
|
{rowStage?.by ? `with ${rowStage.by}` : '—'}
|
||||||
@ -279,8 +403,8 @@ export default function Pipeline() {
|
|||||||
<div className="grid__sub">{exp.on}</div></>
|
<div className="grid__sub">{exp.on}</div></>
|
||||||
: <span className="grid__sub">—</span>}
|
: <span className="grid__sub">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
<td className="num">{r.quoted_premium ? inrFull(r.quoted_premium) : <span className="grid__sub">—</span>}</td>
|
||||||
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
|
<td><span className="grid__sub" title={add.abs}>{add.rel || add.abs}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user