console: numbers in the navigation, and reasoning behind one click

THE SIDEBAR NOW CARRIES COUNTS. It deliberately did not, on the argument
that a tally could only come from a second full list call that would then
disagree with the queue's own total. Right about the cost, wrong about
the conclusion: with no numbers the only way to learn whether anything
was waiting on you was to open all nine queues in turn, which is the
question navigation exists to answer.

There is no second call. The overview's existing fetch moved into a
PortfolioProvider that both surfaces read, so this is one call fewer than
before and the two agree by construction. Bounded at 200 rows, as the
overview always was; past that the honest answer is an aggregate
endpoint, not a bigger limit.

What the badge counts is what needs a PERSON — phaseOf again, so a lead
whose documents are in and whose AI chain is running is reported beside
the badge rather than inside it. An amber dot marks a queue holding a
renewal inside a week, which is the only reason to open one queue before
another and was previously invisible. Zero is shown rather than hidden:
"nothing here" is an answer, and a queue that disappears when it empties
makes the sidebar move under the cursor.

Closed is folded into a summary. Three of the twelve queues, opened about
once a week, and at equal weight they made the live ones harder to find.

REASONING OPENS IN A DIALOG, NOT INLINE. The audit rail is 320px wide
with one entry per step, and a 1,500-character rationale expanding in
place pushed a lead's whole history off screen to read one sentence of
it. Nobody reads a paragraph in a sidebar. The finding — the AI's own
first sentence, which is already the conclusion — stays on the line; the
working is one click away and one Escape back, headed by what is being
read ("Call — Log Contact") rather than by nothing. Escape closes, the
page behind does not scroll, and focus returns to the button that opened
it so a keyboard reader keeps their place.

Two headings that described the container rather than the contents:
Record → "Lead file", and the audit trail's subtitle now says what a
reader gets from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 12:42:40 +05:30
parent d6756b4d2e
commit 5ff74ea22d
9 changed files with 490 additions and 90 deletions

View File

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { useZino } from './api/provider.jsx' import { useZino } from './api/provider.jsx'
import Login from './pages/Login.jsx' import Login from './pages/Login.jsx'
import Shell from './layout/Shell.jsx' import Shell from './layout/Shell.jsx'
import { PortfolioProvider } from './api/portfolio.jsx'
import Overview from './screens/Overview.jsx' import Overview from './screens/Overview.jsx'
import Pipeline from './screens/Pipeline.jsx' import Pipeline from './screens/Pipeline.jsx'
import Lead from './screens/Lead.jsx' import Lead from './screens/Lead.jsx'
@ -22,7 +23,7 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Navigate to="/" replace />} /> <Route path="/login" element={<Navigate to="/" replace />} />
<Route element={<Shell />}> <Route element={<PortfolioProvider><Shell /></PortfolioProvider>}>
{/* The morning page. This used to redirect to the underwriting queue {/* The morning page. This used to redirect to the underwriting queue
one stage, usually empty, and somebody else's. */} one stage, usually empty, and somebody else's. */}
<Route path="/" element={<Overview />} /> <Route path="/" element={<Overview />} />

109
src/api/portfolio.jsx Normal file
View File

@ -0,0 +1,109 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useZino } from './provider.jsx'
import { RV_LEADS, STAGES, phaseOf } from './config.js'
/**
* The whole book, fetched ONCE and shared.
*
* The sidebar used to carry no counts, on the reasoning that a tally could only
* come from a second full list call that would then disagree with the queue's
* own total. That reasoning was right about the cost and wrong about the
* conclusion: a sidebar with no numbers means the only way to learn whether
* anything is waiting on you is to click all nine queues, which is the one
* question a console's navigation exists to answer.
*
* So there is no second call. The overview already fetched the whole book on a
* timer; that fetch moves here and both surfaces read it, which is one call
* fewer than before and makes the two agree by construction.
*
* Bounded at 200 rows, as the overview always was. Beyond that the honest
* answer is an aggregate endpoint rather than a bigger limit, and the counts
* would need to say they are partial worth knowing before this app meets a
* real book.
*/
const PortfolioContext = createContext(null)
const EMPTY = []
export function PortfolioProvider({ children }) {
const { client, user } = useZino()
// Identity, not just presence: switching user must not show the previous
// one's book while the new fetch is in flight.
const who = user?.id ?? user?.email ?? null
// Seeded from `who` rather than set inside the effect. Nothing here writes
// state synchronously during a render pass a signed-out shell is 'ready'
// and empty from the first frame, and the first fetch only ever moves it
// forwards, so there is no loading flash and no cascading render.
const [state, setState] = useState(() => ({
status: who ? 'loading' : 'ready', rows: EMPTY, at: null, error: null,
}))
const cancelled = useRef(false)
const load = useCallback(() => {
if (!who) return Promise.resolve()
return client.recordView(RV_LEADS, { limit: 200 })
.then((res) => {
if (cancelled.current) return
setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? EMPTY, at: new Date(), error: null })
})
.catch((err) => {
if (cancelled.current) return
// A failed poll leaves a good book on screen. Only a first load is an error.
setState((p) => (p.status === 'ready' ? p : { status: 'error', rows: EMPTY, at: null, error: err }))
})
}, [client, who])
useEffect(() => {
cancelled.current = false
load()
const id = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000)
return () => { cancelled.current = true; clearInterval(id) }
}, [load])
const value = useMemo(() => ({ ...state, reload: load }), [state, load])
return <PortfolioContext.Provider value={value}>{children}</PortfolioContext.Provider>
}
export function usePortfolio() {
const ctx = useContext(PortfolioContext)
if (!ctx) throw new Error('usePortfolio must be used inside PortfolioProvider')
return ctx
}
const DAY = 86400000
const daysToExpiry = (v) => {
if (!v) return null
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
return isNaN(d) ? null : Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
}
/**
* Per-stage tallies for the sidebar.
*
* `waiting` is the number that goes on the badge: leads whose PHASE still needs
* a person. `working` is the rest in the state, carried by an agent, not
* anyone's task. Badging the state total told an operator four leads needed
* them when two did, which is how a queue stops being believed.
*
* `urgent` marks a queue holding a renewal that has lapsed or expires within a
* week. It is the only reason to look at one queue before another, and it was
* invisible until you opened each one.
*/
export function useStageCounts() {
const { rows, status } = usePortfolio()
return useMemo(() => {
const out = {}
for (const s of STAGES) out[s.uid] = { total: 0, waiting: 0, working: 0, urgent: 0 }
for (const r of rows) {
const s = STAGES.find((x) => x.name === r.current_state_name)
if (!s) continue
const t = out[s.uid]
t.total += 1
if (s.kind !== 'end' && phaseOf(s, r).kind === 'auto') t.working += 1
else if (s.kind !== 'end') t.waiting += 1
const d = daysToExpiry(r.renewal_due_date)
if (s.kind !== 'end' && d !== null && d <= 7) t.urgent += 1
}
return { counts: out, ready: status === 'ready' }
}, [rows, status])
}

View File

@ -24,11 +24,6 @@
overflow: visible; overflow: visible;
} }
.clamp.is-open .clamp__text {
display: block;
overflow: visible;
}
.clamp__more { .clamp__more {
font: inherit; font: inherit;
font-size: 0.76rem; font-size: 0.76rem;
@ -54,10 +49,6 @@
transition: transform var(--t); transition: transform var(--t);
} }
.clamp.is-open .clamp__more svg {
transform: rotate(180deg);
}
/* The headline lifted out of an AI paragraph. Slightly heavier than the body /* The headline lifted out of an AI paragraph. Slightly heavier than the body
and never clamped it is one sentence by construction, and the point of it and never clamped it is one sentence by construction, and the point of it
is that the finding can be read without opening anything. */ is that the finding can be read without opening anything. */
@ -81,3 +72,97 @@
color: var(--zk-ink); color: var(--zk-ink);
} }
.clamp__lead + .clamp__text { margin-top: 8px; } .clamp__lead + .clamp__text { margin-top: 8px; }
/* The full text, over the page
Expanding inline was the wrong shape for the audit rail: 320px wide, one
entry per step, and a 1,500-character rationale pushed a lead's whole
history off the screen to read one sentence of it. */
.prose__scrim {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(23, 31, 40, 0.42);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
animation: zk-fade 0.14s var(--ease) both;
}
.prose__dlg {
width: min(680px, 100%);
max-height: min(76vh, 720px);
display: flex;
flex-direction: column;
background: var(--zk-white);
border-radius: var(--r-lg);
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
outline: none;
animation: zk-rise 0.18s var(--ease) both;
}
.prose__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 14px 24px;
border-bottom: 1px solid var(--zk-line-soft);
}
.prose__head h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
line-height: 1.35;
color: var(--zk-ink);
}
.prose__head button {
flex: none;
display: grid;
place-items: center;
width: 28px;
height: 28px;
cursor: pointer;
border: none;
border-radius: var(--r-sm, 6px);
background: transparent;
color: var(--zk-muted);
}
.prose__head button svg { width: 14px; height: 14px; }
.prose__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
.prose__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
.prose__body {
overflow-y: auto;
padding: 18px 24px 26px;
}
.prose__body p {
margin: 0 0 12px;
font-size: 0.875rem;
line-height: 1.62;
color: var(--zk-muted);
white-space: pre-wrap;
}
.prose__body p:last-child { margin-bottom: 0; }
/* The finding, kept at the top and kept emphasised: a reader who opened this
to check one thing should not have to find it again. */
.prose__lead {
font-size: 0.92rem !important;
font-weight: 500;
color: var(--zk-ink) !important;
padding-bottom: 12px;
border-bottom: 1px solid var(--zk-line-soft);
margin-bottom: 16px !important;
}
@keyframes zk-fade { from { opacity: 0 } to { opacity: 1 } }
@media (prefers-reduced-motion: reduce) {
.prose__scrim, .prose__dlg { animation: none; }
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import './ClampText.css' import './ClampText.css'
/** /**
@ -21,17 +21,79 @@ function splitLead(value) {
} }
/** /**
* Prose, folded. * The full text, in a dialog.
*
* The reasoning used to expand INLINE, and in the audit rail 320px wide, one
* entry per step a 1,500-character rationale pushed the rest of the lead's
* history off the screen to read one sentence of it. Nobody reads a paragraph
* in a sidebar. It opens over the page instead: the finding stays on the line,
* the working is one click away and one Escape back.
*/
function ProseDialog({ title, lead, body, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
// The page behind must not scroll under the dialog.
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
// Focus goes back to the button that opened this, not to the top of the
// document otherwise a keyboard reader loses their place in the trail.
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return (
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg"
role="dialog"
aria-modal="true"
aria-label={title || 'Full text'}
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>{title || 'In full'}</h3>
<button type="button" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="prose__body">
{/* The finding stays at the top and stays emphasised a reader who
opened this to check one thing should not have to find it again. */}
{lead ? <p className="prose__lead">{lead}</p> : null}
{body.split(/\n{2,}/).map((para, i) => <p key={i}>{para}</p>)}
</div>
</div>
</div>
)
}
/**
* Prose, reduced to its point.
* *
* The AI employees write at length a recommendation rationale runs past 1,500 * The AI employees write at length a recommendation rationale runs past 1,500
* characters and a lead file that prints all of it is a document, not a * characters and a screen that prints all of it is a document. Short text is
* screen. Anything long shows its finding, with the reasoning one click away. * shown as it is; anything longer shows its finding and puts the working behind
* one click.
* *
* Whether to fold is decided on length, not by measuring the rendered box: a * Whether to fold is decided on length, not by measuring the rendered box: a
* ref-and-measure pass would reflow on every resize to answer a question the * ref-and-measure pass would reflow on every resize to answer a question the
* string itself already answers. * string itself already answers.
*/ */
export default function ClampText({ text, lines = 3, threshold = 150 }) { export default function ClampText({ text, title, lines = 3, threshold = 150 }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const value = String(text) const value = String(text)
@ -39,33 +101,26 @@ export default function ClampText({ text, lines = 3, threshold = 150 }) {
const split = splitLead(value) const split = splitLead(value)
// No sensible headline fold the whole thing, as before.
if (!split) {
return (
<div className={'clamp' + (open ? ' is-open' : '')}>
<p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>
<button type="button" className="clamp__more" onClick={() => setOpen((o) => !o)}>
{open ? 'Show less' : 'Read more'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
)
}
return ( return (
<div className={'clamp' + (open ? ' is-open' : '')}> <div className="clamp">
<p className="clamp__lead">{split.lead}</p> {split
{open ? <p className="clamp__text clamp__text--short">{split.rest}</p> : null} ? <p className="clamp__lead">{split.lead}</p>
<button type="button" className="clamp__more" onClick={() => setOpen((o) => !o)}> : <p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>}
{open ? 'Hide the reasoning' : 'Show the reasoning'} <button type="button" className="clamp__more" onClick={() => setOpen(true)}>
{split ? 'Read the reasoning' : 'Read in full'}
<svg viewBox="0 0 12 12" aria-hidden="true"> <svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" <path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" /> strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</button> </button>
{open ? (
<ProseDialog
title={title}
lead={split?.lead}
body={split ? split.rest : value}
onClose={() => setOpen(false)}
/>
) : null}
</div> </div>
) )
} }

View File

@ -298,7 +298,7 @@ export default function Timeline({ instanceId }) {
{it.narrative.map(([label, v]) => ( {it.narrative.map(([label, v]) => (
<div className="tl__say" key={label}> <div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span> <span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} lines={2} threshold={120} /> <ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div> </div>
))} ))}

View File

@ -289,3 +289,120 @@
} }
.shell__today:hover { background: var(--zk-tint); } .shell__today:hover { background: var(--zk-tint); }
.shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); } .shell__today.is-active { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
/* Queue tallies
Three numbers, deliberately unequal in weight, because they are unequal in
importance: what needs YOU, what an agent holds, and whether anything in
there is about to lapse. */
.shell__tally {
display: flex;
align-items: center;
gap: 6px;
flex: none;
}
/* What is waiting on a person. The only figure with full contrast. */
.shell__n {
min-width: 20px;
padding: 1px 6px;
border-radius: var(--r-pill);
text-align: center;
font-size: 0.74rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--zk-blue-dark);
background: var(--zk-tint-blue);
}
/* Zero is shown, not hidden. "Nothing here" is an answer, and a queue that
disappears when it empties makes the sidebar move under the cursor. */
.shell__n.is-zero {
color: var(--zk-grey);
background: transparent;
font-weight: 400;
}
/* What an agent is carrying. Present, quiet, and never added to the badge
it is in the queue, it is not your work. */
.shell__auto {
font-style: normal;
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
color: var(--zk-blue-mid);
}
.shell__auto::after {
content: '·';
margin-left: 5px;
color: var(--zk-line);
}
/* A renewal inside a week sitting in this queue. */
.shell__urgent {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--zk-amber);
flex: none;
}
.shell__stage.is-empty { color: var(--zk-grey); }
.shell__stage.is-empty::before { border-color: var(--zk-line-soft); }
.shell__stage.is-empty.is-needed .shell__stagename { color: var(--zk-muted); font-weight: 400; }
/* The one number worth carrying on a group heading: everything this role has
to clear, across its queues. */
.shell__navlabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.shell__grouptally {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
color: var(--zk-white);
background: var(--zk-blue);
border-radius: var(--r-pill);
padding: 1px 7px;
}
/* Closed, folded
Three of the twelve queues, opened about once a week. */
.shell__closed { margin-top: 22px; }
.shell__closed > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
list-style: none;
padding: 0 10px 10px;
font-size: 0.64rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zk-grey);
}
.shell__closed > summary::-webkit-details-marker { display: none; }
.shell__closed > summary:hover { color: var(--zk-muted); }
.shell__closed > summary b {
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
color: var(--zk-grey);
}
.shell__closed > summary::after {
content: '';
order: -1;
width: 5px; height: 5px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
transition: transform var(--t-fast);
}
.shell__closed[open] > summary::after { transform: rotate(45deg); }

View File

@ -1,6 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { ENTRY, NAV_GROUPS } from '../api/config.js' import { ENTRY, NAV_GROUPS } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { useStageCounts } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import UserMenu from './UserMenu.jsx' import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp' import logo from '../assets/brand/zurich_logo.webp'
@ -12,11 +13,22 @@ import './Shell.css'
* is not the question anyone signing in has; these answer "is anything waiting * is not the question anyone signing in has; these answer "is anything waiting
* on me?". Queues that need a person are named by what they want done. * on me?". Queues that need a person are named by what they want done.
* *
* It deliberately carries no counts. A tally here can only come from fetching * IT CARRIES COUNTS NOW. It used not to, on the reasoning that a tally could
* every lead on a timer and counting client-side, which is a second full list * only come from a second full list call that would then disagree with the
* call per open tab that disagrees with the queue's own total the moment either * queue's own total. That was right about the cost and wrong about the
* one is paged. The count belongs on the queue, which already has it from the * conclusion with no numbers, the only way to learn whether anything is
* server. * waiting on you is to open all nine queues in turn, which is precisely the
* question navigation exists to answer. The overview's existing fetch moved
* into PortfolioProvider and both surfaces read it, so this is one call fewer
* than before rather than one more.
*
* The badge counts leads whose PHASE needs a person, not leads in the state
* see phaseOf. A dot marks a queue holding a renewal inside a week, which is
* the only reason to open one queue before another.
*
* The closed states are folded away. They are three of the twelve, they are
* reporting rather than work, and at equal weight they made the live queues
* harder to find.
*/ */
export default function Shell() { export default function Shell() {
const { user } = useZino() const { user } = useZino()
@ -25,6 +37,37 @@ export default function Shell() {
// lives on the overview, which counts the whole book for everyone. // lives on the overview, which counts the whole book for everyone.
const stages = visibleStages(roles) const stages = visibleStages(roles)
const canFile = entryDoorsFor(roles, ENTRY).length > 0 const canFile = entryDoorsFor(roles, ENTRY).length > 0
const { counts, ready } = useStageCounts()
// One queue row. Extracted because the live groups and the folded Closed
// group render the same thing and drifted apart when they did not.
const queue = (s) => {
const c = counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
const n = s.kind === 'end' ? c.total : c.waiting
return (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
title={s.need ? s.name : undefined}
className={({ isActive }) =>
'shell__stage' + (isActive ? ' is-active' : '')
+ (s.kind === 'needs' ? ' is-needed' : '')
+ (ready && !n && !c.working ? ' is-empty' : '')
}
>
<span className="shell__stagename">{s.need ?? s.name}</span>
{ready ? (
<span className="shell__tally">
{c.urgent ? <span className="shell__urgent" title={`${c.urgent} expiring within 7 days`} /> : null}
{/* The agents' share is shown in the middle dot notation rather
than added in: it is in the queue, it is not your work. */}
{c.working ? <em className="shell__auto" title={`${c.working} being worked by an agent`}>{c.working}</em> : null}
<b className={'shell__n' + (n ? '' : ' is-zero')}>{n}</b>
</span>
) : null}
</NavLink>
)
}
return ( return (
<div className="shell"> <div className="shell">
@ -65,29 +108,37 @@ export default function Shell() {
</NavLink> </NavLink>
) : null} ) : null}
{NAV_GROUPS.map((group) => { {NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
const inGroup = stages.filter((s) => s.kind === group.kind) const inGroup = stages.filter((s) => s.kind === group.kind)
if (!inGroup.length) return null if (!inGroup.length) return null
const outstanding = inGroup.reduce((t, s) => t + (counts[s.uid]?.waiting || 0), 0)
return ( return (
<div className="shell__group" key={group.kind}> <div className="shell__group" key={group.kind}>
<p className="shell__navlabel">{group.label}</p> <p className="shell__navlabel">
<div className="shell__stages"> {group.label}
{inGroup.map((s) => ( {ready && group.kind === 'needs' && outstanding ? (
<NavLink <span className="shell__grouptally">{outstanding}</span>
key={s.uid} ) : null}
to={`/stage/${s.uid}`} </p>
title={s.need ? s.name : undefined} <div className="shell__stages">{inGroup.map(queue)}</div>
className={({ isActive }) =>
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '')
}
>
<span className="shell__stagename">{s.need ?? s.name}</span>
</NavLink>
))}
</div>
</div> </div>
) )
})} })}
{/* Closed. Folded, because it is reporting rather than work and at
equal weight with the live queues it made them harder to find. */}
{stages.some((s) => s.kind === 'end') ? (
<details className="shell__closed">
<summary>
Closed
{ready ? (
<b>{stages.filter((s) => s.kind === 'end')
.reduce((t, s) => t + (counts[s.uid]?.total || 0), 0)}</b>
) : null}
</summary>
<div className="shell__stages">{stages.filter((s) => s.kind === 'end').map(queue)}</div>
</details>
) : null}
</nav> </nav>
<main className="shell__main"> <main className="shell__main">

View File

@ -511,8 +511,8 @@ export default function Lead() {
<div className="panel"> <div className="panel">
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">Record</h2> <h2 className="panel__title">Lead file</h2>
<p className="panel__sub">Captured data, grouped by stage of collection.</p> <p className="panel__sub">Everything captured, grouped by the step that captured it.</p>
</div> </div>
</div> </div>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
@ -550,7 +550,7 @@ export default function Lead() {
) : v.length > LONG ? ( ) : v.length > LONG ? (
<div className="prop prop--note" key={k}> <div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt> <dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} /></dd> <dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
</div> </div>
) : ( ) : (
<div className="prop" key={k}> <div className="prop" key={k}>
@ -571,7 +571,7 @@ export default function Lead() {
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">Audit trail</h2> <h2 className="panel__title">Audit trail</h2>
<p className="panel__sub">Every action recorded against this lead.</p> <p className="panel__sub">Every step, who took it, and what they wrote.</p>
</div> </div>
</div> </div>
<div className="lead__feed"> <div className="lead__feed">

View File

@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { STAGES, phaseOf } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js' import { rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import './screens.css' import './screens.css'
@ -9,10 +10,10 @@ import './screens.css'
/** /**
* Portfolio overview the landing screen. * Portfolio overview the landing screen.
* *
* Everything is derived from one record-view call and computed here. That is * Everything is derived from ONE record-view call and computed here. That call
* honest at this size and it is the same call every queue makes; if the book * now lives in PortfolioProvider and is shared with the sidebar's tallies, so
* outgrows a single page the answer is an aggregate endpoint, not a larger * the two cannot disagree and there is no second fetch. If the book outgrows a
* limit. * single page the answer is an aggregate endpoint, not a larger limit.
* *
* Counts cover the WHOLE portfolio for every role, including queues the role * Counts cover the WHOLE portfolio for every role, including queues the role
* cannot work in. Reporting and permission are different questions: an agent * cannot work in. Reporting and permission are different questions: an agent
@ -54,29 +55,10 @@ function expiryTone(d) {
} }
export default function Overview() { export default function Overview() {
const { client, user } = useZino() const { user } = useZino()
const navigate = useNavigate() const navigate = useNavigate()
const roles = rolesOf(user) const roles = rolesOf(user)
const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null }) const state = usePortfolio()
useEffect(() => {
let cancelled = false
function load(quiet) {
if (!quiet) setState((p) => ({ ...p, status: 'loading' }))
return client.recordView(RV_LEADS, { limit: 200 })
.then((res) => {
if (cancelled) return
setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? [], at: new Date(), error: null })
})
.catch((err) => {
if (cancelled) return
setState((p) => (quiet && p.status === 'ready' ? p : { status: 'error', rows: [], at: null, error: err }))
})
}
load(false)
const id = setInterval(() => { if (document.visibilityState === 'visible') load(true) }, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client])
const m = useMemo(() => { const m = useMemo(() => {
const rows = state.rows const rows = state.rows