Put dates where the work is decided

The timeline carried timestamps; the two screens people actually act on
carried none. A queue with no dates cannot tell you which lead is going
stale, and on a renewal book the countdown to expiry is the number that
decides whether anyone should act today — it was buried in a field group.

Queue gains "Renewal due" and "Added", both absolute plus relative: the
absolute answers "when exactly", the relative answers "is this stale".
The countdown is toned — lapsed, urgent, soon, later — on the same
thresholds the workflow itself uses to decide whether to call, so the
screen and the agent are reading the same bands.

Lead header gains the countdown, the current insurer, the registration,
and when the lead arrived and was last touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-25 12:52:26 +05:30
parent 49c51c24d7
commit 1fb8d0dc98
3 changed files with 91 additions and 1 deletions

View File

@ -6,6 +6,28 @@ import Timeline from '../components/Timeline.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.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' }),
}
}
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',
@ -73,6 +95,19 @@ export default function Lead() {
</div>
</header>
<dl className="lead__meta">
{(() => { const e = expiryOf(row.renewal_due_date); return e ? (
<div>
<dt>Renewal due</dt>
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
</div>
) : null })()}
{row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null}
{row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null}
{row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null}
{row.updated_at ? <div><dt>Last activity</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null}
</dl>
{actions.length ? (
<div className="panel">
<div className="panel__head">

View File

@ -2,6 +2,35 @@ import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
/** Short absolute date plus how long ago a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */
function added(ts) {
if (!ts) return { abs: '—', rel: '' }
const d = new Date(ts)
if (isNaN(d)) return { abs: String(ts), rel: '' }
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 {
abs: d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
rel,
}
}
/** Days to expiry, and how alarmed to be about it. This is a renewal book
* the countdown is the most operationally useful number in the row. */
function expiry(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)
const tone = days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later'
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
}
import './screens.css'
/**
@ -78,25 +107,35 @@ export default function Pipeline() {
<thead>
<tr>
<th>Lead</th><th>Customer</th><th>Channel</th>
<th>Product</th><th className="num">Premium</th><th>Attribution</th>
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
<th>Attribution</th><th>Added</th>
</tr>
</thead>
<tbody>
{state.rows.map((r) => {
const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
const exp = expiry(r.renewal_due_date)
const add = added(r.created_at)
return (
<tr key={id}>
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
<td><span className="chip">{ch.label}</span></td>
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
<td>
{exp
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
<div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>}
</td>
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<td>
{r.attribution_status === 'contested'
? <span className="chip chip--warn">Contested</span>
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
</td>
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
</tr>
)
})}

View File

@ -91,3 +91,19 @@
.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
.lead__back { margin-top: 18px; }
/* ---- renewal countdown ---- */
.due {
display: inline-block; font-size: .76rem; font-weight: 500;
padding: 2px 8px; border-radius: 3px; white-space: nowrap;
}
.due--lapsed { background: #fdeceb; color: #a3261f; border: 1px solid #f0c2bd; }
.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; }
.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); }
.due--later { background: var(--zk-tint); color: var(--zk-muted); border: 1px solid var(--zk-line); }
/* ---- lead header meta ---- */
.lead__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; }
.lead__meta div { display: flex; flex-direction: column; gap: 1px; }
.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); }
.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); }