zurich_kotak/src/components/Timeline.jsx
Yashas e5b413ecac console: the customer accepts from their own phone
Recording the acceptance was the third human touchpoint, and it was a
person in the office typing what a customer had said somewhere else.
The customer's WhatsApp reply now drives it (81), so this stage has no
step of its own.

"Record the acceptance" drops from a step to a folded recovery lever —
kept, because a customer who says yes on a CALL still needs somebody to
record it, and because a reply Engage judged ambiguous has to be
actionable by a person.

That left Quote Presented with an empty screen at exactly the stage an
operator is most likely to wonder whether something has broken, so the
waiting state now says so. Grey, and no pulse: nothing is happening, and
that is the correct state — unlike the AI strip, where a still dot would
mean something is wrong.

The reply itself surfaces in two places: the audit trail carries it as
"The customer said", and the lead file files it under Proposal beside the
acceptance it produced. The evidence and the decision it justifies are
one line apart, which matters when nobody in the office made it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:26:46 +05:30

320 lines
13 KiB
JavaScript

import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import ClampText from './ClampText.jsx'
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 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'],
['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)
}
export default function Timeline({ instanceId }) {
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 <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.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 AI_ROLES, was classed as agent 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 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 })
}
}
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,
actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'),
what: 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')]),
}
})
const sysCount = items.filter((it) => it.kind === 'sys').length
const visible = showSys ? items : items.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">
<strong className="tl__what">{it.what}</strong>
{it.stage ? <span className="tl__stage"> {it.stage.name}</span> : null}
</div>
<div className="tl__meta">
<span className={'tl__who tl__who--' + it.kind}>
{it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor}
</span>
<span className="tl__when">{it.when}</span>
</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>
</>
)
}