Show what happened on a lead, and who did it

The lead file listed the current field values and nothing about how they
got there. On an app whose whole claim is that agents do the work, that
is the wrong thing to show: you could see the outcome and not whether a
machine or a person produced it.

Adds a timeline from the audit trail — every activity in order, badged by
actor, carrying the reasoning the agent actually wrote (attribution,
eligibility, call notes, recommendation rationale, the premium breakup)
rather than the raw field dump that is already below it.

Colour encodes something real: machine work is blue, people amber, the
platform grey, so a file reads at a glance for how much of it was done by
hand.

Also fixes the audit path — the bare /view/audit is not an API route and
falls through to the SPA, returning HTML with a 200 that parses as a JSON
error rather than an HTTP one. It has to be app-scoped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-25 12:40:30 +05:30
parent f61d3a74cd
commit 49c51c24d7
4 changed files with 182 additions and 1 deletions

View File

@ -177,7 +177,15 @@ export class ZinoClient {
})
}
/**
* The instance's audit trail: one entry per activity performed, with WHO
* performed it and the data they wrote.
*
* Must be the APP-SCOPED path. The bare `/view/audit` is not an API route at
* all it falls through to the SPA and returns HTML with a 200, which
* parses as a JSON error rather than an HTTP one.
*/
audit(instanceId) {
return this.request('GET', `/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`)
return this.request('GET', `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`)
}
}

View File

@ -0,0 +1,39 @@
.tl { list-style: none; margin: 0; padding: 0 0 0 4px; }
.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; }
.tl__err { color: var(--zk-danger); }
.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); }
.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; }
.tl__dot {
position: absolute; left: -7px; top: 3px; width: 12px; height: 12px;
border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey);
}
/* Machine work is blue, people are amber, the platform itself is grey so a
file can be read at a glance for how much of it was done by hand. */
.tl__item--ai .tl__dot { border-color: var(--zk-blue); background: var(--zk-blue); }
.tl__item--human .tl__dot { border-color: #b5761f; background: var(--zk-white); }
.tl__item--sys .tl__dot { border-color: var(--zk-line); background: var(--zk-line); }
.tl__body { display: flex; flex-direction: column; gap: 3px; }
.tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; }
.tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); }
.tl__who {
font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap;
}
.tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
.tl__who--human { background: #f7efe2; color: #7a5a12; }
.tl__who--sys { background: var(--zk-tint); color: var(--zk-muted); }
.tl__stage { font-size: .78rem; color: var(--zk-blue); }
.tl__when { font-size: .72rem; color: var(--zk-grey); }
.tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); }
.tl__saylabel {
display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase;
color: var(--zk-grey); margin-bottom: 2px;
}
.tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; }
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; }
.tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; }
.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; }

120
src/components/Timeline.jsx Normal file
View File

@ -0,0 +1,120 @@
import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import { STAGES } 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.
*/
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'],
]
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
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}`
}
export default function Timeline({ instanceId }) {
const { client } = useZino()
const [rows, setRows] = useState(null)
const [err, setErr] = useState(null)
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)))
return (
<ol className="tl">
{ordered.map((r, i) => {
const roles = r.user_roles || []
const aiRole = roles.find((x) => AI_ROLES[x])
const isSystem = r.activity_id === 'DATA_UPDATE'
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
const stage = STAGES.find((s) => s.uid === r.execution_state)
const d = r.data || {}
const narrative = NARRATIVE
.map(([k, label]) => [label, d[k]])
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
const figures = Object.entries(d)
.filter(([k, v]) => MONEY.has(k) && v)
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')])
return (
<li key={r.id ?? i} className={'tl__item tl__item--' + kind}>
<span className="tl__dot" aria-hidden="true" />
<div className="tl__body">
<div className="tl__head">
<strong className="tl__what">
{isSystem ? 'Data updated' : (r.activity_name || r.activity_id)}
</strong>
<span className={'tl__who tl__who--' + kind}>
{kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor}
</span>
{stage ? <span className="tl__stage"> {stage.name}</span> : null}
</div>
<div className="tl__when">{when(r.created_at)}</div>
{narrative.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<p>{String(v)}</p>
</div>
))}
{figures.length ? (
<div className="tl__figs">
{figures.map(([k, v]) => (
<span key={k}><em>{k}</em> {v}</span>
))}
</div>
) : null}
</div>
</li>
)
})}
</ol>
)
}

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import Timeline from '../components/Timeline.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
import './screens.css'
@ -108,6 +109,19 @@ export default function Lead() {
<div className="notice"><strong>Terminal.</strong><p>No activity runs from {stateName}.</p></div>
)}
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">What has happened</h2>
<p className="panel__sub">
Every step on this lead, in order, and who took it an AI employee,
a person, or the platform itself.
</p>
</div>
</div>
<Timeline instanceId={instanceId} />
</div>
{GROUPS.map(([title, keys]) => {
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
if (!shown.length) return null