console: show what the workflow actually did, and when it stopped
Traced the console against the workflow it renders. Five gaps, all of which made a working chain look like a broken one. THE AUDIT TRAIL WAS READING THE WRONG KEYS. 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 — and the timeline matched the unsuffixed global ids. Almost nothing ever matched, so the trail was a list of activity names with the agents' reasoning invisible behind it. The one narrative line that did appear was an accident. It now reads the platform's own typed `fields[]` (label, data_type, value) and matches on the base id. That is also what lets it answer the question that was asked: an upload now renders as "3 documents received" with each one named and openable, instead of a bare "Collect Documents". DATA_UPDATE ROWS WERE CLASSED AS AGENT WORK. A bookkeeping row inherits the roles of whoever caused it, and the AI check ran first — so an AI's field write appeared in the trail as an entry titled "Data updated", while the toggle underneath still offered to reveal the others. The activity id says a row is bookkeeping; the roles say who triggered it. DOCUMENT PENDING IS TWO SITUATIONS. Before the upload a person has to act; after it the lead stays in the same state while three AI steps run. Both rendered as "waiting on the partner agent", so a lead that had just been served showed an Upload documents button under a panel saying the documents had been received, and counted against the Action-required queue. phaseOf() derives the difference once, from documents_status, and the header, the queue count, the row status and the action list all read it. The upload demotes to recovery — "Replace or add a document", folded away with the other levers. A STOPPED CHAIN LOOKED IDENTICAL TO A RUNNING ONE. The rating engine refuses to price without an IDV and writes so into quoted_breakup; Engage then declines to raise a quote it would have to fabricate. Both are right, and nobody was told: the refusal sat in a field on a tab and the lead never moved again. blockedOn() surfaces it as an amber strip that names the missing value and opens the form that carries it. THE STALL MEASURE WAS DATED FROM THE WRONG COLUMN. progress() fell back to created_at when updated_at was absent — which it always was, because neither view returned it — so every lead older than half an hour would have reported stalled. It reads updated_at only; the view supplies it as of 76. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
704afe170e
commit
d6756b4d2e
@ -335,3 +335,71 @@ export function fieldApplies(fieldId, lead) {
|
||||
const slot = DOC_SLOTS[baseFieldId(fieldId)]
|
||||
return slot?.when ? slot.when(lead) : true
|
||||
}
|
||||
|
||||
/**
|
||||
* A STAGE IS NOT ALWAYS ONE SITUATION.
|
||||
*
|
||||
* Document Pending covers two that could not look more different to whoever is
|
||||
* watching. Before the upload a person has to act and nothing moves until they
|
||||
* do; after it the lead stays in the same state while Engage captures the risk,
|
||||
* the Advisor recommends cover and the rating engine prices it — three AI steps,
|
||||
* two to five minutes, no one to chase.
|
||||
*
|
||||
* Rendered from `current_state_name` alone both read as "waiting on the partner
|
||||
* agent", so a lead that had just been served showed an Upload documents button
|
||||
* and sat in the Action-required queue, and the AI work behind it was invisible.
|
||||
* The state machine is right to hold one state here — nothing has been decided
|
||||
* yet — so the distinction belongs in the read model, once, rather than in each
|
||||
* screen's own guess.
|
||||
*
|
||||
* `documents_status` is what separates them. The workflow derives it from the
|
||||
* files themselves (75), so it says what is actually attached rather than what
|
||||
* anyone claimed.
|
||||
*/
|
||||
export function phaseOf(stage, lead) {
|
||||
if (!stage || !lead) return stage
|
||||
if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') {
|
||||
return {
|
||||
...stage,
|
||||
kind: 'auto',
|
||||
doing: 'Reading the documents and pricing the cover',
|
||||
by: 'Engage AI, then the Advisor',
|
||||
// Kept so a screen can say WHY this is not the queue it looks like.
|
||||
after: 'documents received',
|
||||
}
|
||||
}
|
||||
return stage
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an automated stage has stopped, when it has.
|
||||
*
|
||||
* The AI chain behind Document Pending is honest about refusing: the rating
|
||||
* engine writes `quoted_breakup` = "Not priced: no insured value (IDV)
|
||||
* captured…" rather than inventing a premium, and Engage then declines to
|
||||
* raise a quote it would have to fabricate. Both are the right call.
|
||||
*
|
||||
* What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a
|
||||
* tab, the AI's task dies in a queue no operator can see, and the lead sits in
|
||||
* Document Pending looking exactly like one whose documents never arrived —
|
||||
* indefinitely, because nothing wakes the chain again. A workflow that stops
|
||||
* for a good reason and a workflow that is broken must not look the same.
|
||||
*
|
||||
* Recovery is always the same shape: put the missing value in and let the
|
||||
* chain re-run. Collect Documents carries the IDV field and re-performing it
|
||||
* wakes Engage again, so the action is one the partner agent already holds.
|
||||
*/
|
||||
export function blockedOn(lead) {
|
||||
if (!lead) return null
|
||||
const breakup = String(lead.quoted_breakup || '')
|
||||
if (breakup.startsWith('Not priced')) {
|
||||
return {
|
||||
what: breakup.replace(/^Not priced:\s*/, ''),
|
||||
// Named rather than described: the operator has to find this field, and
|
||||
// it is not where they would look for it.
|
||||
fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.',
|
||||
via: 'zk-act-collect-docs',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@ -203,3 +203,45 @@
|
||||
background: var(--zk-tint);
|
||||
color: var(--zk-blue-dark);
|
||||
}
|
||||
|
||||
/* ── What was attached ────────────────────────────────────────────────────
|
||||
The audit trail's job at this step is to prove receipt. A line saying
|
||||
"Collect Documents" and nothing else left the operator to open the record
|
||||
and count for themselves — and until the views were fixed, the record could
|
||||
not see the files either. */
|
||||
.tl__docs {
|
||||
margin-top: 8px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--zk-line-soft);
|
||||
border-radius: var(--r-sm, 6px);
|
||||
background: var(--zk-tint);
|
||||
}
|
||||
|
||||
.tl__docshead {
|
||||
display: block;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--zk-muted);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
.tl__doc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: none;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: var(--r-pill, 999px);
|
||||
background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line);
|
||||
color: var(--zk-blue-dark);
|
||||
transition: border-color .12s, background .12s;
|
||||
}
|
||||
.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; }
|
||||
.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||
.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import ClampText from './ClampText.jsx'
|
||||
import { STAGES } from '../api/config.js'
|
||||
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. */
|
||||
@ -17,6 +17,14 @@ const AI_ROLES = {
|
||||
* 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'],
|
||||
@ -29,10 +37,31 @@ const NARRATIVE = [
|
||||
['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)
|
||||
@ -60,6 +89,13 @@ function prettyUid(uid) {
|
||||
.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)
|
||||
@ -125,8 +161,7 @@ export default function Timeline({ instanceId }) {
|
||||
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,
|
||||
ai_reasoning: r.ai_reasoning || prev.ai_reasoning,
|
||||
ai_confidence: r.ai_confidence ?? prev.ai_confidence,
|
||||
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,
|
||||
@ -136,25 +171,69 @@ export default function Timeline({ instanceId }) {
|
||||
merged.push(r)
|
||||
}
|
||||
|
||||
let lastStage = null
|
||||
const items = merged.map((r, i) => {
|
||||
const roles = r.user_roles || []
|
||||
const aiRole = roles.find((x) => AI_ROLES[x])
|
||||
// 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 kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
|
||||
const d = r.data || {}
|
||||
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: STAGES.find((s) => s.uid === r.execution_state),
|
||||
stage: moved ? stage : null,
|
||||
when: when(r.created_at),
|
||||
docs,
|
||||
narrative: NARRATIVE
|
||||
.map(([k, label]) => [label, d[k]])
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''),
|
||||
figures: Object.entries(d)
|
||||
.filter(([k, v]) => MONEY.has(k) && v)
|
||||
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]),
|
||||
.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')]),
|
||||
}
|
||||
})
|
||||
|
||||
@ -185,6 +264,37 @@ export default function Timeline({ instanceId }) {
|
||||
<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>
|
||||
|
||||
@ -4,7 +4,7 @@ import { useZino } from '../api/provider.jsx'
|
||||
import ActivityForm from '../components/ActivityForm.jsx'
|
||||
import ClampText from '../components/ClampText.jsx'
|
||||
import Timeline from '../components/Timeline.jsx'
|
||||
import { DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
|
||||
import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js'
|
||||
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import './screens.css'
|
||||
@ -58,7 +58,13 @@ const GROUPS = [
|
||||
['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']],
|
||||
['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','documents_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']],
|
||||
@ -94,6 +100,11 @@ function label(k) {
|
||||
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)
|
||||
@ -203,12 +214,17 @@ export default function Lead() {
|
||||
|
||||
// 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])]).filter(([, v]) => v !== null)])
|
||||
.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 || ''
|
||||
const stage = STAGES.find((s) => s.name === stateName)
|
||||
// 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.
|
||||
@ -218,6 +234,10 @@ export default function Lead() {
|
||||
// somebody else, and saying "closed" about it would be a lie.
|
||||
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 (
|
||||
@ -331,12 +351,33 @@ export default function Lead() {
|
||||
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?". */}
|
||||
{stage?.kind === 'auto' ? (
|
||||
{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 === 'auto' ? (
|
||||
<div className="doing">
|
||||
<span className="doing__pulse" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{stage.doing}</strong>
|
||||
<span>Handled by {stage.by}. Typically completes within two minutes; this view refreshes automatically.</span>
|
||||
<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}
|
||||
@ -348,18 +389,22 @@ export default function Lead() {
|
||||
Mark Lost. Rendering them as one flat row of equals is what made
|
||||
six buttons appear where one was the answer. */}
|
||||
{(() => {
|
||||
// Once the three documents are in, the upload is no longer the step:
|
||||
// the lead stays in Document Pending while Engage captures the risk
|
||||
// and the quote is built, and offering "Upload documents" through
|
||||
// that reads as though nothing was received. A further upload stays
|
||||
// possible as a loop — a fourth document, a corrected one — it is
|
||||
// just not what the lead is waiting on.
|
||||
const docsDone = stage?.uid === 'zk-state-docs' && row.documents_status === 'complete'
|
||||
const roleOf = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? 'again' : a.role)
|
||||
const step = actions.filter((a) => roleOf(a) === 'do')
|
||||
const again = actions.filter((a) => roleOf(a) === 'again')
|
||||
const force = actions.filter((a) => a.role === 'force')
|
||||
const exit = actions.filter((a) => a.role === 'exit')
|
||||
// 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'
|
||||
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.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 ? (
|
||||
@ -391,8 +436,8 @@ export default function Lead() {
|
||||
? 'This is what this lead is waiting on.'
|
||||
: isClosed
|
||||
? 'This lead is closed.'
|
||||
: docsDone
|
||||
? 'Documents received. Engage AI is capturing the risk and preparing the quote.'
|
||||
: blocked
|
||||
? 'The chain has stopped. The fix is above.'
|
||||
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
|
||||
</p>
|
||||
</div>
|
||||
@ -482,8 +527,27 @@ export default function Lead() {
|
||||
))}
|
||||
</div>
|
||||
<dl className="props">
|
||||
{groups[active][1].map(([k, v]) => (
|
||||
v.length > LONG ? (
|
||||
{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} /></dd>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { RV_LEADS, STAGES } from '../api/config.js'
|
||||
import { RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import './screens.css'
|
||||
@ -117,7 +117,30 @@ export default function Overview() {
|
||||
const attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d)
|
||||
|
||||
// Queues a person has to clear, in the order the pipeline runs.
|
||||
const actionable = STAGES.filter((s) => s.kind === 'needs' || s.kind === 'customer')
|
||||
//
|
||||
// The count is of leads a person actually has to act on — NOT of leads
|
||||
// sitting in the state. Document Pending holds both: the ones waiting for
|
||||
// an upload, and the ones whose upload arrived and are now being worked by
|
||||
// three AI steps inside the same state. Counting the state told an operator
|
||||
// that four leads needed them when two did, which is how a queue stops
|
||||
// being believed. `working` is reported separately rather than dropped —
|
||||
// the leads are still there, they are just not anyone's task.
|
||||
const actionable = STAGES
|
||||
.filter((s) => s.kind === 'needs' || s.kind === 'customer')
|
||||
.map((s) => {
|
||||
const here = open.filter((r) => r.current_state_name === s.name)
|
||||
const working = here.filter((r) => phaseOf(s, r).kind === 'auto')
|
||||
const waiting = here.filter((r) => phaseOf(s, r).kind !== 'auto')
|
||||
return {
|
||||
...s,
|
||||
n: waiting.length,
|
||||
working: working.length,
|
||||
oldest: waiting
|
||||
.map((r) => ageInDays(r.created_at))
|
||||
.filter((x) => x !== null)
|
||||
.sort((a, b) => b - a)[0],
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable,
|
||||
@ -197,27 +220,20 @@ export default function Overview() {
|
||||
<div>
|
||||
<h2 className="sec">Action required</h2>
|
||||
<div className="qlist">
|
||||
{m.actionable.map((s) => {
|
||||
const n = m.byStage[s.name] || 0
|
||||
const oldest = m.open
|
||||
.filter((r) => r.current_state_name === s.name)
|
||||
.map((r) => ageInDays(r.created_at))
|
||||
.filter((x) => x !== null)
|
||||
.sort((a, b) => b - a)[0]
|
||||
return (
|
||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (n ? ' is-live' : '')}>
|
||||
<strong className="q__n">{n}</strong>
|
||||
{m.actionable.map((s) => (
|
||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
|
||||
<strong className="q__n">{s.n}</strong>
|
||||
<span className="q__t">
|
||||
{s.need ?? s.name}
|
||||
<em>{s.by}</em>
|
||||
</span>
|
||||
<span className="q__age">
|
||||
{n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''}
|
||||
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
|
||||
{s.working ? <b className="q__auto">+{s.working} with the AI</b> : null}
|
||||
</span>
|
||||
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
|
||||
import { CHANNELS, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
|
||||
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
||||
@ -35,7 +35,12 @@ function added(ts) {
|
||||
*/
|
||||
function progress(row, stage) {
|
||||
if (!stage || stage.kind !== 'auto') return null
|
||||
const t = Date.parse(row.updated_at || row.created_at)
|
||||
// `updated_at` ONLY. Falling back to created_at dates the measure from when
|
||||
// the lead was filed rather than from its last step, so every lead older than
|
||||
// half an hour reports "stalled" — an alarm on every row is an alarm on none.
|
||||
// The view returns the column since 76; before that it was silently absent,
|
||||
// which is exactly how that fallback got there.
|
||||
const t = Date.parse(row.updated_at)
|
||||
if (isNaN(t)) return null
|
||||
const mins = (Date.now() - t) / 60000
|
||||
if (mins < 3) return { state: 'working', label: stage.doing }
|
||||
@ -183,7 +188,10 @@ export default function Pipeline() {
|
||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
||||
const exp = expiry(r.renewal_due_date)
|
||||
const add = added(r.created_at)
|
||||
const rowStage = STAGES.find((x) => x.name === r.current_state_name)
|
||||
// What is really happening in the state, not just its name —
|
||||
// a lead whose documents are in is being worked by the AI, not
|
||||
// waiting on the agent whose queue it is sitting in.
|
||||
const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r)
|
||||
const prog = progress(r, rowStage)
|
||||
return (
|
||||
<tr
|
||||
|
||||
@ -443,6 +443,46 @@
|
||||
100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
|
||||
}
|
||||
|
||||
/* ---- and when it has stopped ----
|
||||
Same strip, amber, and NOT pulsing: the animation is what says "something is
|
||||
happening", so it is the first thing that has to go when nothing is. The
|
||||
agents refuse rather than invent — the rating engine will not price without
|
||||
an IDV — and that refusal used to be visible only as a lead that never moved
|
||||
again. */
|
||||
.doing--blocked {
|
||||
border-color: var(--zk-amber-line);
|
||||
background: var(--zk-amber-tint);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.doing--blocked strong { color: var(--zk-amber-ink); }
|
||||
|
||||
.doing__stop {
|
||||
flex: none;
|
||||
margin-top: 5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--zk-amber);
|
||||
}
|
||||
|
||||
.doing__fix {
|
||||
align-self: flex-start;
|
||||
margin-top: 9px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 7px 15px;
|
||||
border-radius: var(--r-pill);
|
||||
color: var(--zk-amber-ink);
|
||||
background: var(--zk-white);
|
||||
border: 1px solid var(--zk-amber-line);
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.doing__fix:hover { background: #fff8f1; border-color: var(--zk-amber); }
|
||||
.doing__fix:focus-visible { outline: 2px solid var(--zk-amber); outline-offset: 1px; }
|
||||
|
||||
/* ---- what the workflow said ----
|
||||
The message the API returns per activity, shown verbatim: it is the workflow
|
||||
telling the operator what it just set in motion. */
|
||||
@ -1152,3 +1192,36 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.run--working .run__dot { animation: none; }
|
||||
}
|
||||
|
||||
/* ── An attached document is a thing to open ──────────────────────────────
|
||||
The record printed a filename as text, so the only way to see what an
|
||||
agent had actually uploaded was to perform the activity again. */
|
||||
.prop__files { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
.prop__file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 100%;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-pill, 999px);
|
||||
background: var(--zk-tint-blue);
|
||||
border: 1px solid transparent;
|
||||
color: var(--zk-blue-dark);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.prop__file:hover { border-color: var(--zk-blue-light); }
|
||||
.prop__file:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||
|
||||
/* The part of a queue that is not anyone's task. Reported rather than hidden:
|
||||
the leads are in that state, they are just being carried by an agent, and an
|
||||
operator who counts the queue by hand should find the same total. */
|
||||
.q__auto {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.72rem;
|
||||
color: var(--zk-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user