import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import { CHANNELS, RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { describeError } from '../api/errors.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, } } /** * 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 // `updated_at` ONLY. Falling back to created_at dates the measure from when // the lead was filed rather than from its last step, so every lead older than // half an hour reports "stalled" — an alarm on every row is an alarm on none. // The view returns the column since 76; before that it was silently absent, // which is exactly how that fallback got there. const t = Date.parse(row.updated_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) { 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' /** * One record view drives every queue; the stage is a server-side filter on * current_state_name. Filtering server-side rather than fetching everything and * narrowing in the browser is what keeps a queue honest once there are more * leads than one page. */ 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. const isAll = stageUid === 'all' const stage = isAll ? { uid: 'all', name: 'All open leads', kind: 'all', by: 'everyone' } : STAGES.find((s) => s.uid === stageUid) const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null }) useEffect(() => { let cancelled = false // `quiet` is what makes polling bearable: the first load may show a skeleton, // a refresh may not — dropping back to the loading state every 30 seconds // would flash the whole queue away under whoever is reading it. function fetchRows(quiet) { if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null }) return client .recordView(RV_LEADS, { limit: isAll ? 200 : 100, // No stage filter on the all view. Closed leads are dropped below // rather than in the query: one request beats three negations, and // 200 covers a demo book comfortably. ...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }), }) .then((res) => { if (cancelled) return let rows = res?.data ?? res?.rows ?? res?.records ?? [] if (isAll) { const closed = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name)) rows = rows.filter((r) => !closed.has(r.current_state_name)) } // total_count is the size of the QUEUE; rows is one page of at most // 100 of it. Counting the page would quietly under-report a busy stage. const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length) setState({ status: 'ready', rows, total, error: null }) }) .catch((err) => { if (cancelled) return // A failed refresh must not throw away a queue that is already on // screen; only a failed first load is an error state. setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], total: 0, error: err })) }) } fetchRows(false) const id = setInterval(() => { if (document.visibilityState === 'visible') fetchRows(true) }, 30000) return () => { cancelled = true; clearInterval(id) } }, [client, stageUid, stage?.name, isAll]) if (!stage) return

Unknown stage.

return (

{stage.need ?? stage.name}

{isAll ? 'Every lead not yet closed, across all stages.' : stage.kind === 'auto' ? `Automated · ${stage.doing.toLowerCase()}` : stage.kind === 'end' ? 'Closed — retained for reporting.' : stage.kind === 'waiting' ? 'Held until the renewal window opens. Re-enters outreach automatically.' : `Pending action by ${stage.by}.`} {stage.need ? Stage · {stage.name} : null}

{state.status === 'ready' ? (
{state.total} {state.total === 1 ? 'lead' : 'leads'}
) : null}
{state.status === 'loading' ? (
{Array.from({ length: 6 }, (_, i) =>
)}
) : null} {state.status === 'error' ? (
Unable to load this stage.

The lead list did not return. Other functions are unaffected; retry before escalating.

{describeError(state.error).title} · {state.error?.status} {state.error?.message}

) : null} {state.status === 'ready' && state.rows.length === 0 ? (

No records in this stage.

) : null} {state.status === 'ready' && state.rows.length > 0 ? (
{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) // What is really happening in the state, not just its name — // a lead whose documents are in is being worked by the AI, not // waiting on the agent whose queue it is sitting in. const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r) 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. */} ) })}
Customer {isAll ? 'Stage' : 'Status'} Renewal due Premium Waiting
{r.customer_name || '—'}
{r.lead_ref || `#${id}`} {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.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'} {add.rel || add.abs}
) : null}
) }