import { useState } from 'react' import { useZino } from '../api/provider.jsx' import AgentChip from './AgentChip.jsx' import ClampText from './ClampText.jsx' import { AGENTS } from '../api/agents.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import './Timeline.css' /* The roster lives in api/agents.js now — one short name, one colour and one set of initials per employee, so the same worker looks the same everywhere it appears. This file only needs to know which roles are agents. */ /** * 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'], ['customer_reply', 'The customer said'], ['customer_answer', 'We replied'], ['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) } /** * `rows` is owned by the lead page and refreshed on its poll, so the trail * keeps up with a lead that five agents are working through in three minutes. * This component fetched them itself once on mount and never again. */ /** The two activities that ARE the WhatsApp thread. */ const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer']) export default function Timeline({ rows, onOpenAgent, onOpenChat }) { const { client } = useZino() // 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) 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 the agent roster, was classed as its 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) => AGENTS[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 }) } } // The channel performs these as the system, but the meaningful actor is // the person who typed. "System · Customer Reply" told the reader the // opposite of what happened. const isChat = CHAT_ACTS.has(r.activity_id) 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: isChat ? 'chat' : kind, isChat, agentKey: aiRole || null, actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'), what: isChat ? 'WhatsApp' : 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')]), } }) /** * THE CONVERSATION IS ONE THING, NOT NINE. * * Every WhatsApp turn was its own entry — "Customer Reply", "Customer * Reply", "Customer Reply", "Answer the Customer" — each with a heading, an * actor and a quote box, so a four-message exchange occupied more of the * trail than the entire underwriting chain. A resend made it worse: the same * sentence printed four times because the customer sent it four times. * * A contiguous run of chat turns now collapses to one entry that says how * many messages, shows the last of them, and opens the thread. The trail goes * back to being a list of decisions, and the conversation goes back to being * a conversation. * * Contiguous, not global: a second exchange after underwriting is a separate * episode in this lead's story and should read as one. */ const grouped = [] for (const it of items) { const last = grouped[grouped.length - 1] if (it.isChat && last && last.isChat) { last.count += 1 last.when = it.when // Keep the newest line as the preview — an operator scanning the trail // wants where the conversation GOT to, not where it started. if (it.narrative.length) last.narrative = it.narrative last.docs = last.docs.concat(it.docs) continue } grouped.push({ ...it, count: 1 }) } const sysCount = grouped.filter((it) => it.kind === 'sys').length const visible = showSys ? grouped : grouped.filter((it) => it.kind !== 'sys') return ( <> {sysCount ? ( ) : null}
    {visible.map((it) => (
  1. ))}
) }