From 49c51c24d7014d5de29623d0601a9f37c167a784 Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 25 Aug 2026 12:40:30 +0530 Subject: [PATCH] Show what happened on a lead, and who did it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/api/client.js | 10 ++- src/components/Timeline.css | 39 ++++++++++++ src/components/Timeline.jsx | 120 ++++++++++++++++++++++++++++++++++++ src/screens/Lead.jsx | 14 +++++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/components/Timeline.css create mode 100644 src/components/Timeline.jsx diff --git a/src/api/client.js b/src/api/client.js index 2ddaf15..c9d8042 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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))}`) } } diff --git a/src/components/Timeline.css b/src/components/Timeline.css new file mode 100644 index 0000000..7e81f25 --- /dev/null +++ b/src/components/Timeline.css @@ -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; } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx new file mode 100644 index 0000000..965dc6c --- /dev/null +++ b/src/components/Timeline.jsx @@ -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
Could not load the timeline — {err.status} {err.message}
+ if (!rows) return

Loading the timeline…

+ if (!rows.length) return

Nothing has happened yet.

+ + // Oldest first: a timeline reads forwards. + const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))) + + return ( +
    + {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 ( +
  1. +
  2. + ) + })} +
+ ) +} diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 78a76cd..d4a208e 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -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() {
Terminal.

No activity runs from {stateName}.

)} +
+
+
+

What has happened

+

+ Every step on this lead, in order, and who took it — an AI employee, + a person, or the platform itself. +

+
+
+ +
+ {GROUPS.map(([title, keys]) => { const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null) if (!shown.length) return null