THE SIDEBAR NOW CARRIES COUNTS. It deliberately did not, on the argument
that a tally could only come from a second full list call that would then
disagree with the queue's own total. Right about the cost, wrong about
the conclusion: with no numbers the only way to learn whether anything
was waiting on you was to open all nine queues in turn, which is the
question navigation exists to answer.
There is no second call. The overview's existing fetch moved into a
PortfolioProvider that both surfaces read, so this is one call fewer than
before and the two agree by construction. Bounded at 200 rows, as the
overview always was; past that the honest answer is an aggregate
endpoint, not a bigger limit.
What the badge counts is what needs a PERSON — phaseOf again, so a lead
whose documents are in and whose AI chain is running is reported beside
the badge rather than inside it. An amber dot marks a queue holding a
renewal inside a week, which is the only reason to open one queue before
another and was previously invisible. Zero is shown rather than hidden:
"nothing here" is an answer, and a queue that disappears when it empties
makes the sidebar move under the cursor.
Closed is folded into a summary. Three of the twelve queues, opened about
once a week, and at equal weight they made the live ones harder to find.
REASONING OPENS IN A DIALOG, NOT INLINE. The audit rail is 320px wide
with one entry per step, and a 1,500-character rationale expanding in
place pushed a lead's whole history off screen to read one sentence of
it. Nobody reads a paragraph in a sidebar. The finding — the AI's own
first sentence, which is already the conclusion — stays on the line; the
working is one click away and one Escape back, headed by what is being
read ("Call — Log Contact") rather than by nothing. Escape closes, the
page behind does not scroll, and focus returns to the button that opened
it so a keyboard reader keeps their place.
Two headings that described the container rather than the contents:
Record → "Lead file", and the audit trail's subtitle now says what a
reader gets from it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
319 lines
13 KiB
JavaScript
319 lines
13 KiB
JavaScript
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 <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)))
|
|
|
|
// 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 ? (
|
|
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
|
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
|
</button>
|
|
) : null}
|
|
|
|
<ol className="tl">
|
|
{visible.map((it) => (
|
|
<li key={it.key} className={'tl__item tl__item--' + it.kind}>
|
|
<span className="tl__dot" aria-hidden="true" />
|
|
<div className="tl__body">
|
|
<div className="tl__head">
|
|
<strong className="tl__what">{it.what}</strong>
|
|
{it.stage ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
|
</div>
|
|
<div className="tl__meta">
|
|
<span className={'tl__who tl__who--' + it.kind}>
|
|
{it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor}
|
|
</span>
|
|
<span className="tl__when">{it.when}</span>
|
|
</div>
|
|
|
|
{/* What was received, named and openable. The count is stated
|
|
rather than left to be inferred from a list — "3 documents
|
|
received" is the sentence the operator is looking for. */}
|
|
{it.docs.length ? (
|
|
<div className="tl__docs">
|
|
<span className="tl__docshead">
|
|
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
|
</span>
|
|
<div className="tl__doclist">
|
|
{it.docs.map((d) => (
|
|
<a
|
|
key={d.uuid}
|
|
className="tl__doc"
|
|
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={d.original_name}
|
|
>
|
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
|
|
strokeLinejoin="round" />
|
|
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
|
|
strokeLinejoin="round" />
|
|
</svg>
|
|
{d.slot}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{it.narrative.map(([label, v]) => (
|
|
<div className="tl__say" key={label}>
|
|
<span className="tl__saylabel">{label}</span>
|
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
|
</div>
|
|
))}
|
|
|
|
{it.figures.length ? (
|
|
<div className="tl__figs">
|
|
{it.figures.map(([k, v]) => (
|
|
<span key={k}><em>{k}</em> {v}</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</>
|
|
)
|
|
}
|