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); }