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 ( + + + {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 ( +
{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. +
+Loading the timeline…
if (!rows.length) returnNothing 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. */}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 ? ( +