From 171aa4c2fa00c8953b63d8bc3a8ba046525a0b21 Mon Sep 17 00:00:00 2001
From: Yashas
Date: Tue, 8 Sep 2026 15:29:02 +0530
Subject: [PATCH] console: a live trail, faces for the agents, and the thread
as a thread
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
src/api/agents.js | 52 +++++++++++++++
src/api/config.js | 9 ++-
src/components/AgentChip.css | 35 ++++++++++
src/components/AgentChip.jsx | 27 ++++++++
src/components/Conversation.css | 63 +++++++++++++++++
src/components/Conversation.jsx | 115 ++++++++++++++++++++++++++++++++
src/components/Timeline.jsx | 48 ++++++-------
src/screens/Lead.jsx | 69 ++++++++++++++++++-
src/screens/Overview.jsx | 25 ++++++-
src/screens/screens.css | 114 +++++++++++++++++++++++++++++++
10 files changed, 524 insertions(+), 33 deletions(-)
create mode 100644 src/api/agents.js
create mode 100644 src/components/AgentChip.css
create mode 100644 src/components/AgentChip.jsx
create mode 100644 src/components/Conversation.css
create mode 100644 src/components/Conversation.jsx
diff --git a/src/api/agents.js b/src/api/agents.js
new file mode 100644
index 0000000..3321296
--- /dev/null
+++ b/src/api/agents.js
@@ -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()
+}
diff --git a/src/api/config.js b/src/api/config.js
index 1a1ecd4..0be3b88 100644
--- a/src/api/config.js
+++ b/src/api/config.js
@@ -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' },
// 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-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' },
// 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
diff --git a/src/components/AgentChip.css b/src/components/AgentChip.css
new file mode 100644
index 0000000..8432410
--- /dev/null
+++ b/src/components/AgentChip.css
@@ -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; }
diff --git a/src/components/AgentChip.jsx b/src/components/AgentChip.jsx
new file mode 100644
index 0000000..077b977
--- /dev/null
+++ b/src/components/AgentChip.jsx
@@ -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 (
+
+ {initials}
+ {showName ? {label} : null}
+
+ )
+}
diff --git a/src/components/Conversation.css b/src/components/Conversation.css
new file mode 100644
index 0000000..f5f2cf5
--- /dev/null
+++ b/src/components/Conversation.css
@@ -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);
+}
diff --git a/src/components/Conversation.jsx b/src/components/Conversation.jsx
new file mode 100644
index 0000000..ccc9583
--- /dev/null
+++ b/src/components/Conversation.jsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+
+
WhatsApp with {name || 'the customer'}
+
+
+
+
+ {turns.length ? turns.map((t) => (
+
+
{t.text}
+ {at(t.at)}
+
+ )) : (
+
Nothing has been exchanged on WhatsApp yet.
+ )}
+
+
+
+ Shows the customer'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.
+
+
+
+ )
+}
diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx
index 050f1c8..99fb46f 100644
--- a/src/components/Timeline.jsx
+++ b/src/components/Timeline.jsx
@@ -1,17 +1,14 @@
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { useZino } from '../api/provider.jsx'
+import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx'
+import { AGENTS } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import './Timeline.css'
-/** Which employee holds which role — used to badge an entry as machine work. */
-const AI_ROLES = {
- ai_intake: 'Intake & Attribution',
- ai_engage: 'Engage',
- ai_kyc: 'KYC & Evidence',
- ai_advisor: 'Advisor',
- ai_uw_referral: 'Underwriting Referral',
-}
+/* The roster lives in api/agents.js now — one short name, one colour and one
+ set 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
@@ -98,24 +95,18 @@ function filesIn(value) {
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 [rows, setRows] = useState(null)
- const [err, setErr] = useState(null)
// 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)
- 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
Could not load the timeline — {err.status} {err.message}
if (!rows) return
Loading the timeline…
if (!rows.length) return
Nothing has happened yet.
@@ -177,13 +168,13 @@ export default function Timeline({ instanceId }) {
const items = merged.map((r, i) => {
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 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,
// 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 aiRole = roles.find((x) => AI_ROLES[x])
+ const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
/**
@@ -225,7 +216,8 @@ export default function Timeline({ instanceId }) {
return {
key: r.id ?? i,
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)),
stage: moved ? stage : null,
when: when(r.created_at),
@@ -259,10 +251,12 @@ export default function Timeline({ instanceId }) {
{it.what}
{it.stage ? → {it.stage.name} : null}
+ {/* The worker, as a face. Recognition down a column beats
+ reading five names to notice it was one agent throughout. */}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx
index 47f496d..aee8d24 100644
--- a/src/screens/Lead.jsx
+++ b/src/screens/Lead.jsx
@@ -2,9 +2,12 @@ import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
+import AgentChip from '../components/AgentChip.jsx'
import ClampText from '../components/ClampText.jsx'
+import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx'
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 { describeError } from '../api/errors.js'
import './screens.css'
@@ -140,9 +143,21 @@ export default function Lead() {
const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0)
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) => {
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)
.then((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
// 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.
+ // 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 heldByOthers = !isClosed && actions.length === 0
// 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
// it is actually for — a wrong file, or one more.
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'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
: 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 again = shown.filter((a) => a.role === 'again')
const force = shown.filter((a) => a.role === 'force')
@@ -454,7 +489,9 @@ export default function Lead() {
? 'This lead is closed.'
: blocked
? '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.`}
@@ -589,14 +626,40 @@ export default function Lead() {
Audit trail
Every step, who took it, and what they wrote.
+ {/* 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 ? (
+
+ ) : null}
+
+ {/* 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 ? (
+
+ Worked by
+ {worked.map((k) => )}
+
+ ) : null}
-
+
+ {showChat ? (
+ setShowChat(false)} />
+ ) : null}
)
}
diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx
index e80f344..a5f87e8 100644
--- a/src/screens/Overview.jsx
+++ b/src/screens/Overview.jsx
@@ -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 {
- 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,
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))
@@ -198,6 +202,23 @@ export default function Overview() {
+ {/* 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) => (
+
+ {s.n}
+
+ {s.approvalLabel}
+ {s.approvalNote}
+
+
+ {s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
+
+
+ ))}
+
Action required
diff --git a/src/screens/screens.css b/src/screens/screens.css
index 478c03d..f1abc67 100644
--- a/src/screens/screens.css
+++ b/src/screens/screens.css
@@ -1244,3 +1244,117 @@
border: 2px solid var(--zk-grey);
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;
+}