Compare commits

...

84 Commits

Author SHA1 Message Date
5f413431d2 rebuild: gitea was down when f47463f was pushed 2026-09-10 22:10:28 +05:30
f47463f099 console: the desk goes full screen, keeps its conversations, and draws
Three changes to Ask the desk.

FULL SCREEN. It was a 560px side panel, which was wrong for what the desk
returns: a fifteen-step timeline, a table of every stalled lead, a
paragraph naming both PAN numbers. Reading those down a gutter while the
page behind sat unused was the panel fighting its own content. Now one
readable column with the conversation list beside it, and Escape to leave.

CONVERSATIONS. The platform already kept them — every question opens a
session and a ticket — but nothing in the console showed them, so an
investigation could not be returned to. The rail lists them, newest first,
and opening one replays the transcript.

CHARTS. The platform has no chart widget and the desk holds no widget
tools — its fourteen tools are plain API reads, so everything it says
arrives as text. What it does do, reliably, when asked anything countable,
is answer with a markdown table. So the chart is that table, drawn: bars by
default, a share ring on request, and every bar checkable against the row
beside it. Inline SVG, no library.

Two details worth keeping: Indian short forms are parsed for magnitude, so
"₹3.96 L" sorts above "₹22,000" instead of below it; and a totals row is
excluded, because charting it draws one bar as long as all the others put
together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 21:20:14 +05:30
141b1b2452 console: the real mark in the masthead, not type set to look like it
The brand was being drawn: a circled "Z" in Source Sans beside "ZURICH kotak"
in letter-spaced caps with "General Insurance" under it. That is a passable
imitation of a logo and not the logo — the real mark's proportions, its weight
contrast and its plate are all in the asset, which the login page has been
using all along.

So the masthead carries the artwork, at half the bar's height, and nothing is
set in type beside it: the asset already says both lines, and the wordmark
next to it was the same sentence twice. Bundled from src/assets rather than
public/, so Vite rewrites the URL relative and it resolves under the
/zurich-kotak/ mount.

On a phone the mark shrinks rather than shedding its second line — that line
is inside the artwork now, so it cannot be dropped on its own the way the type
lockup's could.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:55:52 +05:30
73b6ff7eb0 console: ask the support desk without leaving the console
An "Ask the desk" panel over the console, for ops only. The desk is a
platform Support App bound to app 536: it reads the deployed config and the
live book through read-only tools and answers with evidence. It is a
different kind of thing from the five AI employees — they do the work and
write to the workflow; this one only reads, to answer a person's question.

No new auth. The desk is an ordinary interactive agent, a conversation is a
session and a question is a message on it, and the token this console
already holds is accepted on those routes as-is.

Ops only, deliberately: the desk reads the WHOLE book — every partner,
every premium, every commission — so a partner agent must not open it. The
platform enforces it per user; the button just isn't drawn for anyone else.

The panel renders the markdown the desk actually uses — headings, lists,
bold, code and tables. Tables earn their place: asked anything countable it
replies with one, and rendered as raw pipes a correct answer reads like a
broken one.

Verified end to end against the database: its stage counts matched all ten
states exactly, and its account of why ZK-2026-01040 sits with the
underwriter names both PAN numbers correctly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:04:32 +05:30
ac3dc2965a console: stop the agent dialog capping every table
AgentCard.css owned a bare `.card { max-height: min(82vh, 720px) }`. The
redesign gave every screen panel the same class, so each lead table was
capped at 720px while its rows kept rendering past the card's own border —
the first ten rows looked enclosed and the rest looked loose on the page.

The dialog moves to its own `agc` namespace, which it half had already
(`prose__dlg`). Verified: no card on All leads or the overview now renders
past its own height, and the dialog is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:57:44 +05:30
a310b0a8b9 console: close the drawer from the link, not from an effect
Also leaves the Closed fold openable without dismissing the menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:47:28 +05:30
607693a04a console: make the phone layout work
Opened at 390px the masthead wrapped inside its own 56px bar and clipped —
"ZURICH kotak", "Lead Desk / Lead to policy" and the user's name all on
three lines each — and the nine-item rail stacked above the content, so
every page began with a screenful of menu.

The masthead now keeps one line: the mark, the app name, the person, with
the secondary lines dropped below 900px and the app name below 560px,
where it cannot fit beside the user menu without truncating.

The rail becomes a drawer behind a button, over the page rather than above
it, closing on navigation and on a tap outside. Desktop is untouched: the
button is display:none, the rail is static, and the drawer rules live
entirely inside the media query.

Also: "Ops admin", not "OPS admin" — the acronym rule over-fired on a word.

Verified at 390x844 and 1440x900: no horizontal scroll, nothing clipped in
the masthead, the drawer closes on navigation, and the desktop rail keeps
its transform and its hidden button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:46:27 +05:30
da8ce9147f console: the WhatsApp entry says which voice is a machine
"Priya Raghavan and Engage" reads as two colleagues. Every other entry in the
trail already answers this — the agent's disc carries a sparkle, its name
carries an AI tag, a person's disc carries their initials — and the exchange
entry was the one place printing bare names, on the screen whose whole claim is
that the work was done autonomously.

So the participants are chips rather than a sentence, and so is the label on
every message, in the preview and in the thread: Priya Raghavan wears her
initials, Engage wears the sparkle and the AI tag, and ops replying by hand
wears their initials too — a person answering in the thread must never be
dressed as the machine. The agent chip opens its card, as it does everywhere
else in the trail; the label inside the preview card stays a span, because the
card is already a button.

The disc's tint and the bubble's tint are the same token, so a chip dropped
into a bubble lost its disc; it is white inside one. `.bub span` narrowed to
direct children other than the attribution row, so the chip's own spans are
not restyled and CallTranscript's plain labels still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:26:04 +05:30
708cf4bc40 console: the WhatsApp exchange is one entry, and it opens the whole thread
A four-message exchange about a premium took seven entries in the trail —
"WhatsApp", "WhatsApp", "WhatsApp" — each with its own heading, actor, quote
box and Open thread link, so the conversation occupied more of the story than
the entire underwriting chain. The turns were being grouped per speaker, which
collapses nothing when the customer and Engage are alternating.

An exchange is now one entry: who spoke, how many messages, the last two of
them, and the whole thread behind a click on the card. It carries no single
actor — that was the trap the per-speaker rule was there to avoid, because
collapsing turns while keeping the first speaker's name printed the customer's
words under Engage's chip. Here both sides are named on their own message and
neither owns the entry.

ONE ENTRY PER BURST, not one for the whole thread. The quote objection and the
payment exchange are the same conversation resumed, but they are twenty minutes
and four workflow steps apart; folding them together would date the payment
messages to the moment of the objection and put them above the underwriting
that actually preceded them. Saying what happened in what order is the trail's
one job. So each burst sits where it happened, the later ones marked
"continued" and saying what ran in between, and every one of them opens the
same complete thread.

The thread itself is now built once, in api/thread.js, and shared:

  - It picks up the two sends that are NOT chat activities. Request Premium
    and the issuance step compose their message inside a trigger and write it
    to customer_answer beside their own work (98/99/100), so those messages
    belonged to the conversation and appeared nowhere in the trail. The step
    now says "Sent 1 message on WhatsApp" and links into the thread rather
    than quoting text the exchange below it already shows.
  - The dialog groups the bursts with a divider that says how long the thread
    was quiet and which steps ran meanwhile. Read flat, a reply about the
    premium appeared to answer the payment link that followed it.
  - Every bubble is named, both directions. It stamped only the time, so a
    thread read back months later could not say which of our replies a person
    wrote and which Engage did.
  - Opened from an exchange it opens AT that exchange, marked briefly; opened
    from the header it opens at the newest message, which is where a chat is
    read from.
  - The count on the lead header comes off the thread. Scanning the audit rows
    counted the platform's own repeats — one submission is recorded up to three
    times — so the badge claimed nine messages over a six-message conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:17:51 +05:30
7c328d2b88 console: mirror the new activities' roles
visibleStages hides a queue whose actions this role cannot perform, so
without these three entries Needs a Person was an ops-only queue and the
underwriter could not see a flag sent to them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 12:31:16 +05:30
3dd482f180 console: the Needs a Person queue, and what the AI said when it stopped
An AI employee here has no escalate tool — the platform withholds it from
autonomous employees on purpose — so "a person should take this" has to be
an activity in the workflow. It now is: Flag for Review, which lands the
lead in a new stage, Needs a Person.

The console side of that: the stage joins the nav under Needs you, its two
actions are Send back to the workflow / Send to underwriting, the flag
fields lead the lead-file groups, and a flagged lead opens on an amber
banner quoting the AI's own sentence about what stopped it, with the
blocker turned into something an operator can act on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 12:28:53 +05:30
54333ef847 console: drop the "you" under the next-step button
The banner already says whose turn it is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:44:54 +05:30
97c792a2fc console: the queue card fills the column beside Pipeline by stage
The two cards stretch to one height and the queue rows spread to fill it,
so the shorter card no longer ends in empty canvas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:12:33 +05:30
9e093757d8 console: the risk card lines up with Pipeline by stage
Both right-hand columns are 380px now; the hero's was 300px, so the two
cards stacked with a visible step between them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:58:18 +05:30
79863e2a5f console: the Lead Desk redesign — one colour rule for who holds the work
Implements the design in zurich-kotak-leaddesk across every screen.

Foundation: Source Sans 3 bundled from src/assets/fonts (no Google Fonts
request), 15px root, navy/blue/amber/teal/red palette where blue is the AI,
amber a person, teal the customer and red is reserved for risk; flat white
10px cards on a grey canvas, no shadows.

Shell: 56px navy masthead (Z wordmark, Lead Desk, user), 232px white nav
grouped Needs you / Scheduled / History with coloured count pills and the
closed stages folded.

Overview: the hero says who holds each open lead (stacked bar + legend)
beside the renewals-at-risk card; five KPIs incl. Closed to date; Waiting
on a person queue with owner dots and "N more with the AI"; Pipeline by
stage with owner-coloured bars; Renewal exposure with tabs, dropdown
filters and a Holding it column.

Lead: back button, id line, holder pill; one attention banner (amber your
turn / blocked, red stalled, blue with the AI, teal with the customer) that
carries its button; four facts with a confidence bar; navy steps rail
whose current step takes its holder's colour; timeline as an avatar
gutter with title, stage pill, by-line + AI tag and text-link actions
(See reasoning · N steps, Hear the call, Open thread); side column flows.

Lists: card with search + hide-closed toggle, columns Customer / Stage /
Renewal due / Holding it / Premium / Last activity, stalled rows get a red
inset, closed rows dim, person-gated queues split into Your turn / With the
AI group rows.

src/api/holder.js is the single rule for holder tone + label so the table
cells, the overview and the lead header cannot disagree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 23:56:29 +05:30
0df7d12073 console: stop printing the customer's words under the AI's name
A contiguous run of WhatsApp turns collapsed into one timeline entry that kept
the FIRST turn's speaker and previewed the LAST turn's message. When a run held
the customer's question and Engage's answer, the entry carried Engage's chip
over a quote the customer had typed — the trail claimed the machine said
something a person did, which on an accountability demo is the worst thing it
could get wrong.

Runs now group per speaker. That still collapses the case grouping exists for
(the same sentence resent several times) and can no longer misattribute,
because every row in a run is one voice. Customer runs read "the customer /
THE CUSTOMER SAID"; Engage runs read as Engage / WE REPLIED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:17:25 +05:30
3f36ea610b console: unmangle the document form and rebuild the upload control
Two problems on the collect-documents form.

LAYOUT. Every field sat in one flat auto-fit grid, so on a wide screen the three
uploads took the first half of a row and three text inputs took the rest — and
an upload that has read a document is several times taller than a text box, so
the inputs stranded themselves up beside the buttons and the row went ragged.
Documents now render in their own section above the details, and each grid is
internally one height. The upload cells align to the top so a read document no
longer drags its neighbours down.

THE UPLOAD ITSELF. It was one pink slab that only changed its words, so a
finished upload shouted as loudly as an empty one. Now there are two states: an
empty field is a dashed target with an icon and the formats it takes; a filled
one is a quiet row with a document icon, the file name, and a small Replace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:06:11 +05:30
f6682813a4 console: mark the active queue again, without the checkbox look
Removing the node circles took the active-row marker with them — the tint alone
was too quiet to say which queue you were on. The active row (and Overview /
All leads) now carries a short accent bar on its left edge.

It appears only when active, so inactive rows stay clean and nothing reads as an
empty checkbox, which is what the circles did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:52:23 +05:30
90290a8b37 console: declutter the pipeline sidebar
Five fixes to the confusing nav:

- The two per-row numbers had no key. The AI's share now reads "N with AI" in
  quiet grey, so it stops competing with the pink "needs you" badge.
- Removed the outline node circles (and the rail line they hung on) — with one
  queue per group they read as empty checkboxes, not a pipeline. Rows now align
  flush and the active queue is shown by its tint alone.
- Dropped the section-header count that just repeated the single row beneath it.
- Lightened and tightened the group labels so the queues lead, not the headers.
- Closed now matches a queue row — same padding, hover and count badge — while
  staying a foldable disclosure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:44:10 +05:30
f31d6cb866 console: renewal filters as plain dropdowns
The add-a-chip builder read as a stray pill button. Replace it with three
labelled dropdowns above the table — Stage, Time to expiry, Premium range — plus
a Clear when any is set. Empty option means no filter on that field; they narrow
together. Standard, quiet table-filter UI instead of a build-your-own control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:33:35 +05:30
b46faa70e2 console: replace bucket-click filter with a build-your-own filter
The renewal-exposure buckets are back to plain counts. Above the table is now
a filter builder: press Add filter, pick a field (Stage, Time to expiry,
Premium, or Lead name), give it a value, and add it. Each condition lands as a
removable chip and they AND together; Clear all drops them.

- Stage: choose from the stages actually present in the exposure set.
- Time to expiry: lapsed / within 7 days / 8-30 days.
- Premium: at least / at most an amount (a lead with no premium never matches).
- Lead name: substring over name and reference.

One matcher and one describer per field, so the chip label and the filtering
cannot drift. The table shows more rows once any filter is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:29:11 +05:30
e0d51159f6 console: play the call recording alongside the transcript
The voice agent uploads a dual-channel WAV of each call to blob storage; until
now the console showed only the transcript's conclusions, not the audio. Add a
player wherever the call is read:

- THE CALL panel on the lead gets an inline "Call recording" player.
- The transcript dialog gets the player above the turns, so a reviewer can
  listen while reading — the words and the audio are the same evidence.

Both read row.call_recording_url (the platform's own field for this, surfaced
on the lead by backend 107) and use preload="none", so a multi-MB WAV is only
fetched when someone actually presses play. The player simply does not render
when a lead has no recording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:19:26 +05:30
bd4ced41ac console: make the renewal-exposure buckets filter the table
The three buckets (Lapsed / Within 7 days / 8 to 30 days) were static counts
above a combined table. They now double as the filter: click one to narrow the
table to that bucket, click again — or use the Clear affordance — to show every
open renewal by soonest due. The active bucket fills with its own tint, an
empty bucket is disabled, and the empty state is filter-aware. One predicate
map drives both the counts and the filter so they cannot drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:05:21 +05:30
42af54b3e2 Revert "console: offer the manual reply affordance at Onboarded"
This reverts commit 02012f271a.
2026-09-09 15:42:56 +05:30
02012f271a console: offer the manual reply affordance at Onboarded
Backend 106 keeps the WhatsApp reply loop open after the policy issues, so the
Engage employee answers post-issuance questions. Mirror that in the operator
console: the onboarded (closed) state now offers the same "Reply to the
customer" affordance the quote and payment stages do, folded away as a bounded
loop, so a person can step in on WhatsApp. customer-reply stays channel-only,
never an operator button, as at every other stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 15:39:02 +05:30
956a1703df console: surface reasoning on the underwriting, KYC and nudge steps
The chain of thought reads the employee's "why" out of the workflow data
fields, matched by base id. Three real reasoning fields were written by the
employees but not in the map, so those steps showed as a bare heading even
though the reasoning was captured:

- uw_referral_reason — the underwriting screen and prepare steps write their
  decision reasoning here; the map only knew uw_decision_notes (the
  clear/decline note), so the substantive UW decision showed no chain.
- kyc_ref — a clean KYC leaves the mismatch note empty, so the step had no
  rung; kyc_ref is the document package the employee matched identity against
  and now stands in as the finding.
- nudge_notes — the payment-follow-up employee's rationale.

Traced against instance 57632: underwriting (2.3k-char reasoning), KYC and the
nudge all had their reasoning captured but unmapped. This is frontend-only, no
backend or workflow change.

Known residual: Accept, Payment, Realise and Onboard write only a reference,
no "why" field, so their reasoning lives in aiemployee.tbl_ai_decisions /
the ai_reasoning column, which the audit endpoint does not return and the
org-scoped monitor endpoint a partner_agent cannot call. Forcing a chain onto
those mechanical acks needs a workflow field + charter change (future
instances only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 15:30:25 +05:30
47967c6318 console: use the full screen width on the lead page
The previous change over-corrected: left-anchoring a capped block pushed the
whole page to the left and left the right half of a wide monitor empty. Drop
the width cap so the content fills from the nav to the right edge, the way a
sidebar dashboard should.

- shell__main: no max-width, fills the width beside the nav.
- lead grid: story column takes the slack (1fr), facts rail fixed at its
  content width (360-420px).
- metric tiles: auto-fit, so three or four tiles always fill the row instead
  of leaving an empty hard-coded 4th cell on the right.

Reading comfort is still handled inside the feed (paragraphs cap their own
line length), so a wide panel does not sprawl the text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:34:32 +05:30
6f50c0c30f console: kill the empty river — anchor the page to the nav
The content block was capped at 1460px and centred with margin:0 auto, so on a
wide or zoomed-out monitor a large empty river opened between the sidebar and
the content, with the whole page floating in the middle.

Fix is the standard sidebar-dashboard layout: one governing width, anchored to
the nav instead of centred. shell__main caps at 1320 and left-aligns, so the
header, metric tiles, stage strip and the two-column grid all share that width
and line up. The grid's own columns cap at their natural content widths (the
760px story column at its reading width, the 340-440px facts rail at what its
key/value pairs need) so neither card stretches into internal whitespace. Any
spare width on a very wide screen falls to the right as margin, the way a
sidebar layout should read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:26:16 +05:30
125b3ffcf6 console: 60/40 split on the lead page — close the dead gap
The rail was a fixed 340px, so the main column ballooned much wider than the
feed text (capped for readability) and a dead gap opened between the narrative
and the facts. Proportional split now: the AI decision stream ~60%, the
customer / vehicle / document facts ~40%. The gap closes, the feed fills its
column (max line length nudged 76→82ch), and the facts breathe — the PDF names
stop wrapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:17:02 +05:30
3cc99b718a console: fix the stale "Upload documents" CTA; refine the chain-of-thought
TWO THINGS.

1. Fix: the header "Your move → Upload documents" kept showing after documents
   were uploaded. The render already demotes Collect Documents from a step to a
   "replace or add" recovery lever once the files are in (docsDone), but the
   header CTA computed primaryAction straight off `actions` and never applied
   that demotion — so it surfaced "Upload documents" over a lead whose docs had
   just landed. The same docs-in check now guards primaryAction.

2. Refine: the chain of thought reads as a distinct AI reasoning artifact now,
   not loose text under a link. The affordance is a tinted "thinking" chip; the
   revealed reasoning sits in a soft accent-tinted panel with a stepped rail and
   markers — the record of how the machine decided, given the weight it deserves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 13:23:51 +05:30
818ef871ba fix(console): blank lead page — statusStrip glued to a split stalled ternary
When statusStrip was moved (fda1247), the regex that placed it after `const
stalled` matched only the FIRST line of stalled's multi-line ternary. The
`? { mins, nudge } : null` continuation was left dangling right after
`const statusStrip = (…)`, so statusStrip parsed as `<jsx> ? {mins,nudge} : null`
— a truthy JSX condition — and evaluated to the object {mins, nudge}. Rendering
that object threw React #31 and blanked the page for any stalled instance;
`stalled` itself was left a bare boolean.

Restored stalled to its object form and removed the orphaned ternary. statusStrip
is a clean JSX expression again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:28:46 +05:30
ee57184fa2 console: rebuild the look in the assured-inspired language (approved direction)
Full styling reset to the approved mockup — from scratch, no functional change.

- PALETTE: clean white ground, warm near-black ink, ONE confident magenta-pink
  accent (#e5227f) reserved for action and active state. The accent keeps the
  --zk-blue-* token names it always had; only the values changed. Teal is the
  customer, amber a person; pink is "act / here", nothing else. Neutrals warmed,
  shadows softened to a plum-ink low alpha.
- TYPE: Plus Jakarta Sans — geometric-humanist, bold on headings (page title
  800, panel titles and figures 700+), the modern-product register assured has.
- SHAPE: the flat single-canvas is gone; back to clean rounded cards that float
  a little on the off-white — panels, metric tiles (money in the accent tint),
  the stage rail, the facts rail. Status ribbon in teal, the primary action a
  pink pill with an accent glow.

Same components, same data, same behaviour — only the surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:24:44 +05:30
fda1247ee9 fix(console): blank lead page — statusStrip const used blocked/stalled before they existed
The lifted status strip (e21e394) was defined right after `holds`, but its JSX
references `blocked` and `stalled`, which are declared ~80 lines lower. A const
that reads a const declared below it throws "Cannot access before
initialization" at render — a temporal dead zone the bundler does not catch, so
the build passed and the lead page rendered blank.

Moved the statusStrip definition to just after `stalled`, so every value it
reads exists first. The lifted-above-the-metrics position is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:13:33 +05:30
e21e3949f5 console: state before numbers; frame optional actions; flatten the overview band
Applying the lens from the status-strip critique across the surfaces.

LEAD PAGE
- "What is happening now" lifts above the metrics. An operator's first question
  is what is going on / what do I do, not what the premium is — so the status
  strip (an alert when blocked or stalled, the live state otherwise) sits
  directly under the header, before the four numbers, full-width.
- The optional "Add a document" action stopped floating: with no required step
  it read like the next move and sat under "nothing is waiting on you". A quiet
  "Optional" tag now frames it.

OVERVIEW
- The "right now" band was four heavy dark-navy shadowed cards — the
  cards-on-canvas language removed everywhere else, clashing on the flat white
  page. Flattened to light tiles read by a coloured left rule (blue AI, amber
  person, teal customer, grey risk), the figure taking that colour; a live
  person-queue or lapsed renewal warms its whole tile. Prominence from the big
  number and the accent, not a dark fill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 12:01:46 +05:30
d1ed66b7ae console: sparkle for AI, person for human — two clear symbols
The role-icons-plus-corner-spark were busy and didn't read. Replaced with the
two symbols that are actually universal: a sparkle for the machines — the mark
Claude, Gemini and Copilot all use, so an AI step reads as AI with no legend —
in the agent's own colour, and a plain head-and-shoulders person glyph for the
people. The contrast is the whole message; which agent is said by the name
beside the disc and by the colour. Verified crisp at disc size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:52:32 +05:30
d43b792dcb console: lift the type scale — body 14, values 15, so hierarchy shows
An audit found 124 of ~200 sizings at 12-13px: the 13px "meta" tier had become
the de-facto body size (97 uses), so the app read tiny and uniform with nothing
for the eye to land on, and the upper scale was starved. Shifted the crowded
lower tiers up one notch — dominant text 13->14, field labels 14->15, values
15->16, emphasis 16->17. One change to the scale definitions, lifting every
surface at once; 12px stays reserved for uppercase eyebrows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:49:57 +05:30
c0480e0ae0 console: Geist — a crisp, modern enterprise typeface
Zurich Sans (a soft humanist OTF) read poorly at the data-density sizes this
console runs, and did not carry the modern-enterprise register we are after.
The UI moves to Geist — Vercel's typeface, the register of Linear/Vercel-class
tools: neutral, technical, and sharp at small sizes. Refs and figures use Geist
Mono. Zurich Sans stays in the fallback stack, so a blocked CDN degrades to the
brand face rather than to a system default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 11:44:55 +05:30
e4ca2b92c9 Revert "console: redesign the UI — one card language, icons, filters, live trail"
This reverts commit e90e4204ec.
2026-09-09 11:40:49 +05:30
e90e4204ec console: redesign the UI — one card language, icons, filters, live trail
Visual and information-architecture pass across every screen. No API call,
payload, poll, permission rule or workflow mirror changes; forms still render
from the live schema and the timeline's merge/grouping is unchanged (only the
display order is reversible).

Design language
- Ground + white cards with one hairline and one radius; sentence-case
  headings; shared primitives in index.css (.card .pill .btn .avatar .live).
- One inline icon set (components/Icon.jsx); no more filled navy/red tiles.
- Read-side labels in api/display.js: roles and enum tokens humanized,
  customer initials/tones, money and date formatters. Nothing sent changes.

Shell
- 56px header with brand, global lead search (portfolio rows, "/" to focus)
  and identity chip with a human role label.
- Sidebar rows carry icons and a clear active bar; AI roster at the foot,
  each opening its remit card.

Login
- Operator copy instead of retail copy; centred sign-in card with show/hide
  password; demo persona picker that fills the EMAIL only.

Overview
- "Who holds the work" as four quiet cards that warm only when owed.
- Pipeline flow bar with every stage named beneath it; KPI strip in a card.
- Action rows with chevron / view-only eye; expiry bands filter the table.

Queues
- Filter bar (search, product, channel, expiry band, stalled, hide closed)
  and sortable columns, all client-side on the fetched page.
- Avatars, product/channel chips, stage chips for closed leads, tinted
  stalled rows, count inline with the title.

Lead
- Avatar + chips in the header; metrics card with the stage rail inside,
  and a branch node for Referred / Parked / Lost / Declined.
- Timeline newest-first by default (toggle), the worker's face as the node,
  one footer row per entry, AI markdown rendered as bold.
- "AI team on this lead" panel with per-agent step counts; PDF tags on docs.

Forms & dialogs
- Required marked with an asterisk + legend; "Select…" placeholder; dd Mon
  yyyy hint under date inputs; open activity form in a titled panel;
  one-line dialog footnotes.

Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens
(sme_underwriter, verbal_call, Rsa), findings cut at "1.".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:37:42 +05:30
2965c47b71 console: consolidate the lead page — say each thing once, weight by importance
The layout placed information by convenience, not importance, and repeated
itself. This is the consolidation pass, no new look.

- METRICS: seven at equal weight became four, money first — Premium, Commission,
  AI confidence, Renewal-countdown. Dropped lead age and time-in-stage (they
  overlapped each other, and the feed already carries timing), and product /
  vehicle (the rail's "The vehicle" holds them). Nothing is lost; every removed
  measure is still on the page once, where it belongs.
- STAGE stops appearing as a metric — the rail is where the lead's position
  lives, and the feed shows each transition.
- NEXT STEP no longer repeats the status. Its heading and "nothing is waiting on
  you" subtitle rendered even when nothing was owed, saying a third time what the
  status strip and the header already said. The heading shows only for a real
  step now; secondary actions (add a document, mark lost) still render, without
  the framing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:46:38 +05:30
17561f3f73 console: the immediate action lives top-right, not below the fold
"What do I do now" was answered only by scrolling past the metrics and stage
rail to the Next step panel. When this user owns the next step it now sits in
the header top-right as a prominent amber button — where the eye goes for the
next move — and pressing it opens the form and scrolls to it. When nothing is
owed, the passive status stays there instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:42:36 +05:30
60f6c97201 fix(console): sharp text — stop compositing content on a transformed layer
Text looked soft across the whole app. Cause: the entrance animation used
transform: translateY, and .shell__main (the entire content area) ran it with
fill-mode both. An element that animated transform stays on a GPU layer, and
text composited on a transformed layer renders blurry in Chrome on Linux — so
every word on the page was soft.

zk-rise is opacity-only now — it fades in without ever moving text onto a
fractional-pixel layer — and the page-wide container no longer animates at all
(a full-page fade on every navigation only softened text while it played, for
nothing). The remaining transforms are hover-only and transient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:36:07 +05:30
edda6e9540 console: give each AI employee a role icon; people keep their initials
The avatars were initials-plus-spark for the machines and bare initials for
people — thin, and it made five differently-skilled agents look like the same
badge in five colours. The convention that reads instantly is icons for
machines, initials for people: the contrast itself tells a human step from an
autonomous one before a name is read, and the symbol says WHICH machine.

Each employee now wears a glyph true to its job — a magnifier for Intake (checks
the lead), a speech bubble for Engage (talks to the customer), a shield for the
Advisor (recommends the cover), an ID badge for KYC (verifies identity), balance
scales for the Referral (prepares a file for a human to weigh). The AI spark
stays at the corner. People keep their initials, which is the right avatar for a
person and now the clear signal that a person, not a machine, did the step.

Verified legible at disc size (20-26px) across all five brand hues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:34:03 +05:30
119bea1e76 fix(console): centre modals via a portal, not inside a transformed ancestor
The New Lead form opened off-centre and its backdrop covered only part of the
screen. Cause: the dialog uses position:fixed, but .shell__main animates
transform on entrance, and an element that has animated transform keeps acting
as the containing block in Chrome even after the animation finishes. So "fixed"
resolved against the 1460px max-width, margin-auto content area — offset by the
sidebar — instead of the viewport.

Every scrim dialog shared this latent bug. Each now renders through
createPortal(…, document.body), so the fixed overlay is always relative to the
viewport regardless of any transformed ancestor. New Lead, the conversation and
call threads, the full lead file, the agent card, and the reasoning dialog all
now centre and dim the whole screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:28:37 +05:30
adef7838fc console: flat single-canvas design language, in the Zurich Kotak brand
The real problem was never the font or the shadows — it was the design LANGUAGE.
Floating white cards on a grey-blue ground is the generic admin-template look,
and no restyle escapes it. This ports the approved mockup: one canvas, sections
set off by hairlines and space, the activity feed as the hero.

- ONE CANVAS. The content ground is white; the sidebar is the only tinted
  surface. Panels drop their border, radius and shadow and become plain
  sections under their own headings.
- The metric strips (lead + overview) stop being caged tiles and become an
  inline stat row between two hairlines — money at display size, the rest
  quieter.
- The stage rail loses its card and becomes a slim inline stepper.
- The property rail flattens: Customer / Vehicle / Documents are hairline-
  divided groups aligned to the edge, headings at label size — it is
  reference, and should not shout over the story.
- "Who holds it" drops from a filled pill to a coloured dot and a line of text.
- Bricolage is gone. Display and body are both Zurich Sans, the brand face;
  headings and figures lean on weight and tracking, not a second family. The
  palette is the ZK blues throughout.

Same data, same functionality — only the surface. Revertible in one push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:21:55 +05:30
83b5795b22 console: the surface pass — display face, depth, and whose move it is
The layout was right but the surface still read as a flat corporate form. Three
levers, all from the approved mockup:

- A DISPLAY FACE (Bricolage Grotesque) on the things the eye lands on — the
  lead name, panel headings, and every figure. Zurich Sans stays the body
  voice; this gives the numbers and titles the tighter, more current character
  the plain grotesque could not. Loaded from Google Fonts with Zurich Sans as
  the fallback, so a blocked CDN degrades to the brand face rather than breaking.

- DEPTH. Panels and the metric tiles lift from the barely-there --sh-sm to a
  real --sh-md with rounder corners, so cards read as objects on a surface
  rather than boxes drawn on paper.

- WHOSE MOVE IT IS. The header said "STAGE / Quote Presented" — a label. It now
  carries a coloured pill: "Right now · With the customer" (teal), "With the AI"
  (blue), "Waiting on a person" (amber). The stage name is in the rail below;
  the pill answers the question an operator actually opens a lead to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 01:05:20 +05:30
900f34d11e console: chain of thought, from data the console already has
Follows the previous commit but drops everything that would have needed a
backend change. The chain is built entirely from the audit trail the console
already fetches: the REASONED rung is the employee's own reasoning fields
(attribution_reason, ai_recommendation_rationale, and the rest — already in the
data), and the DECIDED rung is the activity it committed, the state it moved the
lead to, and its confidence (ai_recommendation_confidence, also a real field on
the step). The finding stays visible above the chain.

No dependency on any endpoint change: the tool-call rung and the tbl_ai_decisions
join are gone. What remains is real, self-contained, and ships with a push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:46:42 +05:30
8e86fa7a0a console: the chain of thought is on the page, not behind a link
An agentic console has to answer one question before anyone will rely on it:
why did the AI do that. The reasoning was always recorded — the employees write
it, the platform stores the tools they called — but the trail showed only the
conclusion. An operator either trusted it blind or opened the raw record.

Each AI step now carries its chain of thought, assembled from real data, never
narrated after the fact: what it CHECKED (the tools it called, each marked for
whether it came back with anything), what it REASONED (the employee's own
words), and what it DECIDED (the activity it committed, the state it moved the
lead to, its confidence). The finding — the first sentence — stays visible; the
chain opens under it.

A tool that returned nothing is drawn hollow. That is the Manju Malik failure
made visible: a licence check that came back empty and was reasoned over as if
it were a finding is exactly what wrongly dropped a real lead, and an operator
scanning the chain should see the empty result the machine reasoned from.

The reasoning ships now — it is already in the audit data. The tool rung lights
up once view-service joins tbl_ai_decisions (next commit); until then the chain
shows two rungs and degrades cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:33:18 +05:30
a49ae3d86c console: the story is the page; the reference is the rail
The audit trail is the record of work nobody watched happen — it is what this
product is for — and it sat in a 384px gutter with its own inner scrollbar
beside a full-width panel of form fields. That said, in layout, that the AI's
work was a footnote to the data entry. Exactly backwards.

Swapped. The trail takes the wide column and scrolls with the page under a
heading that says what it is: "What happened". The reference data — customer,
vehicle, documents, the call, the stage's own facts — moves to a 340px sticky
rail, where short key/value pairs belong. The crew who worked the lead sits at
the top of that rail as its own small panel.

The overview now opens on who holds the work rather than on money: four large
numbers on navy — with the AI, waiting on a person, with the customer,
renewals at risk. A live person-queue or a lapsed renewal warms its cell; the
rest stay cool. Money is the outcome; this is the state, and it is the sentence
the whole console exists to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 00:04:12 +05:30
9c05e4b26b console: the shell, the trail and the lead header on the same rhythm
Continues 009153f into the surfaces it had not reached.

The top bar was 66px with a hard rule under it — admin chrome. It is 72 now,
on the softer line, with the product name at 16px instead of 15. The sidebar
gets wider gutters and taller rows, so the queue list stops reading as a dense
menu and starts reading as the pipeline it is drawn to represent.

The audit trail is where people spend their time on this screen, so it gets the
most air: entries at 24px apart rather than 18, quoted text with 12/16 padding
instead of 8/12, and the activity name at semibold so an entry has a heading
rather than a first line.

The stage badge was 15px under a 12px caption. It is the single most important
fact about a lead and now reads at 16px semibold, which is legible from across
a desk — which is how it will actually be read in a demo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:56:59 +05:30
009153fc0c console: a type scale, so size can carry meaning again
The app had FIFTY-FOUR distinct font sizes and forty of them sat between
0.58rem and 0.95rem — a three-pixel band. The most-used size was 0.78rem, so
body copy was 12.5px. That is not a hierarchy, it is a fog of small grey text,
and it is the main reason the screen read as a decade old: nothing could be
emphasised because everything was already the same size.

Nine steps now, with real jumps. 12px is the floor and is reserved for
uppercase eyebrows — every one of the 53 declarations sitting there that was
not an eyebrow has been lifted off it. The surfaces people actually read — the
trail's entries, WhatsApp bubbles, field values, table cells — are at body size
rather than meta size.

The hairline cages are gone with them. The lead metrics and the overview KPIs
were seven equal cells in a 1px grid, every value the same size as its label,
which left the reader to do the prioritising themselves on every lead. They are
separated by whitespace now, values at 22px against 12px labels, with premium
and commission larger again: size does the prioritising instead.

Panels breathe at 32px rather than 24, radii went 10/14 -> 12/16/20, borders
dropped to the softer line, and the stage rail, the queue table and the cards
were rebuilt on the same spacing rhythm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 23:36:21 +05:30
b27141b797 console: mark the AI's work, and give the call its own card
Initials alone do not say a step was autonomous — "EN" reads as a colleague's
badge. On a screen whose whole claim is that the work ran without people, the
one fact every entry has to state is which entries were the machine's. Agent
discs now carry a sparkle, the settled convention for machine-generated work,
so it needs no legend; humans keep a plain disc, because a mark on every actor
would say nothing. Screen readers get "(AI)" said out loud, since they cannot
see it.

The call was the one step nobody could replay from the fields. Everything else
on a lead was typed or derived; this happened out loud, and its outcome sat in
the full file next to the source channel while the words themselves were only
reachable from a button halfway down the trail. It gets its own card, with the
transcript one click from the notes that summarise it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:30:33 +05:30
49d5443c60 console: the page carries what matters, the file holds everything
Fifty-seven fields over thirteen groups sat open on the lead page, which
pushed the audit trail — the part people actually read — a screen and a half
down. Most of it is reference material: nobody opens a lead to find its source
channel. They open it to see who the customer is, what is being insured, what
has been attached, and what this stage turns on.

So the page carries exactly that — four cards at most, the fourth chosen by the
stage the lead is in — and the complete record moves into a drawer behind one
click. Nothing is dropped: the drawer is handed the same computed groups the
page used to render, so the two cannot disagree about a value.

Hiding is only safe while it stays findable, so the trigger names the number it
is holding back rather than saying "more", and the drawer brings a filter over
labels AND values, because Ctrl-F was how people read the open version and a
click breaks that.

Long values are clamped in both places. uw_referral_reason and lost_reason run
to a paragraph, and a paragraph printed raw in a summary card would rebuild the
wall this exists to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:21:56 +05:30
db91daabf8 console: the receipt is gone from the workflow, so stop describing it
The conversation panel told the reader that two automatic sends were absent
from the thread. Only one is now — 97 removed the "I have your message"
receipt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:57:24 +05:30
ea2b56e332 console: the file reads as facts, and filing a lead stays on the page
The lead file put seven AI paragraphs in the same flow as "Mobile", each with
the weight the audit trail gives it, so the card was mostly quotations with the
facts lost between them — and the same prose sat in the trail two feet to the
right. Facts group at the top now; the reasoning is a caption, two lines and a
link to the dialog that already held it. Pairs go side by side rather than
stacked, and the CSS columns become a grid: column flow read top-to-bottom-then-
across, so groups landed in an order nobody could predict and tore raggedly
between 2-field and 12-field groups.

New lead was a route. Pressing it cost you your place in the queue you were
working through and left you three navigations away. It is a dialog over the
page now, with the same modal furniture as the conversation and reasoning ones.

Three of its boxes were answered by the system before anyone saw them — the
channel, the partner code and the submitting agent, all stamped by prefill from
the signed-in identity. They are hidden at RENDER only: `fields` still holds
them, so they validate and submit exactly as before. Filtering them out of
`fields` would have dropped the attribution and 400'd on the mandatory channel.
A stamped field that comes back empty is drawn anyway, so a prefill that does
not resolve cannot fail validation against a box that is not on screen.

Business Name is scoped to SME. An INIT form has no lead to filter on, so
visibility reads the live answer to Product Line first and the saved lead
second — the form narrows as it is filled, and a motor renewal drops from
twelve boxes to nine. It is optional, so hiding it cannot block a submission.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:53:16 +05:30
3069bf4849 console: the lead file is a file again, and the journey is on the page
THE FILE WAS THIRTEEN TABS. One group of fields visible at a time, so
"what do we know about this lead?" meant thirteen clicks and a good
memory — on a page called the Lead FILE. A file you can read one page of
at a time is a filing cabinet.

Every group is now a card, laid out in columns that reflow to the width:
three on a wide screen, two normally, one on a laptop. The whole 360 is
one scroll and Ctrl-F finds anything. Groups avoid breaking across a
column boundary — a heading at the foot of one column with its fields at
the head of the next is worse than an uneven column — and inside a group
the label/value pairs stack, so a long value is not squeezed into half a
column against its label.

THE JOURNEY IS AT THE TOP. The page named the current stage in a corner
chip and said nothing about the path, so "how far along is this?" — the
first question anyone asks — needed the workflow memorised.

Passed steps are read from the AUDIT, not assumed from the order. A lead
can skip (documents uploaded from Qualified carried one straight past
Contacted) and can go backwards (a callback returns it to Awaiting
Contact); colouring everything left of the current stage would claim
steps that never happened. The side states — Parked, Referred, Lost,
Declined — are deliberately off the rail: they are departures from the
path, not points every lead passes, and a row implying otherwise would be
a lie about the process. When the lead is in one, the rail names it at
the end instead.

THE TRAIL STOPS READING AS A MARGIN NOTE. 384px beside a full-width file
made a column of two-line entries look like an aside; it is the half of
the page that says what happened. Widened to 440.

Layout only. The form, the actions, the audit fetch and every submission
path are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:37:32 +05:30
de8b158ca9 console: the nav navigates, and the screens act
"New lead" sat in the sidebar. A sidebar is for moving between places;
filing a lead is an action, and an action in a list of destinations is
the one item that does not behave like its neighbours — everything above
and below it changes what you are looking at, and it did something.

It now sits on the overview and on every queue, beside the lists it adds
to, which is also where somebody is standing when they realise they have
a lead to file. Same role gate as before: shown only to a user who holds
an entry door.

The sidebar's own button CSS went with it rather than being left for
someone to wonder about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:31:07 +05:30
33bf65c5f5 console: All leads means all leads
The page called "All leads" dropped every closed lead from its query, so
a lead that was lost, declined or onboarded vanished from the one place
somebody goes when they cannot find something. That is the page whose
entire job is to answer "where did it go?".

Closed leads are shown now, dimmed, with the stage they ended in. A
"Hide N closed" toggle is offered only when there is something to hide
and is OFF by default — hiding is a choice, not the behaviour.

The filter also moved off the query and onto the render. A view that
throws rows away when it fetches them cannot be un-filtered by a toggle,
and the header can now say how many are closed rather than silently
showing fewer than the count claims.

Dimmed rather than struck through or greyed to unreadability, and hover
restores it: the row is muted, not disabled — it still opens, and the
lead file behind a lost lead is often exactly what someone wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:38:46 +05:30
4b590474e4 console: the call, in the customer's own words
Recording is now on for Meera (93) and the transcript is kept on the lead
(94), so the call stops being a thing only one AI ever saw.

The wake that ends a call already carried far more than Engage used — a
summary, a sentiment analysis, and the FULL TRANSCRIPT, turn by turn —
and all of it was dropped once the three-sentence write-up was done. So a
customer said "I think it's 12,000 rupees" and "lower premium", and the
file held a paraphrase.

That matters here more than it would elsewhere: consent_artefact =
verbal_call is written from what the agent HEARD, and it is the artefact
justifying every later contact. The evidence for it should be the
customer's own words.

"Hear the call" now sits on the Log Contact entry and opens the
conversation, laid out like the WhatsApp thread — agent right, caller left
— because it is the same kind of thing and should not need learning
twice. It also files under Contact in the lead record.

Turns are split on the speaker labels rather than on newlines: a single
turn wraps, and splitting per line shattered one sentence into four
bubbles. A transcript that will not parse is shown raw — it is still
evidence, and "nothing to display" would be a lie.

NOT INCLUDED: the audio. Recording is enabled and the WAV is uploaded,
but recording_url is delivered to the agent's post-call WEBHOOK
(voice_internal_handler.go:429), not in the wake payload, so nothing puts
it on the lead. Wiring that webhook to an external-API activity is a
separate change, worth doing when someone asks to listen rather than to
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:19:53 +05:30
c7cd5602ae console: every step shows what it actually decided
The trail rendered twelve fields as prose and four as money. The other
hundred-odd were invisible — so Capture Motor Risk, which writes engine
capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
add-ons, appeared as a heading and a timestamp. The only way to see what
an agent had decided was to open the Lead file and guess which values
came from which step.

Each entry now carries a folded "N fields written" list: every field that
step committed, by label, with the values already shown above it
(narrative, money, documents) excluded so nothing repeats. Folded because
most steps write a dozen and the trail has to stay readable as a story.

WHAT THIS IS NOT, and it matters: it is not the model's internal
reasoning, and it is not its tool calls. Both ARE recorded — ai_reasoning,
ai_confidence, ai_model and ai_tool_calls sit on the same audit row — but
GetAuditLogsForApp selects eight columns and none of them are those, so
an app JWT cannot read them at all. Surfacing them needs four columns
added to that query and to ActivityLogEntry, which is a view-service
change and an image build.

This is the next best thing and arguably the more useful half: not what
the agent thought, but what it committed, itemised against the step that
committed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 17:12:51 +05:30
cb94e2417f console: Quote Presented is two situations, not one
The page said "Waiting for the customer to reply on WhatsApp" directly
above a panel saying "Accepted by the customer, over WhatsApp. KYC and
underwriting are running." Both rendered from the same instant; only one
was true.

Quote Presented has the same shape as Document Pending — the state holds
still while the situation underneath it changes completely. Before the
customer answers, the lead is genuinely with them and nothing is running.
After they accept, KYC and the underwriting screen run inside the SAME
state, because the lead does not move until underwriting clears. phaseOf
splits it, so the strip now says "Verifying KYC" and then "Screening the
risk for underwriting", and the Next step line records only the fact of
the acceptance rather than repeating what is running.

That also puts this stretch under the stall detector for the first time,
which matters: the KYC agent talked itself out of the underwriting screen
earlier today and the lead sat in Quote Presented with a passed KYC and
nothing happening. With this it would have said so after five minutes and
offered "Run the underwriting screen" — both steps ops_admin can perform,
so the recovery needs nobody's help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:48:25 +05:30
93baba3fa1 console: when an agent stops, say so and offer the way back
The lead page told an operator an automated step "typically completes
within two minutes; this view refreshes automatically" — and went on
saying it indefinitely. Today a lead sat forty minutes under that
sentence while the model provider returned 502s.

Agents stall, and for reasons this app does not control: the provider
slows from 6s a call to 90s or drops the request, a thinking budget runs
out mid-sentence, a late webhook wakes the wrong employee. From the
console every one of those looks identical — a lead that stops — so the
screen now reports the only thing it can honestly know (nothing has
happened for N minutes), says the work so far is safe, and offers the
way out.

THE WAY OUT IS NOT A RETRY BUTTON, because the platform has none and no
agent can be woken directly. But every agent is woken BY AN ACTIVITY, so
performing that activity again wakes it again. nudgeFor() holds that
mapping, and inside Document Pending it picks by how far the chain
actually got — three agents work that state in sequence and the one to
restart is the one that did not finish.

That matters most for the Advisor. It is the only AI step nobody may
perform by hand — permitted to ai_advisor alone — so re-performing the
activity that wakes it is the ONLY route back, and it is the step that
failed twice today. Without this table an operator's only option was to
wait or to call me.

Five minutes before it says anything: an employee wake plus a slow model
is legitimately three or four minutes, and a console that cries stall on
a working lead is one nobody reads.

The clock lives in state, ticking every 20s, rather than Date.now() in
the render body — reading the wall clock while rendering is impure, and
the counter would otherwise only move when something else happened to
re-render the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 16:37:25 +05:30
5d146e9ffb console: a thread that shows what the customer actually received
The order was right — the audit rows are in sequence and the panel
rendered them in sequence. Two other things were wrong.

A MESSAGE THE CUSTOMER GOT WAS NOT IN THE THREAD. After "Okay I confirm
the policy go ahead" the panel showed nothing from us until their next
question, so it read as though we had ignored somebody agreeing to buy.
We had not — the confirmation went out immediately — but it was composed
inside a trigger node and never written to a field, and the console can
only show what the workflow records. 90 stores it in customer_answer, so
it appears here and in the trail. An operator reviewing an acceptance no
human took needs to see exactly what the customer was told at the moment
they said yes.

ORDERING IS NOW (timestamp, id). One submission writes three rows in the
same second — the trigger commit, its repeat, the settle — so a timestamp
alone left their order to the sort's stability, and a reply could print
before the message it answered whenever the two landed in the same
second. It had not happened yet; it was waiting to.

DE-DUPLICATION ONLY COLLAPSES AN IMMEDIATE REPEAT, not any repeat
anywhere in the thread. A customer who asks the same thing twice because
the first went unanswered has said it twice, and a thread that silently
showed it once would hide exactly the impatience an operator needs to
see. Only a same-side, same-words turn directly after its twin is the
platform talking to itself.

The footer says which sends are still absent and why: the quote (a
registered template, not text we compose) and the read receipt (which
would sit between every question and its answer).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:47:49 +05:30
f4c67310cb console: the agents explain themselves, and the chat reads as a chat
TOOLS ARE VISIBLE NOW. Clicking any agent — in the trail or the "Worked
by" strip — opens a card saying what it is, when it runs, what it can
reach and what it reads. The answer is short and it is the reassuring
kind: four of the five hold no tools at all and reason from the policy
wordings, and the only tool in the roster reads a partner registry.

That answer stopped being true once, silently. Intake held a tool that
could telephone customers, its charter never mentioned it, and it used it
on every lead believing it was a duplicate check (89). Nobody could see
that from any screen. This is the screen that would have shown it — which
is the actual argument for building it, beyond a demo looking better.

Where an agent has no tools the card says so in words rather than showing
an empty section. "No tools" is the single most reassuring fact about
something that writes into an insurance file; a blank reads as missing
data.

The roster is a hand-maintained mirror of the employee config, like
STAGES and ACTIONS, and carries the same hazard: a tool added there and
not here is described wrongly, silently. Named in the file.

THE CONVERSATION WAS EATING THE TRAIL. Every WhatsApp turn was its own
entry — heading, actor, quote box — so a four-message exchange occupied
more of the trail than the entire underwriting chain, and a resend
printed the same sentence four times because the customer sent it four
times. A contiguous run now collapses into ONE entry: a WhatsApp mark, a
count, the latest line, and a link that opens the thread. A second
exchange later in the lead stays separate, because that is a different
episode in the story.

And the actor is right. The channel performs these as the system, so the
trail read "System · Customer Reply" — the opposite of what happened.
It says "the customer" now, in the channel's own green, and the rail dot
takes that colour so a scan shows where the conversation was without
reading a word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:41:19 +05:30
171aa4c2fa console: a live trail, faces for the agents, and the thread as a thread
Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.

THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.

THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.

THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.

THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.

CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:29:02 +05:30
8eb80dc371 console: the payment chase is the scheduler's, not a button
86 moved Log Payment Nudge onto a timer — booked 24h out by Request
Premium, re-booked at 48h to a cap of three — because it was firing 47
seconds after the request and recording an outcome for a conversation
nobody had had.

A scheduled activity has to carry ZERO roles or the scheduler's own
perform fails silently, so "Chase the payment" would now 403 for
everyone who pressed it. It stops being offered.

A person who actually wants to reach the customer has Reply to the
customer, which sends a WhatsApp rather than recording that one was due.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 14:59:12 +05:30
927d335280 console: show the WhatsApp exchange, and let ops answer through it
Engage can now answer a customer's question from the policy wordings
(82), so the lead file has a conversation in it rather than a single
recorded reply.

The audit trail renders both halves — "The customer said", then "We
replied" — so the exchange reads as one, which is what an operator
reviewing an acceptance nobody in the office made actually needs.

The lead file gets a Conversation group beside Proposal: the question,
the answer, and the count. Acceptance stays in Proposal, one tab away
from the words that produced it.

"Reply to the customer" is offered to ops_admin only. Engage declines
anything it cannot answer from the wordings, anything that is a
negotiation, and everything past three answers — so a person needs a way
to take over, and it has to be through the same thread or the reply
lands somewhere the lead file never sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:32:12 +05:30
e5b413ecac console: the customer accepts from their own phone
Recording the acceptance was the third human touchpoint, and it was a
person in the office typing what a customer had said somewhere else.
The customer's WhatsApp reply now drives it (81), so this stage has no
step of its own.

"Record the acceptance" drops from a step to a folded recovery lever —
kept, because a customer who says yes on a CALL still needs somebody to
record it, and because a reply Engage judged ambiguous has to be
actionable by a person.

That left Quote Presented with an empty screen at exactly the stage an
operator is most likely to wonder whether something has broken, so the
waiting state now says so. Grey, and no pulse: nothing is happening, and
that is the correct state — unlike the AI strip, where a still dot would
mean something is wrong.

The reply itself surfaces in two places: the audit trail carries it as
"The customer said", and the lead file files it under Proposal beside the
acceptance it produced. The evidence and the decision it justifies are
one line apart, which matters when nobody in the office made it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 13:26:46 +05:30
5ff74ea22d 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>
2026-09-08 12:42:40 +05:30
d6756b4d2e console: show what the workflow actually did, and when it stopped
Traced the console against the workflow it renders. Five gaps, all of
which made a working chain look like a broken one.

THE AUDIT TRAIL WAS READING THE WRONG KEYS. An audit row's data is keyed
by the ACTIVITY's field ids, which carry a per-form suffix — the call
notes arrive as contact_notes_2, the document request as
documents_notes_3 — and the timeline matched the unsuffixed global ids.
Almost nothing ever matched, so the trail was a list of activity names
with the agents' reasoning invisible behind it. The one narrative line
that did appear was an accident. It now reads the platform's own typed
`fields[]` (label, data_type, value) and matches on the base id.

That is also what lets it answer the question that was asked: an upload
now renders as "3 documents received" with each one named and openable,
instead of a bare "Collect Documents".

DATA_UPDATE ROWS WERE CLASSED AS AGENT WORK. A bookkeeping row inherits
the roles of whoever caused it, and the AI check ran first — so an AI's
field write appeared in the trail as an entry titled "Data updated",
while the toggle underneath still offered to reveal the others. The
activity id says a row is bookkeeping; the roles say who triggered it.

DOCUMENT PENDING IS TWO SITUATIONS. Before the upload a person has to
act; after it the lead stays in the same state while three AI steps run.
Both rendered as "waiting on the partner agent", so a lead that had just
been served showed an Upload documents button under a panel saying the
documents had been received, and counted against the Action-required
queue. phaseOf() derives the difference once, from documents_status, and
the header, the queue count, the row status and the action list all read
it. The upload demotes to recovery — "Replace or add a document", folded
away with the other levers.

A STOPPED CHAIN LOOKED IDENTICAL TO A RUNNING ONE. The rating engine
refuses to price without an IDV and writes so into quoted_breakup;
Engage then declines to raise a quote it would have to fabricate. Both
are right, and nobody was told: the refusal sat in a field on a tab and
the lead never moved again. blockedOn() surfaces it as an amber strip
that names the missing value and opens the form that carries it.

THE STALL MEASURE WAS DATED FROM THE WRONG COLUMN. progress() fell back
to created_at when updated_at was absent — which it always was, because
neither view returned it — so every lead older than half an hour would
have reported stalled. It reads updated_at only; the view supplies it as
of 76.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:17:52 +05:30
704afe170e console: the row is the control, and the agent says what it is doing
Two changes to the queue and overview tables.

The row is the click target. An "Open" button was a 90px destination inside
a 1000px row that already reads as one object, and it put a second tab stop
on every line. The row now carries the click, Enter/Space, a focus ring and
the hover tint; the button column is gone, along with its CSS.

The column it freed shows what is actually happening. This workflow is run
by agents and watched by a person, so the question a queue has to answer is
not "which stage" — inside a queue every row shares it — but "is it moving".
An automated stage now reports working (under 3 minutes), waiting (under 30),
or stalled with the elapsed time, with a pulse on the live one and a red left
edge on the stuck one. Thresholds are generous on purpose: an employee wake
takes a minute or two and a scheduled retry can be hours out, so stalled
means "longer than any normal step", not "longer than average".

Before this, a lead three hours into "Calling the customer…" looked identical
to one thirty seconds in — which is the failure this console exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:52:20 +05:30
8b6a387cf6 form: stop asking for what the workflow computes
`documents_status` was a dropdown — not yet / partially / all uploaded —
put to the person who had just attached the files. It asked them to tell
the system what it could see for itself.

Not cosmetic. That field gates the AND-join that wakes Engage after an
upload (55). Left on its default, the lead sits in Document Pending with
all three documents attached and nothing happening, and it reads as the
AI having stalled rather than as a form field nobody filled.

It is derived in the trigger now (75), from the files themselves, after
the commit and before the join reads it — server-side, because a value
computed only in the browser would be right on screen and absent to the
API. `documents_notes` goes with it: the same script writes what is
still missing, and a person overwriting that would be arguing with the
file list.

So both are declared derived and no form asks for them, with or without
a lead behind it. Matched on the base id, so every per-activity suffix
is covered rather than the two that happen to exist today.
2026-09-08 11:43:52 +05:30
fc5eace791 form: make OCR autofill actually reach the fields
Uploading an RC read the document correctly, said so, and filled
nothing.

The endpoint answers keyed by the ocr_config's extraction_fields[].key
— `reg_no`, `make_model`, `mfg_year`, `engine_cc`, `fuel` — because
that is what the vision prompt was asked to produce. Its own package
doc says so. The form's fields are `motor_reg_no_6`, `motor_cc_3`,
`motor_fuel_3`. The console wrote the response straight into values, so
every extracted value landed on a key no field renders: the read
succeeded, the "we read it for you" panel appeared, and the boxes
stayed empty.

This is the third place the same mismatch has bitten. Prefill hit it
(the pipeline names globals, the form uses suffixed activity keys) and
the validation errors hit it (the workflow reports the machine key).
Different surfaces, one cause: nothing in the stack translates between
a global field name and a form's per-activity id, so every consumer has
to do it and each one forgot.

The bridge was already on the field. ocr_config.field_mappings maps
extraction_key -> target_field (the global), and the form field is that
global plus a suffix. Verified against the live Collect Documents
schema: all five RC keys now resolve to real fields.

Also: which value wins. Seeding the form from the lead record (6a6801d)
made every mirrored field non-empty, so the old "only fill what is
blank" rule would have blocked OCR from writing anything at all. Values
copied off the lead are now tracked, and a document may overwrite them —
it is the better source for what it states. Anything typed by a person
is never overwritten, and stops being overwritable the moment it is
typed.
2026-09-08 11:06:41 +05:30
6a6801d2bd console: all-leads view, document form prefill, and two things that should not be offered
Four operator-reported issues, one commit because two of them are about
the same form.

ALL LEADS WAS GONE. The team's rewrite of the queue screen dropped the
/stage/all view, so finding a lead meant guessing which of nine queues
it sat in. Restored: no stage filter, closed leads dropped client-side,
one request for the whole open book. Linked under Overview in the
sidebar so it is one click from anywhere.

THE DOCUMENT FORM ASKED FOR WHAT THE LEAD ALREADY KNEW. Collect
Documents mirrors fourteen record fields — registration, make and model,
previous insurer, expiry, policy number, PAN — so the OCR has somewhere
to write. Rendered blank, they read as fourteen more things to type, on
a form whose whole point is uploading three files. They are now seeded
from the lead by base key: an agent uploading for KA01MF6618 sees
KA01MF6618 already there. Server prefill wins, then anything typed;
files and generated ids are never seeded, and an INIT form has no
record to seed from. SME-only fields were already hidden on a motor
lead by fieldLine — that half was working.

"SEND A REMINDER" IS THE SCHEDULER'S JOB, NOT A BUTTON. It fires at
24h, 72h and 120h on its own. Offering it as a loop action invited a
person to send a reminder the platform was already about to send. It
and Retry Call move to `force` — reachable behind "Lead not moving?"
for the day the schedule itself fails, and otherwise out of sight.

THE UPLOAD STAYED THE "NEXT STEP" AFTER THE UPLOAD. Once
documents_status is complete the lead remains in Document Pending while
Engage captures the risk and the quote is built — and the panel kept
saying "Upload documents", which reads as though nothing was received.
Now, when complete, the upload becomes a loop (a fourth document, a
correction) and the panel says what is actually happening: documents
received, Engage AI is capturing the risk and preparing the quote.

Permissions unchanged; ACTIONS still matches
tbl_wf_state_allowed_activities exactly.
2026-09-07 19:26:13 +05:30
07d4608888 lead: show the next step, not the permission list
The app read as a control panel. Every activity a stage allowed was
rendered as an equal button, so operating it required already knowing
the workflow — which is the opposite of what a workflow tool is for.

Two concrete failures, both visible in the data:

Ops on Document collection got SIX equal buttons. Exactly one was the
next step: upload the three documents. Capture Motor Risk, Capture SME
Risk, AI Cover Recommendation and Generate Quote are the AI's own chain,
present only so a stalled lead can be pushed by hand.

And FOUR stages offered a partner agent nothing but "Mark Lost" — New
Lead, Contacted, Underwriting referral, Premium confirmation. At each of
those the system was actively working the lead, and the only thing the
app suggested was giving up on it.

So an activity now carries what it IS at a stage, not only who may press
it:

  do      the step this stage is waiting on. Usually one. Referred has
          two, because clear and decline are a decision pair rather than
          a step and the option of not taking it.
  again   a bounded loop — retry, reminder, re-quote. Legitimate, never
          the answer to "what now".
  force   an AI employee's own job, offered to a person only so a stalled
          lead can be moved. Folded behind "Lead not moving?".
  exit    Mark Lost. Always reachable, never presented as a step.

The panel is "Next step". With a step, one button at full weight. Without
one it says what is actually happening and who has it, which is the true
answer at six of the nine working stages and was previously rendered as
an empty bar or a lone Mark Lost.

Result per role, checked rather than assumed: an underwriter sees two
buttons, on one stage, and nothing anywhere else. A partner agent sees a
step at three stages. Ops sees a step at four and can still force any
AI activity from the folded section.

Nothing about permissions changed, and the action list still matches
tbl_wf_state_allowed_activities exactly — this is only about which of
them is presented as the thing to do.
2026-09-07 17:55:51 +05:30
2d9c072572 lead: one entry per submission, and the finding above the fold
Two things on the lead page.

THE AUDIT TRAIL REPEATED ITSELF. "Qualify Lead" appeared three times in
a row and "Partner Agent Lead" twice, which reads as the AI having done
the same thing three times. It had not. One submission writes several
rows, told apart by execution_state:

  zk-act-qualify   TRIGGER_PERFORMED     the trigger's commit
  zk-act-qualify   TRIGGER_PERFORMED     again, on the settle path
  zk-act-qualify   zk-state-qualified    the one that moved the lead

Only the last means anything to somebody reading the file. They are now
collapsed into one entry carrying the earliest timestamp — when the
operator acted — and whichever state and payload is populated.

Matched on (same activity, within fifteen seconds) rather than on
execution_state, so a genuine repeat survives: Collect Documents really
is performed twice on a lead whose first upload was short, and those are
minutes apart. And matched by looking BACK through recent entries rather
than at the previous one, because the platform interleaves a DATA_UPDATE
row between the two halves of a submission — the rows to merge are near
each other in time but not adjacent in the list.

THE AI PARAGRAPHS HAD NO SUMMARY. attribution_reason and
eligibility_reason run to a paragraph each and opened folded, so the
finding could not be read without expanding. ClampText now lifts the
first sentence out as a headline and puts the rest behind "Show the
reasoning" — no new field, and nothing invented: the employees already
write a conclusion and then its evidence.

It falls back to plain folding when the split would be useless — no
sentence terminator, a first sentence that is the whole paragraph, or
one short enough to be a fragment rather than a finding.

The other half of that is in the charter (70_verdict_first.sql). The
headline is only as good as the sentence, and Intake was opening with
"Tool 29601 confirmed..." — an internal id — because it had been told to
"name the evidence, not the conclusion". Right about content, wrong about
order: it now leads with what it decided and gives the evidence second.
2026-09-07 17:42:36 +05:30
4e5df66389 form: say what is missing, in the field's own name
Submitting the new-lead form with a blank Product Line produced:

  400 The workflow rejected this submission.
  validation failed for activity zk-act-init-agent: product_line_4(required)

That asks an operator to know what a field_s_id is, that `_4` is a
disambiguating suffix and not part of a name, that "required" is a rule
rather than a value, and that 400 means them and not us. All four are
ours to know.

Two changes, and the first means the second is rarely reached.

The form now checks required fields before it sends anything. It marks
each empty one — on the label, because somebody scanning a thirteen-field
form is looking for the NAME they missed, not for a red box — scrolls the
first into view, and says "Product Line is required". No round trip, and
nothing to decode. The marking clears as the field is filled, since a
field still flagged after being corrected teaches people to ignore the
flagging.

If the workflow does reject a submission on fields — it validates more
than this form can know about — describeValidation now renders the reply
in the form's own labels. It parses every `key(reason)` pair, maps the
reason to a sentence, and resolves the key through the schema, including
the suffix rule the prefill seeding uses. A key the schema does not carry
keeps its raw name: a wrong label is worse than an ugly one, because the
operator goes looking for a field that is not there.

The status code is dropped from that surface. "400" is the first thing
read and the least useful thing shown; it stays on the errors an operator
genuinely cannot fix by typing.
2026-09-07 16:57:20 +05:30
dbbd34f7bf form: apply the prefill the server already sends
"Sourced By", "Submitted By" and "Source Channel" rendered empty on the
new-lead form. The prefill pipeline was working the whole time — the
form-screen response carries

  partner_code    "Shetty Insurance Services (POSP-77341)"
  rm_or_agent_id  "arjun.posp@zurichkotak.example"
  source_channel  "agency"

and the form ignored all three.

The two sides key the same field differently and nothing reconciles it.
A form field is the ACTIVITY key, suffixed because one global may be
used on several activities in a version — partner_code_3,
rm_or_agent_id_3, source_channel_4. A fieldMapping node names the
GLOBAL — partner_code, rm_or_agent_id, source_channel. Neither is
wrong. The seeding loop matched on f.id exactly, found nothing, and
seeded nothing, with no error anywhere.

Now it tries the exact id, then the field uid, then the global name with
the numeric suffix stripped. Taking all three means this survives a
pipeline authored in either convention rather than breaking again the
next time one is written the other way. Empty and null are skipped so a
mapping that resolved to nothing does not overwrite a real default.

Worth noting the failure mode rather than just the fix: a prefill that
matches nothing looks exactly like a prefill that was never configured.
It is why the pipeline read as "empty" from the config tables — the
authoritative store is tbl_wf_activity_prefill_nodes, not the
pipeline_config JSONB, and the quickest honest check is neither: POST
/view/form-screens and read prefill_data.
2026-09-07 16:45:45 +05:30
aca5d1a043 console: role-scoped access, portfolio analytics, enterprise register
Four things, and the first is the one that mattered.

ROLE-BASED ACCESS. The console showed every action to everyone on
purpose — "the refusal is the demo" — and the workflow refused
server-side. That is a defensible engineering position and a poor
product: an underwriter saw a row of six buttons, five of which 403.

src/api/permissions.js now mirrors tbl_wf_activity_permissions, and the
session already carries user.roles, so nothing new had to be fetched.
Action buttons, sidebar queues and the entry doors are all filtered.
It remains presentation only — the platform still refuses, and a role
added there but missing here hides a button that would have worked,
which is the failure mode to watch.

Each role's app now looks like their job. Ops sees the whole board and
still cannot clear a referral. An underwriter sees one queue and two
buttons. Agents see the three queues they work in. Reporting is NOT
gated: the overview counts the whole portfolio for everyone, because an
agent tracking leads they filed is reasonable and acting on them is not.

One deliberate divergence from the workflow, commented where it lives:
the two scheduled activities carry no roles at all, because that is the
only configuration under which the scheduler can perform them. Open to
the SYSTEM is not open to everyone signing in, so the UI narrows them —
otherwise an underwriter is offered "Send Document Reminder".

ANALYTICS. The overview was three lists. It is now a measured page:
open leads, pipeline value, written premium, conversion, commission —
then renewal exposure in three buckets, the queues needing a person with
the age of the oldest item in each, and stage distribution as bars.

Checked against the live book rather than assumed, which found a real
bug before it shipped: Policy Issued is written business AND not yet
terminal, so its premium was counted in pipeline and production both.
₹42,912 double counted on 37 leads. Pipeline now excludes anything
already on risk.

The lead page gained the same treatment: renewal countdown, lead age,
time in current stage, premium, commission, AI confidence. Age and
dwell are computed — neither is on the record, and they are the two
figures that answer "is this moving?", which no field could.

TERMINOLOGY. The previous pass over-corrected: fixing builder jargon
("the workflow decides, server-side") produced chat ("What you can do",
"The agents have it", "Nothing here right now"). Neither is how an
operations console reads. Now: Actions · Record · Audit trail · Action
required · Customer response · Automated · Scheduled · Document
collection · Underwriting referral · Premium confirmation.

LAYOUT. Prose subtitles cut to one line or removed. Measures render as
tabular figures in a bordered strip instead of label/value pairs. The
overview splits into work on the left and distribution on the right.
Stage bars replace a wall of equal-weight cards.

Pre-existing lint errors unchanged at 11; none in the new files.
2026-09-07 16:31:04 +05:30
4c210c7e09 console: a page to monitor from, and words an operator would use
Three problems, and the first one is the reason the other two were hard
to see.

THERE WAS NO DASHBOARD. "/" redirected to the underwriting queue — one
stage, usually empty, and belonging to somebody else. Every screen in
the app answered "show me this queue"; none answered "how is the book
doing, and is anything waiting on me?", which is the only question a
person has before they have picked a queue.

So: a Today page. Three blocks, in the order somebody cares about them.
What is waiting on a person, as three cards that colour only when they
have something in them. Renewals running out, worst first, capped at 30
days because a renewal six months away is not a thing to look at today.
Then every stage with its count, zeroes included — a stage quietly
receiving nothing is only visible if its zero is on screen.

It is one list call, counted in the browser. That is honest at this size
and it is the same call the queues already make. If the book outgrows it
the answer is a counts endpoint, not a bigger page.

THE WORDS WERE WRITTEN FOR WHOEVER BUILT THE WORKFLOW. "What happens
next" was followed by "these are the only activities this state allows
— but the workflow decides, server-side, whether you may". A failed list
said "this queue has no record view yet". Panels were called "Flow
details". A closed lead read "nothing runs from here". None of that is
wrong; all of it is addressed to the wrong reader.

Now: "What you can do", "Lead details", "History", "This queue could not
be loaded". Queue names say what they want — "Documents needed",
"Underwriter to review", "Payment to confirm". The sidebar group called
"Not yet" says "Not due yet", and "Running by itself" says "The agents
have it", which is the actual claim being made.

THE QUEUE HAD NINE COLUMNS AND LED WITH THE WRONG ONE. Two were internal
vocabulary: attribution status renders "clear" or "contested" and means
nothing to an operator, and the channel is background rather than
something anyone scans a queue for. Both fold into a subtitle under the
customer. That leaves six columns and puts the renewal countdown — the
number that decides whether to act today — second instead of fifth.

Also: the timeline printed a raw slug when a step came back without a
name. It is the screen this product is demonstrated on, so a
"zk-act-doc-reminder" in the middle of an otherwise readable story is
expensive. It now reads as English.
2026-09-07 16:13:13 +05:30
eea044b9f9 config: the two scheduled activities
The workflow now schedules work for itself. Two activities exist that
nobody navigates to — the scheduler performs them — and both are offered
here as well, because a person needs to be able to do by hand what the
timer does on its own.

Retry Call, in Awaiting Contact. Booked automatically four hours after an
unanswered call, clamped into 09:00-21:00 IST, three attempts and then
the lead closes. It records the attempt; whether it also DIALS is gated
on the lead's `auto_retry` field, which ships unset, so nothing rings a
phone until somebody arms it deliberately.

Send Document Reminder, in Document Pending. Fires at 24h, 72h and 120h
from Engage's request. On the third unanswered one the lead leaves the
queue — Parked if the renewal is still weeks off, Lost if the cover is
about to lapse. That is the bound: a chase with no ceiling fills a book
with leads nobody will ever work, all of them looking active.

Both are labelled "Scheduled — or you" rather than given an AI or a role,
because neither is true. The performer on the timeline will read System.
2026-09-07 14:12:42 +05:30
b9c76482a2 config: the v2 state model
The workflow dropped four stages on 7 September and renamed two. This
table is the console's mirror of it and was still describing the old
shape, which breaks quietly rather than loudly.

WHAT CHANGED IN THE WORKFLOW

Risk Captured, Proposal Accepted, KYC Verified and Underwriting Cleared
are gone. A lead entered and left each of them inside a minute, so they
were pipeline steps wearing a stage's clothes. Meanwhile the one genuine
stall -- a partner agent finding three documents -- had no stage at all
and happened inside "Contacted", which meant the queue that most needed
watching was the only one that did not exist. zk-state-docs is that
queue, and it is where the work those four stages used to represent now
happens: upload, capture, advise, quote, all without the lead moving.

Quote Presented absorbs the other three the same way -- acceptance,
identity and the underwriting screen -- and leaves in one move for
Payment Pending, Referred or Declined, decided by a single gate that
reads both answers. The two-gate chain it replaces could not express
"identity refers but the risk is fine": the first gate routed to
Referred before appetite had been asked, so an underwriter always
received half a file.

Qualified became Awaiting Contact and Quoted became Quote Presented.
"Qualified" meant pre-call here and post-call on the whiteboard -- the
same word on opposite sides of the dial.

WHY THE RENAME IS THE DANGEROUS HALF

STAGES.name is compared against current_state_name in three places: the
queue filter, the sidebar tally, and the action lookup on the lead
detail page. A workflow rename that is not mirrored here does not error
anywhere -- the record view returns zero rows, correctly, for a name no
lead is in. Both renamed queues would simply have read empty. The header
comment now says so.

Worse, zk-state-docs was absent from STATE_ACTIVITIES entirely, so a
lead sitting there rendered no buttons at all. Document upload is the
human step the entire rest of the flow hangs off.

ALSO

Parked / Nurture has been in the workflow since 4 September and was
never added here, so its Resume Outreach button did not exist. Until a
scheduler lands, that button is the only thing that wakes a parked lead,
so it gets a nav group of its own rather than being filed under
"running by itself", which it is not.

DROPPABLE follows the new state list. Mark Lost now also reaches
Referred, which the workflow had never allowed and now does: a lead
whose cover lapses while an underwriter holds the file previously had
nowhere to go.

doc_vehicle_photos becomes conditional. The comment said no break_in
flag existed to test -- one does now, derived at filing on all three
doors, so the slot honours its own "break-in only" caveat instead of
being shown to every motor renewal.

Verified by diffing STAGES.name and ACTIONS against tbl_wf_states and
tbl_wf_state_allowed_activities on dev; both match exactly.
2026-09-07 12:22:42 +05:30
Likith K R
66ea08e317 Updated to new frontend specifications 2026-09-04 13:26:20 +05:30
Likith K R
ae548a3a10 UI Enhancements in recordsview and detailsview 2026-09-02 15:33:26 +05:30
57 changed files with 7916 additions and 752 deletions

2
.gitignore vendored
View File

@ -24,3 +24,5 @@ dist-ssr
*.sw? *.sw?
.env .env
CLAUDE.md

187
CLAUDE.md Normal file
View File

@ -0,0 +1,187 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm install
npm run dev # http://localhost:5175
npm run build # → dist/
npm run lint # eslint (flat config, ignores dist/)
npm run preview
```
There are no tests and no test runner in this project.
`npm run lint` currently reports 12 pre-existing errors — 8 `react-hooks/refs`, 3
`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider
file exports both a component and a hook). A clean run is not the baseline; compare against
this count rather than against zero.
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
build does **not** — see "Runtime config" below.
## What this is
A React + Vite operator console (no state library, no UI framework — plain CSS files
next to their components) for the Zurich Kotak "Lead to Policy" demo on the Zino
platform. Leads arrive through three channels — direct, bancassurance, agency — and run
one workflow state machine to policy issuance. AI employees carry most of it; a person is
needed at three queues (`Contacted` → upload documents, `Referred to Underwriting`
clear or decline, `Payment Pending` → confirm premium) and the customer holds one
(`Quoted`).
The frontend owns **no business logic**. The workflow (org `84`, app `536`, workflow
`zk_wf_lead`) is the source of truth; this repo is a thin renderer over its API.
Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/`
(SQL migrations `02_workflow.sql`, `04_views.sql`, …). Design doc:
`sm2/app-designs/zurich-kotak/design.html`.
## Architecture
- `src/api/client.js` — every gateway call lives here (`ZinoClient`). JWT + user in
`localStorage`, 401 clears the session via an auth-error handler.
- `src/api/config.js` — the environment identifiers and the hand-maintained mirrors of
workflow tables (`STAGES`, `ACTIONS`, `ENTRY`).
- `src/api/provider.jsx``ZinoProvider` / `useZino()`: one client instance plus session
state. Restores an **identity**, never a permission set.
- `src/api/errors.js``describeError()` turns the gateway's five error shapes into a
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
list).
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding
the lead) rather than by pipeline position, with queue counts from one tallied list call.
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
`current_state_name`.
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
field groups (`GROUPS`, in the order the lead is worked).
- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
one is surfaced.
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
back. `src/components/FileField.jsx` handles `file`/`ocr` fields;
`src/components/Timeline.jsx` renders the audit trail as a story.
- `src/api/thread.js` — the WhatsApp conversation, assembled once from the audit rows
and shared by the timeline entry and the dialog. `turns` is every message deduped
(one submission is recorded up to three times); `episodes` are its contiguous bursts,
each carrying the workflow steps that ran before it; `byRow` says which steps sent a
message from inside their own trigger. The trail renders ONE entry per burst and never
a turn — a burst rather than the whole thread, because the payment exchange is the same
conversation resumed twenty minutes and four steps later, and one entry for all of it
would date those messages to the moment of the first. `THREAD_FIELDS` is why no step
quotes `customer_reply` / `customer_answer` itself: the thread owns them.
Routing: `/stage/:stageUid`, `/lead/:instanceId`, `/add`; unauthenticated renders
`Login` for every path.
## The invariants — break these and it fails in ways tests would not catch
**Forms are never defined in this repo.** `POST /view/form-screens` returns labels,
types, select options and which fields are mandatory; `ActivityForm` renders whatever
comes back. Adding a field in Studio and redeploying surfaces it here with no frontend
change. Do not hardcode a form — it would silently drift from the workflow.
**Permissions are never enforced here.** Nothing hides a button to stop someone. Every
decision is the workflow's, server-side, on each submission; the console reports the
refusal. `/activity` refuses with **403**, `/start` with **400**, both carrying
`permission denied`. Showing an action the signed-in role cannot perform is deliberate —
the refusal is the demo.
**Runtime config, not build-time.** `VITE_ZINO_API_URL` is read at runtime off
`window.__RUNTIME_CONFIG__`, from a `config.js` the server writes next to the artifact at
placement. One artifact is promoted between environments unchanged, so a compiled-in URL
would point every environment at whichever backend built it. `requireConfigValue` throws
rather than falling back — a production build with no `config.js` must fail loudly. The
dev-only `.env` fallback is gated on `import.meta.env.DEV`.
**One build serves any mount path.** `vite.config.js` sets `base: './'`; the router
basename comes from `basePath()`, which reads the `<base href>` the server writes into the
`<!--BASE_HREF-->` placeholder in `index.html`. Do not reintroduce a build-time base, and
do not remove that placeholder or the `config.js` script tag. `import.meta.env.BASE_URL`
is unusable here (it resolves to `./`).
**Bundled assets must live in `src/assets/`, not `public/`.** Vite rewrites bundled asset
URLs to be relative; a `public/` file referenced as `/fonts/…` stays absolute and 404s
under the `/zurich-kotak/` mount. The favicon is the one exception — it stays in
`public/brand/` and is referenced relatively.
**Activity fields are filtered by product line.** `Collect Documents` serves motor
and SME from one form — all eleven upload slots plus both lines' risk fields, to every
lead — and the activity carries no `field_rules` (`[]`), so nothing server-side decides
visibility. A motor renewal was being asked for a Udyam certificate, a stock statement
and a GSTIN. `fieldApplies()` in `api/config.js` decides: `motor_*` is motor and `sme_*`
is SME **by prefix**, so a field added to the workflow tomorrow classifies itself;
`LINE_FIELDS` names the exceptions that carry no prefix (`gstin`, `udyam_no`), and
`DOC_SLOTS` maps the upload slots plus the two genuine conditions (`doc_address_proof`
only when `kyc_outcome === 'refer'`, `doc_financials` only when `sme_sections` includes
`business_interruption`). `ActivityForm` filters on it for both render and submit.
Two things keep this safe. Every slot is `mandatory: false`, so hiding one cannot fail
schema validation. And when the filter would hide EVERY field, `ActivityForm` renders the
form whole instead — `Capture Motor Risk` is twelve motor fields and is runnable from
Contacted whatever the product is, so on an SME lead the filter would otherwise remove
the activity rather than its noise. `entity_name` is deliberately **not** SME-only: motor
leads carry a business name too.
The prefix rule is inference, not configuration — the durable home is `field_rules` on
the activity. When those are seeded, read them and delete this.
**Nothing is pushed — the console polls.** No socket, no SSE, and most steps are carried
by an AI employee that takes one to three minutes, so a lead moves while it is on screen.
The lead detail polls every 12s, queues and the sidebar counts every 30s, all gated on
`document.visibilityState` and all *quiet* — a refresh must never drop a queue back to a
skeleton under whoever is reading it.
**`STAGES` carries `kind`** — `needs` / `customer` / `auto` / `end` — answering "who is
holding this lead", plus `need` (what a queue wants done, which is what the sidebar shows)
and `doing`/`by` (what to say while an AI carries it, instead of an idle screen).
**`STAGES` and `ACTIONS` are hand-mirrored** from `workflow.tbl_wf_states` and
`workflow.tbl_wf_state_allowed_activities`, because the API returns neither the order nor
the labels the sidebar needs. An activity added to the workflow but not added to `ACTIONS`
is simply invisible — the platform allows it, the console never offers it, nothing errors.
This bit once already: `zk-act-drop` (Mark Lost) was missing entirely, so no lead could be
dropped from the console. It is now appended to every state in `DROPPABLE` rather than
written into ten entries. Keep all of it in step with the seed SQL.
No endpoint exposes state → allowed activities (eight plausible paths probed, all 404) and
the detail view carries `current_state_id` but no activity list — which is *why* the map is
hand-maintained. If the platform ever serves it, delete the map.
## API shapes that have already bitten
- `org_id` on `POST /usr/login` must be a **string**; a number returns
`cannot unmarshal number into Go struct field LoginRequest.org_id`.
- `POST /view/recordview` names the view `rv_template_uid` (the GET spelling is `rv_id`),
and paging/sort/filters live **inside** `search_query`. Top-level gives
`400 Missing param: rv_template_uid (body)`.
- Row key varies by source type: workflow views return `data`, rdbms views have returned
`records` — hence the `data ?? records ?? rows` fallbacks.
- Audit must use the **app-scoped** path `/app/536/view/audit`. Bare `/view/audit` is not
an API route; it falls through to the SPA and returns HTML with a 200.
- `POST /upload` **requires** `workflow_uuid` (taken from config, not from the caller's
ctx). The field context (`activity_id`, `field_id`, `instance_id`) is how the backend
resolves allowed types, size limit and `ocr_config` — it ignores anything the client
claims about them.
- `/ocr-extract` takes a **reference** to an already-uploaded file, not bytes, and answers
`{ extracted, raw }`. Files therefore upload on pick, not on submit.
- `POST /view/recordview` takes the view uid in the **body**. The path form the frontend
spec documents (`/view/recordview/zk-rv-leads`) returns **404**.
- The detail view returns `{ config, data }``config.fields[].output_label` names every
one of the 117 fields, so labels come from the server rather than from prettified ids.
- The `message` on a `/start` or `/activity` response is written per activity ("Documents
received — capturing the risk") and is surfaced verbatim, not replaced with a toast.
- On submit, `ActivityForm` sends only fields the activity defines, drops empties (an
unknown field is fatal to schema validation) and skips `id_gen` — that reference is
issued server-side.
- OCR results fill only fields still blank; never overwrite what a person typed with what
a model read.
## Deployment
Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql` (slug
`zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds and serves at
https://preview-dev.getzino.in/zurich-kotak/. The webhook fires only on a **new** push.
Demo logins (all password `ZurichKotak@2026`, org `84`) are listed in `README.md`; sign in
as `sanjay.uw@zurichkotak.example` to land in the one human queue.

View File

@ -13,6 +13,9 @@
<!--BASE_HREF--> <!--BASE_HREF-->
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" /> <link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
<title>Zurich Kotak | Lead Desk</title> <title>Zurich Kotak | Lead Desk</title>
<!-- Typeface is Source Sans 3, self-hosted and bundled from src/assets
(see src/index.css). No CDN request, and it renders under the
sub-path mount because Vite resolves the URLs at build. -->
<!-- Written next to the artifact at placement; carries this environment's <!-- Written next to the artifact at placement; carries this environment's
VITE_ZINO_API_URL, which src/runtimeConfig.js reads off VITE_ZINO_API_URL, which src/runtimeConfig.js reads off
window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the

View File

@ -2,9 +2,10 @@ 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 Pipeline from './screens/Pipeline.jsx' import Pipeline from './screens/Pipeline.jsx'
import Lead from './screens/Lead.jsx' import Lead from './screens/Lead.jsx'
import AddLead from './screens/AddLead.jsx'
export default function App() { export default function App() {
const { isAuthed } = useZino() const { isAuthed } = useZino()
@ -21,10 +22,11 @@ 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>}>
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} /> {/* The morning page. This used to redirect to the underwriting queue
one stage, usually empty, and somebody else's. */}
<Route path="/" element={<Overview />} />
<Route path="/stage/:stageUid" element={<Pipeline />} /> <Route path="/stage/:stageUid" element={<Pipeline />} />
<Route path="/add" element={<AddLead />} />
<Route path="/lead/:instanceId" element={<Lead />} /> <Route path="/lead/:instanceId" element={<Lead />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>

108
src/api/agents.js Normal file
View File

@ -0,0 +1,108 @@
/**
* The AI employees, as people the operator can recognise.
*
* Five agents carry this workflow and the console referred to them by their
* role slug or their full title, differently in each place "ai_engage" in one
* view, "Engage" in another, "Engage AI" in a third. Someone watching a lead
* move could not tell that the thing which called the customer and the thing
* which wrote the quote were the same worker.
*
* So: one roster, one short name, one colour, one initial. Used wherever an
* agent is named. `does` is written in the present tense and in the operator's
* words, not the charter's it appears on hover and in the roster strip, and
* it is what makes an unfamiliar name mean something the first time.
*
* `tools` and `knowledge` are a MIRROR of the employee config in
* aiemployee.tbl_ai_employees. The app frontend cannot read the agents schema,
* so this is hand-maintained the way STAGES and ACTIONS are, and carries the
* same hazard: a tool added or removed there and not here is described wrongly
* in the UI, silently.
*
* Worth the trade, because "what can this thing actually do?" is the first
* question anyone watching an agent asks and the honest answer is short and
* reassuring: four of the five hold no tools at all and work from the policy
* wordings. The one tool that exists only reads a partner registry. Nothing
* here can move money, and since 89 nothing here can telephone anybody.
*
* Keyed by the ROLE, because that is what an audit row carries.
*/
export const AGENTS = {
ai_intake: {
icon: 'inspect',
short: 'Intake',
full: 'Intake & Attribution',
initials: 'IA',
tone: 'violet',
does: 'checks the lead is real, scores it, and works out whose it is',
wakes: 'Once, the moment a lead is filed.',
tools: [
{
name: 'lookup_partner',
does: 'Reads the partner registry by partner code and reports whether that POSP, broker or corporate agent is active and empanelled for this product.',
},
],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
ai_engage: {
icon: 'chat',
short: 'Engage',
full: 'Engage',
initials: 'EN',
tone: 'blue',
does: 'talks to the customer, writes up the call, and asks for what is missing',
wakes: 'Most often of the five: after the call ends, after documents land, after the Advisor reports, when the premium falls due, and on every WhatsApp reply.',
tools: [],
knowledge: ['SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
},
ai_advisor: {
icon: 'shield',
short: 'Advisor',
full: 'Advisor',
initials: 'AD',
tone: 'teal',
does: 'reads the captured risk and recommends the cover and add-ons',
wakes: 'Once, as soon as the risk has been captured.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
},
ai_kyc: {
icon: 'id',
short: 'KYC',
full: 'KYC & Evidence',
initials: 'KY',
tone: 'amber',
does: 'verifies identity and screens the risk for underwriting',
wakes: 'Twice: when the customer accepts, and again to run the underwriting screen.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
ai_uw_referral: {
icon: 'scales',
short: 'Referral',
full: 'Underwriting Referral',
initials: 'UW',
tone: 'plum',
does: 'prepares a referred file so a human underwriter can decide',
wakes: 'Once, and only when the screen refers the risk.',
tools: [],
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
},
}
/** The agent behind a set of roles, or null when a person did it. */
export function agentFor(roles) {
if (!Array.isArray(roles)) return null
const key = roles.find((r) => AGENTS[r])
return key ? { key, ...AGENTS[key] } : null
}
/** Initials for a person, so a human entry reads as a name too. */
export function initialsOf(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase()
}

View File

@ -68,6 +68,37 @@ export class ZinoClient {
return res.json() return res.json()
} }
/* The support desk
Not app-scoped: these are the platform's own agent routes, and the token
this console already holds is accepted on them as-is. A conversation is a
session; a question is a message on it. */
/** This operator's conversations with the desk, newest first. */
deskSessions(agentId) {
return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`)
}
/** Open a new conversation. */
deskStartSession(agentId) {
return this.request('POST', '/api/agent-sessions', { agent_id: agentId })
}
/** The transcript of one conversation. */
deskMessages(sessionId) {
return this.request('GET', `/api/agent-sessions/${sessionId}/messages`)
}
/**
* Ask. Answers with the rows the backend persisted for the turn
* { user_message, call_api_message, agent_message } not a bare string.
*
* These take twenty to forty seconds: the desk runs real queries against the
* book before it answers. The caller has to say so, or the panel looks hung.
*/
deskAsk(sessionId, content) {
return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content })
}
/** /**
* org_id must be a STRING. The gateway rejects a number with * org_id must be a STRING. The gateway rejects a number with
* "cannot unmarshal number into Go struct field LoginRequest.org_id". * "cannot unmarshal number into Go struct field LoginRequest.org_id".

View File

@ -13,24 +13,88 @@ export const WORKFLOW = 'zk_wf_lead'
* *
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched * Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
* because the sidebar has to render its ORDER, and the API returns states as a * because the sidebar has to render its ORDER, and the API returns states as a
* set with no canonical sequence. Keep in step with 02_workflow.sql. * set with no canonical sequence. Keep in step with 53_v2_state_model.sql.
*
* `kind` answers "who is holding this lead", which is the question an operator
* actually has not "where is it in the process":
*
* needs a person has to act, and nothing moves until they do
* customer waiting on someone outside the business
* auto an AI employee is carrying it; `doing` is what to say meanwhile
* waiting real, but not yet the renewal is too far off to work
* end terminal
*
* `need` names the queue by what it wants done. A queue called "Upload
* documents" answers "is anything waiting on me?"; one called "Document
* Pending" describes where the lead sits and leaves the operator to work it out.
*
* `name` MUST match workflow.tbl_wf_states.name exactly the queue filter, the
* sidebar tally and the action lookup all resolve a lead through this string
* against current_state_name. A workflow rename not mirrored here does NOT
* error: the queue returns nothing, correctly, for a name no lead is in. That
* is the failure mode this table has, and it is silent.
*/ */
/**
* The support desk this console can ask questions of.
*
* A Support App on the platform: an AI desk bound to app 536 that reads the
* deployed config and the live book through read-only tools and answers with
* evidence. It is org-scoped and lives outside this app, so its id is config
* here rather than something the console can derive.
*
* Access is granted per user on the platform (usersystem.tbl_org_user_agents)
* and the console additionally gates the panel to ops see ASK_DESK_ROLES.
* Both have to agree; the platform's grant is the one that actually enforces.
*/
export const SUPPORT_AGENT_ID = 29648
export const ASK_DESK_ROLES = ['ops_admin']
export const STAGES = [ export const STAGES = [
{ uid: 'zk-state-new', name: 'New Lead', kind: 'work' }, { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' }, { uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' }, { uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' },
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' }, // The principal stall in the workflow, and the one that had no stage until
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' }, // 7 September: a lead waited for its three documents inside "Contacted", so
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' }, // the queue that most needed watching was the one that did not exist.
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' }, { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' },
// The only human queue in the whole machine. { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' },
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' }, // The one queue where the machine stops and a person decides.
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' }, { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' }, // `approval` marks the one queue that is a person's SIGN-OFF rather than a
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' }, // piece of work: confirming money has arrived. It is locked to ops_admin in
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, // the workflow — no agent and no other role can perform it — because an
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, // employee that can mark a premium received can put a customer on risk for a
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, // policy nobody paid for. It gets its own band on the overview so the role
// that owns it does not have to find it in a list of things mostly handled
// by somebody else.
// WHERE THE AI PUTS DOWN WHAT IT CANNOT HONESTLY FINISH. An AI employee has
// no way to raise a ticket and no way to send a message; the only move it
// has for "a person should take this" is Flag for Review, and it lands here
// with the reason it wrote. Nothing is running on these leads.
{ uid: 'zk-state-review', name: 'Needs a Person', kind: 'needs', need: 'Flagged by the AI', by: 'operations' },
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' },
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' },
// Nurture. There is no scheduler yet, so Resume Outreach is a button and it
// is the only thing that wakes a parked lead — do not present this as a
// stage with nothing to do.
{ uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Nurture', by: 'the renewal calendar' },
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
]
/**
* How the sidebar groups those. Fourteen flat stages answer "what is the
* process?" — the question nobody signing in has. These answer "is anything
* waiting on me?".
*/
export const NAV_GROUPS = [
// The sidebar answers "is anything waiting on me?", so a group is WHO holds
// the work, not where it sits in the process. A person's queues and the
// customer's queues both need a human to look, so they share one heading.
{ label: 'Needs you', kinds: ['needs', 'customer'] },
{ label: 'Scheduled', kinds: ['waiting'] },
{ label: 'History', kinds: ['end'] },
] ]
/** Channel presentation. source_channel is data on the instance, never a branch. */ /** Channel presentation. source_channel is data on the instance, never a branch. */
@ -60,30 +124,106 @@ export const DV_LEAD = 'zk-dv-lead'
* the platform allows it, the console never offers it, and nothing errors. * the platform allows it, the console never offers it, and nothing errors.
* Collect Documents shipped in that state for one round. * Collect Documents shipped in that state for one round.
*/ */
export const ACTIONS = { /**
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }], * What each activity IS at a stage, not merely who may press it.
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }], *
'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }, * `do` the expected next step here. Usually one; Referred has two,
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' }, * because clear and decline are a decision pair rather than a
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }], * step and an alternative to it.
'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' }, * `again` a bounded loop a retry, a reminder, a re-quote. Legitimate,
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }], * never the thing to do next.
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }], * `force` an AI employee's own job, offered to a person ONLY so a
'zk-state-accepted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }, * stalled lead can be pushed by hand. Presenting these as
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }], * actions is what made the app read as a control panel: six
'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }], * equal buttons where five are recovery levers.
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' }, * `exit` Mark Lost. Always reachable, never a step. Four stages used
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' }, * to offer a partner agent this and nothing else, which told
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }], * them their only option was to give up on a lead the system
'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Ops' }], * was actively working.
// 'Payment webhook' was aspirational — there is no payment integration on the */
// platform and none is planned here. Collection happens on the insurer's own const STATE_ACTIVITIES = {
// rails; ops records the reference. See 35_reframe_to_what_exists.sql. 'zk-state-new': [
'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' }, { uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' },
{ uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage' }], ],
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }], 'zk-state-qualified': [
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' },
],
'zk-state-contacted': [
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
],
// The one stage whose next step is a person's: three documents have to
// be found and uploaded. Everything else here is the AI's own chain.
'zk-state-docs': [
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' },
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' },
],
'zk-state-quoted': [
// The customer accepts from their own phone: their WhatsApp reply is
// routed to Customer Reply, Engage reads it, and Engage performs this.
// Kept reachable — role 'force', folded away — because a customer who
// says yes on a CALL still needs somebody to record it, and because a
// reply Engage judged ambiguous has to be actionable by a person.
{ uid: 'zk-act-accept', label: 'Record the acceptance by hand', by: 'you, on consent', role: 'force' },
{ uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' },
{ uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' },
{ uid: 'zk-act-answer-customer', label: 'Reply to the customer', by: 'you, on WhatsApp', role: 'again' },
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' },
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' },
],
'zk-state-referred': [
{ uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' },
{ uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' },
{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' },
],
'zk-state-review': [
{ uid: 'zk-act-review-resume', label: 'Send back to the workflow', by: 'you', role: 'do' },
{ uid: 'zk-act-review-refer', label: 'Send to underwriting', by: 'you', role: 'do' },
],
'zk-state-payment': [
{ uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' },
// Not offered: the chase is a SCHEDULED activity now (86), booked 24h
// out by Request Premium and re-booked at 48h to a cap of three. It
// carries zero roles so the scheduler can perform it at all, which means
// a button here would 403 for everyone. To actually reach the customer,
// use Reply to the customer — that sends something.
{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' },
],
'zk-state-issued': [
{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' },
],
'zk-state-parked': [
{ uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' },
],
} }
/**
* Mark Lost is not tied to one state: a lead can be dropped from anywhere before
* the policy issues, and every role that files a lead may do it. Appended rather
* than written into every entry so there is one place to change it, and so a
* stage added above cannot silently lose it.
*
* It carries role 'exit' precisely so it is never rendered as a step. It is the
* way out, not the way on.
*/
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'whoever holds the lead', role: 'exit' }
const DROPPABLE = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked',
]
export const ACTIONS = Object.fromEntries(
[...new Set([...Object.keys(STATE_ACTIVITIES), ...DROPPABLE])].map((uid) => [
uid,
[...(STATE_ACTIVITIES[uid] ?? []), ...(DROPPABLE.includes(uid) ? [DROP] : [])],
]),
)
/** /**
* Entry points surfaced in the console. * Entry points surfaced in the console.
* *
@ -100,3 +240,337 @@ export const ENTRY = [
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency', { uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
note: 'POSP or broker sourcing a lead. Everything after this happens without them.' }, note: 'POSP or broker sourcing a lead. Everything after this happens without them.' },
] ]
/** Product lines, as stored on the instance and as they should be read. */
export const PRODUCTS = {
motor: 'Motor',
sme_package: 'SME Package',
}
/**
* Which upload slot belongs to which product line and, for the two slots that
* are conditional, when the condition holds.
*
* The Collect Documents activity serves BOTH lines from one form: it returns all
* eleven slots to every lead, so a motor renewal was being asked for a Udyam
* certificate and a stock statement. Nothing on the platform decides otherwise
* `field_rules` comes back as `[]` for this activity, so there is no server-side
* visibility to honour.
*
* This is therefore a MIRROR, in the same sense as STAGES and ACTIONS above, and
* carries the same hazard: a slot added to the activity but not added here is
* shown to every product, and one renamed here stops matching and reverts to the
* same. The durable home for this is `field_rules` on the activity; when those
* are seeded, ActivityForm should read them and this table should go.
*
* Every slot is optional in the workflow (`mandatory: false` on all eleven), so
* hiding one cannot make a submission fail validation.
*/
export const DOC_SLOTS = {
// Motor.
doc_rc: { line: 'motor' },
doc_prev_policy: { line: 'motor' },
// "Break-in only" per the policy. The flag DOES exist now — 49 derives
// break_in at filing on all three doors — so the slot can honour its own
// caveat instead of being shown to every motor renewal.
doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' },
// SME.
doc_gst_cert: { line: 'sme' },
doc_udyam_cert: { line: 'sme' },
doc_premises_proof: { line: 'sme' },
doc_stock_statement: { line: 'sme' },
doc_premises_photos: { line: 'sme' },
// Audited accounts are only read when business interruption is on the risk.
doc_financials: { line: 'sme',
when: (lead) => (lead.sme_sections || []).includes('business_interruption') },
// Both lines.
doc_pan: { line: 'both' },
// Identity is only re-evidenced when KYC actually referred; asking up front
// collects an Aadhaar the file does not need.
doc_address_proof: { line: 'both', when: (lead) => lead.kyc_outcome === 'refer' },
}
/**
* The fields that belong to one product line but do not say so in their name.
* Everything else is classified by prefix: `motor_*` is motor, `sme_*` is SME.
* A prefix rule rather than a list, so a field added to the workflow tomorrow is
* classified without anyone remembering to come back here.
*/
export const LINE_FIELDS = {
gstin: 'sme',
udyam_no: 'sme',
// entity_name is labelled "Business Name" on the form. A company-owned
// vehicle does have an owner with a name, so this is not strictly an SME
// field — but asking a motor renewal for a business name puts an empty box
// on the shortest form in the app, and the answer is already carried by
// customer_name in every motor lead we have. It is optional, so hiding it
// cannot block a submission, and an SME lead still gets it.
entity_name: 'sme',
}
/**
* Fields the form STAMPS rather than asks.
*
* Each is resolved by the prefill pipeline from the signed-in identity
* Arjun IS Crestline Insurance Advisors, submitting through the agency door
* so all three arrived filled and read-only-in-spirit, and the New lead form
* opened with three of its eleven boxes already answered by the system. That
* is three boxes of noise in front of the four that actually need a person.
*
* HIDDEN FROM THE FORM, STILL SENT. These are not decorative: source_channel
* is mandatory, and partner_code and rm_or_agent_id are what Intake attributes
* the lead on. So they are filtered at RENDER only `fields`, which drives
* both validation and the payload, still holds them, and their prefilled
* values submit exactly as before. Filtering them out of `fields` instead
* would drop the attribution and 400 on the mandatory channel.
*/
export const STAMPED_FIELDS = new Set([
'source_channel',
'partner_code',
'rm_or_agent_id',
])
/** Whether a field is stamped by prefill and so should not be drawn. */
export function fieldIsStamped(fieldId) {
return STAMPED_FIELDS.has(baseFieldId(fieldId))
}
/**
* Fields the workflow computes for itself, which a form must therefore not ask
* for. Matched on the base id, so every per-activity suffix is covered.
*
* `documents_status` is the one that matters. It gates the AND-join that wakes
* Engage after an upload (55), and it was rendered as a dropdown asking the
* person who had just attached three files to tell the system what it could
* see. Left unset, the lead sits in Document Pending with everything attached
* and nothing happening, and it reads as the AI having stalled. It is derived
* in the trigger now (75), from the files themselves.
*
* `documents_notes` goes with it: the same script writes what is still missing,
* and a person overwriting that would be arguing with the file list.
*/
export const DERIVED_FIELDS = new Set([
'documents_status',
'documents_notes',
])
/**
* Form field ids carry a per-form suffix `doc_rc` arrives as `doc_rc_2`, `pan`
* as `pan_3` so a field is matched on the id with that suffix removed. No
* underlying field id ends in a number, which is what makes this safe.
*/
export function baseFieldId(id) {
return String(id).replace(/_\d+$/, '')
}
/** Which product line a field belongs to: 'motor', 'sme', or 'both'. */
export function fieldLine(fieldId) {
const id = baseFieldId(fieldId)
if (DOC_SLOTS[id]) return DOC_SLOTS[id].line
if (LINE_FIELDS[id]) return LINE_FIELDS[id]
if (id.startsWith('motor_')) return 'motor'
if (id.startsWith('sme_')) return 'sme'
return 'both'
}
/**
* The line a lead is on, in the vocabulary fieldLine answers in, or null when it
* cannot be told.
*
* Null matters: treating an unknown line as SME would quietly drop the RC and
* the expiring policy from Collect Documents on a lead whose product has not
* been stamped yet, with nothing on screen to say a slot was hidden. Not knowing
* means filtering nothing.
*/
export function leadLine(lead) {
if (lead?.product_line === 'motor') return 'motor'
if (lead?.product_line === 'sme_package') return 'sme'
return null
}
/**
* Whether an activity field should be offered for this lead its line has to
* match, and a conditional upload slot has to have its condition hold.
*
* Everything is shown when there is no instance yet: an INIT form has no product
* line to filter on.
*/
export function fieldApplies(fieldId, lead) {
// Computed by the workflow — never asked for, on any form, whether or not
// there is a lead behind it.
if (DERIVED_FIELDS.has(baseFieldId(fieldId))) return false
if (!lead) return true
const on = leadLine(lead)
if (!on) return true
const line = fieldLine(fieldId)
if (line !== 'both' && line !== on) return false
const slot = DOC_SLOTS[baseFieldId(fieldId)]
return slot?.when ? slot.when(lead) : true
}
/**
* A STAGE IS NOT ALWAYS ONE SITUATION.
*
* Document Pending covers two that could not look more different to whoever is
* watching. Before the upload a person has to act and nothing moves until they
* do; after it the lead stays in the same state while Engage captures the risk,
* the Advisor recommends cover and the rating engine prices it three AI steps,
* two to five minutes, no one to chase.
*
* Rendered from `current_state_name` alone both read as "waiting on the partner
* agent", so a lead that had just been served showed an Upload documents button
* and sat in the Action-required queue, and the AI work behind it was invisible.
* The state machine is right to hold one state here nothing has been decided
* yet so the distinction belongs in the read model, once, rather than in each
* screen's own guess.
*
* `documents_status` is what separates them. The workflow derives it from the
* files themselves (75), so it says what is actually attached rather than what
* anyone claimed.
*/
export function phaseOf(stage, lead) {
if (!stage || !lead) return stage
// Quote Presented has the same shape as Document Pending: the state holds
// still while the situation underneath it changes completely.
//
// Before the customer answers, the lead is genuinely with them and nothing
// is running. After they accept, KYC and the underwriting screen run inside
// the SAME state — the lead does not move until underwriting clears — so a
// page saying "waiting for the customer to reply" sat directly above a panel
// saying "accepted by the customer, KYC and underwriting are running". Both
// were rendered from the same instant; only one was true.
if (stage.uid === 'zk-state-quoted' && lead.acceptance_ref && !lead.uw_outcome) {
return {
...stage,
kind: 'auto',
doing: lead.kyc_outcome ? 'Screening the risk for underwriting' : 'Verifying KYC',
by: 'KYC & Evidence',
after: 'customer accepted',
}
}
if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') {
return {
...stage,
kind: 'auto',
doing: 'Reading the documents and pricing the cover',
by: 'Engage AI, then the Advisor',
// Kept so a screen can say WHY this is not the queue it looks like.
after: 'documents received',
}
}
return stage
}
/**
* Why an automated stage has stopped, when it has.
*
* The AI chain behind Document Pending is honest about refusing: the rating
* engine writes `quoted_breakup` = "Not priced: no insured value (IDV)
* captured" rather than inventing a premium, and Engage then declines to
* raise a quote it would have to fabricate. Both are the right call.
*
* What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a
* tab, the AI's task dies in a queue no operator can see, and the lead sits in
* Document Pending looking exactly like one whose documents never arrived
* indefinitely, because nothing wakes the chain again. A workflow that stops
* for a good reason and a workflow that is broken must not look the same.
*
* Recovery is always the same shape: put the missing value in and let the
* chain re-run. Collect Documents carries the IDV field and re-performing it
* wakes Engage again, so the action is one the partner agent already holds.
*/
export function blockedOn(lead) {
if (!lead) return null
const breakup = String(lead.quoted_breakup || '')
if (breakup.startsWith('Not priced')) {
return {
what: breakup.replace(/^Not priced:\s*/, ''),
// Named rather than described: the operator has to find this field, and
// it is not where they would look for it.
fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.',
via: 'zk-act-collect-docs',
}
}
return null
}
/**
* WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS?
*
* Agents stall. Not often, but they do, and for reasons nothing in this app
* controls: the model provider slows to ninety seconds a call or returns a 502,
* a thinking budget runs out mid-sentence, a late webhook wakes the wrong
* employee. Watched from the console every one of those looks identical
* a lead that simply stops and the screen kept promising it would "complete
* within two minutes" indefinitely.
*
* There is no retry button in the platform, and none of these agents can be
* woken directly. But every one of them is woken BY AN ACTIVITY, so performing
* that activity again wakes it again. That is what this table holds: for a
* lead sitting in an automated step, the activity a person can perform to make
* the stalled agent run once more.
*
* `by` is who to expect to move afterwards, so the button says what will
* happen rather than just what it does.
*
* The choice within Document Pending depends on HOW FAR the chain got, because
* three agents work that state in sequence and the one to restart is the one
* that did not finish.
*/
export function nudgeFor(stage, lead) {
if (!stage || !lead) return null
switch (stage.uid) {
case 'zk-state-new':
return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' }
case 'zk-state-qualified':
return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' }
case 'zk-state-contacted':
return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' }
case 'zk-state-docs': {
// Engage captures, the Advisor recommends, the rating engine prices —
// in that order, each woken by the one before.
const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor'
if (!lead.motor_idv && !lead.sme_value_at_risk) {
return { uid: line, label: 'Capture the risk again', by: 'Engage' }
}
if (!lead.ai_recommended_cover) {
// The Advisor is the ONE step nobody may perform by hand — it is
// permitted to ai_advisor alone. Re-performing the activity that wakes
// it is the only way back, and it is the reason this table exists.
return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' }
}
if (!lead.quoted_premium) {
return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' }
}
return null
}
case 'zk-state-quoted': {
// Only after an acceptance — before that nothing is running and there is
// nothing to wake; the lead is with the customer.
if (!lead.acceptance_ref) return null
if (!lead.kyc_outcome) return { uid: 'zk-act-kyc', label: 'Run KYC again', by: 'KYC & Evidence' }
if (!lead.uw_outcome) return { uid: 'zk-act-uw-screen', label: 'Run the underwriting screen', by: 'KYC & Evidence' }
return null
}
case 'zk-state-issued':
return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' }
default:
return null
}
}
/** How long an automated step may sit before the console stops reassuring and
* starts offering the way out. Generous: an employee wake plus a slow model
* can legitimately take three or four minutes. */
export const STALL_AFTER_MS = 5 * 60 * 1000

130
src/api/errors.js Normal file
View File

@ -0,0 +1,130 @@
/**
* The gateway's error shapes, turned into something an operator can act on.
*
* Five shapes matter, and they are told apart by status plus a phrase in the
* message there is no error code to switch on. `kind` is for the caller to
* branch on ('stale' and 'gone' need the screen to do something); `title` and
* `detail` are what gets shown.
*
* Anything unrecognised keeps its own message: a wrong guess reads worse than
* the server's own words.
*/
export function describeError(err) {
const status = err?.status
const raw = String(err?.message ?? '')
const has = (...words) => words.every((w) => raw.toLowerCase().includes(w))
if (status === 404 && has('no record found')) {
return {
kind: 'gone',
title: 'This lead is no longer there.',
detail: 'It may have been removed since the queue was loaded. Taking you back to the list.',
}
}
if (status === 403 && has('not allowed in state')) {
return {
kind: 'stale',
// Deliberately not "the lead moved": the gateway returns this both when a
// lead advanced under the screen AND when the console offered an activity
// the state never allowed. Only one of those is a move, and claiming the
// wrong one sends the operator looking for something that did not happen.
title: 'That activity is not available at this stage.',
detail: 'Either the lead moved on while this was open, or it never allowed this. Refreshed to show what it does allow.',
}
}
if (status === 403) {
return {
kind: 'forbidden',
title: 'The workflow refused this.',
detail: 'This is the platform deciding, not the console: the signed-in role does not hold this activity.',
}
}
// Field validation runs BEFORE the permission check, so a disallowed
// submission carrying bad data arrives here as a 400 and not a 403.
if (status === 400 && has('validation failed')) {
// "field(unknown), field_2(required)" is the unsuffixed-key mistake, and it
// is a bug in this console rather than anything the operator did.
if (has('unknown') && has('required')) {
return {
kind: 'bug',
title: 'The console sent a field name the workflow does not know.',
detail: raw,
}
}
if (has('enum')) {
return {
kind: 'invalid',
title: 'One of the choices is not one the workflow accepts.',
detail: raw,
}
}
return { kind: 'invalid', title: 'The workflow rejected this submission.', detail: raw }
}
if (status === 401) {
return { kind: 'auth', title: 'The session has expired.', detail: 'Sign in again to continue.' }
}
return { kind: 'unknown', title: raw || 'Something went wrong.', detail: null }
}
/**
* Turns the workflow's field-validation reply into something a person can act
* on, using the form's own labels.
*
* The server answers with the machine keys and a bare reason:
*
* validation failed for activity zk-act-init-agent: product_line_4(required)
*
* Shown raw, that asks an operator to know what a field_s_id is, that `_4` is a
* disambiguating suffix rather than part of a name, and that "required" is a
* rule and not a value. All three are ours to know, not theirs.
*
* `fields` is the schema array, so the reply is rendered in the same words the
* form used a line above it "Product Line", not product_line_4. A key the
* schema does not carry keeps its raw name: a wrong label is worse than an
* ugly one, because the operator goes looking for a field that is not there.
*
* Returns null when the message is not a field-validation reply, so callers
* can fall through to describeError.
*/
export function describeValidation(rawMessage, fields = []) {
const raw = String(rawMessage ?? '')
if (!/validation failed/i.test(raw)) return null
const label = (key) =>
fields.find((f) => f.id === key)?.name ??
// Same suffix rule the prefill seeding uses: the activity key may carry a
// numeric tail the global name does not.
fields.find((f) => String(f.id).replace(/_\d+$/, '') === key)?.name ??
key
const reasons = {
required: (n) => `${n} is required`,
unknown: (n) => `${n} is not a field this form accepts`,
enum: (n) => `${n} is not one of the available choices`,
invalid: (n) => `${n} is not valid`,
type: (n) => `${n} is the wrong kind of value`,
}
const items = []
const re = /([A-Za-z0-9_.]+)\s*\(([^)]+)\)/g
let m
while ((m = re.exec(raw)) !== null) {
const name = label(m[1])
const why = String(m[2]).toLowerCase().trim()
const key = Object.keys(reasons).find((k) => why.includes(k))
items.push(key ? reasons[key](name) : `${name}: ${why}`)
}
if (!items.length) return null
return {
kind: 'validation',
// One problem reads as a sentence; several read as a list.
title: items.length === 1 ? items[0] : 'Some details are missing or not accepted.',
items,
}
}

36
src/api/holder.js Normal file
View File

@ -0,0 +1,36 @@
import { phaseOf } from './config.js'
/**
* Who is holding a lead right now, said the way the colour rule says it:
* blue = the AI, amber = a person, teal = the customer, grey = nobody (closed).
*
* Derived from phaseOf, so it agrees with the sidebar tallies and the lead
* header. `label` is the short form for a table cell: "AI · Intake",
* "Underwriter", "Customer", "Closed".
*/
const AI_NAMES = [
['intake', 'Intake'], ['engage', 'Engage'], ['advisor', 'Advisor'],
['kyc', 'KYC'], ['voice', 'Meera'], ['rating', 'Rating'],
]
function tidy(by) {
const s = String(by || '').replace(/^(the|an|a)\s+/i, '').trim()
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '—'
}
export function holderOf(stageDef, row) {
if (!stageDef) return { tone: 'grey', label: '—' }
const ph = phaseOf(stageDef, row) || stageDef
switch (ph.kind) {
case 'auto': {
const by = String(ph.by || '').toLowerCase()
const hit = AI_NAMES.find(([k]) => by.includes(k))
return { tone: 'blue', label: 'AI · ' + (hit ? hit[1] : 'Engage') }
}
case 'customer': return { tone: 'teal', label: 'Customer' }
case 'needs': return { tone: 'amber', label: tidy(ph.by) }
case 'waiting': return { tone: 'grey', label: 'Calendar' }
case 'end': return { tone: 'grey', label: 'Closed' }
default: return { tone: 'grey', label: tidy(ph.by) }
}
}

159
src/api/permissions.js Normal file
View File

@ -0,0 +1,159 @@
import { ACTIONS, STAGES } from './config.js'
/**
* Who may perform what.
*
* A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same
* sense as STAGES and ACTIONS: the console needs it to decide what to draw,
* and the API returns a refusal rather than a capability list.
*
* This is presentation, never enforcement
* The workflow refuses server-side on every submission, and that refusal is
* the real control. Nothing here can grant anything; a role added here that
* the platform does not recognise gets a 403 the moment it submits. What this
* buys is that an operator is not shown four buttons they will be refused for
* pressing which on a demo reads as a broken app rather than as a fence.
*
* Keep in step with the permissions table. A role REMOVED there but left here
* shows a button that 403s: recoverable and visible. A role ADDED there but
* missing here hides a button that would have worked: invisible, and the
* failure mode worth watching for.
*/
export const ACTIVITY_ROLES = {
'zk-act-init-agent': ['ops_admin', 'partner_agent'],
'zk-act-init-bank': ['ops_admin', 'bank_rm'],
'zk-act-init-direct': ['ops_admin', 'csr_direct'],
'zk-act-qualify': ['ops_admin', 'ai_intake'],
'zk-act-contact': ['ops_admin', 'ai_engage'],
'zk-act-request-docs': ['ops_admin', 'ai_engage'],
'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
'zk-act-capture-motor':['ops_admin', 'ai_engage'],
'zk-act-capture-sme': ['ops_admin', 'ai_engage'],
'zk-act-advise': ['ai_advisor'],
'zk-act-quote': ['ops_admin', 'ai_engage'],
'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// Ops only. The customer's questions are normally Engage's, and this is
// the way a person answers one Engage declined — through the same WhatsApp
// thread, so the reply lands on the lead file instead of somewhere nobody
// can find it later.
'zk-act-answer-customer': ['ops_admin'],
'zk-act-kyc': ['ops_admin', 'ai_kyc'],
'zk-act-uw-screen': ['ops_admin', 'ai_kyc'],
'zk-act-uw-prepare': ['ai_uw_referral'],
// The only exclusive permission in the application. Ops runs everything
// else and cannot touch these two — a referral is cleared by an underwriter
// or it is not cleared.
'zk-act-uw-clear': ['sme_underwriter'],
'zk-act-uw-decline': ['sme_underwriter'],
'zk-act-payment': ['ops_admin', 'ai_engage'],
// Zero roles in the workflow since 86 — the scheduler performs it, and a
// scheduled activity with any role list fails silently. Left here as an
// empty list so the mirror still records that the activity exists.
'zk-act-nudge': [],
'zk-act-realise': ['ops_admin'],
'zk-act-onboard': ['ops_admin', 'ai_engage'],
'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// The AI's one way of saying "a person should take this". Every employee
// holds it, and so does ops — a person can park a lead for the same reasons.
'zk-act-flag-review': ['ops_admin', 'ai_intake', 'ai_engage', 'ai_kyc', 'ai_advisor', 'ai_uw_referral'],
// And the two ways back out of Needs a Person. The underwriter is here too:
// a flag they are sent is theirs to route, not only ops'.
'zk-act-review-resume':['ops_admin', 'sme_underwriter'],
'zk-act-review-refer': ['ops_admin', 'sme_underwriter'],
'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
// ── The one place this mirror deliberately DISAGREES with the workflow ──
//
// Both of these carry NO roles in tbl_wf_activity_permissions, which the
// platform reads as "open to anyone". That is not generosity: the scheduler
// performs them as the synthetic `system` actor, and the trigger's own
// perform step ignores allow_system_perform, so zero roles is the only
// configuration under which a scheduled activity can run at all. See
// 63_scheduled_activities_rbac.sql.
//
// Open to the SYSTEM is not the same as open to everyone signing in. Left
// literal, an underwriter is offered "Send Document Reminder" on a queue
// they have no business in. So the UI narrows them to the roles that would
// plausibly chase by hand. The workflow still accepts either from anyone —
// this hides a button, it does not close a door.
'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
}
/** Roles that belong to an AI employee. They never sign in. */
export const AI_ROLES = new Set([
'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral',
])
export function rolesOf(user) {
const r = user?.roles
if (Array.isArray(r)) return r.filter(Boolean).map(String)
if (Array.isArray(user?.role_assignments)) {
return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String)
}
return []
}
/**
* An empty role list means the activity is open to any signed-in user that
* is what the platform's RBAC does with no roles attached, so mirroring it
* here keeps the two from disagreeing.
*/
export function canPerform(roles, activityUid) {
const allowed = ACTIVITY_ROLES[activityUid]
if (!allowed) return true // unknown activity: show it, let the API decide
if (allowed.length === 0) return true
return roles.some((r) => allowed.includes(r))
}
/** The activities this user may actually run from a given stage. */
export function actionsFor(roles, stageUid) {
return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid))
}
/**
* Activities that are available almost everywhere, or that no person runs.
*
* They must not count towards "does this role have business in this queue".
* Mark Lost is offered in eight stages to every agent role, and the two
* scheduled activities carry no roles at all which the platform reads as
* open. Left in the test, between them they make every queue look relevant
* to everybody, which is the whole thing this is trying to avoid.
*/
const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call'])
/**
* Which queues a role has any business WORKING IN.
*
* Terminal stages stay visible to everyone closed business is reporting,
* not work, and hiding it would leave an underwriter unable to see what
* became of a file they declined. Everything else appears only where the
* role can do something substantive.
*
* This gates the SIDEBAR, not the data. The overview still counts the whole
* book for every role, because an agent tracking leads they filed is a
* reasonable thing to want and a queue they cannot act in is not.
*/
export function visibleStages(roles) {
// Operations runs the desk. They cannot clear a referral — that stays
// exclusive to the underwriter — but the underwriting queue is theirs to
// watch, and an ops console that hides a queue because it holds one
// activity they may not press is hiding the wrong thing.
if (roles.includes('ops_admin')) return STAGES
return STAGES.filter((s) => {
if (s.kind === 'end') return true
return (ACTIONS[s.uid] || [])
.filter((a) => !AMBIENT.has(a.uid))
.some((a) => canPerform(roles, a.uid))
})
}
/** Whether this user can file a lead at all, and through which doors. */
export function entryDoorsFor(roles, entries) {
return entries.filter((e) => canPerform(roles, e.uid))
}

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])
}

216
src/api/thread.js Normal file
View File

@ -0,0 +1,216 @@
import { AGENTS } from './agents.js'
import { baseFieldId } from './config.js'
/**
* THE WHATSAPP THREAD, BUILT ONCE.
*
* The conversation was being reconstructed twice the timeline read it per
* audit row, the dialog read it per field and the two disagreed. The trail
* printed one entry per turn, so a four-message exchange took more of the page
* than the entire underwriting chain, and a message the workflow recorded from
* inside a trigger (the payment request, the issuance note) appeared in the
* dialog and nowhere in the trail. One builder, two readers, one answer.
*
* WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the
* conversation is a thing that persists ACROSS them. Three facts make it awkward
* and they are all handled here rather than in each screen's own guess:
*
* 1. The customer's turns and ours are separate activities `customer-reply`
* is performed by the channel, `answer-customer` by Engage so a turn is
* a FIELD on a row, not a row.
* 2. Two of our sends are not chat activities at all. Request Premium and the
* issuance step compose their message inside a trigger and write it to
* `customer_answer` alongside their own work (see 98/99/100 in the seed
* SQL). They belong in the thread and stay on their own step in the trail.
* 3. One submission is recorded up to three times the trigger commit, its
* repeat, and the settle so the same sentence arrives three times.
*
* `episodes` is what makes this renderable as a timeline entry. The thread is
* one conversation, but it happens in bursts: the customer argues about the
* quote, then KYC and underwriting run for twenty minutes, then the premium
* request reopens the same thread. An episode is one burst. Collapsing the
* whole thread into a single entry would date the payment exchange to the
* moment of the quote objection and destroy the chronology the trail exists
* for; leaving every turn as its own entry is what it did before. A burst is
* the unit that is both honest and short.
*/
/** The two activities that ARE the thread — one turn each, either direction. */
export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
const INBOUND = 'customer_reply'
const OUTBOUND = 'customer_answer'
/**
* The fields the thread OWNS. A step that writes one is not asked to print it
* as well the message belongs to the conversation, and quoting it on the step
* too is how the payment request came to say the same sentence twice.
*/
export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND])
/** Bookkeeping rows. They interrupt nothing: the platform writing a field is
* not a step that broke off a conversation. */
const SYSTEM_ACT = 'DATA_UPDATE'
/**
* Last resort for a step whose activity_name did not come back without it the
* trail prints a raw slug like "zk-act-doc-reminder" in the middle of an
* otherwise readable story. A guess, but one that reads as English.
*/
export function prettyUid(uid) {
if (!uid) return 'Step'
return String(uid)
.replace(/^zk-act-/, '')
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/** What to call a step. The server's own name first; the slug, tidied, after. */
export function stepName(row) {
return row?.activity_name || prettyUid(row?.activity_id)
}
/**
* `fields[]` is the platform's typed rendering of a submission; `data` is the
* raw fallback for a row the workflow could not resolve.
*/
function fieldsOf(row) {
return Array.isArray(row.fields) && row.fields.length
? row.fields
: Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
}
/** Who sent an outbound turn. Usually Engage; sometimes a person answering by
* hand, which must not be attributed to the machine. */
function senderOf(row) {
const key = (row.user_roles || []).find((r) => AGENTS[r]) || null
return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') }
}
/** The turns carried by one audit row, in field order. */
function turnsInRow(row) {
const out = []
for (const f of fieldsOf(row)) {
const base = baseFieldId(f.field_id)
if (!THREAD_FIELDS.has(base)) continue
const text = typeof f.value === 'string' ? f.value.trim() : ''
if (!text) continue
out.push({ side: base === INBOUND ? 'them' : 'us', text })
}
return out
}
/**
* The thread, its bursts, and which audit row carries what.
*
* Returns:
* turns every message, oldest first
* episodes contiguous bursts, each with the steps that preceded it
* byRow row id -> { episodeId, sent, received }, so a step that sent a
* message can link into the thread instead of quoting it
*/
export function buildThread(rows) {
const empty = { turns: [], episodes: [], byRow: new Map() }
if (!Array.isArray(rows) || !rows.length) return empty
// Sorted on (timestamp, id). One submission writes three rows in the same
// second, so a timestamp alone leaves their order to the sort's stability and
// puts a reply before the message it answers whenever the two land together.
const ordered = [...rows].sort((a, b) => {
const t = String(a.created_at).localeCompare(String(b.created_at))
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
})
const turns = []
const episodes = []
const byRow = new Map()
let current = null
// The steps seen since the last message, and whether any of them broke off
// the conversation. `pending` accumulates even before the first message so
// that the first episode can drop it: there is nothing to bridge yet.
let pending = []
let broke = false
for (const row of ordered) {
const mine = turnsInRow(row)
if (!mine.length) {
// A chat activity that recorded no text is not a step that interrupted
// anything — it is an empty row. Bookkeeping is not a step either.
if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue
if (current) broke = true
const name = stepName(row)
if (name && !pending.includes(name)) pending.push(name)
continue
}
const who = senderOf(row)
for (const t of mine) {
const prev = turns[turns.length - 1]
// De-duped against the PREVIOUS turn only. A customer who asks the same
// thing twice because the first went unanswered has said it twice, and a
// thread that showed it once would hide exactly the impatience an
// operator needs to see. Only an immediate repeat is the platform
// recording one submission three times.
if (prev && prev.side === t.side && prev.text === t.text) continue
if (!current || broke) {
current = {
id: `ep${episodes.length + 1}`,
turns: [],
// What ran between this burst and the one before it. Empty on the
// first, which begins the conversation rather than resuming it.
after: episodes.length ? pending : [],
anchorRowId: row.id,
anchorIsChat: CHAT_ACTS.has(row.activity_id),
}
episodes.push(current)
broke = false
}
pending = []
const turn = {
key: `${row.id}-${t.side}-${turns.length}`,
side: t.side,
text: t.text,
at: row.created_at,
rowId: row.id,
episodeId: current.id,
agentKey: t.side === 'us' ? who.agentKey : null,
by: t.side === 'us' ? who.by : 'the customer',
}
current.turns.push(turn)
turns.push(turn)
const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 }
if (t.side === 'us') tally.sent += 1
else tally.received += 1
byRow.set(row.id, tally)
}
}
for (const ep of episodes) {
ep.count = ep.turns.length
ep.from = ep.turns[0].at
ep.to = ep.turns[ep.turns.length - 1].at
// The last exchange, which is where the conversation GOT to — what an
// operator scanning the trail is actually looking for.
ep.preview = ep.turns.slice(-2)
}
return { turns, episodes, byRow }
}
/**
* "Verify KYC and Underwriting Screen", "A, B +2 more" a bridge, not a list.
* Used for the steps that interrupted a conversation and for the voices in it.
*/
export function listOf(names, max = 2) {
if (!names || !names.length) return ''
if (names.length <= max) {
return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
}
return `${names.slice(0, max).join(', ')} +${names.length - max} more`
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,82 +1,398 @@
.af { display: flex; flex-direction: column; gap: 18px; } .af {
.af__loading { color: var(--zk-muted); font-size: .86rem; } display: flex;
.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; } flex-direction: column;
.af__field { display: flex; flex-direction: column; gap: 5px; } gap: 20px;
.af__field--wide { grid-column: 1 / -1; } }
.af__loading {
color: var(--zk-muted);
font-size: var(--fs-xs);
}
.af__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
gap: 16px 20px;
}
.af__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.af__field--wide {
grid-column: 1 / -1;
}
.af__field > span { .af__field > span {
font-size: .74rem; font-weight: 500; color: var(--zk-muted); font-size: var(--fs-2xs);
letter-spacing: .02em; display: flex; align-items: center; gap: 8px; font-weight: 500;
letter-spacing: 0.03em;
color: var(--zk-muted);
display: flex;
align-items: center;
gap: 8px;
} }
.af__req { .af__req {
font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase; font-style: normal;
color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px; font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-blue);
background: var(--zk-tint-blue);
padding: 2px 7px;
border-radius: var(--r-pill);
} }
.af input, .af select, .af textarea {
font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px; .af input,
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%; .af select,
.af textarea {
font: inherit;
font-size: var(--fs-xs);
padding: 10px 12px;
border-radius: var(--r-md);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-ink);
width: 100%;
transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast);
} }
.af input:focus, .af select:focus, .af textarea:focus {
outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue); .af input:hover,
.af select:hover,
.af textarea:hover {
border-color: var(--zk-blue-light);
} }
.af textarea { resize: vertical; }
.af__multi { display: flex; flex-wrap: wrap; gap: 6px; } .af input:focus,
.af select:focus,
.af textarea:focus {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.af select {
appearance: none;
padding-right: 34px;
/* The caret is drawn rather than loaded: an inline data URI keeps the select
consistent across platforms without another asset to serve. */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235d6162' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
}
.af textarea {
resize: vertical;
min-height: 84px;
line-height: 1.55;
}
.af__multi {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.af__chip { .af__chip {
font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer; font: inherit;
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted); font-size: var(--fs-2xs);
padding: 6px 13px;
border-radius: var(--r-pill);
cursor: pointer;
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
} }
.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); }
.af__actions { display: flex; justify-content: flex-end; gap: 10px; } .af__chip:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
color: var(--zk-ink);
}
.af__chip.is-on {
background: var(--grad-blue);
border-color: var(--zk-blue);
color: var(--zk-white);
box-shadow: var(--sh-xs);
}
.af__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
padding-top: 4px;
}
.af__submit { .af__submit {
font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px; font: inherit;
cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white); font-size: var(--fs-xs);
font-weight: 500;
padding: 10px 26px;
border-radius: var(--r-md);
cursor: pointer;
border: 0;
background: var(--grad-blue);
color: var(--zk-white);
box-shadow: var(--sh-sm);
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
} }
.af__submit:disabled { opacity: .55; cursor: default; }
.af__submit:hover:not(:disabled) {
background: var(--grad-blue-hover);
box-shadow: var(--sh-md);
transform: translateY(-1px);
}
.af__submit:active:not(:disabled) {
transform: none;
box-shadow: var(--sh-xs);
}
.af__submit:disabled {
opacity: 0.5;
cursor: default;
box-shadow: none;
}
.af__ghost { .af__ghost {
font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer; font: inherit;
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted); font-size: var(--fs-xs);
padding: 10px 20px;
border-radius: var(--r-md);
cursor: pointer;
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
} }
.af__ghost:hover {
border-color: var(--zk-grey);
background: var(--zk-tint);
color: var(--zk-ink);
}
.af__err { .af__err {
background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger); background: var(--zk-danger-tint);
border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink); border: 1px solid var(--zk-danger-line);
border-left: 3px solid var(--zk-danger);
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
padding: 12px 16px;
font-size: var(--fs-xs);
color: var(--zk-ink);
animation: zk-rise 0.2s var(--ease) both;
}
.af__err strong {
font-family: var(--font-mono);
font-size: 0.85em;
margin-right: 8px;
padding: 1px 7px;
border-radius: var(--r-xs);
background: rgba(211, 47, 47, 0.1);
color: var(--zk-danger-ink);
}
.af__err p {
margin: 7px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-muted);
line-height: 1.55;
} }
.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; }
.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); }
.af__auto { .af__auto {
font-size: .88rem; padding: 8px 10px; border-radius: 4px; min-height: 37px; font-size: var(--fs-xs);
border: 1px dashed var(--zk-line); background: var(--zk-tint); color: var(--zk-muted); padding: 10px 12px;
display: flex; align-items: center; justify-content: space-between; gap: 8px; border-radius: var(--r-md);
min-height: 41px;
border: 1px dashed var(--zk-line);
background: var(--zk-tint);
color: var(--zk-muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
} }
.af__auto span { .af__auto span {
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase; font-size: var(--fs-3xs);
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px; font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--zk-grey);
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
padding: 2px 8px;
background: var(--zk-white);
} }
/* ---- file / ocr ---- */ /* ---- file / ocr ---- */
.ff { display: flex; flex-direction: column; gap: 6px; } .ff {
.ff__input { display: none; } display: flex;
.ff__btn { flex-direction: column;
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer; gap: 8px;
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
color: var(--zk-blue-dark); text-align: center;
} }
.ff__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; } .ff__input {
.ff__got { display: none;
font-size: .78rem; color: var(--zk-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.ff__drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
font: inherit;
padding: 16px 12px;
border-radius: var(--r-md);
cursor: pointer;
border: 1px dashed var(--zk-blue-light);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
text-align: center;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.ff__drop svg { width: 20px; height: 20px; }
.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; }
.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); }
.ff__drop:hover:not(:disabled) {
background: var(--zk-white);
border-style: solid;
border-color: var(--zk-blue);
}
.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; }
/* Uploaded: the file as a fact, not a call to action. */
.ff__file {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 9px 9px 11px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
}
.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); }
.ff__name {
flex: 1;
min-width: 0;
font-size: var(--fs-2xs);
color: var(--zk-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ff__replace {
flex: none;
font: inherit;
font-size: var(--fs-3xs);
font-weight: 500;
padding: 4px 10px;
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
background: var(--zk-white);
color: var(--zk-blue-dark);
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); }
.ff__read { .ff__read {
border-left: 2px solid var(--zk-blue); background: var(--zk-tint); border: 1px solid var(--zk-blue-light);
border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; border-left-width: 3px;
background: var(--zk-tint);
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
animation: zk-rise 0.22s var(--ease) both;
} }
.ff__readlabel { .ff__readlabel {
font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; font-size: var(--fs-3xs);
color: var(--zk-blue-dark); margin-bottom: 2px; font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-blue-dark);
margin-bottom: 3px;
} }
.ff__read div { font-size: .8rem; color: var(--zk-ink); }
.ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; } .ff__read div {
font-size: var(--fs-2xs);
color: var(--zk-ink);
}
.ff__read em {
font-style: normal;
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 6px;
font-size: var(--fs-2xs);
}
.ff__err { .ff__err {
font-size: .78rem; color: var(--zk-danger); font-size: var(--fs-2xs);
background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px; color: var(--zk-danger-ink);
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
border-radius: var(--r-md);
padding: 8px 11px;
} }
/* A field the form is waiting on. Marked on the label rather than the input so
the name is highlighted too an operator scanning a wide form is looking for
the LABEL they missed, not for a red box. */
.af__field.is-missing > span { color: var(--zk-danger-ink); }
.af__field.is-missing input,
.af__field.is-missing select,
.af__field.is-missing textarea {
border-color: var(--zk-danger-line);
background: var(--zk-danger-tint);
}
.af__field.is-missing input:focus,
.af__field.is-missing select:focus,
.af__field.is-missing textarea:focus {
border-color: var(--zk-danger);
outline-color: var(--zk-danger);
}
/* The form talking about itself, or relaying a field-level refusal. Distinct
from af__err, which is for a failure the operator cannot fix by typing. */
.af__err--soft {
background: var(--zk-danger-tint);
border: 1px solid var(--zk-danger-line);
color: var(--zk-danger-ink);
}
.af__err--soft ul { margin: 6px 0 0; padding-left: 18px; }
.af__err--soft li { margin: 2px 0; }
/* ---- form sections ---- */
.af__sec + .af__sec { margin-top: 22px; }
.af__sech {
margin: 0 0 10px;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* Uploads size to their content and never stretch to the tallest card in the
row, so a document that has been read does not pull its neighbours down. */
.af__grid--docs { align-items: start; }

View File

@ -1,8 +1,22 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
import { describeError, describeValidation } from '../api/errors.js'
import FileField from './FileField.jsx' import FileField from './FileField.jsx'
import './ActivityForm.css' import './ActivityForm.css'
/**
* The HTML input each workflow data_type maps to. `phone` and `email` were both
* falling through to plain text, which costs the keyboard on a phone and the
* browser's own validation everywhere.
*/
const INPUT_TYPES = {
number: 'number',
date: 'date',
phone: 'tel',
email: 'email',
}
/** /**
* Renders whatever /view/form-screens returns for an activity labels, types, * Renders whatever /view/form-screens returns for an activity labels, types,
* select options and which fields are mandatory and submits it straight back. * select options and which fields are mandatory and submits it straight back.
@ -11,12 +25,26 @@ import './ActivityForm.css'
* activity in Studio, redeploy, and it appears here with no code change. That * activity in Studio, redeploy, and it appears here with no code change. That
* is the point: the workflow is the source of truth, and a hardcoded form would * is the point: the workflow is the source of truth, and a hardcoded form would
* quietly drift from it. * quietly drift from it.
*
* The ONE thing filtered here is which upload slots apply to the lead's product
* line. Collect Documents serves motor and SME from a single form and the
* activity carries no `field_rules`, so without this a motor renewal is asked
* for a Udyam certificate. See DOC_SLOTS in api/config.js including why that
* table should stop existing once the rules are seeded on the activity.
*/ */
export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) { export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) {
const { client } = useZino() const { client } = useZino()
const [schema, setSchema] = useState(null) const [schema, setSchema] = useState(null)
const [values, setValues] = useState({}) const [values, setValues] = useState({})
const [error, setError] = useState(null) const [error, setError] = useState(null)
// Which required fields were empty on the last attempt. Kept separate from
// `error`, because this is the form talking about itself rather than the
// server refusing something.
const [missing, setMissing] = useState([])
// Field ids filled from the lead record rather than typed. OCR may overwrite
// these the document is more authoritative than a copy of the record but
// must never overwrite something a person typed.
const seededRef = useRef(new Set())
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
useEffect(() => { useEffect(() => {
@ -29,26 +57,191 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
// The server resolved a prefill pipeline for this activity it stamps // The server resolved a prefill pipeline for this activity it stamps
// the channel from the door and the agent from the signed-in user, so // the channel from the door and the agent from the signed-in user, so
// the form never asks for either. Seed the inputs with what it sent. // the form never asks for either. Seed the inputs with what it sent.
//
// The two sides key differently, and matching only on f.id is why this
// silently did nothing. A form field is the ACTIVITY key, which carries
// a numeric suffix because a workflow version may use one global on
// several activities partner_code_3, rm_or_agent_id_3,
// source_channel_4. The pipeline's fieldMapping names the GLOBAL
// partner_code, rm_or_agent_id, source_channel. Neither is wrong; they
// are different names for the same field, and nothing between them
// reconciles it.
//
// So: exact id first, then the field's uid, then the global name with
// the suffix stripped. Accepting all three means this keeps working
// whichever convention a pipeline is authored in, rather than breaking
// again the next time one is written the other way.
const pre = s.prefill_data || s.prefillData || s.field_defaults || {} const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
const seed = {}
if (pre && typeof pre === 'object') { if (pre && typeof pre === 'object') {
const seed = {} for (const f of s.fields) {
for (const f of s.fields) if (pre[f.id] !== undefined) seed[f.id] = pre[f.id] const base = baseFieldId(f.id ?? '')
if (Object.keys(seed).length) setValues(seed) const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
if (v !== undefined && v !== null && v !== '') seed[f.id] = v
}
} }
// THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors
// fourteen fields the record carries registration, make and model,
// previous insurer, expiry, policy number, PAN so the OCR can write
// into them. Rendered blank, they read as fourteen more things to type.
// Seed each from the lead by its base key, so an agent uploading for
// KA01MF6618 sees KA01MF6618 already there.
//
// Server prefill wins, then anything already typed. Files and generated
// ids are never seeded a file reference is not a value to copy, and an
// id_gen is the platform's to issue. Only on an existing lead: an INIT
// form has no record behind it.
const seededFromLead = new Set()
if (lead && instanceId) {
for (const f of s.fields) {
if (seed[f.id] !== undefined) continue
if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue
const v = lead[baseFieldId(f.id ?? '')]
if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') {
seed[f.id] = v
seededFromLead.add(f.id)
}
}
}
seededRef.current = seededFromLead
if (Object.keys(seed).length) setValues(seed)
}) })
.catch((e) => { if (!dead) setError(e) }) .catch((e) => { if (!dead) setError(e) })
return () => { dead = true } return () => { dead = true }
// `lead` is READ here but deliberately not a dependency. It is the parent's
// polled record object, so its identity changes on every refresh; listing it
// would re-run this effect, re-fetch the schema and call setValues(seed)
// discarding whatever the operator had typed, every few seconds, mid-form.
// The seed is a one-time starting point, not a subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, activityUid, instanceId]) }, [client, activityUid, instanceId])
function set(id, v) { setValues((p) => ({ ...p, [id]: v })) } function set(id, v) {
// Typed by hand: from here on it outranks any document.
seededRef.current.delete(id)
setValues((p) => ({ ...p, [id]: v }))
// Clear the complaint as soon as the field is filled. Leaving a red field
// marked after it has been corrected teaches people to ignore the marking.
setMissing((p) => (p.includes(id) ? p.filter((x) => x !== id) : p))
}
// Computed before the early returns below use it, and before submit: a field
// that was never offered must never be sent.
//
// If the lead's line would hide EVERY field, the filter is not removing noise
// any more it is removing the activity. Capture Motor Risk is twelve motor
// fields and is runnable from Contacted whatever the product is, so on an SME
// lead this would otherwise render a form with nothing in it and a live Submit
// button. Show the activity whole and let the operator see what it is asking.
//
// THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no
// lead yet, so `fieldApplies` had nothing to filter on and every entry form
// asked a motor renewal for a business name. But the product line IS on that
// form, three boxes up the person has already said "Motor" by the time the
// question is drawn. So visibility reads the live answer first and the saved
// lead second, and the form narrows as it is filled.
const effLead = (() => {
const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line')
const chosen = live ? values[live.id] : undefined
if (!chosen) return lead
return { ...(lead || {}), product_line: chosen }
})()
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : []
// Filtering must never hide a field the workflow requires: `submit` sends only
// what is rendered, so a hidden mandatory field becomes a 400 naming something
// that is not on screen and cannot be filled. Hiding everything is the same
// failure in the large it removes the activity rather than its noise, and
// Capture Motor Risk is twelve motor fields that stay runnable on an SME lead.
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
/**
* Turn an /ocr-extract response into form values.
*
* THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the
* ocr_config's `extraction_fields[].key` `reg_no`, `engine_cc`, `fuel`
* because that is what the vision prompt was asked to produce. The form's
* fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight
* in, as they were, every extracted value landed on a key no field renders:
* the read succeeded, the panel said so, and nothing filled.
*
* The bridge is already on the field. `ocr_config.field_mappings` maps
* extraction_key -> target_field (the GLOBAL name), and the form field is that
* global plus a per-form suffix. So: key -> target -> the field whose base id
* matches. Same suffix rule as prefill and the same trap, in a third place.
*/
function applyExtraction(ocrField, extracted) {
const maps = ocrField?.properties?.ocr_config?.field_mappings
?? ocrField?.ocr_config?.field_mappings ?? []
const all = schema?.fields ?? []
// extraction_key -> form field id
const target = {}
for (const m of maps) {
if (!m?.extraction_key || !m?.target_field) continue
const hit = all.find((f) => f.id === m.target_field)
?? all.find((f) => baseFieldId(f.id) === m.target_field)
if (hit) target[m.extraction_key] = hit.id
}
setValues((prev) => {
const next = { ...prev }
for (const [key, val] of Object.entries(extracted)) {
if (val === null || val === undefined || String(val) === '') continue
// An unmapped key may still name a field directly on forms whose
// extraction keys ARE the field names.
const id = target[key]
?? all.find((f) => f.id === key)?.id
?? all.find((f) => baseFieldId(f.id) === key)?.id
if (!id) continue
// Never overwrite something a person typed. A value we copied off the
// lead is fair game the document is the better source for it.
const isBlank = next[id] === undefined || next[id] === ''
if (isBlank || seededRef.current.has(id)) {
next[id] = val
seededRef.current.delete(id)
}
}
return next
})
}
function isEmpty(v) {
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
}
async function submit(e) { async function submit(e) {
e.preventDefault() e.preventDefault()
// Check here rather than letting the workflow do it. The server's answer is
// correct and unreadable "product_line_4(required)" and it costs a round
// trip to be told something this form already knew. id_gen is issued
// server-side and is never the operator's to fill.
const gaps = fields.filter((f) => f.mandatory && f.data_type !== 'id_gen' && isEmpty(values[f.id]))
if (gaps.length) {
setMissing(gaps.map((f) => f.id))
setError(null)
// Put the first offender on screen. On a form this wide the empty field
// is often above the fold and the message below it. Scrolling to the
// LABEL rather than focusing an input works for every field type,
// including the file and OCR widgets that render no input at all.
requestAnimationFrame(() => {
document
.querySelector('.af__field.is-missing')
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
return
}
setMissing([])
setBusy(true); setError(null) setBusy(true); setError(null)
// Send only fields the activity defines. A submission is schema-validated // Send only fields the activity defines. A submission is schema-validated
// and an unknown field is fatal, so empties are dropped rather than sent. // and an unknown field is fatal, so empties are dropped rather than sent.
const payload = {} const payload = {}
for (const f of schema.fields) { for (const f of fields) {
// id_gen is issued server-side and stripped from the submission. Sending // id_gen is issued server-side and stripped from the submission. Sending
// it would be forging a reference the platform owns. // it would be forging a reference the platform owns.
if (f.data_type === 'id_gen') continue if (f.data_type === 'id_gen') continue
@ -63,41 +256,50 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
onDone?.(res) onDone?.(res)
} catch (err) { } catch (err) {
setError(err) setError(err)
// The lead moved under the form. Nothing the operator can fix by reading
// tell the page to re-fetch so the actions on offer are the real ones.
if (describeError(err).kind === 'stale') onStale?.()
} finally { } finally {
setBusy(false) setBusy(false)
} }
} }
if (error && !schema) return <div className="af__err">Could not load the form {error.status} {error.message}</div> if (error && !schema) return <div className="af__err">Could not load the form {describeError(error).title}</div>
if (!schema) return <p className="af__loading">Loading the form</p> if (!schema) return <p className="af__loading">Loading the form</p>
return ( // Stamped fields are in `fields` they validate and they submit but they
<form className="af" onSubmit={submit}> // are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is
<div className="af__grid"> // drawn anyway: source_channel is mandatory, so a prefill that did not
{schema.fields.map((f) => { // resolve would otherwise fail validation against a box that is not on the
const opts = f.properties?.options || [] // screen and cannot be filled.
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id]))
return (
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}> /* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat
beside plain inputs, and an upload that has read a document is several
times taller than a text box so a row carried three tall OCR cards and
three short fields, and the form went ragged with the inputs stranded up
beside the buttons. Split in two, each grid is internally one height. */
const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr'
const docFields = visible.filter(isDoc)
const dataFields = visible.filter((f) => !isDoc(f))
const renderField = (f) => {
const opts = f.properties?.options || []
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (
<label
key={f.uid}
className={'af__field'
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
+ (missing.includes(f.id) ? ' is-missing' : '')}
>
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span> <span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
{f.data_type === 'file' || f.data_type === 'ocr' ? ( {f.data_type === 'file' || f.data_type === 'ocr' ? (
<FileField <FileField
field={f} value={v} instanceId={instanceId} activityUid={activityUid} field={f} value={v} instanceId={instanceId} activityUid={activityUid}
onChange={(refs) => set(f.id, refs)} onChange={(refs) => set(f.id, refs)}
onExtract={(fields) => { onExtract={(fields) => applyExtraction(f, fields)}
// The document answered questions the form was about to ask.
// Only fill what is still blank never overwrite something a
// person typed with something a model read.
setValues((prev) => {
const next = { ...prev }
for (const [k, val] of Object.entries(fields)) {
if (val === null || val === undefined || String(val) === '') continue
if (next[k] === undefined || next[k] === '') next[k] = val
}
return next
})
}}
/> />
) : f.data_type === 'id_gen' ? ( ) : f.data_type === 'id_gen' ? (
<div className="af__auto"> <div className="af__auto">
@ -123,24 +325,72 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} /> <textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
) : ( ) : (
<input <input
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'} type={INPUT_TYPES[f.data_type] ?? 'text'}
value={v} onChange={(e) => set(f.id, e.target.value)} value={v} onChange={(e) => set(f.id, e.target.value)}
/> />
)} )}
</label> </label>
) )
})} }
</div>
{error ? ( return (
<div className="af__err"> <form className="af" onSubmit={submit}>
<strong>{error.status}</strong> {error.message} {docFields.length ? (
{error.status === 403 || String(error.message).includes('permission denied') ? ( <section className="af__sec">
<p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p> <h4 className="af__sech">Documents</h4>
<div className="af__grid af__grid--docs">{docFields.map(renderField)}</div>
</section>
) : null}
{dataFields.length ? (
<section className="af__sec">
{docFields.length ? <h4 className="af__sech">Details</h4> : null}
<div className="af__grid">{dataFields.map(renderField)}</div>
</section>
) : null}
{/* The form's own complaint, before anything is sent. */}
{missing.length ? (
<div className="af__err af__err--soft" role="alert">
<strong>
{missing.length === 1
? `${fields.find((f) => f.id === missing[0])?.name ?? 'A field'} is required`
: 'Some required details are missing'}
</strong>
{missing.length > 1 ? (
<ul>
{missing.map((id) => (
<li key={id}>{fields.find((f) => f.id === id)?.name ?? id}</li>
))}
</ul>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{error ? (() => {
// A field-validation reply is rendered in the form's own words. The
// status code is dropped with it: "400" tells an operator nothing they
// can act on, and it is the first thing they read.
const bad = describeValidation(error?.message, fields)
if (bad) {
return (
<div className="af__err af__err--soft" role="alert">
<strong>{bad.title}</strong>
{bad.items.length > 1 ? (
<ul>{bad.items.map((t) => <li key={t}>{t}</li>)}</ul>
) : null}
</div>
)
}
const said = describeError(error)
return (
<div className="af__err" role="alert">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
</div>
)
})() : null}
<div className="af__actions"> <div className="af__actions">
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null} {onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
<button type="submit" className="af__submit" disabled={busy}> <button type="submit" className="af__submit" disabled={busy}>

View File

@ -0,0 +1,91 @@
/* The agent dialog. Namespaced `agc`, not `card`: the screens use `.card`
for every panel, and a bare `.card { max-height }` here was silently
capping the lead tables at 720px while their rows kept rendering. */
.agc { max-height: min(82vh, 720px); }
.agc__head {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px 20px 16px 22px;
border-bottom: 1px solid var(--zk-line-soft);
}
.agc__disc { width: 38px !important; height: 38px !important; font-size: var(--fs-xs) !important; flex: none; }
.agc__head > div { flex: 1; min-width: 0; }
.agc__head h3 { margin: 0 0 3px; font-size: var(--fs-md); font-weight: 500; color: var(--zk-ink); }
.agc__head p { margin: 0; font-size: var(--fs-2xs); line-height: 1.45; color: var(--zk-muted); }
.agc__head button {
flex: none;
display: grid;
place-items: center;
width: 28px; height: 28px;
cursor: pointer;
border: none;
border-radius: 6px;
background: transparent;
color: var(--zk-muted);
}
.agc__head button svg { width: 14px; height: 14px; }
.agc__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
.agc__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
.agc__body { overflow-y: auto; padding: 18px 22px 20px; }
.agc__body section + section { margin-top: 20px; }
.agc__body h4 {
margin: 0 0 8px;
font-size: var(--fs-3xs);
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zk-grey);
}
.agc__body p { margin: 0; font-size: var(--fs-xs); line-height: 1.55; color: var(--zk-muted); }
.agc__tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.agc__tools li {
padding: 11px 13px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
}
.agc__tools code {
display: block;
margin-bottom: 5px;
font-size: var(--fs-2xs);
font-weight: 600;
color: var(--zk-blue-dark);
}
.agc__tools span { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-muted); }
/* "No tools" is the most reassuring fact about an agent writing into an
insurance file. An empty section would read as missing data instead. */
.agc__none {
padding: 11px 13px;
border-radius: var(--r-md);
background: var(--zk-tint);
border: 1px solid var(--zk-line-soft);
}
.agc__kb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.agc__kb li {
font-size: var(--fs-2xs);
padding: 4px 11px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
.agc__foot {
margin-top: 20px !important;
padding-top: 14px;
border-top: 1px solid var(--zk-line-soft);
font-size: var(--fs-2xs) !important;
color: var(--zk-grey) !important;
}

View File

@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { AGENTS } from '../api/agents.js'
import './AgentCard.css'
/**
* What this agent is, and what it can actually reach.
*
* The question anybody asks the first time they watch an AI do a step of their
* job is not "how does it work" it is "what is it ALLOWED to touch". That
* answer is short here and it is reassuring, so it should be one click away
* rather than something a demo has to be trusted on: four of the five hold no
* tools at all and reason from the policy wordings; the only tool in the whole
* roster reads a partner registry.
*
* It is also the answer that stopped being true once, silently. Intake held a
* tool that could telephone customers, its charter never mentioned it, and it
* used it on every lead believing it was a duplicate check. Nobody could see
* that from any screen. This is the screen that would have shown it.
*/
export default function AgentCard({ agentKey, onClose }) {
const a = AGENTS[agentKey]
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)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
if (!a) return null
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg agc"
role="dialog"
aria-modal="true"
aria-label={a.full}
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="agc__head">
<span className={`who__disc who__disc--${a.tone} agc__disc`} aria-hidden="true">{a.initials}</span>
<div>
<h3>{a.full}</h3>
<p>{a.does.charAt(0).toUpperCase() + a.does.slice(1)}.</p>
</div>
<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="agc__body">
<section>
<h4>When it runs</h4>
<p>{a.wakes}</p>
</section>
<section>
<h4>What it can reach</h4>
{a.tools.length ? (
<ul className="agc__tools">
{a.tools.map((t) => (
<li key={t.name}>
<code>{t.name}</code>
<span>{t.does}</span>
</li>
))}
</ul>
) : (
/* Stated positively rather than left blank. "No tools" is the
single most reassuring fact about an agent that writes into an
insurance file, and an empty section reads as missing data. */
<p className="agc__none">
No tools. It reads the lead and the policy wordings, and writes its answer
back into the workflow nothing else.
</p>
)}
</section>
<section>
<h4>What it reads</h4>
<ul className="agc__kb">
{a.knowledge.map((k) => <li key={k}>{k}</li>)}
</ul>
</section>
<p className="agc__foot">
Every step it takes is recorded against the lead, with what it wrote and why.
It cannot confirm a premium, issue a policy or telephone anybody.
</p>
</div>
</div>
</div>
,
document.body,
)
}

View File

@ -0,0 +1,44 @@
.who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
/* A soft disc in the worker's colour: blue for the AI at large, amber for KYC,
teal for the Advisor, grey initials for a person. */
.who__disc {
position: relative; display: grid; place-items: center; flex: none;
width: 22px; height: 22px; border-radius: 50%;
font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
background: var(--zk-tint-blue); color: var(--zk-blue);
}
/* xs is the label on a chat bubble, where the disc sits beside 12.5px type. */
.who--xs { gap: 5px; }
.who--xs .who__disc { width: 16px; height: 16px; font-size: 8.5px; }
.who--xs .who__name { font-size: var(--fs-3xs); }
.who--xs .who__tag { font-size: 10px; padding: 0 4px; }
.who--md .who__disc { width: 36px; height: 36px; font-size: 13px; }
.who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }
.who__disc--blue, .who__disc--violet, .who__disc--plum { background: var(--zk-tint-blue); color: var(--zk-blue); }
.who__disc--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
.who__disc--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.who__disc--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
.who__glyph { width: 52%; height: 52%; }
.who__init { font-weight: 700; }
.who__name { font-size: var(--fs-sm); font-weight: 600; color: var(--zk-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.who__tag {
font-size: 12px; font-weight: 700; letter-spacing: .04em;
color: var(--zk-blue); background: var(--zk-tint-blue);
padding: 1px 6px; border-radius: 4px;
}
.who--btn {
font: inherit; cursor: pointer; padding: 2px 8px 2px 2px;
border: 1px solid transparent; border-radius: var(--r-pill); background: transparent;
transition: background var(--t-fast), border-color var(--t-fast);
}
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
.who--btn:hover .who__i { opacity: 1; }
.who--btn:focus-visible { outline: none; box-shadow: var(--ring); }
.who__i { width: 12px; height: 12px; flex: none; color: var(--zk-grey); opacity: 0; transition: opacity .12s; }
.who__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

View File

@ -0,0 +1,84 @@
import { AGENTS, initialsOf } from '../api/agents.js'
import './AgentChip.css'
/**
* TWO SYMBOLS, ONE CONTRAST: a sparkle for the machines, a person for the
* people.
*
* The sparkle is the settled 2026 signal for AI the mark Claude, Gemini and
* Copilot all use so an agent's disc reads as "made by AI" without a legend,
* in the agent's own colour. A person's disc carries a plain head-and-shoulders
* glyph, so a human step reads as human at the same glance. The contrast is the
* whole message: which of these did the machine do, and which did a person.
* Who exactly is said by the name beside the disc and by the colour.
*/
function SparkleGlyph() {
return (
<svg className="who__glyph" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 .8c.25 2.4.9 3.9 1.9 4.9 1 1 2.5 1.65 4.9 1.9v.8c-2.4.25-3.9.9-4.9 1.9-1 1-1.65 2.5-1.9 4.9h-.8c-.25-2.4-.9-3.9-1.9-4.9-1-1-2.5-1.65-4.9-1.9v-.8c2.4-.25 3.9-.9 4.9-1.9 1-1 1.65-2.5 1.9-4.9z" />
</svg>
)
}
/**
* Who did this as a face, not a slug.
*
* A disc with initials, coloured per agent and consistent everywhere. The
* point is recognition at a glance down a timeline: the operator should see
* that one worker made four consecutive entries without reading four names.
*
* People get a disc too, in grey. A trail where the agents are decorated and
* the humans are plain text reads as though the machines are the important
* ones, which is the wrong way round on a screen whose job is oversight.
*/
export default function AgentChip({ agentKey, name, size = 'sm', showName = true, onOpen }) {
const a = agentKey ? AGENTS[agentKey] : null
const label = a ? a.short : (name || 'System')
const tone = a ? a.tone : 'grey'
const body = (
<>
{/* THE DISC SAYS WHICH WORKER; THE SPARKLE SAYS IT IS NOT A PERSON.
Initials alone do not carry that "EN" reads as a colleague's
badge, and on a screen whose entire claim is that the work was done
autonomously, the one fact every entry must state is which entries
were. The sparkle is the settled convention for machine-generated
work across current interfaces, so it needs no legend.
Only agents get it. A trail where a human's disc also carried one
would say nothing at all. */}
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">
{/* A machine wears the sparkle; a person wears their initials. */}
{a ? <SparkleGlyph /> : <span className="who__init">{initialsOf(name)}</span>}
</span>
{showName ? <span className="who__name">{label}</span> : null}
{showName && a ? <small className="who__tag">AI</small> : null}
{a ? <span className="who__sr"> (AI)</span> : null}
</>
)
// Only an AGENT opens a card. A person's disc is a label there is nothing
// to explain about a human being that this app is entitled to show.
if (a && onOpen) {
return (
<button
type="button"
className={`who who--${size} who--btn`}
onClick={() => onOpen(agentKey)}
title={`${a.full} — what it does and what it can reach`}
>
{body}
<svg className="who__i" viewBox="0 0 14 14" aria-hidden="true">
<circle cx="7" cy="7" r="5.6" fill="none" stroke="currentColor" strokeWidth="1.2" />
<path d="M7 6.2v3.4M7 4.3v.9" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
</button>
)
}
return (
<span className={`who who--${size}`} title={a ? `${a.full}${a.does}` : name || 'System'}>
{body}
</span>
)
}

200
src/components/AskDesk.css Normal file
View File

@ -0,0 +1,200 @@
/* Ask the desk full screen.
It was a 560px side panel, which was wrong for what the desk actually
returns: a timeline of fifteen steps, a table of every stalled lead, a
paragraph naming both PAN numbers. Reading those down a gutter while the
page behind sat unused was the panel fighting the content. Full screen, one
readable column, and the conversation list alongside. */
.desk {
position: fixed; inset: 0; z-index: 60;
display: flex; flex-direction: column;
background: var(--zk-tint);
animation: desk-in .16s var(--ease);
}
@keyframes desk-in { from { opacity: .4; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .desk { animation: none; } }
/* ── Bar ─────────────────────────────────────────────────────────────────── */
.desk__bar {
flex: none; display: flex; align-items: center; gap: 16px;
padding: 12px 22px; background: var(--zk-navy); color: #fff;
}
.desk__id { min-width: 0; flex: 1; }
.desk__id h2 { margin: 0; font-size: var(--fs-lg); font-weight: 700; }
.desk__id p { margin: 2px 0 0; font-size: var(--fs-2xs); opacity: .72; }
.desk__x, .desk__rails {
flex: none; width: 34px; height: 34px; display: grid; place-items: center;
border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer;
}
.desk__x svg, .desk__rails svg { width: 18px; height: 18px; }
.desk__x:hover, .desk__rails:hover { background: rgba(255,255,255,.14); }
.desk__x:focus-visible, .desk__rails:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255,255,255,.3); }
.desk__rails { display: none; margin-left: -6px; } /* narrow screens only */
.desk__new {
flex: none; font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
padding: 7px 15px; border-radius: var(--r-pill);
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
}
.desk__new:hover { background: rgba(255,255,255,.12); }
/* ── Frame ───────────────────────────────────────────────────────────────── */
.desk__body { flex: 1; min-height: 0; display: flex; }
.desk__rail {
flex: none; width: 260px; background: #fff; border-right: 1px solid var(--zk-line);
overflow-y: auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 4px;
}
.desk__railh {
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
color: var(--zk-grey); padding: 4px 8px 8px;
}
.desk__railnone { margin: 4px 8px; font-size: var(--fs-2xs); color: var(--zk-grey); line-height: 1.5; }
.desk__sess {
display: block; width: 100%; text-align: left; cursor: pointer;
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent;
font: inherit; color: var(--zk-ink);
}
.desk__sess:hover { background: var(--zk-tint); }
.desk__sess.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.desk__sesst {
display: block; font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.desk__sessw { display: block; font-size: var(--fs-3xs); color: var(--zk-grey); margin-top: 3px; }
.desk__main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.desk__feed { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 24px 8px; }
/* One column, near 70ch, because the answers are prose and prose has a measure. */
.desk__col { max-width: 760px; margin: 0 auto; }
/* ── Turns ───────────────────────────────────────────────────────────────── */
.desk__turn { margin-bottom: 26px; }
.desk__turn--user { display: flex; justify-content: flex-end; }
.desk__q {
margin: 0; max-width: 78%; padding: 11px 16px;
border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md);
background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5;
}
.desk__a { font-size: var(--fs-sm); color: var(--zk-ink); }
.desk__a p { margin: 0 0 11px; line-height: 1.6; }
.desk__a h4 { margin: 20px 0 8px; font-size: var(--fs-md); font-weight: 700; }
.desk__a ul, .desk__a ol { margin: 0 0 12px; padding-left: 22px; }
.desk__a li { margin-bottom: 5px; line-height: 1.55; }
.desk__a blockquote {
margin: 0 0 12px; padding: 9px 15px; border-left: 3px solid var(--zk-amber-bar);
background: var(--zk-amber-tint); border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.desk__a code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em;
background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
}
.desk__tablewrap { overflow-x: auto; margin: 0 0 14px; }
.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); }
.desk__a th {
text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap;
padding: 8px 14px 8px 0; border-bottom: 1px solid var(--zk-line);
}
.desk__a td { padding: 8px 14px 8px 0; border-bottom: 1px solid var(--zk-line-soft); }
.desk__a th:last-child, .desk__a td:last-child { padding-right: 0; text-align: right; font-variant-numeric: tabular-nums; }
.desk__a tr:last-child td { border-bottom: 0; }
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.desk__tools span {
font-size: var(--fs-3xs); color: var(--zk-muted);
background: var(--zk-line-soft); padding: 2px 9px; border-radius: var(--r-pill);
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.desk__empty { padding: 32px 0 8px; }
.desk__empty h3 { margin: 0 0 6px; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
.desk__empty > p { margin: 0 0 20px; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 60ch; }
.desk__sugg { display: flex; flex-direction: column; gap: 9px; align-items: flex-start; }
.desk__sugg button {
font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer;
padding: 10px 16px; border-radius: var(--r-sm);
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
}
.desk__sugg button:hover:not(:disabled) { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-navy); }
.desk__sugg button:disabled { opacity: .5; cursor: default; }
.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); }
.desk__dots { display: inline-flex; gap: 4px; }
.desk__dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-blue); animation: desk-blink 1.2s infinite; }
.desk__dots i:nth-child(2) { animation-delay: .2s; }
.desk__dots i:nth-child(3) { animation-delay: .4s; }
@keyframes desk-blink { 0%,60%,100% { opacity: .25; } 30% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } }
.desk__err {
border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint);
border-radius: var(--r-md); padding: 12px 15px; display: flex; flex-direction: column; gap: 3px;
}
.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); }
.desk__err span { font-size: var(--fs-2xs); color: var(--zk-muted); }
/* ── Ask ─────────────────────────────────────────────────────────────────── */
.desk__ask { flex: none; border-top: 1px solid var(--zk-line); background: #fff; padding: 14px 24px 18px; }
.desk__askcol { display: flex; gap: 12px; align-items: flex-end; }
.desk__ask textarea {
flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink);
padding: 12px 14px; border: 1px solid var(--zk-line); border-radius: var(--r-sm);
background: #fff; min-height: 46px; max-height: 160px;
}
.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); }
/* ── Chart drawn from the answer's own table ─────────────────────────────── */
.dchart {
margin: 0 0 14px; padding: 15px 18px;
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
}
.dchart__head {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
font-size: var(--fs-3xs); color: var(--zk-grey); margin-bottom: 14px;
}
.dchart__toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--zk-line); border-radius: var(--r-pill); overflow: hidden; }
.dchart__toggle button {
font: inherit; font-size: var(--fs-3xs); font-weight: 600; cursor: pointer;
padding: 4px 12px; border: 0; background: #fff; color: var(--zk-muted);
}
.dchart__toggle button.is-on { background: var(--zk-navy); color: #fff; }
.dchart__bars { display: flex; flex-direction: column; gap: 8px; }
.dchart__row { display: grid; grid-template-columns: minmax(96px, 30%) 1fr auto; gap: 12px; align-items: center; }
.dchart__lab { font-size: var(--fs-2xs); color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dchart__track { height: 16px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
.dchart__fill { display: block; height: 100%; border-radius: 3px; }
.dchart__val { font-size: var(--fs-2xs); font-variant-numeric: tabular-nums; color: var(--zk-muted); white-space: nowrap; }
.dchart__cap { margin: 10px 0 0; font-size: var(--fs-3xs); color: var(--zk-grey); }
.dchart__donut { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
.dchart__donut svg { width: 160px; height: 160px; flex: none; }
.dchart__total { font-size: 21px; font-weight: 700; fill: var(--zk-ink); font-variant-numeric: tabular-nums; }
.dchart__totlab { font-size: 10px; fill: var(--zk-grey); letter-spacing: .06em; text-transform: uppercase; }
.dchart__key { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
.dchart__key li { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 10px; align-items: center; font-size: var(--fs-2xs); }
.dchart__sw { width: 10px; height: 10px; border-radius: 2px; }
.dchart__keylab { color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dchart__keyval { color: var(--zk-muted); font-variant-numeric: tabular-nums; }
.dchart__keypct { color: var(--zk-grey); font-variant-numeric: tabular-nums; min-width: 34px; text-align: right; }
/* ── Narrow ──────────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.desk__rails { display: grid; }
.desk__id p { display: none; }
.desk__new { font-size: 0; padding: 8px; }
.desk__new::before { content: '+'; font-size: 17px; font-weight: 700; }
.desk__rail {
position: absolute; top: 58px; bottom: 0; left: 0; z-index: 2;
transform: translateX(-100%); transition: transform .18s var(--ease);
box-shadow: 8px 0 28px rgba(11,42,91,.14);
}
.desk__rail.is-open { transform: none; }
.desk__body { position: relative; }
.desk__feed { padding: 20px 16px 8px; }
.desk__ask { padding: 12px 16px 16px; }
.dchart__row { grid-template-columns: minmax(72px, 34%) 1fr auto; }
}
@media (prefers-reduced-motion: reduce) { .desk__rail { transition: none; } }

344
src/components/AskDesk.jsx Normal file
View File

@ -0,0 +1,344 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SUPPORT_AGENT_ID } from '../api/config.js'
import { useZino } from '../api/provider.jsx'
import { describeError } from '../api/errors.js'
import DeskChart, { chartableFrom } from './DeskChart.jsx'
import './AskDesk.css'
/**
* Ask the desk the support desk, full screen.
*
* A support desk on the platform, bound to this app, that reads the deployed
* config and the live book through read-only tools and answers with evidence.
* A different kind of thing from the five AI employees: they DO the work and
* write to the workflow; this one only reads, to answer a person's question.
* Nothing it says changes a lead.
*
* Full screen rather than a side panel, because the answers are long a
* timeline, a table of every stalled lead and reading them in a 560px gutter
* meant scrolling a column while the page behind it sat unused. The
* conversation list comes with it: an investigation is worth returning to, and
* every conversation opens a ticket on the platform whether or not anyone
* reads it back.
*/
const SUGGESTIONS = [
'How many leads are in each stage right now?',
'Which leads have not moved in over 12 hours?',
'What reason did the AI give for each underwriting referral?',
'Total written premium, and how many policies were issued?',
]
/* The desk answers in markdown. This renders the five things it uses:
headings, lists, tables, bold and inline code. Anything else falls through
as text, which is the right failure an unrendered asterisk is legible; a
crashed panel is not. */
function inline(text, key) {
return String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((p, i) => {
if (p.startsWith('**') && p.endsWith('**')) return <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong>
if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code>
return p
})
}
const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
const isRow = (line) => /^\s*\|.*\|\s*$/.test(line)
const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line)
/** Parses one answer into blocks, so a table can be drawn as well as printed. */
function parse(text) {
const lines = String(text || '').split('\n')
const out = []
let list = null
let rows = null
const flushList = () => {
if (!list) return
out.push({ t: list.ordered ? 'ol' : 'ul', items: list.items })
list = null
}
const flushTable = () => {
if (!rows) return
out.push({ t: 'table', head: rows[0], body: rows.slice(1) })
rows = null
}
const flush = () => { flushTable(); flushList() }
lines.forEach((raw) => {
const line = raw.trimEnd()
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
const heading = line.match(/^#{1,4}\s+(.*)$/)
const quote = line.match(/^\s*>\s?(.*)$/)
if (isRow(line)) {
if (!isRule(line)) { flushList(); if (!rows) rows = []; rows.push(cells(line)) }
} else if (rows && line.trim() === '') {
/* hold the table open across a blank line */
} else if (bullet) {
flushTable()
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } }
list.items.push(bullet[1])
} else if (numbered) {
flushTable()
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] } }
list.items.push(numbered[1])
} else if (heading) {
flush(); out.push({ t: 'h', text: heading[1] })
} else if (quote) {
flush(); out.push({ t: 'quote', text: quote[1] })
} else if (line.trim() === '') {
flush()
} else {
flush(); out.push({ t: 'p', text: line })
}
})
flush()
return out
}
function Answer({ text }) {
const blocks = useMemo(() => parse(text), [text])
// Chart type is per-table and remembered while the answer is on screen.
const [kinds, setKinds] = useState({})
return (
<>
{blocks.map((b, i) => {
if (b.t === 'h') return <h4 key={i}>{inline(b.text, i)}</h4>
if (b.t === 'p') return <p key={i}>{inline(b.text, i)}</p>
if (b.t === 'quote') return <blockquote key={i}>{inline(b.text, i)}</blockquote>
if (b.t === 'ul') return <ul key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ul>
if (b.t === 'ol') return <ol key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ol>
if (b.t === 'table') {
const chart = chartableFrom(b.head, b.body)
return (
<div key={i}>
{chart ? (
<DeskChart
data={chart}
kind={kinds[i] || 'bar'}
onKind={(k) => setKinds((p) => ({ ...p, [i]: k }))}
/>
) : null}
<div className="desk__tablewrap">
<table>
<thead><tr>{b.head.map((c, j) => <th key={j}>{inline(c, `h${j}`)}</th>)}</tr></thead>
<tbody>
{b.body.map((r, ri) => (
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
return null
})}
</>
)
}
/** The tools a turn ran — the desk's working, not just its answer. */
function toolsOf(row) {
const raw = row?.metadata?.tool_calls ?? row?.metadata?.api_calls ?? row?.metadata?.tools
return (Array.isArray(raw) ? raw : [])
.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
}
const when = (iso) => {
const d = new Date(iso)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return mins + 'm ago'
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
}
export default function AskDesk({ onClose }) {
const { client } = useZino()
const [sessions, setSessions] = useState(null)
const [sessionId, setSessionId] = useState(null)
const [rows, setRows] = useState([])
const [text, setText] = useState('')
const [asking, setAsking] = useState(false)
const [err, setErr] = useState(null)
const [railOpen, setRailOpen] = useState(false)
const feedRef = useRef(null)
const inputRef = useRef(null)
const loadSessions = useCallback(() => {
client.deskSessions(SUPPORT_AGENT_ID)
.then((r) => setSessions(Array.isArray(r) ? r : (r?.data ?? [])))
.catch(() => setSessions([]))
}, [client])
useEffect(() => { loadSessions() }, [loadSessions])
// Escape closes, because a full-screen panel with no way out by keyboard is
// a trap for anyone who opened it by accident.
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape' && !asking) onClose?.() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose, asking])
useEffect(() => {
const el = feedRef.current
if (el) el.scrollTop = el.scrollHeight
}, [rows, asking])
const openSession = useCallback((id) => {
setSessionId(id); setRows([]); setErr(null); setRailOpen(false)
client.deskMessages(id)
.then((r) => {
const msgs = Array.isArray(r) ? r : (r?.data ?? [])
setRows(msgs
.filter((m) => m.role === 'user' || m.role === 'agent')
.map((m) => ({ key: 'm' + m.id, role: m.role === 'user' ? 'user' : 'agent', content: m.content, tools: [] })))
})
.catch(setErr)
}, [client])
const newSession = useCallback(async () => {
setErr(null); setRows([]); setSessionId(null); setRailOpen(false)
try {
const s = await client.deskStartSession(SUPPORT_AGENT_ID)
setSessionId(s?.id ?? s?.data?.id ?? null)
inputRef.current?.focus()
} catch (e) { setErr(e) }
}, [client])
// One conversation is opened on arrival, so the first question needs no setup.
useEffect(() => { newSession() }, [newSession])
const ask = useCallback(async (question) => {
const q = String(question ?? '').trim()
if (!q || !sessionId || asking) return
setErr(null); setText('')
setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }])
setAsking(true)
try {
const res = await client.deskAsk(sessionId, q)
const answer = res?.agent_message
setRows((prev) => [...prev, {
key: 'a' + (answer?.id ?? Date.now()),
role: 'agent',
content: answer?.content ?? 'The desk answered with nothing at all.',
tools: toolsOf(res?.call_api_message),
}])
loadSessions()
} catch (e) { setErr(e) } finally {
setAsking(false); inputRef.current?.focus()
}
}, [client, sessionId, asking, loadSessions])
return (
<div className="desk" role="dialog" aria-label="Ask the desk" aria-modal="true">
<header className="desk__bar">
<button
type="button" className="desk__rails" aria-label="Conversations"
aria-expanded={railOpen} onClick={() => setRailOpen((v) => !v)}
>
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 4.5h12M3 9h12M3 13.5h8" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
</button>
<div className="desk__id">
<h2>Ask the desk</h2>
<p>Reads the book and answers with evidence. It cannot change anything.</p>
</div>
<button type="button" className="desk__new" onClick={newSession}>New question</button>
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M4.5 4.5l9 9M13.5 4.5l-9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
</button>
</header>
<div className="desk__body">
<aside className={'desk__rail' + (railOpen ? ' is-open' : '')} aria-label="Past conversations">
<span className="desk__railh">Conversations</span>
{sessions === null ? <p className="desk__railnone">Loading</p>
: sessions.length === 0 ? <p className="desk__railnone">Nothing yet. Every question you ask is kept here.</p>
: sessions.map((s) => (
<button
key={s.id} type="button"
className={'desk__sess' + (s.id === sessionId ? ' is-on' : '')}
onClick={() => openSession(s.id)}
>
<span className="desk__sesst">{s.title || 'Untitled question'}</span>
<span className="desk__sessw">{when(s.created_at)}</span>
</button>
))}
</aside>
<main className="desk__main">
<div className="desk__feed" ref={feedRef}>
<div className="desk__col">
{rows.length === 0 && !asking ? (
<div className="desk__empty">
<h3>What would you like to know?</h3>
<p>Ask about a lead, a queue, or the book as a whole. Anything countable comes back as a table and a chart.</p>
<div className="desk__sugg">
{SUGGESTIONS.map((s) => (
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
))}
</div>
</div>
) : null}
{rows.map((r) => (
<div key={r.key} className={'desk__turn desk__turn--' + r.role}>
{r.role === 'user'
? <p className="desk__q">{r.content}</p>
: (
<div className="desk__a">
<Answer text={r.content} />
{r.tools?.length ? (
<div className="desk__tools">
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
</div>
) : null}
</div>
)}
</div>
))}
{asking ? (
<div className="desk__turn desk__turn--agent">
<div className="desk__wait">
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
Reading the book this takes half a minute.
</div>
</div>
) : null}
{err ? (
<div className="desk__err" role="alert">
<strong>{describeError(err).title}</strong>
<span>{err?.message}</span>
</div>
) : null}
</div>
</div>
<form className="desk__ask" onSubmit={(e) => { e.preventDefault(); ask(text) }}>
<div className="desk__col desk__askcol">
<textarea
ref={inputRef} value={text} rows={1}
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'}
disabled={!sessionId || asking}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Enter sends; Shift+Enter is a new line. A question is
// usually one line, and reaching for a button every time is
// friction on the thing this panel exists for.
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
}}
/>
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>Ask</button>
</div>
</form>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,118 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import './Conversation.css'
/**
* The call, turn by turn.
*
* The voice agent hands the workflow a full transcript when a call ends, and
* until now Engage read it, wrote three sentences of summary, and the words
* themselves were dropped. So a customer said "I think it's 12,000 rupees" and
* "lower premium", and the file held one AI's paraphrase of that.
*
* It matters more here than it would elsewhere: `consent_artefact =
* verbal_call` is recorded from what the agent HEARD, and it is the artefact
* justifying every later contact. A colleague reviewing that months later
* should see the customer's own words.
*
* Rendered like the WhatsApp thread on purpose agent right, caller left
* because it is the same kind of thing and should not need learning twice.
*/
function turnsFrom(text) {
if (!text || typeof text !== 'string') return []
const out = []
// "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
// Split on the labels rather than on newlines: a single turn wraps, and
// splitting per line would shatter one sentence into four bubbles.
const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
for (const raw of text.split('\n')) {
const line = raw.replace(/\s+$/, '')
if (!line.trim()) continue
const m = line.match(re)
if (m) {
const who = m[1].toLowerCase()
out.push({
side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
text: line.slice(m[0].length).trim(),
})
} else if (out.length) {
out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
}
}
return out.filter((t) => t.text)
}
export default function CallTranscript({ text, audio, name, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
const turns = turnsFrom(text)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg conv"
role="dialog"
aria-modal="true"
aria-label="Call transcript"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<h3>The call with {name || 'the customer'}</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>
{audio ? (
<div className="conv__audio">
{/* Listen along with the transcript. preload=none so opening the
dialog does not pull a multi-MB WAV until the user hits play. */}
<audio controls preload="none" src={audio}>
Your browser cannot play this recording.
</audio>
</div>
) : null}
<div className="conv__body">
{turns.length ? turns.map((t, i) => (
<div key={i} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span>{t.side === 'us' ? 'Meera' : (name || 'the customer')}</span>
</div>
)) : (
/* A transcript that will not parse is still evidence, so it is
shown raw rather than replaced with "nothing to display". */
<pre className="conv__raw">{text}</pre>
)}
</div>
<p className="conv__note">
Recorded by the voice agent as the call happened. This is what was said
the call notes in the trail are Engage&apos;s reading of it, which is a
different thing.
</p>
</div>
</div>
,
document.body,
)
}

View File

@ -0,0 +1,32 @@
.cot { margin-top: 8px; }
.cot__finding { margin: 0 0 8px; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); max-width: 64ch; }
/* The handle is a link, in the row of actions under the entry. */
.cot__toggle {
display: inline-flex; align-items: center; gap: 6px; padding: 0;
border: 0; background: none; cursor: pointer;
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
}
.cot__toggle:hover { text-decoration: underline; }
.cot__toggle:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 4px; }
.cot__brain { display: none; }
.cot__ct { font-weight: 600; }
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); opacity: .7; }
.cot__caret.is-open { transform: rotate(90deg); }
/* The reasoning, as a flat card with a stepped rail. */
.cot__steps {
list-style: none; margin: 10px 0 0; padding: 14px 18px 10px;
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
}
.cot__step { position: relative; display: grid; grid-template-columns: 84px 1fr; gap: 12px; padding: 8px 0 8px 16px; }
.cot__step::before {
content: ''; position: absolute; left: 2px; top: 11px; width: 9px; height: 9px;
border-radius: 50%; background: #fff; border: 2px solid var(--zk-blue); z-index: 1;
}
.cot__step:not(:last-child)::after { content: ''; position: absolute; left: 5.5px; top: 20px; bottom: -6px; width: 1.5px; background: var(--zk-blue-light); }
.cot__mark { font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-blue); padding-top: 2px; }
.cot__b { font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-muted); overflow-wrap: anywhere; }
.cot__b b { color: var(--zk-ink); font-weight: 600; }
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cot__conf { font-size: 12px; font-weight: 700; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 10px; border-radius: var(--r-pill); }

View File

@ -0,0 +1,87 @@
import { useState } from 'react'
import './ChainOfThought.css'
/**
* How an AI employee reached a decision, step by step.
*
* This is the trust surface. A person signing off on or overriding a
* machine's decision needs the answer to one question first: WHY did it do
* that. Before this, the reasoning existed (the employees write it as workflow
* fields, so it is already in the audit trail) but the console showed only the
* conclusion. An operator either trusted it blind or went digging. Neither is
* oversight.
*
* The chain reads the way the employee's own record reads: what it REASONED and
* what it DECIDED. It is assembled from real fields already on the step the
* employee's own words for the reasoning, the activity it committed and the
* state it moved the lead to for the decision never narrated after the fact.
* A step may carry more than one reasoning field (attribution AND eligibility,
* say); each is its own rung.
*
* The finding the first sentence, since the employees write the conclusion
* first is lifted out and stays visible, so the "why" can be read without
* opening the chain.
*/
function findingOf(text) {
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
return m ? m[1].trim() : null
}
export default function ChainOfThought({ reasoning, decided, confidence }) {
const [open, setOpen] = useState(false)
const primary = reasoning.length ? reasoning[0][1] : ''
const finding = findingOf(primary)
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
return (
<div className="cot">
{finding ? <p className="cot__finding">{finding}</p> : null}
<button
type="button"
className="cot__toggle"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
See reasoning
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<ol className="cot__steps">
{reasoning.map(([label, text], i) => (
<li className="cot__step" key={label}>
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
<div className="cot__b">{text}</div>
</li>
))}
<li className="cot__step">
<span className="cot__mark">Decided</span>
<div className="cot__b">
<b>{decided.what}</b>
{decided.stage ? <> moved to <b>{decided.stage}</b></> : null}
{confidence != null ? (
<div className="cot__foot">
<span className="cot__conf">
{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
</span>
</div>
) : null}
</div>
</li>
</ol>
) : null}
</div>
)
}

View File

@ -0,0 +1,168 @@
.clamp {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.clamp__text {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.6;
color: var(--zk-ink);
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--clamp-lines, 3);
line-clamp: var(--clamp-lines, 3);
overflow: hidden;
}
.clamp__text--short {
display: block;
overflow: visible;
}
.clamp__more {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 0;
border: 0;
background: none;
color: var(--zk-blue);
cursor: pointer;
transition: color var(--t-fast);
}
.clamp__more:hover {
color: var(--zk-blue-dark);
}
.clamp__more svg {
width: 11px;
height: 11px;
transition: transform var(--t);
}
/* 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. */
.clamp__lead {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
color: var(--zk-ink);
font-weight: 500;
}
.clamp__lead + .clamp__text { margin-top: 8px; }
/* The finding, lifted out of an AI paragraph. Heavier than the body and never
clamped it is one sentence by construction, and the whole point is that it
can be read without opening anything. */
.clamp__lead {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
font-weight: 500;
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: var(--fs-sm);
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: var(--fs-xs);
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: var(--fs-sm) !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

@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import './ClampText.css'
/**
* Splits a block of AI prose into a finding and its working.
*
* The employees write the conclusion first and the evidence after it
* "POSP-77341 is active and empanelled for motor." then four sentences of
* registry detail. So the first sentence already IS the summary, and lifting it
* out gives one without inventing text or asking the model for a second field
* it would have to be trusted to keep in step with the first.
*
* Returns null when the split would be useless: no sentence terminator, a first
* sentence so long it is the paragraph again, or one so short it is a fragment
* rather than a finding. Those fall back to plain folding.
*/
function splitLead(value) {
const m = value.match(/^(.{40,220}?[.!?])(\s+)(\S[\s\S]*)$/)
if (!m) return null
return { lead: m[1].trim(), rest: m[3].trim() }
}
/**
* 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 createPortal(
<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>,
document.body,
)
}
/**
* Prose, reduced to its point.
*
* The AI employees write at length a recommendation rationale runs past 1,500
* 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, title, lines = 3, threshold = 150 }) {
const [open, setOpen] = useState(false)
const value = String(text)
if (value.length <= threshold) return <p className="clamp__text clamp__text--short">{value}</p>
const split = splitLead(value)
return (
<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="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>
)
}

View File

@ -0,0 +1,152 @@
.conv { max-height: min(80vh, 760px); }
.conv__body {
/* The offset parent for the scroll-to-an-exchange in Conversation.jsx. */
position: relative;
overflow-y: auto;
padding: 18px 22px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.conv__empty {
margin: 0;
padding: 24px 0;
text-align: center;
font-size: var(--fs-xs);
color: var(--zk-grey);
}
/* Theirs left, ours right: the arrangement everybody reads without being told,
which is the whole reason to render a thread rather than a log. */
.bub {
max-width: 78%;
padding: 9px 13px 7px;
border-radius: 14px;
}
.bub p {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Direct children only, and not the attribution row: the chip in there brings
its own spans, and CallTranscript still labels its turns with a plain one. */
.bub > span:not(.bub__by) {
display: block;
margin-top: 4px;
font-size: var(--fs-2xs);
opacity: 0.65;
}
/* Who said it, and whether they are a person. */
.bub__by {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.bub__by time {
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
/* The disc's tint and the bubble's tint are the same token, so a chip dropped
into a bubble loses its disc entirely. White puts it back. */
.bub .who__disc { background: #fff; }
.bub--them {
align-self: flex-start;
background: var(--zk-line-soft);
color: var(--zk-ink);
border-bottom-left-radius: 4px;
}
.bub--us {
align-self: flex-end;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
border-bottom-right-radius: 4px;
}
.conv__note {
margin: 0;
padding: 12px 22px 18px;
border-top: 1px solid var(--zk-line-soft);
font-size: var(--fs-2xs);
line-height: 1.5;
color: var(--zk-grey);
}
/* A transcript the parser could not split into turns is still evidence.
Shown raw rather than replaced with "nothing to display". */
.conv__raw {
margin: 0;
padding: 14px 16px;
font: inherit;
font-size: var(--fs-2xs);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--zk-muted);
background: var(--zk-tint);
border-radius: var(--r-md);
}
/* The call recording, played from inside the transcript dialog. */
.conv__audio {
padding: 12px 20px 4px;
border-bottom: 1px solid var(--zk-line-soft);
}
.conv__audio audio {
width: 100%;
height: 38px;
}
/* "7 messages · 2 exchanges" — the size of the thread, on the header. */
.conv__sub {
margin: 2px 0 0;
font-size: var(--fs-2xs);
color: var(--zk-grey);
}
/* One burst of messages. The bubbles take their left/right alignment from the
flex column they sit in, so an episode has to be one too. */
.conv__ep {
display: flex;
flex-direction: column;
gap: 10px;
border-radius: var(--r-md);
}
/* Opened from the trail, the exchange you arrived from is marked and then
the mark fades, because a permanent tint on one part of a thread reads as a
state something is in rather than as "you came from here". */
.conv__ep.is-anchor { animation: conv-found 2.4s var(--ease) forwards; }
@keyframes conv-found {
0%, 45% { box-shadow: 0 0 0 6px var(--zk-teal-tint); background: var(--zk-teal-tint); }
100% { box-shadow: 0 0 0 6px transparent; background: transparent; }
}
/* THE PAUSE, ACCOUNTED FOR. Two bursts twenty minutes and four workflow steps
apart read as one continuous exchange without this so a reply about the
premium appears to answer the payment link that followed it. */
.conv__gap {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 2px;
font-size: var(--fs-3xs);
color: var(--zk-grey);
}
.conv__gap::before,
.conv__gap::after {
content: '';
flex: 1;
height: 1px;
background: var(--zk-line-soft);
}
.conv__gap span { flex: none; }

View File

@ -0,0 +1,173 @@
import { Fragment, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import AgentChip from './AgentChip.jsx'
import { buildThread, listOf } from '../api/thread.js'
import './Conversation.css'
/**
* The WhatsApp thread, whole, scrollable, in the order it was said.
*
* The trail's job is what happened in what order; this is the other question
* what was actually said and it is one conversation even though the workflow
* touches it from several places. Theirs on the left, ours on the right, oldest
* first, newest in view when it opens.
*
* THE THREAD IS BUILT ONCE, in api/thread.js, and shared with the timeline
* entry that opens it. It used to be reassembled here from the raw fields,
* which is how the two came to disagree about how many messages there were.
*
* WHAT THE GAPS ARE. The conversation runs in bursts: the customer argues about
* the premium, then KYC and the underwriting screen run, then the payment
* request reopens the same thread twenty minutes later. Read as one flat list
* those bursts run together and the reply to a quote appears to answer the
* payment link. So the workflow steps that happened in between are named on the
* divider, and the thread reads as what it is one conversation with pauses in
* it that somebody can account for.
*
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
* than left for someone to discover. The console can only show what the
* workflow RECORDS, so anything sent from inside a trigger node and never
* written to a field is invisible here however certainly the customer received
* it. Both payment-path messages used to be exactly that (98, 99); 100 writes
* them to `customer_answer` and they appear. One send remains absent: the
* quote, which is a registered WhatsApp template rather than text we compose.
*
* A thread that quietly omitted any of this would be worse than one that says
* which parts it holds.
*/
function at(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
/** How long the thread was quiet, and what the workflow did meanwhile. */
function gapOf(prev, ep) {
const a = Date.parse(prev.to)
const b = Date.parse(ep.from)
const mins = isNaN(a) || isNaN(b) ? null : Math.round((b - a) / 60000)
const howLong = mins == null || mins < 1 ? 'Moments later'
: mins === 1 ? 'A minute later'
: mins < 60 ? `${mins} minutes later`
: mins < 120 ? 'An hour later'
: mins < 1440 ? `${Math.round(mins / 60)} hours later`
: mins < 2880 ? 'The next day'
: `${Math.round(mins / 1440)} days later`
return ep.after.length ? `${howLong} — after ${listOf(ep.after)}` : howLong
}
export default function Conversation({ rows, name, anchor, onClose }) {
const ref = useRef(null)
const bodyRef = useRef(null)
const restore = useRef(null)
const { turns, episodes } = buildThread(rows)
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
/**
* Opened from an exchange in the trail, it opens AT that exchange; opened
* from the lead header, it opens at the newest message, which is where a
* chat is read from.
*
* scrollTop rather than scrollIntoView: this element is inside a dialog over
* a frozen page, and scrollIntoView walks every scrollable ancestor to get
* there. `.conv__body` is the offset parent (see the CSS), so the sum is
* exactly the distance wanted.
*/
useEffect(() => {
const body = bodyRef.current
if (!body) return
const target = anchor ? body.querySelector(`[data-ep="${anchor}"]`) : null
body.scrollTop = target ? Math.max(0, target.offsetTop - 12) : body.scrollHeight
}, [anchor])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg conv"
role="dialog"
aria-modal="true"
aria-label="WhatsApp conversation"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<div>
<h3>WhatsApp with {name || 'the customer'}</h3>
{turns.length ? (
<p className="conv__sub">
{turns.length} message{turns.length === 1 ? '' : 's'}
{episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''}
</p>
) : null}
</div>
<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="conv__body" ref={bodyRef}>
{episodes.length ? episodes.map((ep, i) => (
<Fragment key={ep.id}>
{i > 0 ? (
<div className="conv__gap">
<span>{gapOf(episodes[i - 1], ep)}</span>
</div>
) : null}
<div
className={'conv__ep' + (anchor === ep.id ? ' is-anchor' : '')}
data-ep={ep.id}
>
{ep.turns.map((t) => (
<div key={t.key} className={'bub bub--' + t.side}>
<p>{t.text}</p>
<span className="bub__by">
{/* Named AND marked, both directions. It stamped only the
time, so a thread read back months later could not say
which of our replies a person wrote and which Engage
did and a bare "Engage" reads as a colleague. The
sparkle disc and the AI tag are how the rest of this
console says "machine"; a person keeps their initials. */}
{t.side === 'them'
? <AgentChip name={name || 'the customer'} size="xs" />
: <AgentChip agentKey={t.agentKey} name={t.by} size="xs" />}
<time dateTime={t.at}>{at(t.at)}</time>
</span>
</div>
))}
</div>
</Fragment>
)) : (
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
)}
</div>
<p className="conv__note">
Shows the customer&apos;s messages and everything written back to them,
including the acceptance confirmation, the payment request and the
issuance note. One automatic send is not here: the quote, which goes
out as a registered WhatsApp template rather than text we compose.
</p>
</div>
</div>
,
document.body,
)
}

View File

@ -0,0 +1,136 @@
/**
* A chart drawn from the desk's own answer.
*
* The platform has no chart widget and the desk holds no widget tools its
* fourteen tools are plain API reads, so everything it says comes back as text.
* What it DOES do, reliably, when asked anything countable, is answer with a
* markdown table. So the chart here is not a second source: it is that table,
* drawn. Every bar can be checked against the row beside it.
*
* Inline SVG rather than a charting library: two shapes, one scale, and a
* library would be more bytes than the console's whole chart vocabulary.
*/
/** A cell like "₹3.96 L", "69%", "1,204" or "12" as a number, or null. */
function toNumber(raw) {
const s = String(raw ?? '').trim()
if (!s) return null
// Indian short forms carry their own multiplier and would otherwise sort as 3.96 < 12.
const cr = /([\d.,]+)\s*Cr/i.exec(s)
if (cr) return parseFloat(cr[1].replace(/,/g, '')) * 1e7
const lakh = /([\d.,]+)\s*L\b/i.exec(s)
if (lakh) return parseFloat(lakh[1].replace(/,/g, '')) * 1e5
const plain = s.replace(/[₹$,%\s]/g, '').replace(/,/g, '')
const n = Number(plain)
return Number.isFinite(n) ? n : null
}
/**
* Can this table be drawn? It needs a label column, one numeric column, and at
* least two rows one bar is a fact, not a chart.
*/
/* A totals row is the sum of the others, so charting it draws one bar as long
as all the rest put together, or a slice that swallows half the ring. The
desk adds one to most count tables. */
const isTotalRow = (label) => /^\**\s*(total|sum|overall|all)\b/i.test(String(label ?? '').trim())
export function chartableFrom(head, allRows) {
const rows = allRows.filter((r) => !isTotalRow(r[0]))
if (!head?.length || rows.length < 2) return null
// The rightmost column that parses as a number on most rows is the measure.
for (let c = head.length - 1; c >= 1; c--) {
const vals = rows.map((r) => toNumber(r[c]))
const good = vals.filter((v) => v !== null && v >= 0)
if (good.length >= Math.max(2, Math.ceil(rows.length * 0.8))) {
const points = rows
.map((r, i) => ({ label: String(r[0] ?? '').trim(), value: vals[i], raw: String(r[c] ?? '') }))
.filter((p) => p.label && p.value !== null)
if (points.length < 2) return null
if (points.every((p) => p.value === 0)) return null
return { measure: head[c], points }
}
}
return null
}
/* The console's colour rule has meaning attached to it, so a chart of arbitrary
categories cannot borrow it. This is a neutral sequence in the same family
ordered, so the biggest slice is always the deepest. */
const SERIES = ['#1F55A6', '#4E7FC4', '#7CA0D4', '#137A66', '#4FA08D', '#A86A0B', '#D99A2B', '#7A8699']
function Bars({ points, measure }) {
const max = Math.max(...points.map((p) => p.value))
return (
<div className="dchart__bars">
{points.map((p, i) => (
<div className="dchart__row" key={p.label + i}>
<span className="dchart__lab" title={p.label}>{p.label}</span>
<span className="dchart__track">
<span className="dchart__fill" style={{ width: Math.max(1.5, (p.value / max) * 100) + '%', background: SERIES[i % SERIES.length] }} />
</span>
<span className="dchart__val">{p.raw}</span>
</div>
))}
<p className="dchart__cap">{measure}</p>
</div>
)
}
function Donut({ points, measure }) {
const total = points.reduce((t, p) => t + p.value, 0)
if (!total) return <Bars points={points} measure={measure} />
const R = 62, C = 2 * Math.PI * R
let offset = 0
return (
<div className="dchart__donut">
<svg viewBox="0 0 160 160" role="img" aria-label={`${measure} by share`}>
<g transform="rotate(-90 80 80)">
{points.map((p, i) => {
const frac = p.value / total
const dash = `${frac * C} ${C - frac * C}`
const el = (
<circle
key={p.label + i} cx="80" cy="80" r={R} fill="none"
stroke={SERIES[i % SERIES.length]} strokeWidth="26"
strokeDasharray={dash} strokeDashoffset={-offset}
/>
)
offset += frac * C
return el
})}
</g>
<text x="80" y="76" textAnchor="middle" className="dchart__total">{total.toLocaleString('en-IN')}</text>
<text x="80" y="94" textAnchor="middle" className="dchart__totlab">total</text>
</svg>
<ul className="dchart__key">
{points.map((p, i) => (
<li key={p.label + i}>
<span className="dchart__sw" style={{ background: SERIES[i % SERIES.length] }} />
<span className="dchart__keylab">{p.label}</span>
<span className="dchart__keyval">{p.raw}</span>
<span className="dchart__keypct">{Math.round((p.value / total) * 100)}%</span>
</li>
))}
</ul>
</div>
)
}
export default function DeskChart({ data, kind, onKind }) {
const { points, measure } = data
// A share only means something when the parts make a whole. Counts by stage
// do; a column of average premiums does not, and a donut of averages is a
// lie told in colour so the toggle offers it, and bars stay the default.
return (
<figure className="dchart">
<figcaption className="dchart__head">
<span>{measure} · {points.length} rows</span>
<span className="dchart__toggle" role="group" aria-label="Chart type">
<button type="button" className={kind === 'bar' ? 'is-on' : ''} onClick={() => onKind('bar')}>Bars</button>
<button type="button" className={kind === 'donut' ? 'is-on' : ''} onClick={() => onKind('donut')}>Share</button>
</span>
</figcaption>
{kind === 'donut' ? <Donut points={points} measure={measure} /> : <Bars points={points} measure={measure} />}
</figure>
)
}

View File

@ -61,20 +61,40 @@ export default function FileField({ field, value, instanceId, activityUid, onCha
} }
} }
const name = files.length ? (files[0].original_name || files[0].uuid) : ''
const open = () => inputRef.current?.click()
return ( return (
<div className="ff"> <div className="ff">
<input <input
ref={inputRef} type="file" className="ff__input" ref={inputRef} type="file" className="ff__input"
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)} accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
/> />
<button type="button" className="ff__btn" disabled={Boolean(busy)}
onClick={() => inputRef.current?.click()}>
{busy || (files.length ? 'Replace file' : isOcr ? 'Upload — we read it for you' : 'Upload')}
</button>
{files.length ? ( {/* Two states, not one button that changes its words. Nothing uploaded is
<div className="ff__got">{files[0].original_name || files[0].uuid}</div> an invitation a dashed target that says what it takes. Something
) : null} uploaded is a fact the file, named, with a quiet way to swap it. The
old single pink slab stayed the same size and weight either way, which
made a finished upload shout as loudly as an empty one. */}
{files.length && !busy ? (
<div className="ff__file">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.4Zm0 0V5.4h3.6"
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
<span className="ff__name" title={name}>{name}</span>
<button type="button" className="ff__replace" onClick={open}>Replace</button>
</div>
) : (
<button type="button" className="ff__drop" disabled={Boolean(busy)} onClick={open}>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M10 13.5V4.2m0 0L6.6 7.6M10 4.2l3.4 3.4M3.5 13v2a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5v-2"
fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<strong>{busy || (isOcr ? 'Upload — we read it for you' : 'Upload document')}</strong>
{busy ? null : <span>PDF, PNG or JPG</span>}
</button>
)}
{extracted ? ( {extracted ? (
<div className="ff__read"> <div className="ff__read">

View File

@ -0,0 +1,65 @@
/* Wide and tall: this is the whole record, and squeezing thirteen groups into
a prose-width dialog would only move the crowding rather than remove it. */
.lfd { width: min(1180px, 100%); max-height: min(90vh, 940px); }
.lfd__sub {
margin: 4px 0 0;
font-size: var(--fs-2xs);
line-height: 1.5;
color: var(--zk-muted);
}
.lfd__tools {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 24px;
border-bottom: 1px solid var(--zk-line-soft);
background: var(--zk-tint);
}
/* Ctrl-F was how people read the open version. Behind a click that stops
working, so the drawer brings its own. */
.lfd__find {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: var(--zk-white);
border: 1px solid var(--zk-line);
border-radius: var(--r-pill);
}
.lfd__find:focus-within { border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue); }
.lfd__find svg { width: 14px; height: 14px; flex: none; color: var(--zk-grey); }
.lfd__find input {
flex: 1;
border: 0;
outline: none;
background: none;
font: inherit;
font-size: var(--fs-xs);
color: var(--zk-ink);
}
.lfd__find input::-webkit-search-cancel-button { cursor: pointer; }
.lfd__count {
flex: none;
font-size: var(--fs-2xs);
color: var(--zk-muted);
font-variant-numeric: tabular-nums;
}
.lfd__body { overflow-y: auto; }
.lfd__body .file360 { padding: 18px 22px 24px; }
.lfd__none {
margin: 0;
padding: 44px 24px;
text-align: center;
font-size: var(--fs-xs);
color: var(--zk-grey);
}

View File

@ -0,0 +1,166 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import ClampText from './ClampText.jsx'
import './LeadFileDialog.css'
/**
* Everything on the lead, on demand.
*
* This used to sit open on the page. Fifty-seven fields across thirteen groups
* pushed the audit trail the part anyone actually reads a screen and a half
* down, and most of those fields are reference material: nobody opens a lead to
* find out its source channel. The page now carries the handful that matter for
* where the lead IS, and the complete record lives one click away in here.
*
* NOTHING IS LOST AND NOTHING IS BURIED. The groups, the order and the values
* are the same ones the page rendered before this receives them already
* computed, so the two can never disagree. The trigger says how many fields are
* behind it, because a control that hides fifty-seven things should say so.
*
* The filter is the point of doing this as a drawer rather than a collapse.
* Ctrl-F is what people were using on the open version; with the record behind
* a click that stops working, so the drawer brings its own. It matches the
* label AND the value, so "KA01" finds the vehicle and "POSP" finds the
* partner, and it says how many groups it hid rather than silently emptying.
*/
export default function LeadFileDialog({ groups, labels, label, money, fileHref, longAt, onClose }) {
const ref = useRef(null)
const restore = useRef(null)
const [q, setQ] = useState('')
useEffect(() => {
restore.current = document.activeElement
ref.current?.focus()
const onKey = (e) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
const total = groups.reduce((n, [, shown]) => n + shown.length, 0)
const shownGroups = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term) return groups
return groups
.map(([title, rows]) => [
title,
rows.filter(([k, v]) => {
const l = String(labels[k] ?? label(k)).toLowerCase()
return l.includes(term) || String(v).toLowerCase().includes(term)
}),
])
.filter(([, rows]) => rows.length)
}, [groups, q, labels, label])
const found = shownGroups.reduce((n, [, rows]) => n + rows.length, 0)
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg lfd"
role="dialog"
aria-modal="true"
aria-label="Lead file"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<div>
<h3>Lead file</h3>
<p className="lfd__sub">
Everything captured, grouped by the step that captured it.
</p>
</div>
<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="lfd__tools">
<label className="lfd__find">
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle cx="7" cy="7" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.2 10.2 13.5 13.5" fill="none" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Find a field or a value…"
aria-label="Filter the lead file"
/>
</label>
<span className="lfd__count">
{q.trim() ? `${found} of ${total} fields` : `${total} fields`}
</span>
</div>
<div className="lfd__body">
{shownGroups.length ? (
<div className="file360">
{shownGroups.map(([title, shown]) => {
const facts = shown.filter(([, v, files]) => files.length || v.length <= longAt)
const notes = shown.filter(([, v, files]) => !files.length && v.length > longAt)
return (
<section className="fgroup" key={title}>
<h3>{title}<span>{shown.length}</span></h3>
{facts.length ? (
<dl className="props">
{facts.map(([k, v, files]) => (
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
{files.length ? (
<dd className="prop__files">
{files.map((f) => (
<a key={f.uuid} className="prop__file" href={fileHref(f)}
target="_blank" rel="noreferrer">
{f.original_name || 'Document'}
</a>
))}
</dd>
) : (
<dd className={money.has(k) ? 'prop__num' : undefined}>{v}</dd>
)}
</div>
))}
</dl>
) : null}
{notes.length ? (
<div className="fnotes">
{notes.map(([k, v]) => (
<div className="fnote" key={k}>
<span className="fnote__l">{labels[k] ?? label(k)}</span>
<ClampText text={v} title={labels[k] ?? label(k)} lines={2} />
</div>
))}
</div>
) : null}
</section>
)
})}
</div>
) : (
<p className="lfd__none">
Nothing on this lead matches &ldquo;{q.trim()}&rdquo;.
</p>
)}
</div>
</div>
</div>
,
document.body,
)
}

View File

@ -0,0 +1,17 @@
/* Wider than the prose dialogs this holds a four-column form grid, not a
paragraph and allowed to grow taller, because a form that scrolls its own
Submit off the bottom is worse than a tall dialog. */
.nld { width: min(880px, 100%); max-height: min(88vh, 860px); }
.nld__sub {
margin: 4px 0 0;
font-size: var(--fs-2xs);
line-height: 1.5;
color: var(--zk-muted);
max-width: 62ch;
}
.nld__body {
overflow-y: auto;
padding: 18px 24px 22px;
}

View File

@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom'
import ActivityForm from './ActivityForm.jsx'
import { ENTRY } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { useZino } from '../api/provider.jsx'
import './NewLeadDialog.css'
/**
* Filing a lead, over the page rather than on one of its own.
*
* This was a route. Pressing New lead left whatever you were reading a queue
* you were working through, a lead you were half way down filed the form,
* and landed you on the new lead's file, three navigations from where you
* started. Filing a lead is not a destination; it is four fields and a Submit,
* and it belongs over the work rather than instead of it. Cancel now puts you
* back exactly where you were, because you never left.
*
* The door chooser stays: the entry activities have different permissions and
* the bank one lands the instance further along. With one door which is
* today it is skipped rather than shown as a list of one.
*
* Modal furniture is the same as the conversation and reasoning dialogs:
* scrim click, Escape, focus trapped in and restored out, page scroll frozen.
* A fourth hand-rolled variant of that would be a fourth chance to get the
* keyboard wrong.
*/
export default function NewLeadDialog({ onClose }) {
const { user } = useZino()
const doors = entryDoorsFor(rolesOf(user), ENTRY)
const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
const navigate = useNavigate()
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)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
if (restore.current instanceof HTMLElement) restore.current.focus()
}
}, [onClose])
return createPortal(
<div className="prose__scrim" onClick={onClose} role="presentation">
<div
className="prose__dlg nld"
role="dialog"
aria-modal="true"
aria-label="New lead"
tabIndex={-1}
ref={ref}
onClick={(e) => e.stopPropagation()}
>
<div className="prose__head">
<div>
<h3>{entry ? entry.label : 'New lead'}</h3>
<p className="nld__sub">
{entry
? entry.note
: 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'}
</p>
</div>
<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="nld__body">
{!doors.length ? (
<p className="empty">Your role does not create leads.</p>
) : !entry ? (
<div className="doors">
{doors.map((e) => (
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
<strong>{e.label}</strong>
<span className="chip">{e.channel}</span>
<p>{e.note}</p>
</button>
))}
</div>
) : (
<ActivityForm
activityUid={entry.uid}
onCancel={onClose}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
// Carry the workflow's own message across the navigation
// "Lead received Intake is qualifying it" is the answer to
// "did that work?", and it is only said once.
onClose()
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
}}
/>
)}
</div>
</div>
</div>
,
document.body,
)
}

View File

@ -0,0 +1,36 @@
/* The journey, across the top of the lead. Eight steps, one row. */
.rail {
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
margin: 0 0 18px; padding: 20px 28px 18px;
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
}
.rail ol { display: flex; align-items: flex-start; list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; }
.rail__s { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; flex: 1; min-width: 0; text-align: center; }
.rail__dot {
width: 16px; height: 16px; border-radius: 50%; flex: none; z-index: 1;
background: var(--zk-navy); border: 3px solid #fff; box-shadow: 0 0 0 1px var(--zk-navy);
transition: background var(--t-fast), box-shadow var(--t-fast);
}
.rail__l { font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.rail__bar { position: absolute; top: 7px; left: 50%; width: 100%; height: 2px; background: var(--zk-line); }
.rail__bar.is-done { background: var(--zk-navy); }
.is-todo .rail__dot { background: #fff; box-shadow: 0 0 0 1px var(--zk-line); }
.is-todo .rail__l { color: var(--zk-grey); }
.is-here .rail__l { font-weight: 700; color: var(--zk-navy); }
.is-here.tone-teal .rail__dot { background: var(--zk-teal); box-shadow: 0 0 0 1px var(--zk-teal), 0 0 0 5px var(--zk-teal-tint); }
.is-here.tone-blue .rail__dot { background: var(--zk-blue); box-shadow: 0 0 0 1px var(--zk-blue), 0 0 0 5px var(--zk-tint-blue); }
.is-here.tone-blue .rail__l { color: var(--zk-blue); }
.is-here.tone-navy .rail__dot { box-shadow: 0 0 0 1px var(--zk-navy), 0 0 0 5px var(--zk-tint-blue); }
.rail__off {
flex: none; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-amber);
background: var(--zk-amber-tint); border: 1px solid var(--zk-amber-line);
padding: 6px 14px; border-radius: var(--r-pill);
}
.rail__off.is-closed { color: var(--zk-muted); background: var(--zk-line-soft); border-color: var(--zk-line); }
@media (max-width: 900px) { .rail__l { display: none; } .rail { padding: 14px 18px; } }

View File

@ -0,0 +1,84 @@
import { STAGES } from '../api/config.js'
import './StageRail.css'
/**
* Where this lead has been, where it is, and what is left.
*
* The page said the stage in one chip in the corner and nothing about the
* journey, so "how far along is this?" the first question anyone asks about
* a lead could only be answered by knowing the workflow by heart.
*
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
* (documents uploaded from Qualified carried one straight past Contacted) and
* can go backwards (a callback returns it to Awaiting Contact). Colouring
* everything left of the current stage would have claimed steps that never
* happened. The trail knows which states this lead actually entered; this
* reads that.
*
* The side states Parked, Referred, Lost, Declined are deliberately NOT on
* the rail. They are departures from the path rather than points along it, and
* putting them in a row would suggest every lead passes through. When the lead
* is in one, the rail says so at the end instead.
*/
const PATH = [
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
]
/** Short enough to fit eight across; the queue names are written for a sidebar. */
const SHORT = {
'zk-state-new': 'Filed',
'zk-state-qualified': 'Called',
'zk-state-contacted': 'Contacted',
'zk-state-docs': 'Documents',
'zk-state-quoted': 'Quoted',
'zk-state-payment': 'Payment',
'zk-state-issued': 'Issued',
'zk-state-onboarded': 'Onboarded',
}
export default function StageRail({ audit, currentName }) {
const current = STAGES.find((s) => s.name === currentName)
const visited = new Set(
(audit || [])
.map((r) => r.execution_state)
.filter((v) => PATH.includes(v)),
)
if (current) visited.add(current.uid)
const currentIndex = PATH.indexOf(current?.uid)
// The current step takes its holder's colour: teal when the lead is closed
// (issued, onboarded), blue while the AI has it, navy otherwise.
const hereTone = current?.kind === 'end' ? 'teal' : current?.kind === 'auto' ? 'blue' : 'navy'
// A lead sitting somewhere off the path Parked, Referred, Lost, Declined.
const aside = current && currentIndex === -1 ? current : null
return (
<div className="rail" aria-label="Progress">
<ol>
{PATH.map((uid, i) => {
const been = visited.has(uid)
const here = uid === current?.uid
// Ahead of a lead that has left the path is unknowable a referred
// risk may never see payment so nothing after it is called "to do".
const state = here ? 'here' : been ? 'done' : 'todo'
return (
<li key={uid} className={'rail__s is-' + state + (here ? ' tone-' + hereTone : '')}>
<span className="rail__dot" aria-hidden="true" />
<span className="rail__l">{SHORT[uid]}</span>
{i < PATH.length - 1 ? (
<span className={'rail__bar' + (visited.has(PATH[i + 1]) ? ' is-done' : '')} aria-hidden="true" />
) : null}
</li>
)
})}
</ol>
{aside ? (
<div className={'rail__off' + (aside.kind === 'end' ? ' is-closed' : '')}>
{aside.kind === 'end' ? 'Ended in' : 'Now in'} <strong>{aside.name}</strong>
</div>
) : null}
</div>
)
}

View File

@ -1,39 +1,134 @@
.tl { list-style: none; margin: 0; padding: 0 0 0 4px; } .tl { list-style: none; margin: 0; padding: 0; }
.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; } .tl__loading, .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
.tl__err { color: var(--zk-danger); } .tl__err { color: var(--zk-danger-ink); }
.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); } /* One event: a 36px avatar gutter, then the body. A thin connector runs down
.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; } the gutter between avatars. */
.tl__ev { display: grid; grid-template-columns: 36px 1fr; gap: 14px; position: relative; padding-bottom: 22px; }
.tl__ev::before { content: ''; position: absolute; left: 17px; top: 36px; bottom: 0; width: 2px; background: var(--zk-line-soft); }
.tl__ev:last-child::before { display: none; }
.tl__ev:last-child { padding-bottom: 6px; }
.tl__ev--sys { opacity: .75; }
.tl__dot { .tl__gutter { width: 36px; height: 36px; display: grid; place-items: center; position: relative; z-index: 1; }
position: absolute; left: -7px; top: 3px; width: 12px; height: 12px; .tl__gutter .who--btn { padding: 0; border: 0; }
border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey); .tl__disc {
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-weight: 700; font-size: 13px;
background: var(--zk-line-soft); color: var(--zk-muted);
} }
/* Machine work is blue, people are amber, the platform itself is grey so a .tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
file can be read at a glance for how much of it was done by hand. */
.tl__item--ai .tl__dot { border-color: var(--zk-blue); background: var(--zk-blue); }
.tl__item--human .tl__dot { border-color: #b5761f; background: var(--zk-white); }
.tl__item--sys .tl__dot { border-color: var(--zk-line); background: var(--zk-line); }
.tl__body { display: flex; flex-direction: column; gap: 3px; } .tl__body { min-width: 0; display: flex; flex-direction: column; }
.tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; } .tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
.tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); } .tl__what { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
.tl__who { .tl__what--wa { display: inline-flex; align-items: center; gap: 6px; color: var(--zk-teal); }
font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap; .tl__what--wa svg { width: 15px; height: 15px; }
.tl__ev--ai.is-live .tl__what { color: var(--zk-blue); }
.tl__to { font-size: var(--fs-xs); color: var(--zk-muted); padding: 2px 10px; border-radius: var(--r-pill); background: var(--zk-line-soft); }
.tl__to::before { content: '→ '; color: var(--zk-grey); }
.tl__n { font-size: var(--fs-2xs); color: var(--zk-muted); padding: 1px 8px; border-radius: var(--r-pill); background: var(--zk-line-soft); font-variant-numeric: tabular-nums; }
.tl__when { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
.tl__by { font-size: 14.5px; color: var(--zk-muted); margin-top: -2px; display: flex; align-items: center; gap: 6px; }
.tl__byname { font-weight: 600; color: var(--zk-ink); }
.tl__tag { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
/* What was said — a quote block. */
.tl__say { margin-top: 8px; padding: 10px 14px; border-left: 2px solid var(--zk-line); border-radius: 0 var(--r-sm) var(--r-sm) 0; background: var(--zk-tint); max-width: 72ch; }
.tl__saylabel { display: block; font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); margin-bottom: 6px; }
.tl__say p { margin: 0; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); }
/* Figures the step wrote — money, as chips. */
.tl__figs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.tl__figs span { display: inline-flex; gap: 8px; align-items: baseline; padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: 14.5px; font-variant-numeric: tabular-nums; }
.tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; }
/* Documents received, as chips. */
.tl__docs { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14.5px; }
.tl__docshead { color: var(--zk-grey); }
.tl__doclist { display: flex; flex-wrap: wrap; gap: 8px; }
.tl__doc {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px;
border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
font-size: 14.5px; color: var(--zk-ink); text-decoration: none;
} }
.tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); } .tl__doc svg { width: 13px; height: 13px; color: var(--zk-blue); }
.tl__who--human { background: #f7efe2; color: #7a5a12; } .tl__doc:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
.tl__who--sys { background: var(--zk-tint); color: var(--zk-muted); }
.tl__stage { font-size: .78rem; color: var(--zk-blue); }
.tl__when { font-size: .72rem; color: var(--zk-grey); }
.tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); } /* The action row: links, in blue. */
.tl__saylabel { .tl__acts { display: flex; gap: 18px; margin-top: 8px; font-size: var(--fs-xs); align-items: baseline; flex-wrap: wrap; }
display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; .tl__link { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; }
color: var(--zk-grey); margin-bottom: 2px; .tl__link:hover { text-decoration: underline; }
.tl__wrote > summary { cursor: pointer; list-style: none; font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); }
.tl__wrote > summary::-webkit-details-marker { display: none; }
.tl__wrote > summary::after { content: ' ▾'; color: var(--zk-grey); }
.tl__wrote[open] > summary::after { content: ' ▴'; }
.tl__wrote dl { margin: 8px 0 0; padding: 10px 12px; border: 1px solid var(--zk-line-soft); border-radius: var(--r-sm); background: var(--zk-tint); display: flex; flex-direction: column; gap: 6px; }
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
.tl__wrote dt { flex: none; width: 42%; font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
.tl__wrote dd { margin: 0; flex: 1; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
/* System updates, folded behind a pill. */
.tl__toggle {
display: inline-block; margin: 6px 0 14px; font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); background: #fff; cursor: pointer;
} }
.tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; } .tl__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); }
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; } /* THE WHATSAPP EXCHANGE, COLLAPSED
.tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; } One entry for a burst of messages: who spoke, how many there were, the last
.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; } two of them, and the whole thread behind a click. The card IS the control
clicking anywhere in it opens the conversation, which is how every timeline
of this shape behaves and what stops the entry needing its own link row. */
.tl__disc--wa { background: var(--zk-teal-tint); color: var(--zk-teal); }
.tl__disc--wa svg { width: 17px; height: 17px; }
/* "The same thread, picked up after Verify KYC and Underwriting Screen." */
.tl__wacont { margin: 4px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.tl__wa {
display: block; width: 100%; max-width: 72ch; margin-top: 10px;
padding: 12px 14px 10px; text-align: left; cursor: pointer;
font: inherit; color: inherit; background: #fff;
border: 1px solid var(--zk-line); border-radius: var(--r-md);
transition: border-color var(--t-fast);
}
.tl__wa:hover { border-color: var(--zk-blue-light); }
.tl__wa:hover .tl__wafoot { text-decoration: underline; }
/* The participants, as chips. A person's disc carries their initials, an
agent's carries the sparkle and an AI tag which is the whole point of the
line: one of these is not a colleague. */
.tl__wavoices { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 1px; }
.tl__wavoices .who--btn { margin-left: -2px; }
.tl__wapre { display: flex; flex-direction: column; gap: 6px; }
.tl__wamsg {
max-width: 88%; padding: 7px 11px 6px;
border-radius: 12px; background: var(--zk-line-soft);
}
.tl__wamsg-who { display: block; margin-bottom: 3px; }
/* Same reason as in the dialog: the disc's tint and the bubble's are one
token, so the disc vanishes into it without this. */
.tl__wamsg .who__disc { background: #fff; }
/* Two lines of each message, no more. A preview that grows with whatever
somebody typed has stopped being a preview. */
.tl__wamsg-txt {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
overflow: hidden;
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
}
/* Theirs left, ours right the same arrangement as the thread itself, so the
preview is recognisably a piece of the conversation it opens. */
.tl__wamsg--them { align-self: flex-start; border-bottom-left-radius: 4px; }
.tl__wamsg--us {
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
}
.tl__wafoot {
display: flex; align-items: center; gap: 4px; margin-top: 9px;
font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
}
.tl__wafoot svg { width: 11px; height: 11px; }

View File

@ -1,120 +1,675 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { STAGES } from '../api/config.js' import AgentChip from './AgentChip.jsx'
import ClampText from './ClampText.jsx'
import ChainOfThought from './ChainOfThought.jsx'
import { AGENTS, initialsOf } from '../api/agents.js'
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import { CHAT_ACTS, THREAD_FIELDS, buildThread, listOf, stepName } from '../api/thread.js'
import './Timeline.css' import './Timeline.css'
/** Which employee holds which role — used to badge an entry as machine work. */ /* The roster lives in api/agents.js now one short name, one colour and one
const AI_ROLES = { set of initials per employee, so the same worker looks the same everywhere
ai_intake: 'Intake & Attribution', it appears. This file only needs to know which roles are agents. */
ai_engage: 'Engage',
ai_kyc: 'KYC & Evidence',
ai_advisor: 'Advisor',
ai_uw_referral: 'Underwriting Referral',
}
/** /**
* The fields worth surfacing per activity an agent's reasoning, a rule's * The fields worth surfacing per activity an agent's reasoning, a rule's
* output, a call's notes. Everything else stays in the file below; a timeline * output, a call's notes. Everything else stays in the file below; a timeline
* that shows every field is a table, not a story. * that shows every field is a table, not a story.
*
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
* field ids, which carry a per-form suffix the call notes arrive as
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
* global ids directly, as this did, meant almost nothing ever matched: the
* timeline showed a bare list of activity names, and the one narrative line
* that did appear was an accident (a DATA_UPDATE row happens to write the
* unsuffixed key).
*/ */
const NARRATIVE = [ /* AN AI STEP'S REASONING, in the order it is worth reading. These become the
"Reasoned" rung of the chain of thought the employee's own words for why
it did what it did rather than loose quote blocks. The first present one
is the finding shown without opening the chain. */
const REASONING = [
['attribution_reason', 'Attribution'], ['attribution_reason', 'Attribution'],
['eligibility_reason', 'Eligibility'], ['eligibility_reason', 'Eligibility'],
['dedupe_match_ref', 'Duplicate of'], ['ai_recommendation_rationale', 'Cover advice'],
['contact_notes', 'Call'], ['kyc_mismatch_notes', 'KYC mismatch'],
['ai_recommendation_rationale', 'Recommendation'], // A clean KYC leaves the mismatch note empty, so the step showed no chain at
['quoted_breakup', 'How the premium was reached'], // all. kyc_ref is what the KYC employee actually recorded the document
['kyc_mismatch_notes', 'KYC'], // package it matched the identity against so it stands in as the finding.
['kyc_ref', 'KYC check'],
['referral_analysis', 'Referral analysis'], ['referral_analysis', 'Referral analysis'],
// Underwriting writes its reasoning into uw_referral_reason (the screen and
// prepare steps); uw_decision_notes is only the clear/decline note. The map
// knew the second and not the first, so the substantive UW decision the
// one the reader most wants the reasoning for showed as a bare heading.
['uw_referral_reason', 'Underwriting'],
['uw_decision_notes', 'Underwriting decision'], ['uw_decision_notes', 'Underwriting decision'],
['contact_notes', 'Call'],
['documents_notes', 'Documents'], ['documents_notes', 'Documents'],
// The payment-nudge employee records why it chose to follow up now (or hold).
['nudge_notes', 'Payment follow-up'],
['quoted_breakup', 'How the premium was reached'],
['lost_reason', 'Why it was dropped'],
['resume_note', 'Why now'],
] ]
/* WHAT WAS SAID IS NO LONGER QUOTED ON THE STEP.
*
* `customer_reply` and `customer_answer` used to be rendered here, which is
* what made every WhatsApp turn its own entry in the trail: five headings, five
* actors and five quote boxes for one exchange about a premium. They belong to
* the THREAD now (api/thread.js), which owns them for both the collapsed entry
* below and the dialog. Leaving them here as well would print the payment
* request's own message on the Request Premium step and again in the exchange
* it opened. */
const CONVERSATION = [
['dedupe_match_ref', 'Duplicate of'],
]
const NARRATIVE = [...REASONING, ...CONVERSATION]
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv']) const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
function when(ts) { /**
if (!ts) return '' * Fields already shown elsewhere in the entry, so the "what it wrote" list
* does not repeat them: the narrative prose above it, the money figures below
* it, the thread that owns the messages, the document chips, and the platform's
* own bookkeeping.
*/
const SHOWN_ELSEWHERE = new Set([
...NARRATIVE.map(([k]) => k), ...MONEY, ...THREAD_FIELDS, '_system',
])
/** A value as one short line. Long prose is already in the narrative block, so
* anything here is a field value, not a paragraph. */
function short(f) {
const v = f.value
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) {
const named = v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
.filter(Boolean)
return named.length ? named.join(', ') : null
}
if (typeof v === 'object') return null
const t = String(v).trim()
if (!t) return null
// A value long enough to be prose belongs in the narrative block, not in a
// list of what changed truncating it here would show half a sentence and
// teach the reader that this list is unreliable.
return t.length > 90 ? t.slice(0, 88) + '…' : t
}
/** Document slots, in the order they are worth reading. Labels are shorter than
* the workflow's own "Registration Certificate (RC)" is a chip, not a form
* label and a slot missing from here still renders under its server label. */
const DOC_LABELS = {
doc_rc: 'RC',
doc_prev_policy: 'Expiring policy',
doc_pan: 'PAN',
doc_gst_cert: 'GST certificate',
doc_udyam_cert: 'Udyam certificate',
doc_address_proof: 'Address proof',
doc_premises_proof: 'Premises proof',
doc_stock_statement: 'Stock statement',
doc_premises_photos: 'Premises photos',
doc_vehicle_photos: 'Vehicle photos',
doc_financials: 'Financials',
}
const FILE_TYPES = new Set(['file', 'ocr'])
/** The absolute stamp and the relative one, kept apart: a collapsed exchange
* spans two moments and has only one "ago". */
function absAt(ts) {
const d = new Date(ts) const d = new Date(ts)
if (isNaN(d)) return ''
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
function agoAt(ts) {
const d = new Date(ts)
if (isNaN(d)) return ''
const mins = Math.round((Date.now() - d.getTime()) / 60000) const mins = Math.round((Date.now() - d.getTime()) / 60000)
const rel = mins < 1 ? 'just now' return mins < 1 ? 'just now'
: mins < 60 ? `${mins}m ago` : mins < 60 ? `${mins}m ago`
: mins < 1440 ? `${Math.round(mins / 60)}h ago` : mins < 1440 ? `${Math.round(mins / 60)}h ago`
: `${Math.round(mins / 1440)}d ago` : `${Math.round(mins / 1440)}d ago`
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
} }
export default function Timeline({ instanceId }) { function when(ts) {
if (!ts) return ''
const abs = absAt(ts)
return abs ? `${abs} · ${agoAt(ts)}` : ''
}
/**
* An exchange happens over a stretch of time rather than at an instant, and the
* stamp has to say so dated only at its first message, an entry reads as
* though the reply visible inside it arrived before it was written.
*
* The "ago" is measured from the LAST message: how stale the conversation is,
* which is the operationally useful half.
*/
function spanAt(from, to) {
if (!from) return ''
if (!to || to === from) return when(from)
const a = new Date(from)
const b = new Date(to)
if (isNaN(a) || isNaN(b)) return when(from)
const hm = (d) => d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })
const day = (d) => d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
const head = a.toDateString() === b.toDateString()
? `${day(a)}, ${hm(a)} ${hm(b)}`
: `${day(a)} ${day(b)}`
return `${head} · ${agoAt(to)}`
}
/** An uploaded file, as the platform stores it. */
function filesIn(value) {
if (!Array.isArray(value)) return []
return value.filter((f) => f && typeof f === 'object' && f.uuid)
}
/** The WhatsApp mark, on the disc and in the heading. */
function WaGlyph() {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
fill="currentColor" />
</svg>
)
}
/**
* Who spoke in an exchange as CHIPS, not as a sentence.
*
* "Priya Raghavan and Engage" reads as two colleagues. On a screen whose whole
* claim is that the work was done autonomously, the one fact every line has to
* state is which of the two is a machine, and a bare name states the opposite.
*
* Every other entry in the trail already says it the same way the agent's
* disc carries a sparkle, its name carries an AI tag, a person's disc carries
* their initials so this is the existing convention reaching somewhere it was
* missing rather than a new one.
*
* The customer comes first: they are the reason the exchange exists.
*/
function voicesOf(ep, customerName) {
const who = []
if (ep.turns.some((t) => t.side === 'them')) {
who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
}
for (const t of ep.turns) {
if (t.side !== 'us') continue
// Keyed on the agent, or on the name for a person answering by hand: ops
// replying in the thread is a person and must never wear the sparkle.
const key = t.agentKey || `person:${t.by}`
if (!who.some((w) => w.key === key)) who.push({ key, agentKey: t.agentKey, name: t.by })
}
return who
}
/** The label on a preview bubble: the same chip, small, and never a button
* the card it sits in is already one, and a button inside a button is not. */
function TurnWho({ turn, customerName }) {
return turn.side === 'them'
? <AgentChip name={customerName || 'the customer'} size="xs" />
: <AgentChip agentKey={turn.agentKey} name={turn.by} size="xs" />
}
/**
* `rows` is owned by the lead page and refreshed on its poll, so the trail
* keeps up with a lead that five agents are working through in three minutes.
* This component fetched them itself once on mount and never again.
*/
export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat, onOpenCall }) {
const { client } = useZino() const { client } = useZino()
const [rows, setRows] = useState(null) // DATA_UPDATE entries are the platform writing fields, not anyone deciding
const [err, setErr] = useState(null) // anything. They are the bulk of a busy trail and they are hidden until asked
// for the story is what people and agents did.
const [showSys, setShowSys] = useState(false)
useEffect(() => {
let dead = false
client.audit(instanceId)
.then((r) => { if (!dead) setRows(Array.isArray(r) ? r : (r?.data ?? [])) })
.catch((e) => { if (!dead) setErr(e) })
return () => { dead = true }
}, [client, instanceId])
if (err) return <div className="tl__err">Could not load the timeline {err.status} {err.message}</div>
if (!rows) return <p className="tl__loading">Loading the timeline</p> if (!rows) return <p className="tl__loading">Loading the timeline</p>
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p> if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
// The conversation, assembled once and shared with the dialog. `episodes` are
// its bursts; `byRow` says which steps put a message on WhatsApp themselves.
const { episodes, byRow } = buildThread(rows)
// Oldest first: a timeline reads forwards. // Oldest first: a timeline reads forwards.
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))) const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
// submission separately, told apart by execution_state:
//
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
// zk-act-qualify zk-state-qualified the one that moved the lead
//
// Rendered literally that is "Qualify Lead" three times in a row, which
// reads as the AI having done the same thing three times. Only the row
// carrying a real state means anything to somebody reading the file.
//
// Collapsed on (same activity, within fifteen seconds) rather than on the
// execution_state alone, because a genuine repeat has to survive: Collect
// Documents really is performed twice on a lead whose first upload was
// incomplete, and those are minutes apart, not milliseconds.
//
// The kept row takes the EARLIEST timestamp when the operator acted and
// whichever state and payload is actually populated.
const isStage = (v) => STAGES.some((s) => s.uid === v)
const SAME_SUBMISSION_MS = 15000
const merged = []
for (const r of ordered) {
// Look BACK for a match rather than only at the previous entry: the
// platform interleaves a DATA_UPDATE row between the two halves of one
// submission, so the rows to merge are near each other in time but not
// adjacent in the list.
let at = -1
for (let i = merged.length - 1; i >= 0; i--) {
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
if (merged[i].activity_id === r.activity_id) { at = i; break }
}
if (at >= 0) {
const prev = merged[at]
merged[at] = {
...prev,
// The settle row is the one that names the resulting stage.
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
// Whichever row actually carries the submission and the AI's working.
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
user_name: prev.user_name || r.user_name,
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
created_at: prev.created_at,
// Every raw row folded in here. The thread is keyed on RAW ids a
// message may have been recorded on the second row of a submission
// whose first row won the merge so a lookup has to try all of them.
mergedIds: [...(prev.mergedIds || [prev.id]), r.id],
}
continue
}
merged.push(r)
}
let lastStage = null
/** One step: what it was, who took it, what it wrote. */
const stepEntry = (r, ids) => {
const roles = r.user_roles || []
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
// so an AI's own field write matched the agent roster, was classed as its work,
// and appeared in the trail as an entry titled "Data updated" twice,
// while the toggle below still offered to reveal two others. The row's
// activity id is what says it is bookkeeping; the roles say who triggered
// the bookkeeping, which is a different question.
const isSystem = r.activity_id === 'DATA_UPDATE'
const aiRole = roles.find((x) => AGENTS[x])
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
/**
* The value view. `fields[]` is the platform's own typed rendering of the
* submission one entry per configured field with its label, data type and
* value and it is what makes a file field knowable as a file. `data` is
* the raw untyped fallback for a row the workflow could not resolve.
*/
const fields = Array.isArray(r.fields) && r.fields.length
? r.fields
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
const byBase = new Map()
for (const f of fields) {
const base = baseFieldId(f.field_id)
// `_system` is the platform's own marker on every submission.
if (base === '_system') continue
if (!byBase.has(base)) byBase.set(base, f)
}
// What was attached. This is the answer to "show me that the documents
// were captured": the names, from the submission that carried them, each
// one openable.
const docs = []
for (const [base, f] of byBase) {
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
for (const file of filesIn(f.value)) {
docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file })
}
}
// Log Contact is where the call lands. Offer the words themselves beside
// the summary, because they are not the same thing.
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
const stage = STAGES.find((s) => s.uid === r.execution_state)
// A self-loop Capture Motor Risk runs inside Document Pending and settles
// back into it is not a move, and printing " Document Pending" against
// four consecutive entries reads as the lead bouncing.
const moved = stage && stage.uid !== lastStage
if (stage) lastStage = stage.uid
// A step that composed a WhatsApp message inside its own trigger Request
// Premium, the issuance note. The text lives in the thread; the step says
// it sent one and links there rather than quoting what is already shown.
const chat = ids.map((id) => byRow.get(id)).find(Boolean)
return {
key: r.id ?? `${r.activity_id}-${r.created_at}`,
kind,
hasCall,
sent: chat?.sent ?? 0,
chatEpisode: chat?.episodeId ?? null,
agentKey: aiRole || null,
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : stepName(r),
stage: moved ? stage : null,
when: when(r.created_at),
docs,
// The reasoning rungs of the chain of thought the employee's own text.
reasoning: REASONING
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// Conversation lines stay as plain quotes.
conversation: CONVERSATION
.map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
// The decision's confidence, when the step recorded it ai_recommendation_confidence
// is a real field the advice step writes into the audit data, so it needs
// no backend change to reach here.
confidence: (() => {
const c = byBase.get('ai_recommendation_confidence')?.value
const n = c == null ? null : Number(c)
return Number.isFinite(n) ? n : null
})(),
figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
/**
* EVERYTHING THIS STEP WROTE.
*
* Twelve fields were rendered as prose and four as money; the other
* hundred-odd were invisible. So Capture Motor Risk which writes engine
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
* add-ons showed as a heading and a timestamp, and the only way to see
* what an agent had actually decided was to open the Lead file and guess
* which values came from that step.
*
* This is not the model's internal reasoning; that is recorded on the row
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
* It is the next best and arguably more useful thing: the decision it
* committed, field by field, against the step that made it.
*/
wrote: [...byBase]
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
.filter(([, v]) => v !== null),
}
}
/**
* THE CONVERSATION IS ONE ENTRY, NOT NINE.
*
* Every WhatsApp turn used to be its own step in the trail "WhatsApp",
* "WhatsApp", "WhatsApp" each with a heading, an actor, a quote box and an
* Open thread link, so a four-message exchange about a premium took more of
* the story than the entire underwriting chain.
*
* An exchange is now one entry: who spoke, how many messages, the last two of
* them, and the whole thread one click away. It carries NO single actor, which
* is the trap the previous attempt fell into collapsing turns while keeping
* the first speaker's name printed the customer's words under Engage's chip.
* Here both sides are named on their own message and neither owns the entry.
*
* ONE ENTRY PER BURST, not one for the whole thread. The quote objection and
* the payment exchange are the same conversation resumed, but they are twenty
* minutes and four workflow steps apart. Folding them into a single entry
* would date the payment messages to the moment of the objection and place
* them above the underwriting that actually preceded them and saying what
* happened in what order is the trail's one job. So each burst sits where it
* happened, the later ones marked as a continuation, and every one of them
* opens the same complete thread.
*/
const entries = []
const placed = new Set()
const waEntry = (ep) => ({ key: 'wa-' + ep.id, kind: 'wa', ep })
for (const r of merged) {
const ids = r.mergedIds || [r.id]
const opens = episodes.filter((e) => !placed.has(e.id) && ids.includes(e.anchorRowId))
if (CHAT_ACTS.has(r.activity_id)) {
// The row IS a turn, not a step, so it never appears as one. Its exchange
// takes its place once, where the exchange began.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
continue
}
entries.push(stepEntry(r, ids))
// A step whose own message reopened the thread sits directly above the
// exchange it started.
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
}
// A guard, not a case: an exchange whose anchor row did not survive the merge
// would otherwise vanish from the trail entirely.
for (const e of episodes) if (!placed.has(e.id)) entries.push(waEntry(e))
const sysCount = entries.filter((it) => it.kind === 'sys').length
const visible = showSys ? entries : entries.filter((it) => it.kind !== 'sys')
return ( return (
<ol className="tl"> <>
{ordered.map((r, i) => { {sysCount ? (
const roles = r.user_roles || [] <button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
const aiRole = roles.find((x) => AI_ROLES[x]) {showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
const isSystem = r.activity_id === 'DATA_UPDATE' </button>
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human' ) : null}
const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
const stage = STAGES.find((s) => s.uid === r.execution_state)
const d = r.data || {}
const narrative = NARRATIVE <ol className="tl">
.map(([k, label]) => [label, d[k]]) {visible.map((it) => (
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '') it.kind === 'wa' ? (
<li key={it.key} className="tl__ev tl__ev--wa">
<span className="tl__gutter" aria-hidden="true">
<span className="tl__disc tl__disc--wa"><WaGlyph /></span>
</span>
<div className="tl__body">
<div className="tl__line1">
<h4 className="tl__what tl__what--wa">
<WaGlyph />
WhatsApp
</h4>
{/* A resumed exchange says so on its own line, so nobody
reads it as a second conversation out of nowhere. */}
{it.ep.after.length ? <span className="tl__n">continued</span> : null}
<span className="tl__n">
{it.ep.count} message{it.ep.count === 1 ? '' : 's'}
</span>
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
</div>
<div className="tl__wavoices">
{voicesOf(it.ep, customerName).map((w) => (
<AgentChip
key={w.key}
agentKey={w.agentKey}
name={w.name}
size="sm"
onOpen={w.agentKey ? onOpenAgent : undefined}
/>
))}
</div>
const figures = Object.entries(d) {it.ep.after.length ? (
.filter(([k, v]) => MONEY.has(k) && v) <p className="tl__wacont">
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]) The same thread, picked up after {listOf(it.ep.after)}.
</p>
) : null}
return ( {/* The last exchange, as a thread rather than a quote block
<li key={r.id ?? i} className={'tl__item tl__item--' + kind}> and the whole card opens the conversation, which is how
<span className="tl__dot" aria-hidden="true" /> every timeline of this shape behaves. */}
<div className="tl__body"> <button
<div className="tl__head"> type="button"
<strong className="tl__what"> className="tl__wa"
{isSystem ? 'Data updated' : (r.activity_name || r.activity_id)} onClick={() => onOpenChat?.(it.ep.id)}
</strong> aria-label={`Open the WhatsApp conversation — ${it.ep.count} messages`}
<span className={'tl__who tl__who--' + kind}> >
{kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor} <span className="tl__wapre">
</span> {it.ep.preview.map((t) => (
{stage ? <span className="tl__stage"> {stage.name}</span> : null} <span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}>
<span className="tl__wamsg-who">
<TurnWho turn={t} customerName={customerName} />
</span>
<span className="tl__wamsg-txt">{t.text}</span>
</span>
))}
</span>
<span className="tl__wafoot">
{it.ep.count > it.ep.preview.length
? `Open the conversation — all ${it.ep.count} messages`
: 'Open the conversation'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
</div>
</li>
) : (
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
{/* The worker, as a face in the gutter. Recognition down a column
beats reading five names to notice it was one agent throughout. */}
<span className="tl__gutter" aria-hidden="true">
{it.kind === 'sys' ? (
<span className="tl__disc tl__disc--sys">·</span>
) : it.agentKey ? (
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
) : (
<span className="tl__disc tl__disc--hu">{initialsOf(it.actor)}</span>
)}
</span>
<div className="tl__body">
<div className="tl__line1">
<h4 className="tl__what">{it.what}</h4>
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
<span className="tl__when">{it.when}</span>
</div>
<div className="tl__by">
{it.kind === 'sys'
? <span className="tl__byname">System</span>
: it.agentKey
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
: <span className="tl__byname">{it.actor}</span>}
</div> </div>
<div className="tl__when">{when(r.created_at)}</div>
{narrative.map(([label, v]) => ( {/* What was received, named and openable. The count is stated
rather than left to be inferred from a list "3 documents
received" is the sentence the operator is looking for. */}
{it.docs.length ? (
<div className="tl__docs">
<span className="tl__docshead">
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
</span>
<div className="tl__doclist">
{it.docs.map((d) => (
<a
key={d.uuid}
className="tl__doc"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
target="_blank"
rel="noreferrer"
title={d.original_name}
>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
</svg>
{d.slot}
</a>
))}
</div>
</div>
) : null}
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
loose quote it is how the employee reached the decision, so
it renders as the chain: what it checked, what it reasoned,
what it decided. This is the answer to "why did it do that",
which is the first thing a person needs before trusting or
overriding a machine. */}
{it.kind === 'ai' && it.reasoning.length ? (
<ChainOfThought
reasoning={it.reasoning}
confidence={it.confidence}
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
/>
) : null}
{/* A human step's reason, and the AI step's reasoning when it is
not the story's actor (rare), stay as a plain quote. */}
{it.kind !== 'ai'
? it.reasoning.map(([label, v]) => (
<div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div>
))
: null}
{it.conversation.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>
<p>{String(v)}</p> <ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} />
</div> </div>
))} ))}
{figures.length ? ( {it.figures.length ? (
<div className="tl__figs"> <div className="tl__figs">
{figures.map(([k, v]) => ( {it.figures.map(([k, v]) => (
<span key={k}><em>{k}</em> {v}</span> <span key={k}><em>{k}</em> {v}</span>
))} ))}
</div> </div>
) : null} ) : null}
<div className="tl__acts">
{it.hasCall && onOpenCall ? (
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
) : null}
{/* This step put something on WhatsApp itself. The message is in
the thread with the rest of the conversation, not quoted
here a second time. */}
{it.sent && onOpenChat ? (
<button type="button" className="tl__link" onClick={() => onOpenChat(it.chatEpisode)}>
{it.sent === 1 ? 'Sent 1 message on WhatsApp' : `Sent ${it.sent} messages on WhatsApp`}
</button>
) : null}
{/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an
itemised account of what it decided. */}
{it.wrote.length ? (
<details className="tl__wrote">
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
<dl>
{it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
))}
</dl>
</details>
) : null}
</div>
</div> </div>
</li> </li>
) )
})} ))}
</ol> </ol>
</>
) )
} }

View File

@ -1,74 +1,177 @@
/* ── Zurich Kotak brand typeface ───────────────────────────── */ /* Typeface: Source Sans 3, self-hosted
@font-face { Bundled from src/assets so Vite resolves the URLs under the sub-path mount;
font-family: 'Zurich Sans'; an absolute /fonts/ path would 404 behind the BASE_HREF rewrite. */
src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype'); @font-face { font-family: 'Source Sans 3'; font-weight: 400; font-display: swap;
font-weight: 300; src: url('./assets/fonts/source-sans-3-latin-400-normal.woff2') format('woff2'),
font-style: normal; url('./assets/fonts/source-sans-3-latin-ext-400-normal.woff2') format('woff2'); }
font-display: swap; @font-face { font-family: 'Source Sans 3'; font-weight: 500; font-display: swap;
} src: url('./assets/fonts/source-sans-3-latin-500-normal.woff2') format('woff2'),
@font-face { url('./assets/fonts/source-sans-3-latin-ext-500-normal.woff2') format('woff2'); }
font-family: 'Zurich Sans'; @font-face { font-family: 'Source Sans 3'; font-weight: 600; font-display: swap;
src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype'); src: url('./assets/fonts/source-sans-3-latin-600-normal.woff2') format('woff2'),
font-weight: 400; url('./assets/fonts/source-sans-3-latin-ext-600-normal.woff2') format('woff2'); }
font-style: normal; @font-face { font-family: 'Source Sans 3'; font-weight: 700; font-display: swap;
font-display: swap; src: url('./assets/fonts/source-sans-3-latin-700-normal.woff2') format('woff2'),
} url('./assets/fonts/source-sans-3-latin-ext-700-normal.woff2') format('woff2'); }
@font-face {
font-family: 'Zurich Sans';
src: url('./assets/fonts/ZurichSans-Medium.otf') format('opentype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
:root { :root {
/* Brand palette lifted from zurichkotak.com */ /* PALETTE Lead Desk redesign
--zk-blue: #2167ae; Colour MEANS something here, and the meaning is fixed on every screen:
--zk-blue-dark: #1a5490; blue = the AI holds it amber = a person holds it
--zk-blue-deep: #1a528b; teal = the customer holds it red = risk, and nothing else
--zk-navy: #23366f; Navy is the masthead and the brand; blue is the one action colour.
--zk-blue-mid: #5495cf; The token NAMES keep their history the whole app references --zk-blue
--zk-blue-light: #91bfe3; and friends so remapping the values here re-themes every surface. */
--zk-cyan: #c7ebf9; --zk-navy: #0B2A5B;
--zk-navy-2: #12386F;
--zk-blue: #1F55A6; /* primary action; "with the AI" */
--zk-blue-dark: #12386F;
--zk-blue-deep: #0B2A5B;
--zk-blue-mid: #4E7FC4;
--zk-blue-light: #C3D3EC; /* hairline / ring */
--zk-cyan: #E4ECF9; /* blue-soft */
--zk-tint-blue: #E4ECF9; /* blue-soft */
--zk-ink: #2b2e31; --zk-teal: #137A66; /* the customer; issued */
--zk-muted: #5d6162; --zk-teal-ink: #0F6353;
--zk-grey: #a6adaf; --zk-teal-tint: #DDF1EC;
--zk-line: #dde4e3; --zk-teal-line: #B8DDD3;
--zk-line-soft: #eceeef; --zk-good: #137A66;
--zk-tint: #f4f9fc;
--zk-tint-blue: #ebf3fb;
--zk-white: #fff;
--zk-danger: #d32f2f;
--font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif; --zk-amber: #A86A0B; /* a person holds it */
--zk-amber-ink: #8A5609;
--zk-amber-tint: #FBF0DC;
--zk-amber-line: #EAD6AE;
--zk-amber-bar: #D99A2B; /* the brighter amber for bars and legends */
--zk-danger: #C8102E; /* Kotak red: risk only */
--zk-danger-ink: #A30D25;
--zk-danger-tint: #FBE7EA;
--zk-danger-line: #F1C5CC;
--zk-ink: #152238;
--zk-muted: #4B5A72; /* ink-2 */
--zk-grey: #7A8699; /* ink-3 */
--zk-line: #D9DFE9;
--zk-line-soft: #E9EDF3;
--zk-tint: #F2F4F8; /* the canvas */
--zk-sunk: #E9EDF3;
--zk-white: #FFFFFF;
--zk-card: #FFFFFF;
/* Shape
Flat. Cards are white on the grey canvas with a 1px line and a 10px
radius; nothing floats, nothing casts a shadow. */
--r-xs: 6px;
--r-sm: 8px;
--r-md: 10px;
--r-lg: 10px;
--r-xl: 12px;
--r-pill: 999px;
--sh-xs: none;
--sh-sm: none;
--sh-md: none;
--sh-lg: 0 12px 32px -12px rgba(21, 34, 56, 0.28); /* dialogs only */
--sh-accent: none;
--ring: 0 0 0 3px rgba(31, 85, 166, 0.18);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--t-fast: 0.14s var(--ease);
--t: 0.2s var(--ease);
--grad-blue: var(--zk-blue);
--grad-blue-hover: var(--zk-navy-2);
/* TYPE SCALE on a 15px root, so the rem steps land on the design's
pixel sizes: 12.5 eyebrows, 13.5 small, 14 labels, 15 body, 17 card
titles, 20 tab figures, 28 page title / KPI, 44 the one risk number. */
--fs-3xs: 0.8333rem; /* 12.5px — uppercase eyebrows, table heads */
--fs-2xs: 0.9rem; /* 13.5px — meta, pills, secondary lines */
--fs-xs: 0.9333rem; /* 14px — labels */
--fs-sm: 1rem; /* 15px — body */
--fs-md: 1.0667rem; /* 16px — emphasis */
--fs-lg: 1.1333rem; /* 17px — card titles */
--fs-xl: 1.3333rem; /* 20px — tab figures, small headings */
--fs-2xl: 1.8667rem; /* 28px — page title, KPI figures */
--fs-3xl: 2.9333rem; /* 44px — renewals at risk */
--lh-tight: 1.15;
--lh-snug: 1.35;
--lh-normal: 1.4;
--fw-normal: 400;
--fw-medium: 500;
--fw-semi: 600;
--fw-bold: 700;
--fw-x: 700;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-7: 32px;
--sp-8: 40px;
--sp-9: 56px;
--font-display: 'Source Sans 3', system-ui, -apple-system, sans-serif;
--font-zurich: 'Source Sans 3', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-family: var(--font-zurich); font-family: var(--font-zurich);
font-size: 16px; /* Absolute on purpose: every --fs-* token is rem and sizing the root from
line-height: 1.5; one of them would be circular. */
font-size: 15px;
line-height: var(--lh-normal);
color: var(--zk-ink); color: var(--zk-ink);
background: var(--zk-white); background: var(--zk-tint);
font-feature-settings: 'tnum' 1;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
* { * { box-sizing: border-box; }
box-sizing: border-box;
html, body, #root { height: 100%; }
body { margin: 0; background: var(--zk-tint); }
button, input, select, textarea { font: inherit; color: inherit; }
/* One focus treatment for the whole console. */
:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
::selection { background: var(--zk-tint-blue); color: var(--zk-navy); }
* { scrollbar-width: thin; scrollbar-color: var(--zk-line) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
border: 3px solid transparent; border-radius: var(--r-pill);
background-clip: content-box; background-color: var(--zk-line);
}
::-webkit-scrollbar-thumb:hover { background-color: var(--zk-grey); }
@keyframes zk-rise {
/* Opacity only a transform here composites text onto a GPU layer and it
renders soft in Chrome on Linux. */
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes zk-shimmer {
from { background-position: -420px 0; }
to { background-position: 420px 0; }
} }
html, @media (prefers-reduced-motion: reduce) {
body, *, *::before, *::after {
#root { animation-duration: 0.001ms !important;
height: 100%; animation-iteration-count: 1 !important;
} transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
body { }
margin: 0;
}
button,
input {
font: inherit;
color: inherit;
} }

View File

@ -1,58 +1,209 @@
.shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); } .shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--zk-tint);
}
/* ── Masthead ─────────────────────────────────────────────────────────────── */
.shell__top { .shell__top {
display: flex; align-items: center; justify-content: space-between; position: sticky;
gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white); top: 0;
border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10; z-index: 30;
display: flex;
align-items: center;
gap: 22px;
height: 56px;
padding: 0 24px;
background: var(--zk-navy);
color: #fff;
} }
.shell__brand { display: flex; align-items: center; gap: 14px; }
.shell__brand img { height: 30px; width: auto; display: block; }
.shell__brand div { display: flex; flex-direction: column; line-height: 1.25; padding-left: 14px; border-left: 1px solid var(--zk-line); }
.shell__brand strong { font-size: .95rem; font-weight: 500; color: var(--zk-ink); }
.shell__brand span { font-size: .74rem; color: var(--zk-muted); }
.shell__who { display: flex; align-items: center; gap: 16px; } .mast__burger {
.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; } display: none; /* a phone-only control; see the media block */
.shell__user strong { font-size: .85rem; font-weight: 500; } width: 34px; height: 34px; flex: none;
.shell__user span { font-size: .72rem; color: var(--zk-muted); } place-items: center; margin-left: -6px;
.shell__signout { border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer;
font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer;
background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line);
} }
.shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); } .mast__burger svg { width: 20px; height: 20px; }
.mast__burger:hover { background: rgba(255, 255, 255, .12); }
.mast__burger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
/* Half the bar's height, which is where a 3.7:1 lockup sits without crowding
the app name beside it. The asset carries its own rounded plate and the
words "ZURICH kotak / General Insurance", so nothing is set in type next to
it that was the same sentence twice. The radius matches the artwork's own
corners so no hairline shows at the edge. */
.mast__logo { flex: none; display: block; height: 28px; width: auto; border-radius: 6px; }
.mast__sep { width: 1px; height: 26px; background: rgba(255, 255, 255, .22); }
.mast__app { font-size: 17px; font-weight: 600; white-space: nowrap; }
.mast__app span { font-weight: 400; opacity: .7; margin-left: 8px; font-size: 15px; }
.mast__user { margin-left: auto; }
.shell__scrim {
display: none;
position: fixed; inset: 56px 0 0 0; z-index: 35;
border: 0; padding: 0; background: rgba(11, 42, 91, .35); cursor: default;
}
/* ── Frame ────────────────────────────────────────────────────────────────── */
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; } .shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
.shell__nav { .shell__nav {
width: 244px; flex: none; background: var(--zk-white); position: sticky;
border-right: 1px solid var(--zk-line); padding: 18px 12px 28px; top: 56px;
display: flex; flex-direction: column; gap: 1px; align-self: flex-start;
} width: 232px;
.shell__navlabel { flex: none;
margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500; min-height: calc(100vh - 56px);
letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); max-height: calc(100vh - 56px);
} overflow-y: auto;
.shell__navlabel--closed { margin-top: 22px; } overscroll-behavior: contain;
background: #fff;
.shell__stage { border-right: 1px solid var(--zk-line);
display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 20px 12px;
padding: 8px 10px; border-radius: 4px; text-decoration: none; display: flex;
color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent; flex-direction: column;
} gap: 2px;
.shell__stage:hover { background: var(--zk-tint); }
.shell__stage.is-active { background: var(--zk-tint-blue); border-left-color: var(--zk-blue); color: var(--zk-blue-dark); font-weight: 500; }
/* The one queue where the machine stops and a person decides. */
.shell__stage.is-human .shell__stagename { font-weight: 500; }
.shell__badge {
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white);
} }
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; } .nav__h {
margin: 0;
.shell__add { padding: 18px 12px 6px;
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px; font-size: var(--fs-3xs);
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500; font-weight: 600;
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue); letter-spacing: .04em;
text-transform: uppercase;
color: var(--zk-grey);
} }
.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); } .nav__group { display: flex; flex-direction: column; gap: 2px; }
.nav__link {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-radius: var(--r-sm);
font-size: var(--fs-sm);
font-weight: 500;
color: var(--zk-muted);
text-decoration: none;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.nav__link:hover { background: var(--zk-tint); color: var(--zk-ink); }
.nav__link.is-on {
background: var(--zk-tint-blue);
color: var(--zk-navy);
font-weight: 600;
box-shadow: inset 3px 0 0 var(--zk-blue);
}
.nav__name { min-width: 0; line-height: 1.35; }
/* The count is who is being waited on: red a person urgently, amber the
customer, grey the calendar or history. */
.nav__n {
margin-left: auto;
font-size: var(--fs-2xs);
font-weight: 600;
padding: 1px 8px;
border-radius: var(--r-pill);
font-variant-numeric: tabular-nums;
background: var(--zk-line-soft);
color: var(--zk-muted);
}
.nav__n--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
.nav__n--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.nav__n--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
/* Closed folds. The summary IS a nav row; a chevron says it opens. */
.nav__fold > summary { list-style: none; }
.nav__fold > summary::-webkit-details-marker { display: none; }
.nav__fold > summary .nav__name::before {
content: '';
display: inline-block;
width: 5px; height: 5px;
margin: 0 8px 1px 0;
border-right: 1.5px solid var(--zk-grey);
border-bottom: 1.5px solid var(--zk-grey);
transform: rotate(-45deg);
transition: transform var(--t-fast);
}
.nav__fold[open] > summary .nav__name::before { transform: rotate(45deg); }
.nav__sub { display: flex; flex-direction: column; gap: 2px; padding-left: 12px; }
/* ── Canvas ───────────────────────────────────────────────────────────────── */
.shell__main {
flex: 1;
min-width: 0;
width: 100%;
background: var(--zk-tint);
padding: 28px 32px 40px;
}
@media (max-width: 900px) {
/* PHONE
The masthead keeps one line and one line only: the mark, the app name,
the person. Everything secondary goes, because a 56px bar that wraps is
a bar that clips, which is what it did.
The rail becomes a drawer over the page rather than a block above it
nine queues stacked at the top meant scrolling past the whole menu on
every screen before reaching a single lead. */
.shell__top { gap: 12px; padding: 0 14px; }
.mast__burger { display: grid; }
.mast__sep,
.mast__app span { display: none; }
/* "General Insurance" is inside the artwork, so it cannot be dropped on its
own the way the type lockup's second line was. The mark shrinks instead. */
.mast__logo { height: 24px; }
.mast__app { font-size: 15.5px; }
.shell__body { flex-direction: row; }
.shell__nav {
position: fixed; top: 56px; left: 0; bottom: 0; z-index: 40;
width: 268px; max-width: 82vw; min-height: 0; max-height: none;
border-right: 1px solid var(--zk-line);
transform: translateX(-100%);
transition: transform .22s var(--ease);
overflow-y: auto; overscroll-behavior: contain;
}
.shell__nav.is-open { transform: none; box-shadow: 0 12px 40px rgba(11, 42, 91, .22); }
.shell__scrim { display: block; }
.shell__main { padding: 20px 16px 48px; min-width: 0; flex: 1; }
}
@media (max-width: 560px) {
/* Below this the app name and the wordmark cannot both fit beside the user
menu without one of them truncating. The mark wins it is the brand. */
.mast__app { display: none; }
.um__id { display: none; }
.um__caret { display: none; }
.um__trigger { padding: 4px; }
}
@media (prefers-reduced-motion: reduce) {
.shell__nav { transition: none; }
}
/* The support desk, reachable from anywhere in the console. Ops only the
button is not drawn for anyone else, and the platform refuses them anyway. */
.mast__desk {
margin-left: auto;
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: var(--r-pill);
border: 1px solid rgba(255, 255, 255, .28); background: transparent; color: #fff;
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast);
}
.mast__desk svg { width: 14px; height: 14px; }
.mast__desk:hover { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .5); }
.mast__desk:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
/* With the desk button present it owns the auto margin, so the user menu must
not also claim one or the two separate to opposite ends. */
.mast__desk ~ .mast__user { margin-left: 0; }
@media (max-width: 900px) { .mast__desk span, .mast__desk { font-size: 0; gap: 0; padding: 8px; } .mast__desk svg { font-size: initial; } }

View File

@ -1,75 +1,159 @@
import { useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
import { 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 { STAGES } from '../api/config.js' import AskDesk from '../components/AskDesk.jsx'
import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp' import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css' import './Shell.css'
/** /**
* The sidebar IS the pipeline, in the order a lead moves so the shape of the * The frame: a navy masthead, a white rail, a grey canvas.
* process is legible before any record is opened. The one human queue is marked *
* as such, because it is the only place the machine stops. * The rail groups the pipeline by WHO IS HOLDING each lead a person, the
* customer, the calendar because "is anything waiting on me?" is the question
* anyone signing in actually has. The count on each row is what needs a person;
* its colour says how urgently, on the one colour rule the whole console keeps:
* red for risk, amber for a person, teal for the customer, blue for the AI.
*
* Closed lives under History and stays folded: it is reporting, not work.
*/ */
/** The badge colour for a queue: the colour of whoever is being waited on. */
function tone(stage) {
if (stage.kind === 'needs') return 'red'
if (stage.kind === 'customer') return 'amber'
return 'grey'
}
export default function Shell() { export default function Shell() {
const { user, signOut } = useZino() const { user } = useZino()
const work = STAGES.filter((s) => s.kind !== 'end') const roles = rolesOf(user)
const closed = STAGES.filter((s) => s.kind === 'end') const stages = visibleStages(roles)
const { counts, ready } = useStageCounts()
// On a phone the rail becomes a drawer. It is closed on every navigation,
// because a menu that stays open over the page you just chose is a menu you
// have to dismiss twice. Desktop never reads this the rail is always there.
const [navOpen, setNavOpen] = useState(false)
// The support desk reads the WHOLE book every partner, every premium so
// it is an ops tool, not something a partner agent may open. The platform
// enforces this too (the desk is assigned per user); this only decides
// whether the button is drawn.
const [deskOpen, setDeskOpen] = useState(false)
const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
// Closing on the link rather than on the route: a menu that stays open over
// the page you just chose is a menu you have to dismiss twice. Keyed off the
// anchor so opening the Closed fold which is not navigation leaves it up.
const closeIfLink = (e) => { if (e.target.closest('a')) setNavOpen(false) }
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
// "All leads" carries the open book, not the whole book.
const openTotal = STAGES
.filter((s) => s.kind !== 'end')
.reduce((t, s) => t + count(s).total, 0)
const row = (s) => {
const c = count(s)
const n = s.kind === 'end' ? c.total : c.waiting
const hint = c.working ? `${c.working} being worked by the AI` : undefined
return (
<NavLink
key={s.uid}
to={`/stage/${s.uid}`}
title={hint}
className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}
>
<span className="nav__name">{s.need ?? s.name}</span>
{ready ? <span className={`nav__n nav__n--${n ? tone(s) : 'grey'}`}>{n}</span> : null}
</NavLink>
)
}
return ( return (
<div className="shell"> <div className="shell">
<header className="shell__top"> <header className="shell__top">
<div className="shell__brand"> <button
<img src={logo} alt="Zurich Kotak General Insurance" /> type="button" className="mast__burger"
<div> aria-label={navOpen ? 'Close the menu' : 'Open the menu'} aria-expanded={navOpen}
<strong>Lead Desk</strong> onClick={() => setNavOpen((o) => !o)}
<span>Lead to Policy</span> >
</div> <svg viewBox="0 0 20 20" aria-hidden="true">
</div> {navOpen
<div className="shell__who"> ? <path d="M5 5l10 10M15 5L5 15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<div className="shell__user"> : <path d="M3 6h14M3 10h14M3 14h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />}
<strong>{user?.name || user?.email || 'Signed in'}</strong> </svg>
{/* The role is shown because RBAC is the demo: what this person can </button>
and cannot do is the point, so who they are signed in as should {/* The lockup as ARTWORK, not as type. It was set in Source Sans with
never be a guess. */} a drawn Z, which is a passable imitation of a brand and not the
<span>{(user?.roles || []).join(', ') || user?.email}</span> brand: the real mark's proportions, its weight contrast and its
</div> plate are all in the asset. Bundled from src/assets so Vite
<button type="button" className="shell__signout" onClick={signOut}> rewrites the URL and it resolves under the sub-path mount. */}
Sign out <img className="mast__logo" src={logo} alt="Zurich Kotak General Insurance" />
<span className="mast__sep" aria-hidden="true" />
<div className="mast__app">Lead Desk <span>Lead to policy</span></div>
{canAskDesk ? (
<button type="button" className="mast__desk" onClick={() => setDeskOpen(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
strokeWidth="1.3" strokeLinejoin="round" />
</svg>
Ask the desk
</button> </button>
</div> ) : null}
<div className="mast__user"><UserMenu /></div>
</header> </header>
<div className="shell__body"> <div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline"> {/* The scrim only exists while the drawer is open, and only on a phone:
<NavLink to="/add" className="shell__add">+ Add a lead</NavLink> tapping the page you can see should put the menu away. */}
<p className="shell__navlabel">Pipeline</p> {navOpen ? <button type="button" className="shell__scrim" aria-label="Close the menu" onClick={() => setNavOpen(false)} /> : null}
{work.map((s) => ( <nav className={'shell__nav' + (navOpen ? ' is-open' : '')} aria-label="Pipeline" onClick={closeIfLink}>
<NavLink <NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
key={s.uid} <span className="nav__name">Overview</span>
to={`/stage/${s.uid}`} </NavLink>
className={({ isActive }) => <NavLink to="/stage/all" className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '') <span className="nav__name">All leads</span>
} {ready ? <span className="nav__n nav__n--grey">{openTotal}</span> : null}
> </NavLink>
<span className="shell__stagename">{s.name}</span>
{s.kind === 'human' ? <span className="shell__badge">you</span> : null} {NAV_GROUPS.map((group) => {
</NavLink> const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
))} if (!inGroup.length) return null
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
{closed.map((s) => ( if (group.kinds.includes('end')) {
<NavLink const closed = inGroup.reduce((t, s) => t + count(s).total, 0)
key={s.uid} return (
to={`/stage/${s.uid}`} <div className="nav__group" key={group.label}>
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')} <h6 className="nav__h">{group.label}</h6>
> <details className="nav__fold">
<span className="shell__stagename">{s.name}</span> <summary className="nav__link">
</NavLink> <span className="nav__name">Closed</span>
))} {ready ? <span className="nav__n nav__n--grey">{closed}</span> : null}
</summary>
<div className="nav__sub">{inGroup.map(row)}</div>
</details>
</div>
)
}
return (
<div className="nav__group" key={group.label}>
<h6 className="nav__h">{group.label}</h6>
{inGroup.map(row)}
</div>
)
})}
</nav> </nav>
<main className="shell__main"> <main className="shell__main">
<Outlet /> <Outlet />
</main> </main>
</div> </div>
{deskOpen ? <AskDesk onClose={() => setDeskOpen(false)} /> : null}
</div> </div>
) )
} }

59
src/layout/UserMenu.css Normal file
View File

@ -0,0 +1,59 @@
.um { position: relative; }
/* The trigger sits on the navy masthead, so it is white type on navy and the
avatar inverts: a white disc with navy initials. */
.um__trigger {
display: flex; align-items: center; gap: 10px;
cursor: pointer;
padding: 5px 10px 5px 5px;
border-radius: var(--r-pill);
background: transparent;
border: 1px solid transparent;
color: #fff;
transition: background var(--t-fast);
}
.um__trigger:hover, .um__trigger.is-open { background: rgba(255, 255, 255, .10); }
.um__trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
.um__avatar {
flex: none; width: 32px; height: 32px; border-radius: 50%;
display: grid; place-items: center;
background: #fff; color: var(--zk-navy);
font-size: 13px; font-weight: 700; letter-spacing: .02em;
}
.um__avatar--lg { width: 40px; height: 40px; font-size: 15px; background: var(--zk-tint-blue); color: var(--zk-navy); }
.um__id { display: flex; flex-direction: column; text-align: left; line-height: 1.25; }
.um__id strong { font-size: 15px; font-weight: 600; }
.um__id > span { font-size: 12.5px; opacity: .72; }
.um__caret { width: 12px; height: 12px; flex: none; opacity: .6; transition: transform var(--t); }
.um__trigger.is-open .um__caret { transform: rotate(180deg); }
/* The menu drops onto the white canvas below the bar. */
.um__menu {
position: absolute; top: calc(100% + 10px); right: 0; z-index: 40;
min-width: 300px; padding: 7px;
border-radius: var(--r-md);
background: #fff; color: var(--zk-ink);
border: 1px solid var(--zk-line);
box-shadow: var(--sh-lg);
animation: zk-rise .16s var(--ease) both;
}
.um__head { display: flex; align-items: flex-start; gap: 12px; padding: 12px 11px 14px; }
.um__headid { display: flex; flex-direction: column; gap: 1px; line-height: 1.35; min-width: 0; }
.um__headid strong { font-size: var(--fs-sm); font-weight: 600; }
.um__headid span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
.um__headid .um__roles {
margin-top: 4px; align-self: flex-start;
font-size: var(--fs-2xs); font-weight: 600;
color: var(--zk-navy); background: var(--zk-tint-blue);
border-radius: var(--r-pill); padding: 2px 9px;
}
.um__item {
display: block; width: 100%; text-align: left; cursor: pointer;
font: inherit; font-size: var(--fs-sm); padding: 10px 11px;
border-radius: var(--r-sm); background: none; border: 0;
border-top: 1px solid var(--zk-line-soft); color: var(--zk-ink);
}
.um__item:hover, .um__item:focus-visible { outline: none; background: var(--zk-tint); color: var(--zk-navy); }

92
src/layout/UserMenu.jsx Normal file
View File

@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import { useZino } from '../api/provider.jsx'
import './UserMenu.css'
/** First letters of the first two words — a name is all we have for an avatar. */
function initials(user) {
const from = user?.name || user?.email || ''
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
if (!parts.length) return '?'
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
}
/**
* The signed-in identity, with sign-out folded inside it.
*
* The role stays visible on the trigger rather than only inside the menu: RBAC is
* the demo, so who you are signed in as should never take a click to find out.
*/
export default function UserMenu() {
const { user, signOut } = useZino()
const [open, setOpen] = useState(false)
const wrapRef = useRef(null)
const triggerRef = useRef(null)
// Close on an outside click or Escape. Bound only while open, so the shell
// costs nothing at rest.
useEffect(() => {
if (!open) return
function onDown(e) {
if (!wrapRef.current?.contains(e.target)) setOpen(false)
}
function onKey(e) {
if (e.key !== 'Escape') return
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
const name = user?.name || user?.email || 'Signed in'
// Role slugs read as titles: partner_agent -> Partner agent.
const roles = (user?.roles || [])
// `sme_underwriter` "SME underwriter": the acronyms the roles here use stay upper.
// `sme_underwriter` "SME underwriter". Ops is a word, not an acronym.
.map((r) => String(r).replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
.replace(/\b(sme|uw|rm|posp|csr|kyc)\b/gi, (m) => m.toUpperCase()))
.join(', ')
return (
<div className="um" ref={wrapRef}>
<button
ref={triggerRef}
type="button"
className={'um__trigger' + (open ? ' is-open' : '')}
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
>
<span className="um__avatar" aria-hidden="true">{initials(user)}</span>
<span className="um__id">
<strong>{name}</strong>
<span>{roles || user?.email}</span>
</span>
<svg className="um__caret" width="12" height="12" 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.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<div className="um__menu" role="menu">
<div className="um__head">
<span className="um__avatar um__avatar--lg" aria-hidden="true">{initials(user)}</span>
<div className="um__headid">
<strong>{name}</strong>
{user?.email ? <span>{user.email}</span> : null}
{roles ? <span className="um__roles">{roles}</span> : null}
</div>
</div>
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
Sign out
</button>
</div>
) : null}
</div>
)
}

View File

@ -68,7 +68,7 @@
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--zk-cyan); color: var(--zk-cyan);
font-size: 13px; font-size: var(--fs-2xs);
font-weight: 400; font-weight: 400;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
@ -81,7 +81,7 @@
.login__headline { .login__headline {
/* Zurich Kotak set their hero in Zurich Sans Light */ /* Zurich Kotak set their hero in Zurich Sans Light */
margin: 26px 0 0; margin: 26px 0 0;
font-size: 44px; font-size: var(--fs-3xl);
font-weight: 300; font-weight: 300;
line-height: 1.16; line-height: 1.16;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@ -104,7 +104,7 @@
margin: 16px 0 0; margin: 16px 0 0;
max-width: 430px; max-width: 430px;
text-wrap: balance; text-wrap: balance;
font-size: 16px; font-size: var(--fs-md);
font-weight: 300; font-weight: 300;
line-height: 1.5; line-height: 1.5;
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
@ -144,7 +144,7 @@
.login__features strong { .login__features strong {
display: block; display: block;
font-size: 15px; font-size: var(--fs-sm);
font-weight: 500; font-weight: 500;
letter-spacing: 0.005em; letter-spacing: 0.005em;
} }
@ -152,7 +152,7 @@
.login__features em { .login__features em {
display: block; display: block;
margin-top: 3px; margin-top: 3px;
font-size: 14px; font-size: var(--fs-xs);
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
color: rgba(255, 255, 255, 0.74); color: rgba(255, 255, 255, 0.74);
@ -178,7 +178,7 @@
.login__title { .login__title {
margin: 0; margin: 0;
font-size: 30px; font-size: var(--fs-2xl);
font-weight: 500; font-weight: 500;
letter-spacing: -0.015em; letter-spacing: -0.015em;
color: var(--zk-navy); color: var(--zk-navy);
@ -186,7 +186,7 @@
.login__subtitle { .login__subtitle {
margin: 8px 0 0; margin: 8px 0 0;
font-size: 14px; font-size: var(--fs-xs);
font-weight: 300; font-weight: 300;
color: var(--zk-muted); color: var(--zk-muted);
} }
@ -201,7 +201,7 @@
.login__field span { .login__field span {
display: block; display: block;
margin-bottom: 7px; margin-bottom: 7px;
font-size: 13px; font-size: var(--fs-2xs);
font-weight: 500; font-weight: 500;
color: var(--zk-ink); color: var(--zk-ink);
} }
@ -212,7 +212,7 @@
border: 1px solid var(--zk-line); border: 1px solid var(--zk-line);
border-radius: 8px; border-radius: 8px;
background: var(--zk-white); background: var(--zk-white);
font-size: 14px; font-size: var(--fs-xs);
font-weight: 400; font-weight: 400;
color: var(--zk-ink); color: var(--zk-ink);
transition: transition:
@ -232,7 +232,7 @@
.login__error { .login__error {
margin: -4px 0 0; margin: -4px 0 0;
font-size: 13px; font-size: var(--fs-2xs);
color: var(--zk-danger); color: var(--zk-danger);
} }
@ -247,7 +247,7 @@
border-radius: 8px; border-radius: 8px;
background: var(--zk-blue); background: var(--zk-blue);
color: var(--zk-white); color: var(--zk-white);
font-size: 14px; font-size: var(--fs-xs);
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: transition:
@ -277,7 +277,7 @@
.login__powered { .login__powered {
margin: 26px 0 0; margin: 26px 0 0;
text-align: center; text-align: center;
font-size: 12px; font-size: var(--fs-2xs);
font-weight: 300; font-weight: 300;
color: var(--zk-grey); color: var(--zk-grey);
} }
@ -296,11 +296,11 @@
} }
.login__headline { .login__headline {
font-size: 36px; font-size: var(--fs-3xl);
} }
.login__lede { .login__lede {
font-size: 15px; font-size: var(--fs-sm);
} }
} }

View File

@ -35,7 +35,19 @@ export function requireConfigValue(key) {
* *
* `import.meta.env.BASE_URL` is NOT usable here with Vite's relative base it * `import.meta.env.BASE_URL` is NOT usable here with Vite's relative base it
* resolves to "./". * resolves to "./".
*
* It reads the TAG, not `document.baseURI`. With no <base> in the document
* which is every `npm run dev` session, where the placeholder is still an HTML
* comment baseURI falls back to the current page URL, so the basename became
* whatever deep path happened to be loaded. Open /lead/16911 directly and every
* link then rendered under it: /lead/16911/lead/11411, and again on the next
* click. Unmounted means mounted at the root.
*/ */
export function basePath() { export function basePath() {
return new URL(document.baseURI).pathname const tag = document.querySelector('base[href]')
if (!tag) return '/'
const path = new URL(tag.href, window.location.origin).pathname
// A relative href ("./") resolves against the current URL and would reintroduce
// exactly the same drift. Only an absolute mount path is a mount path.
return tag.getAttribute('href').startsWith('/') ? path : '/'
} }

View File

@ -1,58 +0,0 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import ActivityForm from '../components/ActivityForm.jsx'
import { ENTRY } from '../api/config.js'
import './screens.css'
/**
* The three doors. Each is a separate INIT activity with its own permissions,
* and the bank one lands the instance further along because the bank already
* did CKYC. Whichever door is used, everything after it is identical.
*/
export default function AddLead() {
// One door surfaced: skip the chooser entirely rather than show a list of one.
const [entry, setEntry] = useState(ENTRY.length === 1 ? ENTRY[0] : null)
const navigate = useNavigate()
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Add a lead</h1>
<p className="page__sub">Three doors, one machine. The door decides who may submit and where it lands nothing after that.</p>
</div>
</header>
{!entry ? (
<div className="doors">
{ENTRY.map((e) => (
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
<strong>{e.label}</strong>
<span className="chip">{e.channel}</span>
<p>{e.note}</p>
</button>
))}
</div>
) : (
<div className="panel">
<div className="panel__head">
<div>
<h2 className="panel__title">{entry.label}</h2>
<p className="panel__sub">{entry.note}</p>
</div>
{ENTRY.length > 1 ? (
<button type="button" className="af__ghost" onClick={() => setEntry(null)}>Change door</button>
) : null}
</div>
<ActivityForm
activityUid={entry.uid}
onDone={(res) => {
const id = res?.instance_id || res?.data?.instance_id
if (id) navigate(`/lead/${id}`)
}}
/>
</div>
)}
</section>
)
}

File diff suppressed because it is too large Load Diff

461
src/screens/Overview.jsx Normal file
View File

@ -0,0 +1,461 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
import { holderOf } from '../api/holder.js'
import './screens.css'
/**
* Portfolio overview the landing screen.
*
* 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
* tracking leads they filed is reasonable, acting on them is not, and the
* sidebar already gates the second.
*/
const DAY = 86400000
const today = () => Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')
function 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 - today()) / DAY)
}
function ageInDays(v) {
if (!v) return null
const d = Date.parse(v)
return isNaN(d) ? null : Math.floor((Date.now() - d) / DAY)
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
/** Indian money, short. A dashboard tile has no room for eight digits. */
function inr(n) {
if (!n) return '₹0'
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
/* The renewal-exposure buckets, as predicates on days-to-expiry, so the filter
and the three counts stay one definition. */
const EXPO_BUCKETS = {
lapsed: (d) => d < 0,
week: (d) => d >= 0 && d <= 7,
month: (d) => d > 7 && d <= 30,
}
const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' }
/* The renewal-exposure table is filterable on the columns it shows. Each field
knows how to match a row and how to describe itself as a chip. */
const PREM_RANGES = [
['lt10', 'Under ₹10,000', (p) => p > 0 && p < 10000],
['10-25', '₹10,000 25,000', (p) => p >= 10000 && p < 25000],
['25-50', '₹25,000 50,000', (p) => p >= 25000 && p < 50000],
['gt50', 'Over ₹50,000', (p) => p >= 50000],
]
const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn]))
export default function Overview() {
const { user } = useZino()
const navigate = useNavigate()
const roles = rolesOf(user)
const canFile = entryDoorsFor(roles, ENTRY).length > 0
// Filing a lead happens over this page, not instead of it see
// NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false)
// Renewal-exposure dropdown filters. Empty string = no filter on that field.
const [fStage, setFStage] = useState('')
const [fExpiry, setFExpiry] = useState('')
const [fPrem, setFPrem] = useState('')
const state = usePortfolio()
const m = useMemo(() => {
const rows = state.rows
const byStage = {}
for (const r of rows) byStage[r.current_state_name || '—'] = (byStage[r.current_state_name || '—'] || 0) + 1
const endNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name))
const open = rows.filter((r) => !endNames.has(r.current_state_name))
const won = rows.filter((r) => r.current_state_name === 'Onboarded' || r.current_state_name === 'Policy Issued')
const lost = rows.filter((r) => r.current_state_name === 'Lost / Dropped' || r.current_state_name === 'Declined')
const closed = won.length + lost.length
// Written premium counts only policies that exist. A quote nobody accepted
// is pipeline, not production, and adding the two is how a dashboard ends
// up flattering itself.
const gwp = won.reduce((t, r) => t + num(r.quoted_premium), 0)
const commission = won.reduce((t, r) => t + num(r.commission_amount), 0)
// Policy Issued is written business AND not yet terminal, so it appears in
// `open` as well as in `won`. Summing both would count the same premium
// twice once as pipeline, once as production. Pipeline is what is still
// to be placed, so anything already on risk comes out of it.
const wonIds = new Set(won.map((r) => r.instance_id ?? r.id))
const pipeline = open
.filter((r) => !wonIds.has(r.instance_id ?? r.id))
.reduce((t, r) => t + num(r.quoted_premium), 0)
const dated = open
.map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date), _age: ageInDays(r.created_at) }))
.filter((r) => r._d !== null)
const exposure = {
lapsed: dated.filter((r) => r._d < 0),
week: dated.filter((r) => r._d >= 0 && r._d <= 7),
month: dated.filter((r) => r._d > 7 && r._d <= 30),
}
const attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d)
// Queues a person has to clear, in the order the pipeline runs.
//
// The count is of leads a person actually has to act on NOT of leads
// sitting in the state. Document Pending holds both: the ones waiting for
// an upload, and the ones whose upload arrived and are now being worked by
// three AI steps inside the same state. Counting the state told an operator
// that four leads needed them when two did, which is how a queue stops
// being believed. `working` is reported separately rather than dropped
// the leads are still there, they are just not anyone's task.
const actionable = STAGES
.filter((s) => s.kind === 'needs' || s.kind === 'customer')
.map((s) => {
const here = open.filter((r) => r.current_state_name === s.name)
const working = here.filter((r) => phaseOf(s, r).kind === 'auto')
const waiting = here.filter((r) => phaseOf(s, r).kind !== 'auto')
return {
...s,
n: waiting.length,
working: working.length,
oldest: waiting
.map((r) => ageInDays(r.created_at))
.filter((x) => x !== null)
.sort((a, b) => b - a)[0],
}
})
// The sign-off queues this ROLE owns. Empty for everyone else, so the band
// never appears to a partner agent who could not act on it anyway.
const approvals = actionable.filter((s) => s.approval && roles.includes(s.approval))
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
// Every open lead is in exactly one of three hands: a person's, the AI's,
// or the customer's. The page led with money, which is the outcome; this
// is the state, and it is the sentence the whole product exists to say.
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
let withAI = 0, withCustomer = 0, withPerson = 0
for (const r of open) {
const def = byStageDef.get(r.current_state_name)
if (!def) continue
const ph = phaseOf(def, r)
if (ph.kind === 'auto') withAI += 1
else if (ph.kind === 'customer') withCustomer += 1
else withPerson += 1
}
return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
withAI, withCustomer, withPerson,
conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
}
}, [state.rows, roles])
const mine = new Set(visibleStages(roles).map((s) => s.uid))
// Stages present in the exposure set, for the Stage filter's options.
const expoStages = [...new Set(m.attention.map((r) => r.current_state_name).filter(Boolean))]
const anyFilter = Boolean(fStage || fExpiry || fPrem)
// The exposure table, narrowed by whichever dropdowns are set (AND).
const shown = m.attention.filter((r) =>
(!fStage || r.current_state_name === fStage) &&
(!fExpiry || (EXPO_BUCKETS[fExpiry] ? EXPO_BUCKETS[fExpiry](r._d) : true)) &&
(!fPrem || (PREM_MATCH[fPrem] ? PREM_MATCH[fPrem](num(r.quoted_premium)) : true)),
)
if (state.status === 'error') {
const said = describeError(state.error)
return (
<section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
<div className="notice">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
</div>
</section>
)
}
if (state.status === 'loading' && !state.rows.length) {
return (
<section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
</section>
)
}
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals, organisation-wide</p>
</div>
{/* The action sits with the thing it acts on. It was in the sidebar,
which is for moving between places an action among destinations
is the one item that does not behave like its neighbours. */}
<div className="page__right">
{state.at ? (
<span className="stamp">
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span>
) : null}
{canFile ? (
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</button>
) : null}
</div>
</header>
{/* Who holds the work, right now. Three numbers, one sentence: this many
are being worked by the AI, this many are waiting on a person, this
many on a customer. It sits above the money because it is the claim
the money is evidence for. */}
{/* WHO HOLDS THE WORK, RIGHT NOW. Every open lead is in exactly one of
three hands the AI's, a person's, the customer's and that is the
sentence this console exists to say. The money below is evidence for
it. Risk stands apart, in the one colour reserved for it. */}
{(() => {
const total = m.open.length || 1
const pct = (n) => Math.max(n ? 2 : 0, Math.round((n / total) * 100))
const risk = m.exposure.lapsed.length + m.exposure.week.length
return (
<section className="hero">
<div className="card holders">
<div className="holders__top">
<b>{m.open.length}</b>
<span>open leads. Who is holding each one right now:</span>
</div>
<div className="bar" aria-hidden="true">
<div className="bar__blue" style={{ width: pct(m.withAI) + '%' }} />
<div className="bar__amber" style={{ width: pct(m.withPerson) + '%' }} />
<div className="bar__teal" style={{ width: pct(m.withCustomer) + '%' }} />
</div>
<div className="legend">
<div className="legend__item legend__item--blue">
<b>{m.withAI}</b><strong>With the AI</strong>
<span>Being qualified, rated, checked or chased. Nothing for anyone to do.</span>
</div>
<div className="legend__item legend__item--amber">
<b>{m.withPerson}</b><strong>Waiting on a person</strong>
<span>A decision or an upload is owed by an agent, underwriter or ops.</span>
</div>
<div className="legend__item legend__item--teal">
<b>{m.withCustomer}</b><strong>With the customer</strong>
<span>A quote or a payment is theirs to answer.</span>
</div>
</div>
</div>
<div className={'card risk' + (risk ? '' : ' is-quiet')}>
<b>{risk}</b><strong>{risk === 1 ? 'renewal at risk' : 'renewals at risk'}</strong>
<div className="risk__rows">
<div><span>Already lapsed</span><em>{m.exposure.lapsed.length}</em></div>
<div><span>Expire within 7 days</span><em>{m.exposure.week.length}</em></div>
</div>
</div>
</section>
)
})()}
<section className="card kpis">
<div className="kpi">
<span className="kpi__l">Pipeline value</span>
<strong className="kpi__v">{inr(m.pipeline)}</strong>
<span className="kpi__s">quoted, not yet on risk</span>
</div>
<div className="kpi">
<span className="kpi__l">Written premium</span>
<strong className="kpi__v">{inr(m.gwp)}</strong>
<span className="kpi__s">{m.won.length} {m.won.length === 1 ? 'policy' : 'policies'} issued</span>
</div>
<div className="kpi">
<span className="kpi__l">Conversion</span>
<strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong>
<span className="kpi__s">of leads that closed</span>
</div>
<div className="kpi">
<span className="kpi__l">Commission</span>
<strong className="kpi__v">{inr(m.commission)}</strong>
<span className="kpi__s">payable to partners</span>
</div>
<div className="kpi">
<span className="kpi__l">Closed to date</span>
<strong className="kpi__v">{m.closed}</strong>
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span>
</div>
</section>
{/* SIGN-OFF, not work: the one queue that is a person's approval rather
than a task, shown to the role that owns it and to nobody else. */}
{m.approvals.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
<strong className="appr__n">{s.n}</strong>
<span className="appr__t">{s.approvalLabel}<em>{s.approvalNote}</em></span>
<span className="appr__go">{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest} days` : 'review') : 'nothing waiting'}</span>
</Link>
))}
<section className="cols">
<div className="card">
<div className="card__hd">
<h3>Waiting on a person</h3>
<p>{m.actionable.reduce((t, s) => t + s.n, 0)} leads, oldest first</p>
</div>
<div className="queue">
{[...m.actionable].sort((a, b) => (b.oldest ?? -1) - (a.oldest ?? -1)).map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'queue__row' + (s.n ? '' : ' is-clear')}>
<b className="queue__n">{s.n}</b>
<div>
<div className="queue__t">{s.need ?? s.name}</div>
<div className="queue__who">
<span className={'dot dot--' + (s.kind === 'customer' ? 'teal' : 'amber')} />
{String(s.by).replace(/^(the|an|a)\s+/i, '').replace(/^./, (c) => c.toUpperCase())}
{!mine.has(s.uid) ? <span className="queue__ro" title="View only for your role">view</span> : null}
</div>
</div>
<div className="queue__age">
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest} day${s.oldest === 1 ? '' : 's'}` : ''}
{s.working ? <em>{s.working} more with the AI</em> : null}
</div>
<span className="btn btn--ghost btn--small">Open</span>
</Link>
))}
</div>
</div>
<div className="card">
<div className="card__hd"><h3>Pipeline by stage</h3><p>{m.open.length} open</p></div>
<div className="dist">
{STAGES.filter((s) => s.kind !== 'end').map((s) => {
const n = m.byStage[s.name] || 0
const tone = s.kind === 'auto' ? 'blue' : s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'amber' : 'grey'
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + tone} />
</span>
<b className="dist__n">{n}</b>
</Link>
)
})}
<div className="dist__sec">Closed · {m.closed}</div>
{STAGES.filter((s) => s.kind === 'end').map((s) => {
const n = m.byStage[s.name] || 0
const max = Math.max(1, ...STAGES.filter((x) => x.kind === 'end').map((x) => m.byStage[x.name] || 0))
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className="dist__r is-dim">
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / max) * 100 + '%' }} className="dist__f dist__f--grey" />
</span>
<b className="dist__n">{n}</b>
</Link>
)
})}
</div>
</div>
</section>
<section className="card exp">
<div className="card__hd"><h3>Renewal exposure</h3><p>open leads by time to expiry</p></div>
<div className="exp__tabs">
<div className="tab tab--red"><b>{m.exposure.lapsed.length}</b>lapsed</div>
<div className="tab tab--amber"><b>{m.exposure.week.length}</b>within 7 days</div>
<div className="tab"><b>{m.exposure.month.length}</b>8 to 30 days</div>
<div className="exp__filters">
<label className="xfd__f">
<select value={fStage} onChange={(e) => setFStage(e.target.value)} aria-label="Stage">
<option value="">All stages</option>
{expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
</select>
</label>
<label className="xfd__f">
<select value={fExpiry} onChange={(e) => setFExpiry(e.target.value)} aria-label="Time to expiry">
<option value="">Any time</option>
{Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</label>
<label className="xfd__f">
<select value={fPrem} onChange={(e) => setFPrem(e.target.value)} aria-label="Premium">
<option value="">Any premium</option>
{PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</label>
{anyFilter ? (
<button type="button" className="xfd__clear" onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>Clear</button>
) : null}
</div>
</div>
{shown.length ? (
<div className="gridwrap">
<table className="grid">
<thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th>Holding it</th><th className="num">Premium</th></tr>
</thead>
<tbody>
{shown.slice(0, anyFilter ? 100 : 8).map((r) => {
const id = r.instance_id ?? r.id
const def = STAGES.find((s) => s.name === r.current_state_name)
const h = holderOf(def, r)
const tone = r._d < 0 ? 'red' : r._d <= 7 ? 'amber' : 'grey'
return (
<tr key={id} className="grid__row" tabIndex={0} role="link"
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } }}
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}>
<td>
<span className="grid__name" style={{ fontSize: 'var(--fs-sm)' }}>{r.customer_name || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div>
</td>
<td><span className={'pill pill--' + tone}>
{r._d < 0 ? Math.abs(r._d) + (r._d === -1 ? ' day' : ' days') + ' overdue' : r._d === 0 ? 'today' : r._d + (r._d === 1 ? ' day' : ' days')}
</span></td>
<td>{r.current_state_name}</td>
<td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
<td className="num">{r.quoted_premium ? '₹' + Math.round(num(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p className="empty" style={{ margin: '0 20px 20px' }}>
{anyFilter ? 'No open leads match these filters.' : 'No renewals due within 30 days.'}
</p>
)}
</section>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section>
)
}

View File

@ -1,7 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js' import { ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import NewLeadDialog from '../components/NewLeadDialog.jsx'
import { holderOf } from '../api/holder.js'
/** Short absolute date plus how long ago a queue needs both: the absolute /** Short absolute date plus how long ago a queue needs both: the absolute
* for "when exactly", the relative for "is this going stale". */ * for "when exactly", the relative for "is this going stale". */
@ -20,6 +24,33 @@ function added(ts) {
} }
} }
/**
* Is an automated stage actually moving?
*
* The agents carry six of the nine stages, and a lead sitting in one is in
* exactly one of two states: being worked right now, or stuck. The row showed
* neither, so a lead three hours into "Calling the customer" looked identical
* to one thirty seconds in which is the failure this console exists to catch.
*
* The threshold is generous on purpose. An employee wake takes a minute or two
* and a scheduled retry can be hours out, so `stalled` means "longer than any
* normal step", not "longer than average".
*/
function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null
// `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 /** 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. */ * the countdown is the most operationally useful number in the row. */
function expiry(dateStr) { function expiry(dateStr) {
@ -41,107 +72,263 @@ import './screens.css'
*/ */
export default function Pipeline() { export default function Pipeline() {
const { stageUid } = useParams() const { stageUid } = useParams()
const { client } = useZino() const navigate = useNavigate()
const stage = STAGES.find((s) => s.uid === stageUid) const { client, user } = useZino()
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
// Over the queue rather than away from it: filing a lead used to cost you
// your place in the list you were working through.
const [adding, setAdding] = useState(false)
// "all" is not a stage it is every lead in one list, so an operator
// wanting to find one does not have to guess which queue it is in.
//
// IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
// or onboarded disappeared from the page called "All leads" the one place
// somebody goes when they cannot find something. A lead that ended is still
// a lead, and "where did it go?" is exactly the question this page exists to
// answer. Closed rows are shown, and dimmed, and can be hidden by choice.
const isAll = stageUid === 'all'
const [hideClosed, setHideClosed] = useState(false)
// Find-a-lead. Client-side, over the page already fetched.
const [q, setQ] = useState('')
const stage = isAll
? { uid: 'all', name: 'All leads', kind: 'all', by: 'everyone' }
: STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], error: null }) const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setState({ status: 'loading', rows: [], error: null })
client // `quiet` is what makes polling bearable: the first load may show a skeleton,
.recordView(RV_LEADS, { // a refresh may not dropping back to the loading state every 30 seconds
limit: 100, // would flash the whole queue away under whoever is reading it.
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }], function fetchRows(quiet) {
}) if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
.then((res) => { return client
if (cancelled) return .recordView(RV_LEADS, {
const rows = res?.data ?? res?.rows ?? res?.records ?? [] limit: isAll ? 200 : 100,
setState({ status: 'ready', rows, error: null }) // No stage filter on the all view. Closed leads are dropped below
}) // rather than in the query: one request beats three negations, and
.catch((err) => { // 200 covers a demo book comfortably.
if (cancelled) return ...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
setState({ status: 'error', rows: [], error: err }) })
}) .then((res) => {
return () => { cancelled = true } if (cancelled) return
}, [client, stageUid, stage?.name]) let rows = res?.data ?? res?.rows ?? res?.records ?? []
// Nothing is dropped here any more. Whether closed leads are shown
// is a choice made below, on data already fetched a filter that
// throws rows away at the query cannot be undone by a toggle.
// 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])
// Split rather than filtered at the query, so the header can say how many
// are closed instead of silently showing fewer than the page claims.
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
const needle = q.trim().toLowerCase()
const shownRows = (isAll && hideClosed
? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
: state.rows
).filter((r) => !needle || [r.customer_name, r.lead_ref, r.vehicle_reg, r.entity_name, r.customer_phone]
.some((v) => String(v || '').toLowerCase().includes(needle)))
// Inside a person-gated queue the rows split in two: the ones that are that
// person's turn, and the ones the AI is still working inside the same state.
// Two group rows say so; a flat list read as one queue when it was two.
const grouped = !isAll && (stage.kind === 'needs' || stage.kind === 'customer')
const yourTurn = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind !== 'auto') : shownRows
const withAI = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind === 'auto') : []
if (!stage) return <p className="empty">Unknown stage.</p> if (!stage) return <p className="empty">Unknown stage.</p>
function renderRows(rows) {
return (
<>
{rows.map((r) => {
const id = r.instance_id ?? r.id
const exp = expiry(r.renewal_due_date)
const add = added(r.updated_at || 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 def = STAGES.find((x) => x.name === r.current_state_name)
const rowStage = phaseOf(def, r)
const prog = progress(r, rowStage)
const h = holderOf(def, r)
const done = CLOSED.has(r.current_state_name)
const dueTone = exp ? (exp.tone === 'lapsed' ? 'red' : exp.tone === 'urgent' ? 'amber' : '') : ''
return (
<tr
key={id}
className={'grid__row'
+ (prog?.state === 'stalled' ? ' is-stalled' : '')
+ (done ? ' is-done' : '')}
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
}}
tabIndex={0}
role="link"
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
{r.lead_ref || `#${id}`}
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{r.vehicle_reg ? ` · ${r.vehicle_reg}` : r.entity_name ? ` · ${r.entity_name}` : ''}
</div>
</td>
<td>
{prog?.state === 'stalled' ? (
<><span className="stall">Stalled</span><div className="grid__sub">{prog.label}</div></>
) : (
<>{r.current_state_name || '—'}{prog ? <div className="grid__sub">{prog.label}</div> : null}</>
)}
</td>
<td>
{exp
? <span className={'due' + (dueTone ? ' due--' + dueTone : '')}><b>{exp.label}</b><small>{exp.on}</small></span>
: <span className="grid__sub"></span>}
</td>
<td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
<td className="num">{r.quoted_premium ? '₹' + Math.round(Number(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
</tr>
)
})}
</>
)
}
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
<div> <div>
<h1 className="page__title">{stage.name}</h1> <h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub"> <p className="page__sub">
{stage.kind === 'human' {isAll
? 'The only queue in the machine that waits for a person.' ? 'Every lead not yet closed, across all stages.'
: 'Handled by the agents; shown here for visibility.'} : 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 ? <span className="page__stage">Stage · {stage.name}</span> : null}
</p> </p>
</div> </div>
{state.status === 'ready' ? ( {canFile ? (
<span className="page__count">{state.rows.length}</span> <div className="page__right">
<button type="button" className="newlead" onClick={() => setAdding(true)}>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
strokeWidth="1.7" strokeLinecap="round" />
</svg>
New lead
</button>
</div>
) : null} ) : null}
</header> </header>
{state.status === 'loading' ? <p className="empty">Loading</p> : null} {state.status === 'loading' ? (
<div className="skel" aria-busy="true" aria-label="Loading the queue">
{state.status === 'error' ? ( {Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
<div className="notice">
<strong>This queue has no record view yet.</strong>
<p>
The console reads every queue from the <code>{RV_LEADS}</code> record view.
It is not seeded yet, so there is nothing to list. Everything else
sign-in, routing and the pipeline itself is live.
</p>
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
</div> </div>
) : null} ) : null}
{state.status === 'ready' && state.rows.length === 0 ? ( {state.status === 'error' ? (
<p className="empty">No leads at this stage.</p> <div className="notice">
<strong>Unable to load this stage.</strong>
<p>The lead list did not return. Other functions are unaffected; retry before escalating.</p>
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div>
) : null} ) : null}
{state.status === 'ready' && state.rows.length > 0 ? ( {state.status === 'ready' ? (
<table className="grid"> <div className="card">
<thead> <div className="tools">
<tr> <label className="search">
<th>Lead</th><th>Customer</th><th>Channel</th> <svg viewBox="0 0 16 16" aria-hidden="true">
<th>Product</th><th>Renewal due</th><th className="num">Premium</th> <circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<th>Attribution</th><th>Added</th> <path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</tr> </svg>
</thead> <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a lead, a registration, a reference" aria-label="Find a lead" />
<tbody> </label>
{state.rows.map((r) => { {/* Offered only where there is something to hide, and OFF by
const id = r.instance_id ?? r.id default: this page's job is that nothing disappears from it. */}
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } {isAll && closedCount ? (
const exp = expiry(r.renewal_due_date) <label className={'tools__toggle' + (hideClosed ? ' is-on' : '')}>
const add = added(r.created_at) <input type="checkbox" checked={hideClosed} onChange={(ev) => setHideClosed(ev.target.checked)} />
return ( <i aria-hidden="true" />
<tr key={id}> Hide {closedCount} closed
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td> </label>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td> ) : null}
<td><span className="chip">{ch.label}</span></td> <span className="tools__count">
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td> {shownRows.length} {shownRows.length === 1 ? 'lead' : 'leads'}
<td> {!isAll && state.total > shownRows.length && !needle ? ` of ${state.total}` : ''}
{exp </span>
? <><span className={'due due--' + exp.tone}>{exp.label}</span> </div>
<div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>} {shownRows.length === 0 ? (
</td> <p className="empty" style={{ margin: 20 }}>
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td> {needle ? 'No lead matches that.' : isAll ? 'No leads yet.' : 'No records in this stage.'}
<td> </p>
{r.attribution_status === 'contested' ) : (
? <span className="chip chip--warn">Contested</span> <div className="gridwrap">
: <span className="grid__sub">{r.attribution_status || '—'}</span>} <table className="grid">
</td> <thead>
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td> <tr>
</tr> <th>Customer</th>
) <th>Stage</th>
})} <th>Renewal due</th>
</tbody> <th>Holding it</th>
</table> <th className="num">Premium</th>
<th>Last activity</th>
</tr>
</thead>
<tbody>
{grouped && withAI.length ? (
<tr className="grp-row"><td colSpan={6}>
<span className={'dot dot--' + (stage.kind === 'customer' ? 'teal' : 'amber')} />
{stage.kind === 'customer' ? 'With the customer' : 'Your turn'}<span>{yourTurn.length}</span>
</td></tr>
) : null}
{renderRows(yourTurn)}
{grouped && withAI.length ? (
<>
<tr className="grp-row"><td colSpan={6}>
<span className="dot dot--blue" />With the AI<span>{withAI.length} · nothing for anyone to do</span>
</td></tr>
{renderRows(withAI)}
</>
) : null}
</tbody>
</table>
</div>
)}
</div>
) : null} ) : null}
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section> </section>
) )
} }

View File

@ -1,109 +1,399 @@
.page__head { /*
display: flex; align-items: flex-start; justify-content: space-between; Lead Desk screens. Flat white cards on the grey canvas, one colour rule:
gap: 24px; margin-bottom: 20px; blue = the AI holds it, amber = a person, teal = the customer, red = risk.
} */
.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); }
.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); }
.page__count {
font-size: 1.35rem; font-weight: 500; color: var(--zk-blue);
background: var(--zk-white); border: 1px solid var(--zk-line);
border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums;
}
.empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; } /* ── Page head ──────────────────────────────────────────────────────────── */
.page__head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 22px; }
.page__lead { display: flex; align-items: flex-start; gap: 14px; min-width: 0; }
.page__title { margin: 0; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; color: var(--zk-ink); }
.page__sub { margin: 4px 0 0; font-size: var(--fs-sm); color: var(--zk-muted); }
.page__stage { color: var(--zk-grey); }
.page__stage::before { content: ' · '; }
.page__sub .idline, .idline { font-size: var(--fs-sm); color: var(--zk-grey); margin-top: 2px; }
.page__right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: var(--fs-xs); color: var(--zk-grey); }
.stamp { font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
/* ── Buttons, pills, dots ───────────────────────────────────────────────── */
.btn, .newlead {
display: inline-flex; align-items: center; gap: 8px;
padding: 9px 16px; border-radius: var(--r-sm);
font: inherit; font-weight: 600; font-size: var(--fs-sm);
cursor: pointer; border: 1px solid transparent; text-decoration: none;
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.btn svg, .newlead svg { width: 14px; height: 14px; }
.newlead, .btn--primary { background: var(--zk-blue); color: #fff; }
.newlead:hover, .btn--primary:hover { background: var(--zk-navy-2); }
.btn--navy { background: var(--zk-navy); color: #fff; }
.btn--navy:hover { background: var(--zk-navy-2); }
.btn--ghost { border-color: var(--zk-line); background: #fff; color: var(--zk-ink); }
.btn--ghost:hover { background: var(--zk-tint); border-color: var(--zk-grey); }
.btn--small { padding: 5px 12px; font-size: var(--fs-xs); }
.btn:focus-visible, .newlead:focus-visible { outline: none; box-shadow: var(--ring); }
.btn:disabled { opacity: .55; cursor: default; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 10px; border-radius: var(--r-pill);
font-size: var(--fs-2xs); font-weight: 600; line-height: 1.5; white-space: nowrap;
}
.pill--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
.pill--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
.pill--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
.pill--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
.pill--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
.dot--blue { background: var(--zk-blue); }
.dot--amber { background: var(--zk-amber); }
.dot--teal { background: var(--zk-teal); }
.dot--red { background: var(--zk-danger); }
.dot--grey { background: var(--zk-grey); }
.who { display: inline-flex; align-items: center; gap: 7px; color: var(--zk-muted); white-space: nowrap; }
.muted { color: var(--zk-grey); }
.link { color: var(--zk-blue); font-weight: 600; }
/* ── Cards ──────────────────────────────────────────────────────────────── */
.card { background: var(--zk-card); border: 1px solid var(--zk-line); border-radius: var(--r-md); }
.card__hd { display: flex; align-items: baseline; gap: 12px; padding: 16px 20px 12px; }
.card__hd h3 { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
.card__hd p { margin: 0; font-size: var(--fs-xs); color: var(--zk-grey); }
.card__hd .card__right { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-muted); }
/* ── States: notice, empty, skeleton ────────────────────────────────────── */
.notice { .notice {
background: var(--zk-white); border: 1px solid var(--zk-line); background: #fff; border: 1px solid var(--zk-line); border-left: 3px solid var(--zk-blue);
border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px; border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs); padding: 18px 22px; max-width: 64ch;
max-width: 60ch;
} }
.notice strong { display: block; font-weight: 500; margin-bottom: 6px; } .notice strong { display: block; font-weight: 600; margin-bottom: 7px; }
.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; } .notice p { margin: 0 0 6px; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.55; }
.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; } .notice__detail { font-size: var(--fs-2xs) !important; color: var(--zk-grey) !important; }
.notice code { .empty {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; margin: 0; color: var(--zk-muted); font-size: var(--fs-xs); text-align: center;
background: var(--zk-tint); border: 1px solid var(--zk-line-soft); padding: 44px 24px; border-radius: var(--r-md); border: 1px dashed var(--zk-line); background: #fff;
border-radius: 3px; padding: 1px 4px; }
.skel { display: flex; flex-direction: column; gap: 10px; }
.skel__row {
height: 52px; border-radius: var(--r-md); border: 1px solid var(--zk-line-soft);
background: linear-gradient(90deg, #fff 0, var(--zk-tint) 40%, #fff 80%);
background-size: 840px 100%; animation: zk-shimmer 1.4s linear infinite;
} }
.grid { /* ── Overview: who holds the work ───────────────────────────────────────── */
width: 100%; border-collapse: collapse; background: var(--zk-white); .hero { display: grid; grid-template-columns: 1fr 380px; gap: 18px; margin-bottom: 22px; }
border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem; .holders { padding: 20px 24px 22px; }
.holders__top { display: flex; align-items: baseline; gap: 10px; margin-bottom: 14px; }
.holders__top b { font-size: 30px; font-weight: 700; letter-spacing: -.01em; }
.holders__top span { font-size: var(--fs-md); color: var(--zk-muted); }
.bar { display: flex; height: 22px; border-radius: 6px; overflow: hidden; gap: 3px; margin-bottom: 16px; background: var(--zk-line-soft); }
.bar div { height: 100%; transition: width var(--t); }
.bar__blue { background: var(--zk-blue); } .bar__amber { background: var(--zk-amber-bar); } .bar__teal { background: var(--zk-teal); }
.legend { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
.legend__item { border-left: 3px solid; padding-left: 14px; }
.legend__item--blue { border-color: var(--zk-blue); } .legend__item--amber { border-color: var(--zk-amber-bar); } .legend__item--teal { border-color: var(--zk-teal); }
.legend__item b { display: block; font-size: 26px; font-weight: 700; line-height: 1.1; }
.legend__item strong { display: block; font-size: var(--fs-sm); font-weight: 600; margin-top: 3px; }
.legend__item span { display: block; font-size: var(--fs-xs); color: var(--zk-muted); margin-top: 2px; }
.risk { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); padding: 20px 22px; display: flex; flex-direction: column; }
.risk b { font-size: var(--fs-3xl); font-weight: 700; color: var(--zk-danger); line-height: 1; letter-spacing: -.02em; }
.risk strong { display: block; font-size: var(--fs-lg); font-weight: 600; margin-top: 6px; }
.risk__rows { margin-top: auto; padding-top: 14px; display: grid; gap: 6px; font-size: var(--fs-sm); }
.risk__rows div { display: flex; justify-content: space-between; }
.risk__rows em { font-style: normal; font-weight: 700; color: var(--zk-danger); }
.risk.is-quiet b { color: var(--zk-teal); } .risk.is-quiet { background: var(--zk-teal-tint); border-color: var(--zk-teal-line); }
.risk.is-quiet .risk__rows em { color: var(--zk-teal); }
/* ── KPI strip ──────────────────────────────────────────────────────────── */
.kpis { display: grid; grid-template-columns: repeat(5, 1fr); padding: 18px 0; margin-bottom: 22px; }
.kpi { padding: 0 24px; border-left: 1px solid var(--zk-line-soft); }
.kpi:first-child { border-left: 0; }
.kpi__l { display: block; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 500; }
.kpi__v { display: block; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; margin: 4px 0 2px; color: var(--zk-ink); }
.kpi__s { font-size: var(--fs-xs); color: var(--zk-grey); }
/* ── Sign-off band (the role that confirms premium) ─────────────────────── */
.appr {
display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center;
padding: 16px 22px; margin-bottom: 22px; text-decoration: none; color: inherit;
border-radius: var(--r-md); border: 1px solid var(--zk-amber-line); background: var(--zk-amber-tint);
} }
.appr__n { font-size: 30px; font-weight: 700; color: var(--zk-amber); min-width: 40px; text-align: center; }
.appr__t { font-size: var(--fs-md); font-weight: 600; display: flex; flex-direction: column; gap: 3px; }
.appr__t em { font-style: normal; font-size: var(--fs-xs); font-weight: 400; color: var(--zk-muted); }
.appr__go { font-size: var(--fs-xs); font-weight: 600; color: var(--zk-amber); }
.appr:not(.is-live) { background: #fff; border-color: var(--zk-line); }
.appr:not(.is-live) .appr__n, .appr:not(.is-live) .appr__go { color: var(--zk-grey); }
/* ── Two columns: queue + distribution ──────────────────────────────────── */
.cols { display: grid; grid-template-columns: 1fr 380px; gap: 18px; margin-bottom: 22px; align-items: stretch; }
/* The two cards share a height; the queue's rows spread to fill it so the
shorter card never ends in a block of empty canvas. */
.cols > .card { display: flex; flex-direction: column; }
.queue { flex: 1; display: flex; flex-direction: column; }
.queue__row { flex: 1; }
.queue { padding: 0 0 6px; }
.queue__row {
display: grid; grid-template-columns: 52px 1fr auto auto; gap: 14px; align-items: center;
padding: 14px 20px; border-top: 1px solid var(--zk-line-soft); text-decoration: none; color: inherit;
transition: background var(--t-fast);
}
.queue__row:first-child { border-top: 0; }
.queue__row:hover { background: var(--zk-tint); }
.queue__n { font-size: 26px; font-weight: 700; text-align: center; color: var(--zk-ink); }
.queue__row.is-clear .queue__n { color: var(--zk-grey); }
.queue__t { font-size: var(--fs-md); font-weight: 600; }
.queue__who { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--zk-muted); margin-top: 2px; }
.queue__age { font-size: var(--fs-xs); color: var(--zk-grey); text-align: right; line-height: 1.35; }
.queue__age em { display: block; font-style: normal; color: var(--zk-blue); font-weight: 600; }
.queue__ro { font-size: var(--fs-3xs); color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: var(--r-pill); padding: 1px 8px; }
.dist { padding: 6px 20px 14px; }
.dist__r {
display: grid; grid-template-columns: 1fr 120px 34px; gap: 12px; align-items: center;
padding: 8px 0; font-size: var(--fs-sm); text-decoration: none; color: var(--zk-ink);
}
.dist__r:hover .dist__l { color: var(--zk-blue); }
.dist__r.is-zero, .dist__r.is-dim { color: var(--zk-grey); }
.dist__bar { height: 8px; background: var(--zk-line-soft); border-radius: 4px; overflow: hidden; }
.dist__f { display: block; height: 100%; border-radius: 4px; background: var(--zk-navy); transition: width var(--t); }
.dist__f--blue { background: var(--zk-blue); } .dist__f--amber { background: var(--zk-amber-bar); }
.dist__f--teal { background: var(--zk-teal); } .dist__f--grey { background: #AEB7C6; }
.dist__n { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
.dist__sec { padding: 12px 0 4px; font-size: var(--fs-2xs); color: var(--zk-grey); font-weight: 600; border-top: 1px solid var(--zk-line-soft); margin-top: 6px; }
/* ── Renewal exposure ───────────────────────────────────────────────────── */
.exp__tabs { display: flex; align-items: center; gap: 10px; padding: 0 20px 14px; flex-wrap: wrap; }
.tab {
display: flex; align-items: baseline; gap: 8px; padding: 8px 14px;
border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: var(--fs-sm); background: #fff;
}
.tab b { font-size: var(--fs-xl); font-weight: 700; }
.tab--red { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); } .tab--red b { color: var(--zk-danger); }
.tab--amber { border-color: var(--zk-amber-line); background: var(--zk-amber-tint); } .tab--amber b { color: var(--zk-amber); }
.exp__filters { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
.xfd__f select {
font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
padding: 8px 30px 8px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm);
background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%237A8699' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat right 10px center;
-webkit-appearance: none; appearance: none; cursor: pointer;
}
.xfd__f select:hover { border-color: var(--zk-grey); }
.xfd__f select:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.xfd__clear { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; cursor: pointer; padding: 8px 6px; }
.xfd__clear:hover { text-decoration: underline; }
/* ── Tables ─────────────────────────────────────────────────────────────── */
.gridwrap { overflow-x: auto; }
.grid { width: 100%; border-collapse: collapse; }
.grid th { .grid th {
text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em; font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey); text-align: left;
text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px; padding: 10px 20px; border-top: 1px solid var(--zk-line-soft); border-bottom: 1px solid var(--zk-line-soft); background: #FAFBFD; white-space: nowrap;
background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
} }
.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; } .grid td { padding: 12px 20px; border-bottom: 1px solid var(--zk-line-soft); font-size: var(--fs-sm); vertical-align: middle; }
.grid tbody tr:last-child td { border-bottom: none; } .grid tr:last-child td { border-bottom: 0; }
.grid tbody tr:hover { background: var(--zk-tint); } .grid th.num, .grid td.num { text-align: right; }
.grid .num { text-align: right; font-variant-numeric: tabular-nums; } .grid td.num { font-weight: 600; font-variant-numeric: tabular-nums; }
.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; } .grid__row { cursor: pointer; transition: background var(--t-fast); }
.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; } .grid__row:hover td { background: var(--zk-tint); }
.grid__link:hover { text-decoration: underline; } .grid__row:focus-visible { outline: none; } .grid__row:focus-visible td { background: var(--zk-tint-blue); }
.grid__name { display: block; font-size: var(--fs-md); font-weight: 600; color: var(--zk-ink); }
.grid__sub { font-size: var(--fs-2xs); color: var(--zk-grey); margin-top: 2px; }
.grid tr.is-done td { color: var(--zk-grey); }
.grid tr.is-done .grid__name { font-weight: 500; color: var(--zk-muted); }
.grid tr.is-stalled td:first-child { box-shadow: inset 3px 0 0 var(--zk-danger); }
.chip { .due { display: flex; flex-direction: column; gap: 2px; }
display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px; .due b { font-weight: 600; }
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); .due--amber b { color: var(--zk-amber); } .due--red b { color: var(--zk-danger); }
.due small { font-size: var(--fs-2xs); color: var(--zk-grey); }
.stall { display: inline-flex; align-items: center; gap: 7px; color: var(--zk-danger); font-weight: 600; font-size: var(--fs-xs); }
.stall::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--zk-danger); }
.grp-row td { background: #FAFBFD; padding: 10px 20px; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-muted); border-top: 1px solid var(--zk-line-soft); }
.grp-row td span { font-weight: 400; color: var(--zk-grey); margin-left: 8px; }
.grp-row td .dot { margin-right: 8px; vertical-align: 1px; }
.need { display: flex; gap: 6px; flex-wrap: wrap; }
.need span { font-size: var(--fs-2xs); padding: 2px 9px; border-radius: 6px; background: var(--zk-amber-tint); color: var(--zk-amber); font-weight: 600; }
.need span.need__ok { background: var(--zk-teal-tint); color: var(--zk-teal); }
.pager { display: flex; align-items: center; gap: 14px; padding: 14px 20px; border-top: 1px solid var(--zk-line-soft); font-size: var(--fs-xs); color: var(--zk-grey); }
.pager__pg { margin-left: auto; display: flex; gap: 4px; }
.pager__pg button {
font: inherit; font-size: var(--fs-xs); padding: 4px 10px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted);
} }
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; } .pager__pg button.is-on { background: var(--zk-navy); color: #fff; border-color: var(--zk-navy); }
.pager__pg button:disabled { opacity: .45; cursor: default; }
/* ---- doors ---- */ /* ── List tools ─────────────────────────────────────────────────────────── */
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } .tools { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--zk-line-soft); flex-wrap: wrap; }
.door { .search {
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white); display: flex; align-items: center; gap: 8px; padding: 8px 12px; min-width: 260px;
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue); border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; color: var(--zk-grey);
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
} }
.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); } .search svg { width: 14px; height: 14px; flex: none; }
.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); } .search input { border: 0; outline: 0; background: none; font: inherit; font-size: var(--fs-xs); color: var(--zk-ink); flex: 1; min-width: 0; }
.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; } .search:focus-within { border-color: var(--zk-blue); box-shadow: var(--ring); }
.door .chip { align-self: flex-start; } .fchip {
font: inherit; font-size: var(--fs-xs); color: var(--zk-muted); background: #fff;
/* ---- panel ---- */ padding: 7px 30px 7px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); cursor: pointer;
.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; } -webkit-appearance: none; appearance: none;
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; } background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%237A8699' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/></svg>");
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; } background-repeat: no-repeat; background-position: right 10px center;
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
/* ---- actions ---- */
.acts { display: flex; flex-wrap: wrap; gap: 10px; }
.act {
font: inherit; cursor: pointer; text-align: left; background: var(--zk-white);
border: 1px solid var(--zk-line); border-radius: 5px; padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px; min-width: 190px;
} }
.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); } .fchip.is-on { border-color: var(--zk-navy); color: var(--zk-navy); font-weight: 600; background-color: var(--zk-tint-blue); }
.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); } .fchip:focus-visible { outline: none; box-shadow: var(--ring); }
.act strong { font-size: .88rem; font-weight: 500; } .tools__count { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); }
.act__by { font-size: .72rem; color: var(--zk-muted); } .tools__toggle { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-xs); color: var(--zk-muted); cursor: pointer; user-select: none; }
.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); } .tools__toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.tools__toggle i { width: 34px; height: 20px; border-radius: 10px; background: var(--zk-line); position: relative; display: inline-block; transition: background var(--t-fast); }
.tools__toggle i::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left var(--t-fast); }
.tools__toggle.is-on i { background: var(--zk-blue); } .tools__toggle.is-on i::after { left: 16px; }
.tools__toggle:focus-within i { box-shadow: var(--ring); }
/* ---- lead file ---- */ /* ── Lead detail ────────────────────────────────────────────────────────── */
.lead__stage { text-align: right; } .back {
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); } width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--zk-line); background: #fff;
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); } display: grid; place-items: center; color: var(--zk-muted); margin-top: 4px; cursor: pointer; flex: none;
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; } transition: background var(--t-fast), border-color var(--t-fast);
.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
.lead__back { margin-top: 18px; }
/* ---- renewal countdown ---- */
.due {
display: inline-block; font-size: .76rem; font-weight: 500;
padding: 2px 8px; border-radius: 3px; white-space: nowrap;
} }
.due--lapsed { background: #fdeceb; color: #a3261f; border: 1px solid #f0c2bd; } .back svg { width: 16px; height: 16px; }
.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; } .back:hover { background: var(--zk-tint); border-color: var(--zk-grey); color: var(--zk-ink); }
.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); } .state { display: flex; align-items: center; gap: 10px; font-size: var(--fs-sm); color: var(--zk-muted); }
.due--later { background: var(--zk-tint); color: var(--zk-muted); border: 1px solid var(--zk-line); } .state .pill { font-size: var(--fs-xs); }
/* ---- lead header meta ---- */ .attn {
.lead__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; } display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center;
.lead__meta div { display: flex; flex-direction: column; gap: 1px; } padding: 18px 22px; margin-bottom: 18px; border-radius: var(--r-md);
.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); } border: 1px solid var(--zk-amber-line); background: var(--zk-amber-tint);
.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); } }
.attn--red { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); }
.attn--blue { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
.attn h3 { margin: 0; font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
.attn h3 .dot { width: 10px; height: 10px; }
.attn p { margin: 6px 0 0; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 78ch; line-height: 1.5; }
.attn__acts { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.attn__q { font: inherit; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 600; padding: 0 6px; background: none; border: 0; cursor: pointer; }
.attn__q:hover { color: var(--zk-navy); text-decoration: underline; }
.facts { display: grid; grid-template-columns: repeat(4, 1fr); padding: 18px 0; margin-bottom: 18px; }
.fact { padding: 0 24px; border-left: 1px solid var(--zk-line-soft); min-width: 0; }
.fact:first-child { border-left: 0; }
.fact__l { display: block; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 500; }
.fact__v { display: block; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; margin: 4px 0 2px; color: var(--zk-ink); }
.fact__v.is-none { color: var(--zk-grey); font-weight: 500; font-size: 22px; }
.fact__v.due--red { color: var(--zk-danger); } .fact__v.due--amber { color: var(--zk-amber); }
.fact__s { font-size: var(--fs-xs); color: var(--zk-grey); }
.fact__conf { display: flex; align-items: center; gap: 10px; }
.fact__conf .tr { flex: 1; height: 6px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
.fact__conf .tr i { display: block; height: 100%; background: var(--zk-teal); border-radius: 3px; }
.fact__conf span { font-size: var(--fs-xs); color: var(--zk-grey); }
.detail { display: grid; grid-template-columns: 1fr 400px; gap: 18px; align-items: start; }
.detail__main { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
.detail__side { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
/* The panels on the lead page are cards. */
.panel { background: var(--zk-card); border: 1px solid var(--zk-line); border-radius: var(--r-md); padding: 0 24px 20px; }
.panel__head { display: flex; align-items: baseline; gap: 12px; padding: 18px 0 6px; flex-wrap: wrap; }
.panel__title { margin: 0; font-size: 19px; font-weight: 700; color: var(--zk-ink); }
.panel__sub { margin: 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
.panel__act {
margin-left: auto; display: inline-flex; align-items: center; gap: 8px;
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-ink);
padding: 5px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; cursor: pointer;
}
.panel__act svg { width: 14px; height: 14px; }
.panel__act b { font-weight: 600; color: var(--zk-muted); }
.panel__act:hover { background: var(--zk-tint); }
/* Next step */
.step { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
.step__btn { justify-content: center;
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
font: inherit; text-align: left; cursor: pointer;
padding: 12px 18px; border-radius: var(--r-sm); border: 1px solid var(--zk-navy); background: var(--zk-navy); color: #fff;
transition: background var(--t-fast);
}
.step__btn strong { font-size: var(--fs-sm); font-weight: 700; }
.step__btn span { font-size: var(--fs-2xs); opacity: .8; }
.step__btn:hover, .step__btn.is-open { background: var(--zk-navy-2); }
.step__btn--no { background: #fff; color: var(--zk-danger); border-color: var(--zk-danger-line); }
.step__btn--no:hover, .step__btn--no.is-open { background: var(--zk-danger-tint); }
.alt { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.alt__note { font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-grey); margin-right: 4px; }
.alt__btn {
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 8px;
padding: 7px 12px; border-radius: var(--r-sm); border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
}
.alt__btn em { font-style: normal; font-weight: 400; font-size: var(--fs-2xs); color: var(--zk-grey); }
.alt__btn:hover, .alt__btn.is-open { border-color: var(--zk-navy); background: var(--zk-tint-blue); color: var(--zk-navy); }
.alt__btn--exit { color: var(--zk-danger); } .alt__btn--exit:hover { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); color: var(--zk-danger); }
.stuck { margin-top: 14px; border-top: 1px solid var(--zk-line-soft); padding-top: 10px; }
.stuck > summary { cursor: pointer; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-muted); list-style: none; }
.stuck > summary::-webkit-details-marker { display: none; }
.stuck > summary::before { content: '▸ '; color: var(--zk-grey); }
.stuck[open] > summary::before { content: '▾ '; }
.stuck__why { margin: 10px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); max-width: 64ch; }
.acts__form { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--zk-line-soft); }
.said {
display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
border-radius: var(--r-md); border: 1px solid var(--zk-teal-line); background: var(--zk-teal-tint); color: var(--zk-teal-ink);
}
.said p { margin: 0; font-size: var(--fs-sm); flex: 1; }
.said button { font: inherit; font-size: 18px; line-height: 1; background: none; border: 0; cursor: pointer; color: inherit; opacity: .7; }
/* Side cards: worked by, and the facts */
.panel--crew { padding: 16px 20px 18px; }
.crew__l { display: block; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-grey); margin-bottom: 10px; }
.crew { display: flex; flex-wrap: wrap; gap: 8px; }
.glance { display: flex; flex-direction: column; }
.fgroup { padding-top: 14px; border-top: 1px solid var(--zk-line-soft); }
.fgroup:first-child { border-top: 0; padding-top: 6px; }
.fgroup h3 { margin: 0 0 4px; font-size: var(--fs-2xs); font-weight: 600; color: var(--zk-grey); display: flex; justify-content: space-between; }
.fgroup h3 span { font-weight: 500; }
.props { margin: 0; }
.prop { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 7px 0; font-size: var(--fs-sm); }
.prop dt { color: var(--zk-muted); }
.prop dd { margin: 0; min-width: 0; font-weight: 500; overflow-wrap: anywhere; }
.prop__num { font-variant-numeric: tabular-nums; }
.prop__files { display: flex; flex-direction: column; gap: 4px; }
.prop__file { color: var(--zk-blue); font-weight: 600; text-decoration: none; display: inline-flex; gap: 6px; align-items: center; overflow-wrap: anywhere; }
.prop__file::before { content: ''; width: 12px; height: 14px; flex: none; border: 1.4px solid currentColor; border-radius: 2px; opacity: .7; }
.prop__file:hover { text-decoration: underline; }
.pending { display: flex; gap: 8px; flex-wrap: wrap; padding: 6px 0 4px; }
.pending span { font-size: var(--fs-2xs); padding: 3px 10px; border-radius: 6px; background: var(--zk-line-soft); color: var(--zk-muted); }
.glance__act {
display: inline-flex; align-items: center; gap: 6px; margin-top: 10px;
padding: 5px 10px 5px 8px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
cursor: pointer; font: inherit; font-size: var(--fs-2xs); font-weight: 600; color: var(--zk-blue);
}
.glance__act svg { width: 13px; height: 13px; }
.glance__act:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.glance__rec { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
.glance__rec-l { font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-grey); }
.glance__rec audio { width: 100%; height: 36px; }
.glance__all {
display: flex; align-items: center; gap: 8px; width: 100%; margin-top: 14px; padding: 10px 0 0;
border: 0; border-top: 1px solid var(--zk-line-soft); background: none; cursor: pointer;
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); text-align: left;
}
.glance__all svg { width: 14px; height: 14px; }
.glance__all b { margin-left: auto; font-weight: 500; color: var(--zk-grey); }
.glance__all:hover { text-decoration: underline; }
@media (max-width: 1100px) {
.hero, .cols, .detail { grid-template-columns: 1fr; }
.kpis { grid-template-columns: repeat(2, 1fr); row-gap: 16px; }
.facts { grid-template-columns: repeat(2, 1fr); row-gap: 16px; }
}
/* The AI's own words, when it flagged a lead. Quoted, because deciding what
to do next is a judgement made on the sentence it wrote. */
.attn__said {
margin: 8px 0 0 !important; padding: 8px 14px;
border-left: 2px solid var(--zk-amber-bar); background: #fff;
border-radius: 0 var(--r-sm) var(--r-sm) 0;
font-size: var(--fs-md) !important; color: var(--zk-ink) !important; max-width: 78ch;
}