console: the row is the control, and the agent says what it is doing
Two changes to the queue and overview tables. The row is the click target. An "Open" button was a 90px destination inside a 1000px row that already reads as one object, and it put a second tab stop on every line. The row now carries the click, Enter/Space, a focus ring and the hover tint; the button column is gone, along with its CSS. The column it freed shows what is actually happening. This workflow is run by agents and watched by a person, so the question a queue has to answer is not "which stage" — inside a queue every row shares it — but "is it moving". An automated stage now reports working (under 3 minutes), waiting (under 30), or stalled with the elapsed time, with a pulse on the live one and a red left edge on the stuck one. Thresholds are 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". Before this, a lead three hours into "Calling the customer…" looked identical to one thirty seconds in — which is the failure this console exists to catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b6a387cf6
commit
704afe170e
@ -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() {
|
||||
<div className="gridwrap">
|
||||
<table className="grid grid--tight">
|
||||
<thead>
|
||||
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th><th aria-label="Open" /></tr>
|
||||
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.attention.slice(0, 8).map((r) => {
|
||||
const id = r.instance_id ?? r.id
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>{r.customer_name || r.lead_ref || `#${id}`}
|
||||
<div className="grid__sub">{r.lead_ref || ''}</div></td>
|
||||
<tr
|
||||
key={id}
|
||||
className="grid__row"
|
||||
onClick={() => 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}`}
|
||||
>
|
||||
<td>
|
||||
<span className="grid__name">{r.customer_name || r.lead_ref || `#${id}`}</span>
|
||||
<div className="grid__sub">{r.lead_ref || ''}</div>
|
||||
</td>
|
||||
<td><span className={'due due--' + expiryTone(r._d)}>
|
||||
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
|
||||
</span></td>
|
||||
<td><span className="grid__sub">{r.current_state_name}</span></td>
|
||||
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
|
||||
<td className="grid__act"><Link className="grid__btn" to={`/lead/${id}`}>Open</Link></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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() {
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th><th>Renewal due</th><th>Product</th>
|
||||
<th className="num">Premium</th><th>Waiting</th><th aria-label="Open" />
|
||||
<th>Customer</th>
|
||||
<th>{isAll ? 'Stage' : 'Status'}</th>
|
||||
<th>Renewal due</th>
|
||||
<th className="num">Premium</th>
|
||||
<th>Waiting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -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 (
|
||||
<tr key={id}>
|
||||
<tr
|
||||
key={id}
|
||||
className={'grid__row' + (prog ? ' is-' + prog.state : '')}
|
||||
onClick={() => 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}`}
|
||||
>
|
||||
<td>
|
||||
{r.customer_name || '—'}
|
||||
<span className="grid__name">{r.customer_name || '—'}</span>
|
||||
<div className="grid__sub">
|
||||
{r.lead_ref || `#${id}`}
|
||||
{r.entity_name ? ` · ${r.entity_name}` : ''}
|
||||
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
|
||||
{ch.label ? ` · ${ch.label}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
{/* 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. */}
|
||||
<td>
|
||||
{prog ? (
|
||||
<span className={'run run--' + prog.state}>
|
||||
<span className="run__dot" aria-hidden="true" />
|
||||
{prog.label}
|
||||
</span>
|
||||
) : isAll ? (
|
||||
<span className="grid__sub">{r.current_state_name || '—'}</span>
|
||||
) : (
|
||||
<span className="grid__sub">
|
||||
{rowStage?.by ? `with ${rowStage.by}` : '—'}
|
||||
</span>
|
||||
)}
|
||||
</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>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
||||
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
||||
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
|
||||
<td className="grid__act">
|
||||
<Link className="grid__btn" to={`/lead/${id}`}>Open</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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 <tr> rather than wrapping cells in <a>: an
|
||||
anchor cannot span table cells without breaking the table layout, and
|
||||
display:block on a <td> 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; }
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user