TOOLS ARE VISIBLE NOW. Clicking any agent — in the trail or the "Worked by" strip — opens a card saying what it is, when it runs, what it can reach and what it reads. The answer is short and it is the reassuring kind: four of the five hold no tools at all and reason from the policy wordings, and the only tool in the roster reads a partner registry. That answer stopped being true once, silently. Intake held a tool that could telephone customers, its charter never mentioned it, and it used it on every lead believing it was a duplicate check (89). Nobody could see that from any screen. This is the screen that would have shown it — which is the actual argument for building it, beyond a demo looking better. Where an agent has no tools the card says so in words rather than showing an empty section. "No tools" is the single most reassuring fact about something that writes into an insurance file; a blank reads as missing data. The roster is a hand-maintained mirror of the employee config, like STAGES and ACTIONS, and carries the same hazard: a tool added there and not here is described wrongly, silently. Named in the file. THE CONVERSATION WAS EATING THE TRAIL. Every WhatsApp turn was its own entry — heading, actor, quote box — so a four-message exchange occupied more of the trail than the entire underwriting chain, and a resend printed the same sentence four times because the customer sent it four times. A contiguous run now collapses into ONE entry: a WhatsApp mark, a count, the latest line, and a link that opens the thread. A second exchange later in the lead stays separate, because that is a different episode in the story. And the actor is right. The channel performs these as the system, so the trail read "System · Customer Reply" — the opposite of what happened. It says "the customer" now, in the channel's own green, and the rail dot takes that colour so a scan shows where the conversation was without reading a word. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
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'
|
|
|
|
/* 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
|
|
* 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).
|
|
*/
|
|
const NARRATIVE = [
|
|
['attribution_reason', 'Attribution'],
|
|
['eligibility_reason', 'Eligibility'],
|
|
['dedupe_match_ref', 'Duplicate of'],
|
|
['contact_notes', 'Call'],
|
|
['ai_recommendation_rationale', 'Recommendation'],
|
|
['quoted_breakup', 'How the premium was reached'],
|
|
['kyc_mismatch_notes', 'KYC'],
|
|
['referral_analysis', 'Referral analysis'],
|
|
['uw_decision_notes', 'Underwriting decision'],
|
|
['documents_notes', 'Documents'],
|
|
['customer_reply', 'The customer said'],
|
|
['customer_answer', 'We replied'],
|
|
['lost_reason', 'Why it was dropped'],
|
|
['resume_note', 'Why now'],
|
|
]
|
|
|
|
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
|
|
|
/** 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 = {
|
|
doc_rc: 'RC',
|
|
doc_prev_policy: 'Expiring policy',
|
|
doc_pan: 'PAN',
|
|
doc_gst_cert: 'GST certificate',
|
|
doc_udyam_cert: 'Udyam certificate',
|
|
doc_address_proof: 'Address proof',
|
|
doc_premises_proof: 'Premises proof',
|
|
doc_stock_statement: 'Stock statement',
|
|
doc_premises_photos: 'Premises photos',
|
|
doc_vehicle_photos: 'Vehicle photos',
|
|
doc_financials: 'Financials',
|
|
}
|
|
|
|
const FILE_TYPES = new Set(['file', 'ocr'])
|
|
|
|
function when(ts) {
|
|
if (!ts) return ''
|
|
const d = new Date(ts)
|
|
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
|
const rel = mins < 1 ? 'just now'
|
|
: mins < 60 ? `${mins}m ago`
|
|
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
|
: `${Math.round(mins / 1440)}d ago`
|
|
return `${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. 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) {
|
|
if (!uid) return 'Step'
|
|
return String(uid)
|
|
.replace(/^zk-act-/, '')
|
|
.split('-')
|
|
.filter(Boolean)
|
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
/** An uploaded file, as the platform stores it. */
|
|
function filesIn(value) {
|
|
if (!Array.isArray(value)) return []
|
|
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. */
|
|
const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
|
|
|
export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
|
|
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)
|
|
|
|
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
|
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
|
|
|
|
// Oldest first: a timeline reads forwards.
|
|
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
|
|
// submission separately, told apart by execution_state:
|
|
//
|
|
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
|
|
// 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 SAME_SUBMISSION_MS = 15000
|
|
|
|
const merged = []
|
|
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
|
|
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 (merged[i].activity_id === r.activity_id) { at = i; break }
|
|
}
|
|
|
|
if (at >= 0) {
|
|
const prev = merged[at]
|
|
merged[at] = {
|
|
...prev,
|
|
// The settle row is the one that names the resulting stage.
|
|
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,
|
|
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
|
|
user_name: prev.user_name || r.user_name,
|
|
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
|
|
created_at: prev.created_at,
|
|
}
|
|
continue
|
|
}
|
|
merged.push(r)
|
|
}
|
|
|
|
let lastStage = null
|
|
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 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) => AGENTS[x])
|
|
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
|
|
? r.fields
|
|
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
|
|
|
|
const byBase = new Map()
|
|
for (const f of fields) {
|
|
const base = baseFieldId(f.field_id)
|
|
// `_system` is the platform's own marker on every submission.
|
|
if (base === '_system') continue
|
|
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 = []
|
|
for (const [base, f] of byBase) {
|
|
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
|
|
for (const file of filesIn(f.value)) {
|
|
docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file })
|
|
}
|
|
}
|
|
|
|
// 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 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
|
|
if (stage) lastStage = stage.uid
|
|
|
|
return {
|
|
key: r.id ?? i,
|
|
kind: isChat ? 'chat' : kind,
|
|
isChat,
|
|
agentKey: aiRole || null,
|
|
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
|
|
what: isChat ? 'WhatsApp' : isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)),
|
|
stage: moved ? stage : null,
|
|
when: when(r.created_at),
|
|
docs,
|
|
narrative: NARRATIVE
|
|
.map(([k, label]) => [label, byBase.get(k)?.value])
|
|
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
|
figures: [...byBase]
|
|
.filter(([base, f]) => MONEY.has(base) && f.value)
|
|
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
|
|
}
|
|
})
|
|
|
|
/**
|
|
* THE CONVERSATION IS ONE THING, NOT NINE.
|
|
*
|
|
* 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 = []
|
|
for (const it of items) {
|
|
const last = grouped[grouped.length - 1]
|
|
if (it.isChat && last && last.isChat) {
|
|
last.count += 1
|
|
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.narrative.length) last.narrative = it.narrative
|
|
last.docs = last.docs.concat(it.docs)
|
|
continue
|
|
}
|
|
grouped.push({ ...it, count: 1 })
|
|
}
|
|
|
|
const sysCount = grouped.filter((it) => it.kind === 'sys').length
|
|
const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys')
|
|
|
|
return (
|
|
<>
|
|
{sysCount ? (
|
|
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
|
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
|
</button>
|
|
) : null}
|
|
|
|
<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__head">
|
|
{it.isChat ? (
|
|
<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>
|
|
)}
|
|
{it.count > 1 ? <span className="tl__n">{it.count} messages</span> : null}
|
|
{it.stage && !it.isChat ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
|
</div>
|
|
{/* The worker, as a face. Recognition down a column beats
|
|
reading five names to notice it was one agent throughout. */}
|
|
<div className="tl__meta">
|
|
{it.kind === 'sys'
|
|
? <span className="tl__who tl__who--sys">system</span>
|
|
: it.isChat && !it.agentKey
|
|
? <span className="tl__who tl__who--cust">the customer</span>
|
|
: <AgentChip agentKey={it.agentKey} name={it.actor} onOpen={onOpenAgent} />}
|
|
<span className="tl__when">{it.when}</span>
|
|
{it.isChat && onOpenChat ? (
|
|
<button type="button" className="tl__open" onClick={onOpenChat}>
|
|
Open thread
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* What was received, named and openable. The count is stated
|
|
rather than left to be inferred from a list — "3 documents
|
|
received" is the sentence the operator is looking for. */}
|
|
{it.docs.length ? (
|
|
<div className="tl__docs">
|
|
<span className="tl__docshead">
|
|
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
|
</span>
|
|
<div className="tl__doclist">
|
|
{it.docs.map((d) => (
|
|
<a
|
|
key={d.uuid}
|
|
className="tl__doc"
|
|
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
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}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{it.narrative.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 ? (
|
|
<div className="tl__figs">
|
|
{it.figures.map(([k, v]) => (
|
|
<span key={k}><em>{k}</em> {v}</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</>
|
|
)
|
|
}
|