From 1fb8d0dc98fa86885431ea09fa263bc9730fcc54 Mon Sep 17 00:00:00 2001 From: Yashas Date: Tue, 25 Aug 2026 12:52:26 +0530 Subject: [PATCH] Put dates where the work is decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/screens/Lead.jsx | 35 ++++++++++++++++++++++++++++++++++ src/screens/Pipeline.jsx | 41 +++++++++++++++++++++++++++++++++++++++- src/screens/screens.css | 16 ++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index d4a208e..bc7c342 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -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() { +
+ {(() => { const e = expiryOf(row.renewal_due_date); return e ? ( +
+
Renewal due
+
{e.label} {e.on}
+
+ ) : null })()} + {row.current_insurer ?
Current insurer
{row.current_insurer}
: null} + {row.motor_reg_no ?
Registration
{row.motor_reg_no}
: null} + {row.created_at ?
Lead added
{fmtWhen(row.created_at)}
: null} + {row.updated_at ?
Last activity
{fmtWhen(row.updated_at)}
: null} +
+ {actions.length ? (
diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index c0e6c24..c05e260 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -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() { LeadCustomerChannel - ProductPremiumAttribution + ProductRenewal duePremium + AttributionAdded {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 ( {r.lead_ref || id} {r.customer_name || '—'}
{r.entity_name || ''}
{ch.label} {r.product_line === 'motor' ? 'Motor' : 'SME Package'} + + {exp + ? <>{exp.label} +
{exp.on}
+ : } + {r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'} {r.attribution_status === 'contested' ? Contested : {r.attribution_status || '—'}} + {add.abs}
{add.rel}
) })} diff --git a/src/screens/screens.css b/src/screens/screens.css index d6921e6..8a514ff 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -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); }