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:
parent
f61d3a74cd
commit
49c51c24d7
@ -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) {
|
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))}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/components/Timeline.css
Normal file
39
src/components/Timeline.css
Normal 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
120
src/components/Timeline.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import ActivityForm from '../components/ActivityForm.jsx'
|
import ActivityForm from '../components/ActivityForm.jsx'
|
||||||
|
import Timeline from '../components/Timeline.jsx'
|
||||||
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
|
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
|
||||||
import './screens.css'
|
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="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]) => {
|
{GROUPS.map(([title, keys]) => {
|
||||||
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
|
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
|
||||||
if (!shown.length) return null
|
if (!shown.length) return null
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user