zurich_kotak/src/screens/Lead.jsx
Yashas 171aa4c2fa console: a live trail, faces for the agents, and the thread as a thread
Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.

THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.

THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.

THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.

THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.

CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:29:02 +05:30

666 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx'
import AgentChip from '../components/AgentChip.jsx'
import ClampText from '../components/ClampText.jsx'
import Conversation from '../components/Conversation.jsx'
import Timeline from '../components/Timeline.jsx'
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
import { AGENTS } from '../api/agents.js'
import { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import './screens.css'
function fmtWhen(ts) {
if (!ts) return null
const d = new Date(ts)
if (isNaN(d)) return String(ts)
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
/** The countdown, not just the date. On a renewal book this is the number
* that decides whether anyone should act today. */
function expiryOf(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000)
return {
days,
tone: days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later',
label: days < 0 ? Math.abs(days) + ' days overdue' : days === 0 ? 'expires today' : 'in ' + days + ' days',
on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }),
}
}
/** Whole days since a timestamp. Age and dwell time are the two numbers that
* say whether a lead is moving, and neither is on the record. */
function daysSince(ts) {
if (!ts) return null
const t = Date.parse(ts)
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000)
}
function inrShort(v) {
const n = Number(v)
if (!Number.isFinite(n) || !n) return null
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount','sme_stock_si'])
/** Field groups, in the order the lead was actually worked. */
const GROUPS = [
['Source', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes']],
// The five OCR slots led this list and were absent from it, which is how a
// lead with an RC, an expiring policy and a PAN attached showed "Documents 2"
// — the status and the notes. They were also absent from the view itself
// until 76; adding them here without that would have changed nothing.
['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert',
'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos',
'doc_vehicle_photos','doc_financials','documents_status','documents_notes']],
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']],
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']],
['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
['Conversation', ['customer_reply','customer_reply_from','customer_answer','answer_count']],
['Proposal', ['acceptance_ref','accepted_at']],
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
['Commission', ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
['Outcome', ['lost_reason','welcome_sent','renewal_due']],
]
/** Past this, a value is prose and gets folded rather than printed in full. */
const LONG = 150
/**
* Field ids are snake_case and several carry acronyms, which sentence-casing
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
* other word passes through.
*
* This is the FALLBACK. The detail view ships an output_label for every field
* and that is preferred — this covers a field the config does not describe.
*/
const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE',
amc: 'AMC', km: 'km', posp: 'POSP',
}
function label(k) {
const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ')
return words.charAt(0).toUpperCase() + words.slice(1)
}
/** The uploaded files on a field value, if that is what it holds. */
function filesOf(v) {
return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : []
}
function fmt(k, v) {
if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
if (Array.isArray(v)) {
// A file field holds an array of upload references — {uuid, original_name,
// blob_path, …} — so joining it raw prints [object Object].
return v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
.filter((x) => x !== '' && x !== null && x !== undefined)
.join(', ') || null
}
// No field carries a bare object today, but one arriving as JSON should not
// print as [object Object].
if (typeof v === 'object') return JSON.stringify(v)
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
return String(v)
}
export default function Lead() {
const { instanceId } = useParams()
const { client, user } = useZino()
const roles = rolesOf(user)
const navigate = useNavigate()
const location = useLocation()
// What the workflow said when the last activity was submitted. It is written
// per activity — "Documents received — capturing the risk" — and is the
// workflow telling the operator what it just set in motion, so it is shown
// verbatim rather than replaced with a generic "Saved".
const [note, setNote] = useState(location.state?.message ?? null)
const [row, setRow] = useState(null)
const [err, setErr] = useState(null)
const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0)
const [labels, setLabels] = useState({})
// The audit rows live HERE, not inside Timeline, for two reasons: Timeline
// fetched them once on mount and never again — so a lead worked by five
// agents in three minutes showed the trail as it was when you opened the
// page — and the conversation view needs the same rows. One fetch, one
// poll, two readers that cannot disagree.
const [audit, setAudit] = useState(null)
const [showChat, setShowChat] = useState(false)
const load = useCallback((quiet = false) => {
if (!quiet) setErr(null)
// Best-effort and never awaited with the record: a failed audit call must
// not blank the lead, and a slow one must not hold up the fields.
client.audit(instanceId)
.then((r) => setAudit(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => { /* keep whatever is on screen */ })
return client.detailView(DV_LEAD, instanceId)
.then((r) => {
setRow(r?.data ?? r?.record ?? r)
// The detail view ships an output_label for every field. Using the
// server's names means a field renamed in Studio is renamed here.
const fields = r?.config?.fields
if (Array.isArray(fields)) {
setLabels(Object.fromEntries(
fields.filter((f) => f.field_key && f.output_label).map((f) => [f.field_key, f.output_label]),
))
}
})
// A poll that fails leaves the screen exactly as it is. Only a first load
// becomes an error page: a gateway blip must not throw away a lead the
// operator is reading, or unmount a half-filled form under them.
.catch((e) => { if (!quiet) setErr(e) })
}, [client, instanceId])
// `load` is CALLED here, not handed to useEffect: it returns a promise so the
// poll below and onDone can chain on it, and an effect that returns anything
// but a function has that value called as cleanup — React invokes the promise
// as destroy(), throws, and unmounts the whole tree to a blank screen.
useEffect(() => { load() }, [load])
// The message is handed over on the history entry, so it would show again
// every time this lead is reached by Back — on a lead that has since moved on.
// Consume it once, then strike it from the entry.
useEffect(() => {
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
}, [location.state, location.pathname, navigate])
// A lead that no longer exists is not a page to look at. Send the operator
// back to the queue rather than leaving them on an empty shell.
useEffect(() => {
if (err && describeError(err).kind === 'gone') {
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
return () => clearTimeout(id)
}
}, [err, navigate])
/**
* Nothing is pushed — no socket, no SSE — and most of this workflow is carried
* by AI employees that take one to three minutes a step, so a lead genuinely
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
* at all while the tab is in the background.
*/
useEffect(() => {
// Not while a form is open. A successful poll rewrites `actions`, and if the
// lead has moved on the open form unmounts — taking whatever was typed into
// it with no warning. A submission against a stale state is refused by the
// workflow anyway, and that refusal is handled properly below.
if (open) return undefined
const id = setInterval(() => {
if (document.visibilityState === 'visible') load(true)
}, 12000)
return () => clearInterval(id)
}, [load, open])
if (err) {
const said = describeError(err)
return (
<div className="notice">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
<p className="notice__detail">Lead {instanceId} · {err.status} {err.message}</p>
</div>
)
}
if (!row) return <p className="empty">Loading</p>
// Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS
.map(([title, keys]) => [
title,
keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null),
])
.filter(([, shown]) => shown.length)
const active = Math.min(tab, Math.max(groups.length - 1, 0))
const stateName = row.current_state_name || ''
// The state, then what is really happening inside it — see phaseOf. Actions
// are still looked up on the state's own uid; only the presentation moves.
const stage = phaseOf(STAGES.find((s) => s.name === stateName), row)
// Only what THIS user may run. The workflow refuses the rest server-side
// anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control.
const actions = actionsFor(roles, stage?.uid)
// Two different reasons for an empty action list, and they must not read the
// same. A terminal lead is finished; a live one you cannot act on belongs to
// somebody else, and saying "closed" about it would be a lie.
// Derived from the same audit rows the trail renders, so they cannot drift.
const worked = [...new Set((audit || [])
.flatMap((r) => (r.user_roles || []).filter((x) => AGENTS[x])))]
const chatTurns = (audit || []).reduce((n, r) => {
const fs = Array.isArray(r.fields) && r.fields.length ? r.fields : []
return n + fs.filter((f) => {
const b = String(f.field_id || '').replace(/_\d+$/, '')
return (b === 'customer_reply' || b === 'customer_answer')
&& typeof f.value === 'string' && f.value.trim()
}).length
}, 0)
const isClosed = stage?.kind === 'end'
const heldByOthers = !isClosed && actions.length === 0
// An automated stage that has stopped for a stated reason. Checked before
// the "in progress" strip below, which would otherwise keep promising that
// something is happening for as long as the lead is left alone.
const blocked = stage?.kind === 'auto' ? blockedOn(row) : null
return (
<section>
<header className="page__head">
<div className="page__lead">
{/* React Router stamps the first entry in a session with key 'default',
so a lead opened from its own URL has nothing to go back TO and
lands on the queue instead of leaving the app. */}
<button
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div>
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub">
{row.lead_ref || `Lead ${instanceId}`}
{row.entity_name ? ` · ${row.entity_name}` : ''}
</p>
</div>
</div>
<div className="lead__stage">
<span className="lead__stagelabel">Stage</span>
<strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
{/* An end state runs no activity. That is worth one line next to the
stage, not a panel of its own where the actions would be. */}
{isClosed ? <span className="lead__closed">Closed no further activity</span> : null}
{heldByOthers ? (
<span className="lead__closed">
{stage?.kind === 'auto'
? `In progress with ${stage.by}`
: `Assigned to ${stage?.by ?? 'another role'}`}
</span>
) : null}
</div>
</header>
{/* Measures, not fields. Age and dwell time are computed — neither is on
the record — and they are the two that answer "is this lead moving?",
which no label/value pair on the page could. */}
{(() => {
const e = expiryOf(row.renewal_due_date)
const age = daysSince(row.created_at)
const dwell = daysSince(row.updated_at)
const premium = inrShort(row.quoted_premium)
const commission = inrShort(row.commission_amount)
const conf = Number(row.ai_recommendation_confidence)
return (
<div className="lmetrics">
<div className="lm">
<span className="lm__l">Renewal</span>
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong>
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
</div>
<div className="lm">
<span className="lm__l">Lead age</span>
<strong className="lm__v">{age === null ? '—' : age === 0 ? 'Today' : age + 'd'}</strong>
<span className="lm__s">{row.created_at ? fmtWhen(row.created_at) : ''}</span>
</div>
<div className="lm">
<span className="lm__l">In this stage</span>
<strong className="lm__v">{dwell === null ? '—' : dwell === 0 ? '<1d' : dwell + 'd'}</strong>
<span className="lm__s">last activity {row.updated_at ? fmtWhen(row.updated_at) : '—'}</span>
</div>
<div className="lm">
<span className="lm__l">Premium</span>
<strong className="lm__v">{premium ?? '—'}</strong>
<span className="lm__s">{premium ? 'quoted' : 'not yet rated'}</span>
</div>
<div className="lm">
<span className="lm__l">Commission</span>
<strong className="lm__v">{commission ?? '—'}</strong>
<span className="lm__s">
{row.commission_rate_pct ? row.commission_rate_pct + '% of premium' : 'on placement'}
</span>
</div>
<div className="lm">
<span className="lm__l">Product</span>
<strong className="lm__v lm__v--sm">{PRODUCTS[row.product_line] ?? '—'}</strong>
<span className="lm__s">
{row.motor_reg_no || row.entity_name || ''}
{row.current_insurer ? ` · from ${row.current_insurer}` : ''}
</span>
</div>
{Number.isFinite(conf) && conf > 0 ? (
<div className="lm">
<span className="lm__l">AI confidence</span>
<strong className="lm__v">{conf}%</strong>
<span className="lm__s">on the cover advice</span>
</div>
) : null}
</div>
)
})()}
<div className="lead">
<div className="lead__main">
{note ? (
<div className="said" role="status">
<p>{note}</p>
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
</div>
) : null}
{/* An AI-carried stage has no empty action bar and no idle screen: it
says what is happening and who is doing it. The activities below it
stay available — ops can run them by hand — but they are not the
answer to "why is nothing moving?". */}
{blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents
</button>
</div>
</div>
) : stage?.kind === 'customer' && stage.doing ? (
/* Not an agent working, and not a task of yours either — the lead
is with someone outside the business. Without this the screen
went blank at exactly the stage where an operator is most likely
to wonder whether something has broken. */
<div className="doing doing--waiting">
<span className="doing__wait" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Their answer comes back to this lead on its own Engage reads it and
records the acceptance. Nothing is waiting on you.
</span>
</div>
</div>
) : stage?.kind === 'auto' ? (
<div className="doing">
<span className="doing__pulse" aria-hidden="true" />
<div>
<strong>{stage.doing}</strong>
<span>
Handled by {stage.by}.
{stage.after ? ` The lead stays in ${stateName} until a quote exists — nothing is waiting on you.` : ''}
{' '}Typically completes within two minutes; this view refreshes automatically.
</span>
</div>
</div>
) : null}
{/* WHAT TO DO NEXT, not what is permitted.
Every action carries a role: `do` is the step this stage is
waiting on, `again` is a bounded loop, `force` is an AI's own job
offered only so a stalled lead can be pushed by hand, `exit` is
Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */}
{(() => {
// Once the documents are in, the upload is not the step, not an
// alternative to the step, and not a loop worth offering: it is
// recovery. Demoting it only to 'again' still printed an Upload
// documents button under a panel saying the documents had been
// received, which is the contradiction that was reported. It goes
// into the fold with the other recovery levers, relabelled for what
// it is actually for — a wrong file, or one more.
const docsDone = stage?.after === 'documents received'
// Recording an acceptance a second time is not a loop, a recovery
// or an alternative — it is meaningless. The customer accepted on
// WhatsApp at a stated time and the lead carries the reference;
// offering the button again invites somebody to overwrite that
// with a worse record of the same event.
const accepted = Boolean(row.acceptance_ref)
const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
: a)
const shown = actions
.filter((a) => !(accepted && a.uid === 'zk-act-accept'))
.map(recast)
const step = shown.filter((a) => a.role === 'do')
const again = shown.filter((a) => a.role === 'again')
const force = shown.filter((a) => a.role === 'force')
const exit = shown.filter((a) => a.role === 'exit')
const extras = [...force, ...exit]
const form = open ? (
<div className="acts__form">
<ActivityForm
activityUid={open}
instanceId={Number(instanceId)}
lead={row}
onCancel={() => setOpen(null)}
onStale={() => {
setOpen(null)
setNote('Stage changed. Available actions have been refreshed.')
load()
}}
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
/>
</div>
) : null
if (!step.length && !again.length && !extras.length) return null
return (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">Next step</h2>
<p className="panel__sub">
{step.length
? 'This is what this lead is waiting on.'
: isClosed
? 'This lead is closed.'
: blocked
? 'The chain has stopped. The fix is above.'
: accepted && stage?.uid === 'zk-state-quoted'
? `Accepted by the customer${row.accepted_at ? ' on ' + row.accepted_at : ''}${row.acceptance_ref === 'whatsapp' ? ', over WhatsApp' : ''}. KYC and underwriting are running.`
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
</p>
</div>
</div>
{/* The step. One button, sized like a decision. Referred has
two because clear and decline are a pair, not a choice
between doing something and doing nothing. */}
{step.length ? (
<div className="step">
{step.map((a) => (
<button
key={a.uid} type="button"
className={'step__btn' + (open === a.uid ? ' is-open' : '')
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}
>
<strong>{a.label}</strong>
<span>{a.by}</span>
</button>
))}
</div>
) : null}
{again.length ? (
<div className="alt">
{again.map((a) => (
<button key={a.uid} type="button"
className={'alt__btn' + (open === a.uid ? ' is-open' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
{a.label}
</button>
))}
</div>
) : null}
{form}
{/* Recovery, folded. An operator needs these on the day an
agent stalls and never otherwise, and putting them in the
open makes an AI's own work look like an outstanding task. */}
{extras.length ? (
<details className="stuck">
<summary>Lead not moving?</summary>
<p className="stuck__why">
{force.length
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
: 'Close this lead if it is going nowhere.'}
</p>
<div className="alt">
{extras.map((a) => (
<button key={a.uid} type="button"
className={'alt__btn' + (a.role === 'exit' ? ' alt__btn--exit' : '')
+ (open === a.uid ? ' is-open' : '')}
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
{a.label}
<em>{a.by}</em>
</button>
))}
</div>
</details>
) : null}
</div>
)
})()}
{/* The flow's own detail. Each group is one click away rather than one
long scroll, and prose is folded to a few lines — the page can be
scanned, and still holds everything for whoever wants it. */}
{groups.length ? (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">Lead file</h2>
<p className="panel__sub">Everything captured, grouped by the step that captured it.</p>
</div>
</div>
<div className="tabs" role="tablist">
{groups.map(([title, shown], i) => (
<button
key={title} type="button" role="tab" aria-selected={i === active}
className={'tab' + (i === active ? ' is-on' : '')}
onClick={() => setTab(i)}
>
{title}<span className="tab__n">{shown.length}</span>
</button>
))}
</div>
<dl className="props">
{groups[active][1].map(([k, v, files]) => (
files.length ? (
/* An attached document is a thing to open, not a filename
to read. The preview route is app-scoped and public, so
a plain link works with no token plumbing. */
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className="prop__files">
{files.map((f) => (
<a
key={f.uuid}
className="prop__file"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer"
>
{f.original_name || 'Document'}
</a>
))}
</dd>
</div>
) : v.length > LONG ? (
<div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
</div>
) : (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)
))}
</dl>
</div>
) : null}
</div>
{/* The story runs alongside the file rather than under it, so what was
done and what was captured can be read against each other. */}
<aside className="lead__side">
<div className="panel panel--rail">
<div className="panel__head">
<div>
<h2 className="panel__title">Audit trail</h2>
<p className="panel__sub">Every step, who took it, and what they wrote.</p>
</div>
{/* The WhatsApp exchange is spread across the trail one turn at a
time, so reading it meant scrolling a log and holding the order
in your head. This shows it as the thread it is. */}
{chatTurns ? (
<button type="button" className="panel__act" onClick={() => setShowChat(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Conversation
<b>{chatTurns}</b>
</button>
) : null}
</div>
{/* Who worked this lead. Five agents carry it and their names appear
only inside individual entries, so the team was never visible at
a glance. */}
{worked.length ? (
<div className="crew">
<span className="crew__l">Worked by</span>
{worked.map((k) => <AgentChip key={k} agentKey={k} />)}
</div>
) : null}
<div className="lead__feed">
<Timeline rows={audit} />
</div>
</div>
</aside>
</div>
{showChat ? (
<Conversation rows={audit} name={row.customer_name} onClose={() => setShowChat(false)} />
) : null}
</section>
)
}