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:
parent
d6756b4d2e
commit
5ff74ea22d
@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { useZino } from './api/provider.jsx'
|
||||
import Login from './pages/Login.jsx'
|
||||
import Shell from './layout/Shell.jsx'
|
||||
import { PortfolioProvider } from './api/portfolio.jsx'
|
||||
import Overview from './screens/Overview.jsx'
|
||||
import Pipeline from './screens/Pipeline.jsx'
|
||||
import Lead from './screens/Lead.jsx'
|
||||
@ -22,7 +23,7 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<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 —
|
||||
one stage, usually empty, and somebody else's. */}
|
||||
<Route path="/" element={<Overview />} />
|
||||
|
||||
109
src/api/portfolio.jsx
Normal file
109
src/api/portfolio.jsx
Normal 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])
|
||||
}
|
||||
@ -24,11 +24,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.clamp.is-open .clamp__text {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.clamp__more {
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
@ -54,10 +49,6 @@
|
||||
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
|
||||
and never clamped — it is one sentence by construction, and the point of it
|
||||
is that the finding can be read without opening anything. */
|
||||
@ -81,3 +72,97 @@
|
||||
color: var(--zk-ink);
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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
|
||||
* characters — and a lead file that prints all of it is a document, not a
|
||||
* screen. Anything long shows its finding, with the reasoning one click away.
|
||||
* characters — and a screen that prints all of it is a document. Short text is
|
||||
* 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
|
||||
* ref-and-measure pass would reflow on every resize to answer a question the
|
||||
* 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 value = String(text)
|
||||
|
||||
@ -39,33 +101,26 @@ export default function ClampText({ text, lines = 3, threshold = 150 }) {
|
||||
|
||||
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 (
|
||||
<div className={'clamp' + (open ? ' is-open' : '')}>
|
||||
<p className="clamp__lead">{split.lead}</p>
|
||||
{open ? <p className="clamp__text clamp__text--short">{split.rest}</p> : null}
|
||||
<button type="button" className="clamp__more" onClick={() => setOpen((o) => !o)}>
|
||||
{open ? 'Hide the reasoning' : 'Show the reasoning'}
|
||||
<div className="clamp">
|
||||
{split
|
||||
? <p className="clamp__lead">{split.lead}</p>
|
||||
: <p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>}
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
{open ? (
|
||||
<ProseDialog
|
||||
title={title}
|
||||
lead={split?.lead}
|
||||
body={split ? split.rest : value}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -298,7 +298,7 @@ export default function Timeline({ instanceId }) {
|
||||
{it.narrative.map(([label, v]) => (
|
||||
<div className="tl__say" key={label}>
|
||||
<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>
|
||||
))}
|
||||
|
||||
|
||||
@ -289,3 +289,120 @@
|
||||
}
|
||||
.shell__today:hover { background: var(--zk-tint); }
|
||||
.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); }
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { ENTRY, NAV_GROUPS } from '../api/config.js'
|
||||
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
||||
import { useStageCounts } from '../api/portfolio.jsx'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import UserMenu from './UserMenu.jsx'
|
||||
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
|
||||
* 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
|
||||
* every lead on a timer and counting client-side, which is a second full list
|
||||
* call per open tab that disagrees with the queue's own total the moment either
|
||||
* one is paged. The count belongs on the queue, which already has it from the
|
||||
* server.
|
||||
* IT CARRIES COUNTS NOW. It used not to, 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 was right about the cost and wrong about the
|
||||
* conclusion — with no numbers, the only way to learn whether anything is
|
||||
* 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() {
|
||||
const { user } = useZino()
|
||||
@ -25,6 +37,37 @@ export default function Shell() {
|
||||
// lives on the overview, which counts the whole book for everyone.
|
||||
const stages = visibleStages(roles)
|
||||
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 (
|
||||
<div className="shell">
|
||||
@ -65,29 +108,37 @@ export default function Shell() {
|
||||
</NavLink>
|
||||
) : null}
|
||||
|
||||
{NAV_GROUPS.map((group) => {
|
||||
{NAV_GROUPS.filter((g) => g.kind !== 'end').map((group) => {
|
||||
const inGroup = stages.filter((s) => s.kind === group.kind)
|
||||
if (!inGroup.length) return null
|
||||
const outstanding = inGroup.reduce((t, s) => t + (counts[s.uid]?.waiting || 0), 0)
|
||||
return (
|
||||
<div className="shell__group" key={group.kind}>
|
||||
<p className="shell__navlabel">{group.label}</p>
|
||||
<div className="shell__stages">
|
||||
{inGroup.map((s) => (
|
||||
<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' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.need ?? s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<p className="shell__navlabel">
|
||||
{group.label}
|
||||
{ready && group.kind === 'needs' && outstanding ? (
|
||||
<span className="shell__grouptally">{outstanding}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="shell__stages">{inGroup.map(queue)}</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>
|
||||
|
||||
<main className="shell__main">
|
||||
|
||||
@ -511,8 +511,8 @@ export default function Lead() {
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">Record</h2>
|
||||
<p className="panel__sub">Captured data, grouped by stage of collection.</p>
|
||||
<h2 className="panel__title">Lead file</h2>
|
||||
<p className="panel__sub">Everything captured, grouped by the step that captured it.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tabs" role="tablist">
|
||||
@ -550,7 +550,7 @@ export default function Lead() {
|
||||
) : v.length > LONG ? (
|
||||
<div className="prop prop--note" key={k}>
|
||||
<dt>{labels[k] ?? label(k)}</dt>
|
||||
<dd><ClampText text={v} /></dd>
|
||||
<dd><ClampText text={v} title={labels[k] ?? label(k)} /></dd>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop" key={k}>
|
||||
@ -571,7 +571,7 @@ export default function Lead() {
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<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 className="lead__feed">
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { usePortfolio } from '../api/portfolio.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 { describeError } from '../api/errors.js'
|
||||
import './screens.css'
|
||||
@ -9,10 +10,10 @@ import './screens.css'
|
||||
/**
|
||||
* Portfolio overview — the landing screen.
|
||||
*
|
||||
* Everything is derived from one record-view call and computed here. That is
|
||||
* honest at this size and it is the same call every queue makes; if the book
|
||||
* outgrows a single page the answer is an aggregate endpoint, not a larger
|
||||
* limit.
|
||||
* Everything is derived from ONE record-view call and computed here. That call
|
||||
* now lives in PortfolioProvider and is shared with the sidebar's tallies, so
|
||||
* the two cannot disagree and there is no second fetch. If the book outgrows a
|
||||
* single page the answer is an aggregate endpoint, not a larger limit.
|
||||
*
|
||||
* Counts cover the WHOLE portfolio for every role, including queues the role
|
||||
* cannot work in. Reporting and permission are different questions: an agent
|
||||
@ -54,29 +55,10 @@ function expiryTone(d) {
|
||||
}
|
||||
|
||||
export default function Overview() {
|
||||
const { client, user } = useZino()
|
||||
const { user } = useZino()
|
||||
const navigate = useNavigate()
|
||||
const roles = rolesOf(user)
|
||||
const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null })
|
||||
|
||||
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 state = usePortfolio()
|
||||
|
||||
const m = useMemo(() => {
|
||||
const rows = state.rows
|
||||
|
||||
Loading…
Reference in New Issue
Block a user