diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index 18a066f..8a4ff1a 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import { RV_LEADS, STAGES } from '../api/config.js' import { rolesOf, visibleStages } from '../api/permissions.js' @@ -55,6 +55,7 @@ function expiryTone(d) { export default function Overview() { const { client, user } = useZino() + const navigate = useNavigate() const roles = rolesOf(user) const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null }) @@ -236,21 +237,32 @@ export default function Overview() {
- + {m.attention.slice(0, 8).map((r) => { const id = r.instance_id ?? r.id return ( - - + navigate(`/lead/${id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } + }} + tabIndex={0} + role="link" + aria-label={`Open ${r.customer_name || r.lead_ref || id}`} + > + - ) })} diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index 126c71b..205b25d 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Link, useParams } from 'react-router-dom' +import { useNavigate, useParams } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js' import { describeError } from '../api/errors.js' @@ -21,6 +21,28 @@ function added(ts) { } } +/** + * Is an automated stage actually moving? + * + * The agents carry six of the nine stages, and a lead sitting in one is in + * exactly one of two states: being worked right now, or stuck. The row showed + * neither, so a lead three hours into "Calling the customer" looked identical + * to one thirty seconds in — which is the failure this console exists to catch. + * + * The threshold is generous on purpose. An employee wake takes a minute or two + * and a scheduled retry can be hours out, so `stalled` means "longer than any + * normal step", not "longer than average". + */ +function progress(row, stage) { + if (!stage || stage.kind !== 'auto') return null + const t = Date.parse(row.updated_at || row.created_at) + if (isNaN(t)) return null + const mins = (Date.now() - t) / 60000 + if (mins < 3) return { state: 'working', label: stage.doing } + if (mins < 30) return { state: 'waiting', label: stage.doing } + return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') } +} + /** 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) { @@ -42,6 +64,7 @@ import './screens.css' */ export default function Pipeline() { const { stageUid } = useParams() + const navigate = useNavigate() const { client } = useZino() // "all" is not a stage — it is every open lead in one list. An operator // wanting to find a lead should not have to guess which queue it is in. @@ -147,8 +170,11 @@ export default function Pipeline() {
LeadExpiryStagePremium
LeadExpiryStagePremium
{r.customer_name || r.lead_ref || `#${id}`} -
{r.lead_ref || ''}
+ {r.customer_name || r.lead_ref || `#${id}`} +
{r.lead_ref || ''}
+
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'} {r.current_state_name} {r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}Open
- - + + + + @@ -157,28 +183,54 @@ 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) + const prog = progress(r, rowStage) return ( - + navigate(`/lead/${id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } + }} + tabIndex={0} + role="link" + aria-label={`Open ${r.customer_name || r.lead_ref || id}`} + > + {/* Who holds it, and — where an agent does — whether it is + moving. On the all-leads view the stage name is the + useful column; inside a queue every row shares it, so + the useful thing is progress. */} + - - ) })} diff --git a/src/screens/screens.css b/src/screens/screens.css index cac5190..b5c999a 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -193,53 +193,6 @@ border-bottom-color: currentColor; } -/* Row action. Renders as a button; stays an anchor so an operator can open a - lead in a new tab from a queue. - NOT `.act` — that class belongs to the Lead page's activity buttons and is - display:flex, which stretched this anchor to fill the cell. */ -.grid__act { - text-align: right; - white-space: nowrap; - width: 1%; -} - -.grid__btn { - display: inline-flex; - align-items: center; - gap: 6px; - text-decoration: none; - white-space: nowrap; - font-size: 0.78rem; - font-weight: 500; - padding: 6px 14px; - border-radius: var(--r-pill); - background: var(--zk-white); - color: var(--zk-blue); - border: 1px solid var(--zk-line); - transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast); -} - -.grid__btn::after { - content: '→'; - font-size: 0.85em; - transition: transform var(--t-fast); -} - -.grid__btn:hover { - background: var(--zk-tint-blue); - border-color: var(--zk-blue-light); -} - -.grid__btn:hover::after { - transform: translateX(2px); -} - -.grid__btn:focus-visible { - outline: none; - border-color: var(--zk-blue); - box-shadow: var(--ring); -} - /* ---- loading skeleton ---- */ .skel { display: flex; @@ -1148,3 +1101,54 @@ .stuck[open] > summary::before { content: '▾'; } .stuck > summary:hover { color: var(--zk-blue-dark); } .stuck__why { margin: 10px 0 0; font-size: 12.5px; color: var(--zk-muted); max-width: 64ch; } + +/* ── The row IS the control ─────────────────────────────────────────────── + An "Open" button was a ~90px target inside a 1000px row that already reads + as one object, and it put a second tab stop on every line. The row carries + the click, the keyboard and the affordance; the button is gone. + + role="link" + tabIndex on a rather than wrapping cells in : an + anchor cannot span table cells without breaking the table layout, and + display:block on a
CustomerRenewal dueProductPremiumWaiting + Customer{isAll ? 'Stage' : 'Status'}Renewal duePremiumWaiting
- {r.customer_name || '—'} + {r.customer_name || '—'}
{r.lead_ref || `#${id}`} - {r.entity_name ? ` · ${r.entity_name}` : ''} + {r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''} {ch.label ? ` · ${ch.label}` : ''}
+ {prog ? ( + + + ) : isAll ? ( + {r.current_state_name || '—'} + ) : ( + + {rowStage?.by ? `with ${rowStage.by}` : '—'} + + )} + {exp ? <>{exp.label}
{exp.on}
: }
{r.product_line === 'motor' ? 'Motor' : 'SME Package'} {r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'} {add.rel || add.abs} - Open -
collapses the column alignment the queue depends on. */ +.grid__row { cursor: pointer; transition: background .11s, box-shadow .11s; } +.grid__row:hover { background: var(--zk-tint); } +.grid__row:hover .grid__name { color: var(--zk-blue-dark); } +.grid__row:focus-visible { + outline: 2px solid var(--zk-blue); + outline-offset: -2px; + background: var(--zk-tint); +} +.grid__name { font-weight: 500; color: var(--zk-ink); } + +/* A left edge that says who holds the lead, readable before any text is. + Only the states worth acting on are marked — colouring every row would + make the marking mean nothing. */ +.grid__row.is-stalled td:first-child { box-shadow: inset 3px 0 0 var(--zk-danger); } +.grid__row.is-working td:first-child { box-shadow: inset 3px 0 0 var(--zk-blue-mid); } + +/* ── What the agent is doing ────────────────────────────────────────────── + Agent-status disclosure: an automated stage is either moving or stuck, and + after an hour those look identical without this. `working` pulses because + something is happening right now; `waiting` is steady because it is normal; + `stalled` is red because a lead three hours into a two-minute step is the + thing this console exists to catch. */ +.run { display: inline-flex; align-items: center; gap: 7px; font-size: 0.8rem; line-height: 1.3; } +.run__dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } + +.run--working { color: var(--zk-blue-dark); } +.run--working .run__dot { background: var(--zk-blue); animation: runPulse 1.6s ease-in-out infinite; } + +.run--waiting { color: var(--zk-muted); } +.run--waiting .run__dot { background: var(--zk-blue-light); } + +.run--stalled { color: var(--zk-danger-ink); font-weight: 500; } +.run--stalled .run__dot { background: var(--zk-danger); } + +@keyframes runPulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(33,103,174,.45); } + 50% { opacity: .55; box-shadow: 0 0 0 5px rgba(33,103,174,0); } +} +/* Respect a reader who has asked the interface to stop moving. */ +@media (prefers-reduced-motion: reduce) { + .run--working .run__dot { animation: none; } +}