Compare commits

..

No commits in common. "main" and "chore/request-premium-and-nudge" have entirely different histories.

57 changed files with 742 additions and 7906 deletions

2
.gitignore vendored
View File

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

187
CLAUDE.md
View File

@ -1,187 +0,0 @@
# 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,9 +13,6 @@
<!--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,10 +2,9 @@ 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()
@ -22,11 +21,10 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<Navigate to="/" replace />} /> <Route path="/login" element={<Navigate to="/" replace />} />
<Route element={<PortfolioProvider><Shell /></PortfolioProvider>}> <Route element={<Shell />}>
{/* The morning page. This used to redirect to the underwriting queue <Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
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>

View File

@ -1,108 +0,0 @@
/**
* 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,37 +68,6 @@ 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,88 +13,24 @@ 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 53_v2_state_model.sql. * set with no canonical sequence. Keep in step with 02_workflow.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: 'auto', doing: 'Checking the lead', by: 'Intake AI' }, { uid: 'zk-state-new', name: 'New Lead', kind: 'work' },
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' }, { uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' },
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' }, { uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' },
// The principal stall in the workflow, and the one that had no stage until { uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' },
// 7 September: a lead waited for its three documents inside "Contacted", so { uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' },
// the queue that most needed watching was the one that did not exist. { uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' },
{ uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' }, { uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' },
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' }, // The only human queue in the whole machine.
// The one queue where the machine stops and a person decides. { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' },
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' }, { uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' },
// `approval` marks the one queue that is a person's SIGN-OFF rather than a { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' },
// piece of work: confirming money has arrived. It is locked to ops_admin in { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' },
// the workflow — no agent and no other role can perform it — because an { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
// employee that can mark a premium received can put a customer on risk for a { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
// policy nobody paid for. It gets its own band on the overview so the role { uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
// 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. */
@ -124,106 +60,30 @@ 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 = {
* What each activity IS at a stage, not merely who may press it. 'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
* 'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }],
* `do` the expected next step here. Usually one; Referred has two, 'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' },
* because clear and decline are a decision pair rather than a { uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
* step and an alternative to it. { uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }],
* `again` a bounded loop a retry, a reminder, a re-quote. Legitimate, 'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
* never the thing to do next. { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }],
* `force` an AI employee's own job, offered to a person ONLY so a 'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }],
* stalled lead can be pushed by hand. Presenting these as 'zk-state-accepted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' },
* actions is what made the app read as a control panel: six { uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }],
* equal buttons where five are recovery levers. 'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }],
* `exit` Mark Lost. Always reachable, never a step. Four stages used 'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
* to offer a partner agent this and nothing else, which told { uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
* them their only option was to give up on a lead the system { uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
* was actively working. 'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Ops' }],
*/ // 'Payment webhook' was aspirational — there is no payment integration on the
const STATE_ACTIVITIES = { // platform and none is planned here. Collection happens on the insurer's own
'zk-state-new': [ // rails; ops records the reference. See 35_reframe_to_what_exists.sql.
{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' }, 'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' },
], { uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage' }],
'zk-state-qualified': [ 'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }],
{ 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.
* *
@ -240,337 +100,3 @@ 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

View File

@ -1,130 +0,0 @@
/**
* 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,
}
}

View File

@ -1,36 +0,0 @@
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) }
}
}

View File

@ -1,159 +0,0 @@
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))
}

View File

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

View File

@ -1,216 +0,0 @@
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`
}

View File

@ -1,398 +1,82 @@
.af { .af { display: flex; flex-direction: column; gap: 18px; }
display: flex; .af__loading { color: var(--zk-muted); font-size: .86rem; }
flex-direction: column; .af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
gap: 20px; .af__field { display: flex; flex-direction: column; gap: 5px; }
} .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: var(--fs-2xs); font-size: .74rem; font-weight: 500; color: var(--zk-muted);
font-weight: 500; letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
letter-spacing: 0.03em;
color: var(--zk-muted);
display: flex;
align-items: center;
gap: 8px;
} }
.af__req { .af__req {
font-style: normal; font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
font-size: var(--fs-3xs); color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
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 {
.af input, font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px;
.af select, border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
.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 {
.af input:hover, outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue);
.af select:hover,
.af textarea:hover {
border-color: var(--zk-blue-light);
} }
.af textarea { resize: vertical; }
.af input:focus, .af__multi { display: flex; flex-wrap: wrap; gap: 6px; }
.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: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
font-size: var(--fs-2xs); border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
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__chip:hover { .af__actions { display: flex; justify-content: flex-end; gap: 10px; }
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: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
font-size: var(--fs-xs); cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
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: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
font-size: var(--fs-xs); border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
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: var(--zk-danger-tint); background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
border: 1px solid var(--zk-danger-line); border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
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: var(--fs-xs); font-size: .88rem; padding: 8px 10px; border-radius: 4px; min-height: 37px;
padding: 10px 12px; border: 1px dashed var(--zk-line); background: var(--zk-tint); color: var(--zk-muted);
border-radius: var(--r-md); display: flex; align-items: center; justify-content: space-between; gap: 8px;
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: var(--fs-3xs); font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
font-weight: 500; color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
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 { .ff { display: flex; flex-direction: column; gap: 6px; }
display: flex; .ff__input { display: none; }
flex-direction: column; .ff__btn {
gap: 8px; font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
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__input { .ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; }
display: none; .ff__got {
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: 1px solid var(--zk-blue-light); border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
border-left-width: 3px; border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
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: var(--fs-3xs); font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
font-weight: 500; color: var(--zk-blue-dark); margin-bottom: 2px;
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 div { .ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; }
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: var(--fs-2xs); font-size: .78rem; color: var(--zk-danger);
color: var(--zk-danger-ink); background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
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,22 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, 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.
@ -25,26 +11,12 @@ const INPUT_TYPES = {
* 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, lead, onDone, onCancel, onStale }) { export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) {
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(() => {
@ -57,191 +29,26 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
// 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') {
for (const f of s.fields) { const seed = {}
const base = baseFieldId(f.id ?? '') for (const f of s.fields) if (pre[f.id] !== undefined) seed[f.id] = pre[f.id]
const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined) if (Object.keys(seed).length) setValues(seed)
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) { function set(id, v) { setValues((p) => ({ ...p, [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 fields) { for (const f of schema.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
@ -256,50 +63,41 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
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 {describeError(error).title}</div> if (error && !schema) return <div className="af__err">Could not load the form {error.status} {error.message}</div>
if (!schema) return <p className="af__loading">Loading the form</p> if (!schema) return <p className="af__loading">Loading the form</p>
// Stamped fields are in `fields` they validate and they submit but they return (
// are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is <form className="af" onSubmit={submit}>
// drawn anyway: source_channel is mandatory, so a prefill that did not <div className="af__grid">
// resolve would otherwise fail validation against a box that is not on the {schema.fields.map((f) => {
// screen and cannot be filled. const opts = f.properties?.options || []
const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])) const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
return (
/* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat <label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
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) => applyExtraction(f, fields)} onExtract={(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">
@ -325,72 +123,24 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
<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={INPUT_TYPES[f.data_type] ?? 'text'} type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
value={v} onChange={(e) => set(f.id, e.target.value)} value={v} onChange={(e) => set(f.id, e.target.value)}
/> />
)} )}
</label> </label>
) )
} })}
</div>
return ( {error ? (
<form className="af" onSubmit={submit}> <div className="af__err">
{docFields.length ? ( <strong>{error.status}</strong> {error.message}
<section className="af__sec"> {error.status === 403 || String(error.message).includes('permission denied') ? (
<h4 className="af__sech">Documents</h4> <p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p>
<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

@ -1,91 +0,0 @@
/* 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

@ -1,112 +0,0 @@
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

@ -1,44 +0,0 @@
.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

@ -1,84 +0,0 @@
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>
)
}

View File

@ -1,200 +0,0 @@
/* 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; } }

View File

@ -1,344 +0,0 @@
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

@ -1,118 +0,0 @@
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

@ -1,32 +0,0 @@
.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

@ -1,87 +0,0 @@
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

@ -1,168 +0,0 @@
.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

@ -1,128 +0,0 @@
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

@ -1,152 +0,0 @@
.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

@ -1,173 +0,0 @@
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

@ -1,136 +0,0 @@
/**
* 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,40 +61,20 @@ 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>
{/* Two states, not one button that changes its words. Nothing uploaded is {files.length ? (
an invitation a dashed target that says what it takes. Something <div className="ff__got">{files[0].original_name || files[0].uuid}</div>
uploaded is a fact the file, named, with a quiet way to swap it. The ) : null}
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

@ -1,65 +0,0 @@
/* 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

@ -1,166 +0,0 @@
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

@ -1,17 +0,0 @@
/* 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

@ -1,112 +0,0 @@
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

@ -1,36 +0,0 @@
/* 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

@ -1,84 +0,0 @@
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,134 +1,39 @@
.tl { list-style: none; margin: 0; padding: 0; } .tl { list-style: none; margin: 0; padding: 0 0 0 4px; }
.tl__loading, .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; } .tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; }
.tl__err { color: var(--zk-danger-ink); } .tl__err { color: var(--zk-danger); }
/* One event: a 36px avatar gutter, then the body. A thin connector runs down .tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); }
the gutter between avatars. */ .tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; }
.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__gutter { width: 36px; height: 36px; display: grid; place-items: center; position: relative; z-index: 1; } .tl__dot {
.tl__gutter .who--btn { padding: 0; border: 0; } position: absolute; left: -7px; top: 3px; width: 12px; height: 12px;
.tl__disc { border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey);
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);
} }
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); } /* Machine work is blue, people are amber, the platform itself is grey so a
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 { min-width: 0; display: flex; flex-direction: column; } .tl__body { display: flex; flex-direction: column; gap: 3px; }
.tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; } .tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; }
.tl__what { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); } .tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); }
.tl__what--wa { display: inline-flex; align-items: center; gap: 6px; color: var(--zk-teal); } .tl__who {
.tl__what--wa svg { width: 15px; height: 15px; } font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap;
.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__doc svg { width: 13px; height: 13px; color: var(--zk-blue); } .tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
.tl__doc:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); } .tl__who--human { background: #f7efe2; color: #7a5a12; }
.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); }
/* The action row: links, in blue. */ .tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); }
.tl__acts { display: flex; gap: 18px; margin-top: 8px; font-size: var(--fs-xs); align-items: baseline; flex-wrap: wrap; } .tl__saylabel {
.tl__link { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; } display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase;
.tl__link:hover { text-decoration: underline; } color: var(--zk-grey); margin-bottom: 2px;
.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__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); } .tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; }
/* THE WHATSAPP EXCHANGE, COLLAPSED .tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; }
One entry for a burst of messages: who spoke, how many there were, the last .tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; }
two of them, and the whole thread behind a click. The card IS the control .tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; }
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,675 +1,120 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import AgentChip from './AgentChip.jsx' import { STAGES } from '../api/config.js'
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'
/* The roster lives in api/agents.js now one short name, one colour and one /** Which employee holds which role — used to badge an entry as machine work. */
set of initials per employee, so the same worker looks the same everywhere const AI_ROLES = {
it appears. This file only needs to know which roles are agents. */ ai_intake: 'Intake & Attribution',
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).
*/ */
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the const NARRATIVE = [
"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'],
['ai_recommendation_rationale', 'Cover advice'],
['kyc_mismatch_notes', 'KYC mismatch'],
// A clean KYC leaves the mismatch note empty, so the step showed no chain at
// all. kyc_ref is what the KYC employee actually recorded the document
// package it matched the identity against so it stands in as the finding.
['kyc_ref', 'KYC check'],
['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'],
['contact_notes', 'Call'],
['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'], ['dedupe_match_ref', 'Duplicate of'],
['contact_notes', 'Call'],
['ai_recommendation_rationale', 'Recommendation'],
['quoted_breakup', 'How the premium was reached'],
['kyc_mismatch_notes', 'KYC'],
['referral_analysis', 'Referral analysis'],
['uw_decision_notes', 'Underwriting decision'],
['documents_notes', 'Documents'],
] ]
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) {
* Fields already shown elsewhere in the entry, so the "what it wrote" list if (!ts) return ''
* 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)
return mins < 1 ? 'just now' const rel = 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}`
} }
function when(ts) { export default function Timeline({ instanceId }) {
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()
// DATA_UPDATE entries are the platform writing fields, not anyone deciding const [rows, setRows] = useState(null)
// anything. They are the bulk of a busy trail and they are hidden until asked const [err, setErr] = useState(null)
// 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">
{sysCount ? ( {ordered.map((r, i) => {
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}> const roles = r.user_roles || []
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'} const aiRole = roles.find((x) => AI_ROLES[x])
</button> const isSystem = r.activity_id === 'DATA_UPDATE'
) : null} const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
const stage = STAGES.find((s) => s.uid === r.execution_state)
const d = r.data || {}
<ol className="tl"> const narrative = NARRATIVE
{visible.map((it) => ( .map(([k, label]) => [label, d[k]])
it.kind === 'wa' ? ( .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
<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>
{it.ep.after.length ? ( const figures = Object.entries(d)
<p className="tl__wacont"> .filter(([k, v]) => MONEY.has(k) && v)
The same thread, picked up after {listOf(it.ep.after)}. .map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')])
</p>
) : null}
{/* The last exchange, as a thread rather than a quote block return (
and the whole card opens the conversation, which is how <li key={r.id ?? i} className={'tl__item tl__item--' + kind}>
every timeline of this shape behaves. */} <span className="tl__dot" aria-hidden="true" />
<button
type="button"
className="tl__wa"
onClick={() => onOpenChat?.(it.ep.id)}
aria-label={`Open the WhatsApp conversation — ${it.ep.count} messages`}
>
<span className="tl__wapre">
{it.ep.preview.map((t) => (
<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__body">
<div className="tl__line1"> <div className="tl__head">
<h4 className="tl__what">{it.what}</h4> <strong className="tl__what">
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null} {isSystem ? 'Data updated' : (r.activity_name || r.activity_id)}
<span className="tl__when">{it.when}</span> </strong>
</div> <span className={'tl__who tl__who--' + kind}>
<div className="tl__by"> {kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor}
{it.kind === 'sys' </span>
? <span className="tl__byname">System</span> {stage ? <span className="tl__stage"> {stage.name}</span> : null}
: 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>
{/* What was received, named and openable. The count is stated {narrative.map(([label, v]) => (
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>
<ClampText text={String(v)} title={`${label}${it.what}`} lines={2} threshold={120} /> <p>{String(v)}</p>
</div> </div>
))} ))}
{it.figures.length ? ( {figures.length ? (
<div className="tl__figs"> <div className="tl__figs">
{it.figures.map(([k, v]) => ( {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,177 +1,74 @@
/* Typeface: Source Sans 3, self-hosted /* ── Zurich Kotak brand typeface ───────────────────────────── */
Bundled from src/assets so Vite resolves the URLs under the sub-path mount; @font-face {
an absolute /fonts/ path would 404 behind the BASE_HREF rewrite. */ font-family: 'Zurich Sans';
@font-face { font-family: 'Source Sans 3'; font-weight: 400; font-display: swap; src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype');
src: url('./assets/fonts/source-sans-3-latin-400-normal.woff2') format('woff2'), font-weight: 300;
url('./assets/fonts/source-sans-3-latin-ext-400-normal.woff2') format('woff2'); } font-style: normal;
@font-face { font-family: 'Source Sans 3'; font-weight: 500; font-display: swap; font-display: swap;
src: url('./assets/fonts/source-sans-3-latin-500-normal.woff2') format('woff2'), }
url('./assets/fonts/source-sans-3-latin-ext-500-normal.woff2') format('woff2'); } @font-face {
@font-face { font-family: 'Source Sans 3'; font-weight: 600; font-display: swap; font-family: 'Zurich Sans';
src: url('./assets/fonts/source-sans-3-latin-600-normal.woff2') format('woff2'), src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype');
url('./assets/fonts/source-sans-3-latin-ext-600-normal.woff2') format('woff2'); } font-weight: 400;
@font-face { font-family: 'Source Sans 3'; font-weight: 700; font-display: swap; font-style: normal;
src: url('./assets/fonts/source-sans-3-latin-700-normal.woff2') format('woff2'), font-display: swap;
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 {
/* PALETTE Lead Desk redesign /* Brand palette lifted from zurichkotak.com */
Colour MEANS something here, and the meaning is fixed on every screen: --zk-blue: #2167ae;
blue = the AI holds it amber = a person holds it --zk-blue-dark: #1a5490;
teal = the customer holds it red = risk, and nothing else --zk-blue-deep: #1a528b;
Navy is the masthead and the brand; blue is the one action colour. --zk-navy: #23366f;
The token NAMES keep their history the whole app references --zk-blue --zk-blue-mid: #5495cf;
and friends so remapping the values here re-themes every surface. */ --zk-blue-light: #91bfe3;
--zk-navy: #0B2A5B; --zk-cyan: #c7ebf9;
--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-teal: #137A66; /* the customer; issued */ --zk-ink: #2b2e31;
--zk-teal-ink: #0F6353; --zk-muted: #5d6162;
--zk-teal-tint: #DDF1EC; --zk-grey: #a6adaf;
--zk-teal-line: #B8DDD3; --zk-line: #dde4e3;
--zk-good: #137A66; --zk-line-soft: #eceeef;
--zk-tint: #f4f9fc;
--zk-tint-blue: #ebf3fb;
--zk-white: #fff;
--zk-danger: #d32f2f;
--zk-amber: #A86A0B; /* a person holds it */ --font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif;
--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);
/* Absolute on purpose: every --fs-* token is rem and sizing the root from font-size: 16px;
one of them would be circular. */ line-height: 1.5;
font-size: 15px;
line-height: var(--lh-normal);
color: var(--zk-ink); color: var(--zk-ink);
background: var(--zk-tint); background: var(--zk-white);
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; }
} }
@media (prefers-reduced-motion: reduce) { html,
*, *::before, *::after { body,
animation-duration: 0.001ms !important; #root {
animation-iteration-count: 1 !important; height: 100%;
transition-duration: 0.001ms !important; }
scroll-behavior: auto !important;
} body {
margin: 0;
}
button,
input {
font: inherit;
color: inherit;
} }

View File

@ -1,209 +1,58 @@
.shell { .shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); }
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--zk-tint);
}
/* ── Masthead ─────────────────────────────────────────────────────────────── */
.shell__top { .shell__top {
position: sticky; display: flex; align-items: center; justify-content: space-between;
top: 0; gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white);
z-index: 30; border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10;
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); }
.mast__burger { .shell__who { display: flex; align-items: center; gap: 16px; }
display: none; /* a phone-only control; see the media block */ .shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; }
width: 34px; height: 34px; flex: none; .shell__user strong { font-size: .85rem; font-weight: 500; }
place-items: center; margin-left: -6px; .shell__user span { font-size: .72rem; color: var(--zk-muted); }
border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer; .shell__signout {
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);
} }
.mast__burger svg { width: 20px; height: 20px; } .shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
.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 {
position: sticky; width: 244px; flex: none; background: var(--zk-white);
top: 56px; border-right: 1px solid var(--zk-line); padding: 18px 12px 28px;
align-self: flex-start; display: flex; flex-direction: column; gap: 1px;
width: 232px; }
flex: none; .shell__navlabel {
min-height: calc(100vh - 56px); margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500;
max-height: calc(100vh - 56px); letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey);
overflow-y: auto; }
overscroll-behavior: contain; .shell__navlabel--closed { margin-top: 22px; }
background: #fff;
border-right: 1px solid var(--zk-line); .shell__stage {
padding: 20px 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
display: flex; padding: 8px 10px; border-radius: 4px; text-decoration: none;
flex-direction: column; color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent;
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);
} }
.nav__h { .shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
margin: 0;
padding: 18px 12px 6px; .shell__add {
font-size: var(--fs-3xs); display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
font-weight: 600; padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
letter-spacing: .04em; background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
text-transform: uppercase;
color: var(--zk-grey);
} }
.nav__group { display: flex; flex-direction: column; gap: 2px; } .shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
.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,159 +1,75 @@
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 AskDesk from '../components/AskDesk.jsx' import { STAGES } from '../api/config.js'
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 frame: a navy masthead, a white rail, a grey canvas. * The sidebar IS the pipeline, in the order a lead moves so the shape of the
* * process is legible before any record is opened. The one human queue is marked
* The rail groups the pipeline by WHO IS HOLDING each lead a person, the * as such, because it is the only place the machine stops.
* 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 } = useZino() const { user, signOut } = useZino()
const roles = rolesOf(user) const work = STAGES.filter((s) => s.kind !== 'end')
const stages = visibleStages(roles) const closed = STAGES.filter((s) => s.kind === 'end')
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">
<button <div className="shell__brand">
type="button" className="mast__burger" <img src={logo} alt="Zurich Kotak General Insurance" />
aria-label={navOpen ? 'Close the menu' : 'Open the menu'} aria-expanded={navOpen} <div>
onClick={() => setNavOpen((o) => !o)} <strong>Lead Desk</strong>
> <span>Lead to Policy</span>
<svg viewBox="0 0 20 20" aria-hidden="true"> </div>
{navOpen </div>
? <path d="M5 5l10 10M15 5L5 15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> <div className="shell__who">
: <path d="M3 6h14M3 10h14M3 14h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />} <div className="shell__user">
</svg> <strong>{user?.name || user?.email || 'Signed in'}</strong>
</button> {/* The role is shown because RBAC is the demo: what this person can
{/* The lockup as ARTWORK, not as type. It was set in Source Sans with and cannot do is the point, so who they are signed in as should
a drawn Z, which is a passable imitation of a brand and not the never be a guess. */}
brand: the real mark's proportions, its weight contrast and its <span>{(user?.roles || []).join(', ') || user?.email}</span>
plate are all in the asset. Bundled from src/assets so Vite </div>
rewrites the URL and it resolves under the sub-path mount. */} <button type="button" className="shell__signout" onClick={signOut}>
<img className="mast__logo" src={logo} alt="Zurich Kotak General Insurance" /> Sign out
<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>
) : null} </div>
<div className="mast__user"><UserMenu /></div>
</header> </header>
<div className="shell__body"> <div className="shell__body">
{/* The scrim only exists while the drawer is open, and only on a phone: <nav className="shell__nav" aria-label="Pipeline">
tapping the page you can see should put the menu away. */} <NavLink to="/add" className="shell__add">+ Add a lead</NavLink>
{navOpen ? <button type="button" className="shell__scrim" aria-label="Close the menu" onClick={() => setNavOpen(false)} /> : null} <p className="shell__navlabel">Pipeline</p>
<nav className={'shell__nav' + (navOpen ? ' is-open' : '')} aria-label="Pipeline" onClick={closeIfLink}> {work.map((s) => (
<NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}> <NavLink
<span className="nav__name">Overview</span> key={s.uid}
</NavLink> to={`/stage/${s.uid}`}
<NavLink to="/stage/all" className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}> className={({ isActive }) =>
<span className="nav__name">All leads</span> 'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
{ready ? <span className="nav__n nav__n--grey">{openTotal}</span> : null} }
</NavLink> >
<span className="shell__stagename">{s.name}</span>
{NAV_GROUPS.map((group) => { {s.kind === 'human' ? <span className="shell__badge">you</span> : null}
const inGroup = stages.filter((s) => group.kinds.includes(s.kind)) </NavLink>
if (!inGroup.length) return null ))}
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
if (group.kinds.includes('end')) { {closed.map((s) => (
const closed = inGroup.reduce((t, s) => t + count(s).total, 0) <NavLink
return ( key={s.uid}
<div className="nav__group" key={group.label}> to={`/stage/${s.uid}`}
<h6 className="nav__h">{group.label}</h6> className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
<details className="nav__fold"> >
<summary className="nav__link"> <span className="shell__stagename">{s.name}</span>
<span className="nav__name">Closed</span> </NavLink>
{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>
) )
} }

View File

@ -1,59 +0,0 @@
.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); }

View File

@ -1,92 +0,0 @@
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: var(--fs-2xs); font-size: 13px;
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: var(--fs-3xl); font-size: 44px;
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: var(--fs-md); font-size: 16px;
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: var(--fs-sm); font-size: 15px;
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: var(--fs-xs); font-size: 14px;
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: var(--fs-2xl); font-size: 30px;
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: var(--fs-xs); font-size: 14px;
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: var(--fs-2xs); font-size: 13px;
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: var(--fs-xs); font-size: 14px;
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: var(--fs-2xs); font-size: 13px;
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: var(--fs-xs); font-size: 14px;
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: var(--fs-2xs); font-size: 12px;
font-weight: 300; font-weight: 300;
color: var(--zk-grey); color: var(--zk-grey);
} }
@ -296,11 +296,11 @@
} }
.login__headline { .login__headline {
font-size: var(--fs-3xl); font-size: 36px;
} }
.login__lede { .login__lede {
font-size: var(--fs-sm); font-size: 15px;
} }
} }

View File

@ -35,19 +35,7 @@ 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() {
const tag = document.querySelector('base[href]') return new URL(document.baseURI).pathname
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 : '/'
} }

58
src/screens/AddLead.jsx Normal file
View File

@ -0,0 +1,58 @@
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

View File

@ -1,461 +0,0 @@
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,11 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import { ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { CHANNELS, RV_LEADS, STAGES } 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". */
@ -24,33 +20,6 @@ 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) {
@ -72,263 +41,107 @@ import './screens.css'
*/ */
export default function Pipeline() { export default function Pipeline() {
const { stageUid } = useParams() const { stageUid } = useParams()
const navigate = useNavigate() const { client } = useZino()
const { client, user } = useZino() const stage = STAGES.find((s) => s.uid === stageUid)
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: [], total: 0, error: null }) const [state, setState] = useState({ status: 'loading', rows: [], error: null })
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setState({ status: 'loading', rows: [], error: null })
// `quiet` is what makes polling bearable: the first load may show a skeleton, client
// a refresh may not dropping back to the loading state every 30 seconds .recordView(RV_LEADS, {
// would flash the whole queue away under whoever is reading it. limit: 100,
function fetchRows(quiet) { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null }) })
return client .then((res) => {
.recordView(RV_LEADS, { if (cancelled) return
limit: isAll ? 200 : 100, const rows = res?.data ?? res?.rows ?? res?.records ?? []
// No stage filter on the all view. Closed leads are dropped below setState({ status: 'ready', rows, error: null })
// rather than in the query: one request beats three negations, and })
// 200 covers a demo book comfortably. .catch((err) => {
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }), if (cancelled) return
}) setState({ status: 'error', rows: [], error: err })
.then((res) => { })
if (cancelled) return return () => { cancelled = true }
let rows = res?.data ?? res?.rows ?? res?.records ?? [] }, [client, stageUid, stage?.name])
// 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.need ?? stage.name}</h1> <h1 className="page__title">{stage.name}</h1>
<p className="page__sub"> <p className="page__sub">
{isAll {stage.kind === 'human'
? 'Every lead not yet closed, across all stages.' ? 'The only queue in the machine that waits for a person.'
: stage.kind === 'auto' : 'Handled by the agents; shown here for visibility.'}
? `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>
{canFile ? ( {state.status === 'ready' ? (
<div className="page__right"> <span className="page__count">{state.rows.length}</span>
<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' ? ( {state.status === 'loading' ? <p className="empty">Loading</p> : null}
<div className="skel" aria-busy="true" aria-label="Loading the queue">
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
</div>
) : null}
{state.status === 'error' ? ( {state.status === 'error' ? (
<div className="notice"> <div className="notice">
<strong>Unable to load this stage.</strong> <strong>This queue has no record view yet.</strong>
<p>The lead list did not return. Other functions are unaffected; retry before escalating.</p> <p>
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</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.status === 'ready' && state.rows.length === 0 ? (
<div className="card"> <p className="empty">No leads at this stage.</p>
<div className="tools"> ) : null}
<label className="search">
<svg viewBox="0 0 16 16" aria-hidden="true"> {state.status === 'ready' && state.rows.length > 0 ? (
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" /> <table className="grid">
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <thead>
</svg> <tr>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a lead, a registration, a reference" aria-label="Find a lead" /> <th>Lead</th><th>Customer</th><th>Channel</th>
</label> <th>Product</th><th>Renewal due</th><th className="num">Premium</th>
{/* Offered only where there is something to hide, and OFF by <th>Attribution</th><th>Added</th>
default: this page's job is that nothing disappears from it. */} </tr>
{isAll && closedCount ? ( </thead>
<label className={'tools__toggle' + (hideClosed ? ' is-on' : '')}> <tbody>
<input type="checkbox" checked={hideClosed} onChange={(ev) => setHideClosed(ev.target.checked)} /> {state.rows.map((r) => {
<i aria-hidden="true" /> const id = r.instance_id ?? r.id
Hide {closedCount} closed const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
</label> const exp = expiry(r.renewal_due_date)
) : null} const add = added(r.created_at)
<span className="tools__count"> return (
{shownRows.length} {shownRows.length === 1 ? 'lead' : 'leads'} <tr key={id}>
{!isAll && state.total > shownRows.length && !needle ? ` of ${state.total}` : ''} <td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
</span> <td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
</div> <td><span className="chip">{ch.label}</span></td>
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
{shownRows.length === 0 ? ( <td>
<p className="empty" style={{ margin: 20 }}> {exp
{needle ? 'No lead matches that.' : isAll ? 'No leads yet.' : 'No records in this stage.'} ? <><span className={'due due--' + exp.tone}>{exp.label}</span>
</p> <div className="grid__sub">{exp.on}</div></>
) : ( : <span className="grid__sub"></span>}
<div className="gridwrap"> </td>
<table className="grid"> <td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<thead> <td>
<tr> {r.attribution_status === 'contested'
<th>Customer</th> ? <span className="chip chip--warn">Contested</span>
<th>Stage</th> : <span className="grid__sub">{r.attribution_status || '—'}</span>}
<th>Renewal due</th> </td>
<th>Holding it</th> <td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
<th className="num">Premium</th> </tr>
<th>Last activity</th> )
</tr> })}
</thead> </tbody>
<tbody> </table>
{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,399 +1,109 @@
/* .page__head {
Lead Desk screens. Flat white cards on the grey canvas, one colour rule: display: flex; align-items: flex-start; justify-content: space-between;
blue = the AI holds it, amber = a person, teal = the customer, red = risk. gap: 24px; margin-bottom: 20px;
*/
/* ── 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; } .page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); }
.newlead, .btn--primary { background: var(--zk-blue); color: #fff; } .page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); }
.newlead:hover, .btn--primary:hover { background: var(--zk-navy-2); } .page__count {
.btn--navy { background: var(--zk-navy); color: #fff; } font-size: 1.35rem; font-weight: 500; color: var(--zk-blue);
.btn--navy:hover { background: var(--zk-navy-2); } background: var(--zk-white); border: 1px solid var(--zk-line);
.btn--ghost { border-color: var(--zk-line); background: #fff; color: var(--zk-ink); } border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums;
.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; } .empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; }
.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: #fff; border: 1px solid var(--zk-line); border-left: 3px solid var(--zk-blue); background: var(--zk-white); border: 1px solid var(--zk-line);
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs); padding: 18px 22px; max-width: 64ch; border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px;
max-width: 60ch;
} }
.notice strong { display: block; font-weight: 600; margin-bottom: 7px; } .notice strong { display: block; font-weight: 500; margin-bottom: 6px; }
.notice p { margin: 0 0 6px; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.55; } .notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; }
.notice__detail { font-size: var(--fs-2xs) !important; color: var(--zk-grey) !important; } .notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; }
.empty { .notice code {
margin: 0; color: var(--zk-muted); font-size: var(--fs-xs); text-align: center; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em;
padding: 44px 24px; border-radius: var(--r-md); border: 1px dashed var(--zk-line); background: #fff; background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
} 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;
} }
/* ── Overview: who holds the work ───────────────────────────────────────── */ .grid {
.hero { display: grid; grid-template-columns: 1fr 380px; gap: 18px; margin-bottom: 22px; } width: 100%; border-collapse: collapse; background: var(--zk-white);
.holders { padding: 20px 24px 22px; } border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem;
.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 {
font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey); text-align: left; text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em;
padding: 10px 20px; border-top: 1px solid var(--zk-line-soft); border-bottom: 1px solid var(--zk-line-soft); background: #FAFBFD; white-space: nowrap; text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px;
background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
} }
.grid td { padding: 12px 20px; border-bottom: 1px solid var(--zk-line-soft); font-size: var(--fs-sm); vertical-align: middle; } .grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; }
.grid tr:last-child td { border-bottom: 0; } .grid tbody tr:last-child td { border-bottom: none; }
.grid th.num, .grid td.num { text-align: right; } .grid tbody tr:hover { background: var(--zk-tint); }
.grid td.num { font-weight: 600; font-variant-numeric: tabular-nums; } .grid .num { text-align: right; font-variant-numeric: tabular-nums; }
.grid__row { cursor: pointer; transition: background var(--t-fast); } .grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; }
.grid__row:hover td { background: var(--zk-tint); } .grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; }
.grid__row:focus-visible { outline: none; } .grid__row:focus-visible td { background: var(--zk-tint-blue); } .grid__link:hover { text-decoration: underline; }
.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); }
.due { display: flex; flex-direction: column; gap: 2px; } .chip {
.due b { font-weight: 600; } display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px;
.due--amber b { color: var(--zk-amber); } .due--red b { color: var(--zk-danger); } background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
.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);
} }
.pager__pg button.is-on { background: var(--zk-navy); color: #fff; border-color: var(--zk-navy); } .chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
.pager__pg button:disabled { opacity: .45; cursor: default; }
/* ── List tools ─────────────────────────────────────────────────────────── */ /* ---- doors ---- */
.tools { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--zk-line-soft); flex-wrap: wrap; } .doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
.search { .door {
display: flex; align-items: center; gap: 8px; padding: 8px 12px; min-width: 260px; font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; color: var(--zk-grey); border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
} }
.search svg { width: 14px; height: 14px; flex: none; } .door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
.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 strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
.search:focus-within { border-color: var(--zk-blue); box-shadow: var(--ring); } .door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
.fchip { .door .chip { align-self: flex-start; }
font: inherit; font-size: var(--fs-xs); color: var(--zk-muted); background: #fff;
padding: 7px 30px 7px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); cursor: pointer;
-webkit-appearance: none; appearance: none;
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>");
background-repeat: no-repeat; background-position: right 10px center;
}
.fchip.is-on { border-color: var(--zk-navy); color: var(--zk-navy); font-weight: 600; background-color: var(--zk-tint-blue); }
.fchip:focus-visible { outline: none; box-shadow: var(--ring); }
.tools__count { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); }
.tools__toggle { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-xs); color: var(--zk-muted); cursor: pointer; user-select: none; }
.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 detail ────────────────────────────────────────────────────────── */ /* ---- panel ---- */
.back { .panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--zk-line); background: #fff; .panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
display: grid; place-items: center; color: var(--zk-muted); margin-top: 4px; cursor: pointer; flex: none; .panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
transition: background var(--t-fast), border-color var(--t-fast); .panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
}
.back svg { width: 16px; height: 16px; }
.back:hover { background: var(--zk-tint); border-color: var(--zk-grey); color: var(--zk-ink); }
.state { display: flex; align-items: center; gap: 10px; font-size: var(--fs-sm); color: var(--zk-muted); }
.state .pill { font-size: var(--fs-xs); }
.attn { /* ---- actions ---- */
display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center; .acts { display: flex; flex-wrap: wrap; gap: 10px; }
padding: 18px 22px; margin-bottom: 18px; border-radius: var(--r-md); .act {
border: 1px solid var(--zk-amber-line); background: var(--zk-amber-tint); 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;
} }
.attn--red { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); } .act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
.attn--blue { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); } .act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
.attn h3 { margin: 0; font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 10px; } .act strong { font-size: .88rem; font-weight: 500; }
.attn h3 .dot { width: 10px; height: 10px; } .act__by { font-size: .72rem; color: var(--zk-muted); }
.attn p { margin: 6px 0 0; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 78ch; line-height: 1.5; } .acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
.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; } /* ---- lead file ---- */
.fact { padding: 0 24px; border-left: 1px solid var(--zk-line-soft); min-width: 0; } .lead__stage { text-align: right; }
.fact:first-child { border-left: 0; } .lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
.fact__l { display: block; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 500; } .lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
.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); } .grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
.fact__v.is-none { color: var(--zk-grey); font-weight: 500; font-size: 22px; } .grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
.fact__v.due--red { color: var(--zk-danger); } .fact__v.due--amber { color: var(--zk-amber); } .grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
.fact__s { font-size: var(--fs-xs); color: var(--zk-grey); } .grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
.fact__conf { display: flex; align-items: center; gap: 10px; } .grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
.fact__conf .tr { flex: 1; height: 6px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; } .grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
.fact__conf .tr i { display: block; height: 100%; background: var(--zk-teal); border-radius: 3px; } .lead__back { margin-top: 18px; }
.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; } /* ---- renewal countdown ---- */
.detail__main { min-width: 0; display: flex; flex-direction: column; gap: 18px; } .due {
.detail__side { min-width: 0; display: flex; flex-direction: column; gap: 18px; } 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; }
.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; }
.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); }
.due--later { background: var(--zk-tint); color: var(--zk-muted); border: 1px solid var(--zk-line); }
/* The panels on the lead page are cards. */ /* ---- lead header meta ---- */
.panel { background: var(--zk-card); border: 1px solid var(--zk-line); border-radius: var(--r-md); padding: 0 24px 20px; } .lead__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; }
.panel__head { display: flex; align-items: baseline; gap: 12px; padding: 18px 0 6px; flex-wrap: wrap; } .lead__meta div { display: flex; flex-direction: column; gap: 1px; }
.panel__title { margin: 0; font-size: 19px; font-weight: 700; color: var(--zk-ink); } .lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); }
.panel__sub { margin: 0; font-size: var(--fs-2xs); color: var(--zk-grey); } .lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); }
.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;
}