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