console: a live trail, faces for the agents, and the thread as a thread

Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.

THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.

THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.

THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.

THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.

CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 15:29:02 +05:30
parent 8eb80dc371
commit 171aa4c2fa
10 changed files with 524 additions and 33 deletions

52
src/api/agents.js Normal file
View File

@ -0,0 +1,52 @@
/**
* The AI employees, as people the operator can recognise.
*
* Five agents carry this workflow and the console referred to them by their
* role slug or their full title, differently in each place "ai_engage" in one
* view, "Engage" in another, "Engage AI" in a third. Someone watching a lead
* move could not tell that the thing which called the customer and the thing
* which wrote the quote were the same worker.
*
* So: one roster, one short name, one colour, one initial. Used wherever an
* agent is named. `does` is written in the present tense and in the operator's
* words, not the charter's it appears on hover and in the roster strip, and
* it is what makes an unfamiliar name mean something the first time.
*
* Keyed by the ROLE, because that is what an audit row carries.
*/
export const AGENTS = {
ai_intake: {
short: 'Intake', full: 'Intake & Attribution', initials: 'IA', tone: 'violet',
does: 'checks the lead is real, scores it, and works out whose it is',
},
ai_engage: {
short: 'Engage', full: 'Engage', initials: 'EN', tone: 'blue',
does: 'talks to the customer, writes up the call, and asks for what is missing',
},
ai_advisor: {
short: 'Advisor', full: 'Advisor', initials: 'AD', tone: 'teal',
does: 'reads the risk and recommends the cover',
},
ai_kyc: {
short: 'KYC', full: 'KYC & Evidence', initials: 'KY', tone: 'amber',
does: 'verifies identity and screens the risk for underwriting',
},
ai_uw_referral: {
short: 'Referral', full: 'Underwriting Referral', initials: 'UW', tone: 'plum',
does: 'prepares a referred file so an underwriter can decide',
},
}
/** The agent behind a set of roles, or null when a person did it. */
export function agentFor(roles) {
if (!Array.isArray(roles)) return null
const key = roles.find((r) => AGENTS[r])
return key ? { key, ...AGENTS[key] } : null
}
/** Initials for a person, so a human entry reads as a name too. */
export function initialsOf(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase()
}

View File

@ -45,7 +45,14 @@ export const STAGES = [
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' }, { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' },
// The one queue where the machine stops and a person decides. // The one queue where the machine stops and a person decides.
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' }, { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations' }, // `approval` marks the one queue that is a person's SIGN-OFF rather than a
// piece of work: confirming money has arrived. It is locked to ops_admin in
// the workflow — no agent and no other role can perform it — because an
// employee that can mark a premium received can put a customer on risk for a
// policy nobody paid for. It gets its own band on the overview so the role
// that owns it does not have to find it in a list of things mostly handled
// by somebody else.
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' }, { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' },
// Nurture. There is no scheduler yet, so Resume Outreach is a button and it // Nurture. There is no scheduler yet, so Resume Outreach is a button and it
// is the only thing that wakes a parked lead — do not present this as a // is the only thing that wakes a parked lead — do not present this as a

View File

@ -0,0 +1,35 @@
.who { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.who__disc {
display: grid;
place-items: center;
flex: none;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #fff;
user-select: none;
}
.who--md .who__disc { width: 26px; height: 26px; font-size: 0.66rem; }
.who__name {
font-size: 0.76rem;
font-weight: 500;
color: var(--zk-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Five agents, five hues, all readable on white at 20px and all distinguishable
from the Zurich blue the interface already uses for its own chrome. */
.who__disc--blue { background: #2167ae; }
.who__disc--teal { background: #0f7b78; }
.who__disc--violet { background: #5b4bb7; }
.who__disc--amber { background: #a8651a; }
.who__disc--plum { background: #8a3d6b; }
.who__disc--grey { background: #6b7280; }

View File

@ -0,0 +1,27 @@
import { AGENTS, initialsOf } from '../api/agents.js'
import './AgentChip.css'
/**
* Who did this as a face, not a slug.
*
* A disc with initials, coloured per agent and consistent everywhere. The
* point is recognition at a glance down a timeline: the operator should see
* that one worker made four consecutive entries without reading four names.
*
* People get a disc too, in grey. A trail where the agents are decorated and
* the humans are plain text reads as though the machines are the important
* ones, which is the wrong way round on a screen whose job is oversight.
*/
export default function AgentChip({ agentKey, name, size = 'sm', showName = true }) {
const a = agentKey ? AGENTS[agentKey] : null
const label = a ? a.short : (name || 'System')
const initials = a ? a.initials : initialsOf(name)
const tone = a ? a.tone : 'grey'
return (
<span className={`who who--${size}`} title={a ? `${a.full}${a.does}` : name || 'System'}>
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">{initials}</span>
{showName ? <span className="who__name">{label}</span> : null}
</span>
)
}

View File

@ -0,0 +1,63 @@
.conv { max-height: min(80vh, 760px); }
.conv__body {
overflow-y: auto;
padding: 18px 22px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.conv__empty {
margin: 0;
padding: 24px 0;
text-align: center;
font-size: 0.85rem;
color: var(--zk-grey);
}
/* Theirs left, ours right: the arrangement everybody reads without being told,
which is the whole reason to render a thread rather than a log. */
.bub {
max-width: 78%;
padding: 9px 13px 7px;
border-radius: 14px;
}
.bub p {
margin: 0;
font-size: 0.86rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.bub span {
display: block;
margin-top: 4px;
font-size: 0.66rem;
opacity: 0.65;
}
.bub--them {
align-self: flex-start;
background: var(--zk-line-soft);
color: var(--zk-ink);
border-bottom-left-radius: 4px;
}
.bub--us {
align-self: flex-end;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
border-bottom-right-radius: 4px;
}
.conv__note {
margin: 0;
padding: 12px 22px 18px;
border-top: 1px solid var(--zk-line-soft);
font-size: 0.72rem;
line-height: 1.5;
color: var(--zk-grey);
}

View File

@ -0,0 +1,115 @@
import { useEffect, useRef } from 'react'
import { baseFieldId } from '../api/config.js'
import './Conversation.css'
/**
* The WhatsApp thread, read out of the audit trail.
*
* The trail shows each turn as its own entry among twenty others, so reading a
* conversation meant scrolling a log and holding the order in your head. Here
* it is what it is: a thread, oldest first, theirs on the left and ours on the
* right.
*
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
* than left for someone to discover. Every message the workflow RECORDS is
* here: the customer's replies (customer_reply) and the replies written back
* (customer_answer). The three automatic sends are not. The quote goes out as
* a registered WhatsApp template, and the acknowledgement and the acceptance
* confirmation are composed inside trigger nodes and never written to a field.
* A thread that quietly omitted them would be worse than one that says which
* parts it holds.
*/
function turnsFrom(rows) {
if (!Array.isArray(rows)) return []
const out = []
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
for (const r of ordered) {
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
for (const f of fields) {
const base = baseFieldId(f.field_id)
const v = typeof f.value === 'string' ? f.value.trim() : ''
if (!v) continue
if (base === 'customer_reply') out.push({ side: 'them', text: v, at: r.created_at, key: r.id + '-in' })
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
}
}
// One submission can be recorded more than once (the trigger commit and the
// settle both carry the payload), so the same sentence would print twice.
// De-duped on side + text rather than on the row id for that reason.
const seen = new Set()
return out.filter((t) => {
const k = t.side + ' ' + t.text
if (seen.has(k)) return false
seen.add(k)
return true
})
}
function at(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
export default function Conversation({ rows, name, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
const turns = turnsFrom(rows)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return (
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg conv"
role="dialog"
aria-modal="true"
aria-label="WhatsApp conversation"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>WhatsApp with {name || 'the customer'}</h3>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="conv__body">
{turns.length ? turns.map((t) => (
<div key={t.key} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span>{at(t.at)}</span>
</div>
)) : (
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
)}
</div>
<p className="conv__note">
Shows the customer&apos;s messages and the replies written back. The quote
itself, the read receipt and the acceptance confirmation are sent
automatically and are not recorded as text, so they do not appear here.
</p>
</div>
</div>
)
}

View File

@ -1,17 +1,14 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx' import ClampText from './ClampText.jsx'
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 './Timeline.css' import './Timeline.css'
/** Which employee holds which role — used to badge an entry as machine work. */ /* The roster lives in api/agents.js now one short name, one colour and one
const AI_ROLES = { set of initials per employee, so the same worker looks the same everywhere
ai_intake: 'Intake & Attribution', it appears. This file only needs to know which roles are agents. */
ai_engage: 'Engage',
ai_kyc: 'KYC & Evidence',
ai_advisor: 'Advisor',
ai_uw_referral: 'Underwriting Referral',
}
/** /**
* The fields worth surfacing per activity an agent's reasoning, a rule's * The fields worth surfacing per activity an agent's reasoning, a rule's
@ -98,24 +95,18 @@ function filesIn(value) {
return value.filter((f) => f && typeof f === 'object' && f.uuid) return value.filter((f) => f && typeof f === 'object' && f.uuid)
} }
export default function Timeline({ instanceId }) { /**
* `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.
*/
export default function Timeline({ rows }) {
const { client } = useZino() const { client } = useZino()
const [rows, setRows] = useState(null)
const [err, setErr] = useState(null)
// DATA_UPDATE entries are the platform writing fields, not anyone deciding // 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 // anything. They are the bulk of a busy trail and they are hidden until asked
// for the story is what people and agents did. // for the story is what people and agents did.
const [showSys, setShowSys] = useState(false) const [showSys, setShowSys] = useState(false)
useEffect(() => {
let dead = false
client.audit(instanceId)
.then((r) => { if (!dead) setRows(Array.isArray(r) ? r : (r?.data ?? [])) })
.catch((e) => { if (!dead) setErr(e) })
return () => { dead = true }
}, [client, instanceId])
if (err) return <div className="tl__err">Could not load the timeline {err.status} {err.message}</div>
if (!rows) return <p className="tl__loading">Loading the timeline</p> if (!rows) return <p className="tl__loading">Loading the timeline</p>
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p> if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
@ -177,13 +168,13 @@ export default function Timeline({ instanceId }) {
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, // SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
// so an AI's own field write matched AI_ROLES, was classed as agent work, // 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, // 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 // 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 // activity id is what says it is bookkeeping; the roles say who triggered
// the bookkeeping, which is a different question. // 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) => AI_ROLES[x]) const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human' const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
/** /**
@ -225,7 +216,8 @@ export default function Timeline({ instanceId }) {
return { return {
key: r.id ?? i, key: r.id ?? i,
kind, kind,
actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'), agentKey: aiRole || null,
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)), what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
stage: moved ? stage : null, stage: moved ? stage : null,
when: when(r.created_at), when: when(r.created_at),
@ -259,10 +251,12 @@ export default function Timeline({ instanceId }) {
<strong className="tl__what">{it.what}</strong> <strong className="tl__what">{it.what}</strong>
{it.stage ? <span className="tl__stage"> {it.stage.name}</span> : null} {it.stage ? <span className="tl__stage"> {it.stage.name}</span> : null}
</div> </div>
{/* The worker, as a face. Recognition down a column beats
reading five names to notice it was one agent throughout. */}
<div className="tl__meta"> <div className="tl__meta">
<span className={'tl__who tl__who--' + it.kind}> {it.kind === 'sys'
{it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor} ? <span className="tl__who tl__who--sys">system</span>
</span> : <AgentChip agentKey={it.agentKey} name={it.actor} />}
<span className="tl__when">{it.when}</span> <span className="tl__when">{it.when}</span>
</div> </div>

View File

@ -2,9 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx' import ActivityForm from '../components/ActivityForm.jsx'
import AgentChip from '../components/AgentChip.jsx'
import ClampText from '../components/ClampText.jsx' import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx' import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.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 './screens.css' import './screens.css'
@ -140,9 +143,21 @@ export default function Lead() {
const [open, setOpen] = useState(null) const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0) const [tab, setTab] = useState(0)
const [labels, setLabels] = useState({}) const [labels, setLabels] = useState({})
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline
// fetched them once on mount and never again so a lead worked by five
// agents in three minutes showed the trail as it was when you opened the
// page and the conversation view needs the same rows. One fetch, one
// poll, two readers that cannot disagree.
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const load = useCallback((quiet = false) => { const load = useCallback((quiet = false) => {
if (!quiet) setErr(null) if (!quiet) setErr(null)
// Best-effort and never awaited with the record: a failed audit call must
// not blank the lead, and a slow one must not hold up the fields.
client.audit(instanceId)
.then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => { /* keep whatever is on screen */ })
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)
@ -233,6 +248,18 @@ export default function Lead() {
// Two different reasons for an empty action list, and they must not read the // Two different reasons for an empty action list, and they must not read the
// same. A terminal lead is finished; a live one you cannot act on belongs to // same. A terminal lead is finished; a live one you cannot act on belongs to
// somebody else, and saying "closed" about it would be a lie. // somebody else, and saying "closed" about it would be a lie.
// Derived from the same audit rows the trail renders, so they cannot drift.
const worked = [...new Set((audit || [])
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
const chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => {
const b = String(f.field_id || '').replace(/_\d+$/, '')
return (b === 'customer_reply' || b === 'customer_answer')
&& typeof f.value === 'string' && f.value.trim()
}).length
}, 0)
const isClosed = stage?.kind === 'end' const isClosed = stage?.kind === 'end'
const heldByOthers = !isClosed && actions.length === 0 const heldByOthers = !isClosed && actions.length === 0
// An automated stage that has stopped for a stated reason. Checked before // An automated stage that has stopped for a stated reason. Checked before
@ -413,10 +440,18 @@ export default function Lead() {
// into the fold with the other recovery levers, relabelled for what // into the fold with the other recovery levers, relabelled for what
// it is actually for a wrong file, or one more. // 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 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' }
: a) : a)
const shown = actions.map(recast) const shown = actions
.filter((a) => !(accepted && a.uid === 'zk-act-accept'))
.map(recast)
const step = shown.filter((a) => a.role === 'do') const step = shown.filter((a) => a.role === 'do')
const again = shown.filter((a) => a.role === 'again') const again = shown.filter((a) => a.role === 'again')
const force = shown.filter((a) => a.role === 'force') const force = shown.filter((a) => a.role === 'force')
@ -454,7 +489,9 @@ export default function Lead() {
? 'This lead is closed.' ? 'This lead is closed.'
: blocked : blocked
? 'The chain has stopped. The fix is above.' ? 'The chain has stopped. The fix is above.'
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`} : accepted && stage?.uid === 'zk-state-quoted'
? `Accepted by the customer${row.accepted_at ? ' on ' + row.accepted_at : ''}${row.acceptance_ref === 'whatsapp' ? ', over WhatsApp' : ''}. KYC and underwriting are running.`
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
</p> </p>
</div> </div>
</div> </div>
@ -589,14 +626,40 @@ export default function Lead() {
<h2 className="panel__title">Audit trail</h2> <h2 className="panel__title">Audit trail</h2>
<p className="panel__sub">Every step, who took it, and what they wrote.</p> <p className="panel__sub">Every step, who took it, and what they wrote.</p>
</div> </div>
{/* The WhatsApp exchange is spread across the trail one turn at a
time, so reading it meant scrolling a log and holding the order
in your head. This shows it as the thread it is. */}
{chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Conversation
<b>{chatTurns}</b>
</button>
) : null}
</div> </div>
{/* Who worked this lead. Five agents carry it and their names appear
only inside individual entries, so the team was never visible at
a glance. */}
{worked.length ? (
<div className="crew">
<span className="crew__l">Worked by</span>
{worked.map((k) => <AgentChip key={k} agentKey={k} />)}
</div>
) : null}
<div className="lead__feed"> <div className="lead__feed">
<Timeline instanceId={instanceId} /> <Timeline rows={audit} />
</div> </div>
</div> </div>
</aside> </aside>
</div> </div>
{showChat ? (
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
) : null}
</section> </section>
) )
} }

View File

@ -124,12 +124,16 @@ export default function Overview() {
} }
}) })
// The sign-off queues this ROLE owns. Empty for everyone else, so the band
// never appears to a partner agent who could not act on it anyway.
const approvals = actionable.filter((s) => s.approval && roles.includes(s.approval))
return { return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
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)),
} }
}, [state.rows]) }, [state.rows, roles])
const mine = new Set(visibleStages(roles).map((s) => s.uid)) const mine = new Set(visibleStages(roles).map((s) => s.uid))
@ -198,6 +202,23 @@ export default function Overview() {
</div> </div>
</div> </div>
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
than a task, shown to the role that owns it and to nobody else. It sat
fourth in a list of six and read like any other item, which is the
wrong weight for the step that decides whether cover begins. */}
{m.approvals.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
<strong className="appr__n">{s.n}</strong>
<span className="appr__t">
{s.approvalLabel}
<em>{s.approvalNote}</em>
</span>
<span className="appr__go">
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
</span>
</Link>
))}
<div className="split"> <div className="split">
<div> <div>
<h2 className="sec">Action required</h2> <h2 className="sec">Action required</h2>

View File

@ -1244,3 +1244,117 @@
border: 2px solid var(--zk-grey); border: 2px solid var(--zk-grey);
background: transparent; background: transparent;
} }
/* ---- panel action ----
A secondary control on a panel heading. Quiet: it opens a view, it does not
change anything. */
.panel__act {
display: inline-flex;
align-items: center;
gap: 7px;
flex: none;
font: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
padding: 6px 12px;
border-radius: var(--r-pill);
color: var(--zk-blue-dark);
background: var(--zk-white);
border: 1px solid var(--zk-line);
transition: background var(--t-fast), border-color var(--t-fast);
}
.panel__act svg { width: 13px; height: 13px; opacity: .75; }
.panel__act b {
font-size: 0.68rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 1px 6px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
}
.panel__act:hover { background: var(--zk-tint); border-color: var(--zk-blue-light); }
.panel__act:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* ---- who worked this lead ----
Five agents carry a lead and their names appeared only inside individual
entries. The team is a fact about the lead, so it sits above the trail. */
.crew {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 10px 18px 12px;
border-bottom: 1px solid var(--zk-line-soft);
}
.crew__l {
font-size: 0.64rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* ---- a sign-off, not a task ----
Confirming a premium is the only step in this workflow that is a person's
approval rather than a piece of work, and the only one locked to one role.
It sat fourth in a list of six queues, which is the wrong weight for the
decision that starts somebody's cover. Shown only to the role that owns it. */
.appr {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 22px;
padding: 16px 22px;
border-radius: var(--r-lg);
text-decoration: none;
border: 1px solid var(--zk-line);
background: var(--zk-white);
transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
/* Amber only when something is actually waiting. A permanent alert colour on
an empty queue teaches people to stop seeing it. */
.appr.is-live {
border-color: var(--zk-amber-line);
background: var(--zk-amber-tint);
}
.appr:hover { box-shadow: var(--sh-sm); border-color: var(--zk-blue-light); }
.appr:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
.appr__n {
flex: none;
min-width: 46px;
font-size: 1.9rem;
font-weight: 300;
line-height: 1;
letter-spacing: -0.02em;
color: var(--zk-grey);
font-variant-numeric: tabular-nums;
}
.appr.is-live .appr__n { color: var(--zk-amber-ink); }
.appr__t {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
font-size: 0.95rem;
font-weight: 500;
color: var(--zk-ink);
}
.appr__t em {
font-style: normal;
font-size: 0.78rem;
font-weight: 400;
line-height: 1.45;
color: var(--zk-muted);
}
.appr__go {
flex: none;
font-size: 0.76rem;
color: var(--zk-muted);
white-space: nowrap;
}