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'], ['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
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))) // 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 ? ( ) : null}
    {visible.map((it) => (
  1. ))}
) }