diff --git a/src/App.jsx b/src/App.jsx index 758a770..f00e451 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( } /> - }> + }> {/* The morning page. This used to redirect to the underwriting queue — one stage, usually empty, and somebody else's. */} } /> diff --git a/src/api/portfolio.jsx b/src/api/portfolio.jsx new file mode 100644 index 0000000..4d46450 --- /dev/null +++ b/src/api/portfolio.jsx @@ -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 {children} +} + +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]) +} diff --git a/src/components/ClampText.css b/src/components/ClampText.css index 08de61e..30131b2 100644 --- a/src/components/ClampText.css +++ b/src/components/ClampText.css @@ -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; } +} diff --git a/src/components/ClampText.jsx b/src/components/ClampText.jsx index 4e559ff..166cf66 100644 --- a/src/components/ClampText.jsx +++ b/src/components/ClampText.jsx @@ -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 ( +
+
e.stopPropagation()} + > +
+

{title || 'In full'}

+ +
+
+ {/* 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 ?

{lead}

: null} + {body.split(/\n{2,}/).map((para, i) =>

{para}

)} +
+
+
+ ) +} + +/** + * 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 ( -
-

{value}

- -
- ) - } - return ( -
-

{split.lead}

- {open ?

{split.rest}

: null} - + {open ? ( + setOpen(false)} + /> + ) : null}
) } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 6e47a75..3c46f7a 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -298,7 +298,7 @@ export default function Timeline({ instanceId }) { {it.narrative.map(([label, v]) => (
{label} - +
))} diff --git a/src/layout/Shell.css b/src/layout/Shell.css index 03c77ff..413cdda 100644 --- a/src/layout/Shell.css +++ b/src/layout/Shell.css @@ -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); } diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx index 78e634f..9952df7 100644 --- a/src/layout/Shell.jsx +++ b/src/layout/Shell.jsx @@ -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 ( + + 'shell__stage' + (isActive ? ' is-active' : '') + + (s.kind === 'needs' ? ' is-needed' : '') + + (ready && !n && !c.working ? ' is-empty' : '') + } + > + {s.need ?? s.name} + {ready ? ( + + {c.urgent ? : 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 ? {c.working} : null} + {n} + + ) : null} + + ) + } return (
@@ -65,29 +108,37 @@ export default function Shell() { ) : 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 (
-

{group.label}

-
- {inGroup.map((s) => ( - - 'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '') - } - > - {s.need ?? s.name} - - ))} -
+

+ {group.label} + {ready && group.kind === 'needs' && outstanding ? ( + {outstanding} + ) : null} +

+
{inGroup.map(queue)}
) })} + + {/* 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') ? ( +
+ + Closed + {ready ? ( + {stages.filter((s) => s.kind === 'end') + .reduce((t, s) => t + (counts[s.uid]?.total || 0), 0)} + ) : null} + +
{stages.filter((s) => s.kind === 'end').map(queue)}
+
+ ) : null}
diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index bc4b2a0..4ecf97a 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -511,8 +511,8 @@ export default function Lead() {
-

Record

-

Captured data, grouped by stage of collection.

+

Lead file

+

Everything captured, grouped by the step that captured it.

@@ -550,7 +550,7 @@ export default function Lead() { ) : v.length > LONG ? (
{labels[k] ?? label(k)}
-
+
) : (
@@ -571,7 +571,7 @@ export default function Lead() {

Audit trail

-

Every action recorded against this lead.

+

Every step, who took it, and what they wrote.

diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index f5d018d..e80f344 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -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