Compare commits
84 Commits
chore/requ
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f413431d2 | |||
| f47463f099 | |||
| 141b1b2452 | |||
| 73b6ff7eb0 | |||
| ac3dc2965a | |||
| a310b0a8b9 | |||
| 607693a04a | |||
| da8ce9147f | |||
| 708cf4bc40 | |||
| 7c328d2b88 | |||
| 3dd482f180 | |||
| 54333ef847 | |||
| 97c792a2fc | |||
| 9e093757d8 | |||
| 79863e2a5f | |||
| 0df7d12073 | |||
| 3f36ea610b | |||
| f6682813a4 | |||
| 90290a8b37 | |||
| f31d6cb866 | |||
| b46faa70e2 | |||
| e0d51159f6 | |||
| bd4ced41ac | |||
| 42af54b3e2 | |||
| 02012f271a | |||
| 956a1703df | |||
| 47967c6318 | |||
| 6f50c0c30f | |||
| 125b3ffcf6 | |||
| 3cc99b718a | |||
| 818ef871ba | |||
| ee57184fa2 | |||
| fda1247ee9 | |||
| e21e3949f5 | |||
| d1ed66b7ae | |||
| d43b792dcb | |||
| c0480e0ae0 | |||
| e4ca2b92c9 | |||
| e90e4204ec | |||
| 2965c47b71 | |||
| 17561f3f73 | |||
| 60f6c97201 | |||
| edda6e9540 | |||
| 119bea1e76 | |||
| adef7838fc | |||
| 83b5795b22 | |||
| 900f34d11e | |||
| 8e86fa7a0a | |||
| a49ae3d86c | |||
| 9c05e4b26b | |||
| 009153fc0c | |||
| b27141b797 | |||
| 49d5443c60 | |||
| db91daabf8 | |||
| ea2b56e332 | |||
| 3069bf4849 | |||
| de8b158ca9 | |||
| 33bf65c5f5 | |||
| 4b590474e4 | |||
| c7cd5602ae | |||
| cb94e2417f | |||
| 93baba3fa1 | |||
| 5d146e9ffb | |||
| f4c67310cb | |||
| 171aa4c2fa | |||
| 8eb80dc371 | |||
| 927d335280 | |||
| e5b413ecac | |||
| 5ff74ea22d | |||
| d6756b4d2e | |||
| 704afe170e | |||
| 8b6a387cf6 | |||
| fc5eace791 | |||
| 6a6801d2bd | |||
| 07d4608888 | |||
| 2d9c072572 | |||
| 4e5df66389 | |||
| dbbd34f7bf | |||
| aca5d1a043 | |||
| 4c210c7e09 | |||
| eea044b9f9 | |||
| b9c76482a2 | |||
|
|
66ea08e317 | ||
|
|
ae548a3a10 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -24,3 +24,5 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5175
|
||||||
|
npm run build # → dist/
|
||||||
|
npm run lint # eslint (flat config, ignores dist/)
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
There are no tests and no test runner in this project.
|
||||||
|
|
||||||
|
`npm run lint` currently reports 12 pre-existing errors — 8 `react-hooks/refs`, 3
|
||||||
|
`react-hooks/set-state-in-effect`, 1 `react-refresh/only-export-components` (the provider
|
||||||
|
file exports both a component and a hook). A clean run is not the baseline; compare against
|
||||||
|
this count rather than against zero.
|
||||||
|
|
||||||
|
Local dev reads `VITE_ZINO_API_URL` from `.env` (`https://dev.getzino.in`). A deployed
|
||||||
|
build does **not** — see "Runtime config" below.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A React + Vite operator console (no state library, no UI framework — plain CSS files
|
||||||
|
next to their components) for the Zurich Kotak "Lead to Policy" demo on the Zino
|
||||||
|
platform. Leads arrive through three channels — direct, bancassurance, agency — and run
|
||||||
|
one workflow state machine to policy issuance. AI employees carry most of it; a person is
|
||||||
|
needed at three queues (`Contacted` → upload documents, `Referred to Underwriting` →
|
||||||
|
clear or decline, `Payment Pending` → confirm premium) and the customer holds one
|
||||||
|
(`Quoted`).
|
||||||
|
|
||||||
|
The frontend owns **no business logic**. The workflow (org `84`, app `536`, workflow
|
||||||
|
`zk_wf_lead`) is the source of truth; this repo is a thin renderer over its API.
|
||||||
|
|
||||||
|
Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/`
|
||||||
|
(SQL migrations `02_workflow.sql`, `04_views.sql`, …). Design doc:
|
||||||
|
`sm2/app-designs/zurich-kotak/design.html`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `src/api/client.js` — every gateway call lives here (`ZinoClient`). JWT + user in
|
||||||
|
`localStorage`, 401 clears the session via an auth-error handler.
|
||||||
|
- `src/api/config.js` — the environment identifiers and the hand-maintained mirrors of
|
||||||
|
workflow tables (`STAGES`, `ACTIONS`, `ENTRY`).
|
||||||
|
- `src/api/provider.jsx` — `ZinoProvider` / `useZino()`: one client instance plus session
|
||||||
|
state. Restores an **identity**, never a permission set.
|
||||||
|
- `src/api/errors.js` — `describeError()` turns the gateway's five error shapes into a
|
||||||
|
sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the
|
||||||
|
list).
|
||||||
|
- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding
|
||||||
|
the lead) rather than by pipeline position, with queue counts from one tallied list call.
|
||||||
|
- `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on
|
||||||
|
`current_state_name`.
|
||||||
|
- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then
|
||||||
|
field groups (`GROUPS`, in the order the lead is worked).
|
||||||
|
- `src/screens/AddLead.jsx` — the INIT doors from `ENTRY`; skips the chooser when only
|
||||||
|
one is surfaced.
|
||||||
|
- `src/components/ActivityForm.jsx` — renders the **live** activity schema and submits it
|
||||||
|
back. `src/components/FileField.jsx` handles `file`/`ocr` fields;
|
||||||
|
`src/components/Timeline.jsx` renders the audit trail as a story.
|
||||||
|
- `src/api/thread.js` — the WhatsApp conversation, assembled once from the audit rows
|
||||||
|
and shared by the timeline entry and the dialog. `turns` is every message deduped
|
||||||
|
(one submission is recorded up to three times); `episodes` are its contiguous bursts,
|
||||||
|
each carrying the workflow steps that ran before it; `byRow` says which steps sent a
|
||||||
|
message from inside their own trigger. The trail renders ONE entry per burst and never
|
||||||
|
a turn — a burst rather than the whole thread, because the payment exchange is the same
|
||||||
|
conversation resumed twenty minutes and four steps later, and one entry for all of it
|
||||||
|
would date those messages to the moment of the first. `THREAD_FIELDS` is why no step
|
||||||
|
quotes `customer_reply` / `customer_answer` itself: the thread owns them.
|
||||||
|
|
||||||
|
Routing: `/stage/:stageUid`, `/lead/:instanceId`, `/add`; unauthenticated renders
|
||||||
|
`Login` for every path.
|
||||||
|
|
||||||
|
## The invariants — break these and it fails in ways tests would not catch
|
||||||
|
|
||||||
|
**Forms are never defined in this repo.** `POST /view/form-screens` returns labels,
|
||||||
|
types, select options and which fields are mandatory; `ActivityForm` renders whatever
|
||||||
|
comes back. Adding a field in Studio and redeploying surfaces it here with no frontend
|
||||||
|
change. Do not hardcode a form — it would silently drift from the workflow.
|
||||||
|
|
||||||
|
**Permissions are never enforced here.** Nothing hides a button to stop someone. Every
|
||||||
|
decision is the workflow's, server-side, on each submission; the console reports the
|
||||||
|
refusal. `/activity` refuses with **403**, `/start` with **400**, both carrying
|
||||||
|
`permission denied`. Showing an action the signed-in role cannot perform is deliberate —
|
||||||
|
the refusal is the demo.
|
||||||
|
|
||||||
|
**Runtime config, not build-time.** `VITE_ZINO_API_URL` is read at runtime off
|
||||||
|
`window.__RUNTIME_CONFIG__`, from a `config.js` the server writes next to the artifact at
|
||||||
|
placement. One artifact is promoted between environments unchanged, so a compiled-in URL
|
||||||
|
would point every environment at whichever backend built it. `requireConfigValue` throws
|
||||||
|
rather than falling back — a production build with no `config.js` must fail loudly. The
|
||||||
|
dev-only `.env` fallback is gated on `import.meta.env.DEV`.
|
||||||
|
|
||||||
|
**One build serves any mount path.** `vite.config.js` sets `base: './'`; the router
|
||||||
|
basename comes from `basePath()`, which reads the `<base href>` the server writes into the
|
||||||
|
`<!--BASE_HREF-->` placeholder in `index.html`. Do not reintroduce a build-time base, and
|
||||||
|
do not remove that placeholder or the `config.js` script tag. `import.meta.env.BASE_URL`
|
||||||
|
is unusable here (it resolves to `./`).
|
||||||
|
|
||||||
|
**Bundled assets must live in `src/assets/`, not `public/`.** Vite rewrites bundled asset
|
||||||
|
URLs to be relative; a `public/` file referenced as `/fonts/…` stays absolute and 404s
|
||||||
|
under the `/zurich-kotak/` mount. The favicon is the one exception — it stays in
|
||||||
|
`public/brand/` and is referenced relatively.
|
||||||
|
|
||||||
|
**Activity fields are filtered by product line.** `Collect Documents` serves motor
|
||||||
|
and SME from one form — all eleven upload slots plus both lines' risk fields, to every
|
||||||
|
lead — and the activity carries no `field_rules` (`[]`), so nothing server-side decides
|
||||||
|
visibility. A motor renewal was being asked for a Udyam certificate, a stock statement
|
||||||
|
and a GSTIN. `fieldApplies()` in `api/config.js` decides: `motor_*` is motor and `sme_*`
|
||||||
|
is SME **by prefix**, so a field added to the workflow tomorrow classifies itself;
|
||||||
|
`LINE_FIELDS` names the exceptions that carry no prefix (`gstin`, `udyam_no`), and
|
||||||
|
`DOC_SLOTS` maps the upload slots plus the two genuine conditions (`doc_address_proof`
|
||||||
|
only when `kyc_outcome === 'refer'`, `doc_financials` only when `sme_sections` includes
|
||||||
|
`business_interruption`). `ActivityForm` filters on it for both render and submit.
|
||||||
|
|
||||||
|
Two things keep this safe. Every slot is `mandatory: false`, so hiding one cannot fail
|
||||||
|
schema validation. And when the filter would hide EVERY field, `ActivityForm` renders the
|
||||||
|
form whole instead — `Capture Motor Risk` is twelve motor fields and is runnable from
|
||||||
|
Contacted whatever the product is, so on an SME lead the filter would otherwise remove
|
||||||
|
the activity rather than its noise. `entity_name` is deliberately **not** SME-only: motor
|
||||||
|
leads carry a business name too.
|
||||||
|
|
||||||
|
The prefix rule is inference, not configuration — the durable home is `field_rules` on
|
||||||
|
the activity. When those are seeded, read them and delete this.
|
||||||
|
|
||||||
|
**Nothing is pushed — the console polls.** No socket, no SSE, and most steps are carried
|
||||||
|
by an AI employee that takes one to three minutes, so a lead moves while it is on screen.
|
||||||
|
The lead detail polls every 12s, queues and the sidebar counts every 30s, all gated on
|
||||||
|
`document.visibilityState` and all *quiet* — a refresh must never drop a queue back to a
|
||||||
|
skeleton under whoever is reading it.
|
||||||
|
|
||||||
|
**`STAGES` carries `kind`** — `needs` / `customer` / `auto` / `end` — answering "who is
|
||||||
|
holding this lead", plus `need` (what a queue wants done, which is what the sidebar shows)
|
||||||
|
and `doing`/`by` (what to say while an AI carries it, instead of an idle screen).
|
||||||
|
|
||||||
|
**`STAGES` and `ACTIONS` are hand-mirrored** from `workflow.tbl_wf_states` and
|
||||||
|
`workflow.tbl_wf_state_allowed_activities`, because the API returns neither the order nor
|
||||||
|
the labels the sidebar needs. An activity added to the workflow but not added to `ACTIONS`
|
||||||
|
is simply invisible — the platform allows it, the console never offers it, nothing errors.
|
||||||
|
This bit once already: `zk-act-drop` (Mark Lost) was missing entirely, so no lead could be
|
||||||
|
dropped from the console. It is now appended to every state in `DROPPABLE` rather than
|
||||||
|
written into ten entries. Keep all of it in step with the seed SQL.
|
||||||
|
|
||||||
|
No endpoint exposes state → allowed activities (eight plausible paths probed, all 404) and
|
||||||
|
the detail view carries `current_state_id` but no activity list — which is *why* the map is
|
||||||
|
hand-maintained. If the platform ever serves it, delete the map.
|
||||||
|
|
||||||
|
## API shapes that have already bitten
|
||||||
|
|
||||||
|
- `org_id` on `POST /usr/login` must be a **string**; a number returns
|
||||||
|
`cannot unmarshal number into Go struct field LoginRequest.org_id`.
|
||||||
|
- `POST /view/recordview` names the view `rv_template_uid` (the GET spelling is `rv_id`),
|
||||||
|
and paging/sort/filters live **inside** `search_query`. Top-level gives
|
||||||
|
`400 Missing param: rv_template_uid (body)`.
|
||||||
|
- Row key varies by source type: workflow views return `data`, rdbms views have returned
|
||||||
|
`records` — hence the `data ?? records ?? rows` fallbacks.
|
||||||
|
- Audit must use the **app-scoped** path `/app/536/view/audit`. Bare `/view/audit` is not
|
||||||
|
an API route; it falls through to the SPA and returns HTML with a 200.
|
||||||
|
- `POST /upload` **requires** `workflow_uuid` (taken from config, not from the caller's
|
||||||
|
ctx). The field context (`activity_id`, `field_id`, `instance_id`) is how the backend
|
||||||
|
resolves allowed types, size limit and `ocr_config` — it ignores anything the client
|
||||||
|
claims about them.
|
||||||
|
- `/ocr-extract` takes a **reference** to an already-uploaded file, not bytes, and answers
|
||||||
|
`{ extracted, raw }`. Files therefore upload on pick, not on submit.
|
||||||
|
- `POST /view/recordview` takes the view uid in the **body**. The path form the frontend
|
||||||
|
spec documents (`/view/recordview/zk-rv-leads`) returns **404**.
|
||||||
|
- The detail view returns `{ config, data }` — `config.fields[].output_label` names every
|
||||||
|
one of the 117 fields, so labels come from the server rather than from prettified ids.
|
||||||
|
- The `message` on a `/start` or `/activity` response is written per activity ("Documents
|
||||||
|
received — capturing the risk") and is surfaced verbatim, not replaced with a toast.
|
||||||
|
- On submit, `ActivityForm` sends only fields the activity defines, drops empties (an
|
||||||
|
unknown field is fatal to schema validation) and skips `id_gen` — that reference is
|
||||||
|
issued server-side.
|
||||||
|
- OCR results fill only fields still blank; never overwrite what a person typed with what
|
||||||
|
a model read.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql` (slug
|
||||||
|
`zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds and serves at
|
||||||
|
https://preview-dev.getzino.in/zurich-kotak/. The webhook fires only on a **new** push.
|
||||||
|
|
||||||
|
Demo logins (all password `ZurichKotak@2026`, org `84`) are listed in `README.md`; sign in
|
||||||
|
as `sanjay.uw@zurichkotak.example` to land in the one human queue.
|
||||||
@ -13,6 +13,9 @@
|
|||||||
<!--BASE_HREF-->
|
<!--BASE_HREF-->
|
||||||
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
|
<link rel="icon" type="image/webp" href="./brand/zurich_logo.webp" />
|
||||||
<title>Zurich Kotak | Lead Desk</title>
|
<title>Zurich Kotak | Lead Desk</title>
|
||||||
|
<!-- Typeface is Source Sans 3, self-hosted and bundled from src/assets
|
||||||
|
(see src/index.css). No CDN request, and it renders under the
|
||||||
|
sub-path mount because Vite resolves the URLs at build. -->
|
||||||
<!-- Written next to the artifact at placement; carries this environment's
|
<!-- Written next to the artifact at placement; carries this environment's
|
||||||
VITE_ZINO_API_URL, which src/runtimeConfig.js reads off
|
VITE_ZINO_API_URL, which src/runtimeConfig.js reads off
|
||||||
window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the
|
window.__RUNTIME_CONFIG__. Without it requireConfigValue throws and the
|
||||||
|
|||||||
10
src/App.jsx
10
src/App.jsx
@ -2,9 +2,10 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { useZino } from './api/provider.jsx'
|
import { useZino } from './api/provider.jsx'
|
||||||
import Login from './pages/Login.jsx'
|
import Login from './pages/Login.jsx'
|
||||||
import Shell from './layout/Shell.jsx'
|
import Shell from './layout/Shell.jsx'
|
||||||
|
import { PortfolioProvider } from './api/portfolio.jsx'
|
||||||
|
import Overview from './screens/Overview.jsx'
|
||||||
import Pipeline from './screens/Pipeline.jsx'
|
import Pipeline from './screens/Pipeline.jsx'
|
||||||
import Lead from './screens/Lead.jsx'
|
import Lead from './screens/Lead.jsx'
|
||||||
import AddLead from './screens/AddLead.jsx'
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { isAuthed } = useZino()
|
const { isAuthed } = useZino()
|
||||||
@ -21,10 +22,11 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||||
<Route element={<Shell />}>
|
<Route element={<PortfolioProvider><Shell /></PortfolioProvider>}>
|
||||||
<Route path="/" element={<Navigate to="/stage/zk-state-referred" replace />} />
|
{/* The morning page. This used to redirect to the underwriting queue —
|
||||||
|
one stage, usually empty, and somebody else's. */}
|
||||||
|
<Route path="/" element={<Overview />} />
|
||||||
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
<Route path="/stage/:stageUid" element={<Pipeline />} />
|
||||||
<Route path="/add" element={<AddLead />} />
|
|
||||||
<Route path="/lead/:instanceId" element={<Lead />} />
|
<Route path="/lead/:instanceId" element={<Lead />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
108
src/api/agents.js
Normal file
108
src/api/agents.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* The AI employees, as people the operator can recognise.
|
||||||
|
*
|
||||||
|
* Five agents carry this workflow and the console referred to them by their
|
||||||
|
* role slug or their full title, differently in each place — "ai_engage" in one
|
||||||
|
* view, "Engage" in another, "Engage AI" in a third. Someone watching a lead
|
||||||
|
* move could not tell that the thing which called the customer and the thing
|
||||||
|
* which wrote the quote were the same worker.
|
||||||
|
*
|
||||||
|
* So: one roster, one short name, one colour, one initial. Used wherever an
|
||||||
|
* agent is named. `does` is written in the present tense and in the operator's
|
||||||
|
* words, not the charter's — it appears on hover and in the roster strip, and
|
||||||
|
* it is what makes an unfamiliar name mean something the first time.
|
||||||
|
*
|
||||||
|
* `tools` and `knowledge` are a MIRROR of the employee config in
|
||||||
|
* aiemployee.tbl_ai_employees. The app frontend cannot read the agents schema,
|
||||||
|
* so this is hand-maintained the way STAGES and ACTIONS are, and carries the
|
||||||
|
* same hazard: a tool added or removed there and not here is described wrongly
|
||||||
|
* in the UI, silently.
|
||||||
|
*
|
||||||
|
* Worth the trade, because "what can this thing actually do?" is the first
|
||||||
|
* question anyone watching an agent asks — and the honest answer is short and
|
||||||
|
* reassuring: four of the five hold no tools at all and work from the policy
|
||||||
|
* wordings. The one tool that exists only reads a partner registry. Nothing
|
||||||
|
* here can move money, and since 89 nothing here can telephone anybody.
|
||||||
|
*
|
||||||
|
* Keyed by the ROLE, because that is what an audit row carries.
|
||||||
|
*/
|
||||||
|
export const AGENTS = {
|
||||||
|
ai_intake: {
|
||||||
|
icon: 'inspect',
|
||||||
|
short: 'Intake',
|
||||||
|
full: 'Intake & Attribution',
|
||||||
|
initials: 'IA',
|
||||||
|
tone: 'violet',
|
||||||
|
does: 'checks the lead is real, scores it, and works out whose it is',
|
||||||
|
wakes: 'Once, the moment a lead is filed.',
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'lookup_partner',
|
||||||
|
does: 'Reads the partner registry by partner code and reports whether that POSP, broker or corporate agent is active and empanelled for this product.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
|
},
|
||||||
|
|
||||||
|
ai_engage: {
|
||||||
|
icon: 'chat',
|
||||||
|
short: 'Engage',
|
||||||
|
full: 'Engage',
|
||||||
|
initials: 'EN',
|
||||||
|
tone: 'blue',
|
||||||
|
does: 'talks to the customer, writes up the call, and asks for what is missing',
|
||||||
|
wakes: 'Most often of the five: after the call ends, after documents land, after the Advisor reports, when the premium falls due, and on every WhatsApp reply.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
|
||||||
|
},
|
||||||
|
|
||||||
|
ai_advisor: {
|
||||||
|
icon: 'shield',
|
||||||
|
short: 'Advisor',
|
||||||
|
full: 'Advisor',
|
||||||
|
initials: 'AD',
|
||||||
|
tone: 'teal',
|
||||||
|
does: 'reads the captured risk and recommends the cover and add-ons',
|
||||||
|
wakes: 'Once, as soon as the risk has been captured.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)', 'Motor Renewal Sales Desk'],
|
||||||
|
},
|
||||||
|
|
||||||
|
ai_kyc: {
|
||||||
|
icon: 'id',
|
||||||
|
short: 'KYC',
|
||||||
|
full: 'KYC & Evidence',
|
||||||
|
initials: 'KY',
|
||||||
|
tone: 'amber',
|
||||||
|
does: 'verifies identity and screens the risk for underwriting',
|
||||||
|
wakes: 'Twice: when the customer accepts, and again to run the underwriting screen.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
|
},
|
||||||
|
|
||||||
|
ai_uw_referral: {
|
||||||
|
icon: 'scales',
|
||||||
|
short: 'Referral',
|
||||||
|
full: 'Underwriting Referral',
|
||||||
|
initials: 'UW',
|
||||||
|
tone: 'plum',
|
||||||
|
does: 'prepares a referred file so a human underwriter can decide',
|
||||||
|
wakes: 'Once, and only when the screen refers the risk.',
|
||||||
|
tools: [],
|
||||||
|
knowledge: ['Products, UINs & Commission', 'SME Package (BSUS / BLUS)', 'Motor (Car Secure)'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agent behind a set of roles, or null when a person did it. */
|
||||||
|
export function agentFor(roles) {
|
||||||
|
if (!Array.isArray(roles)) return null
|
||||||
|
const key = roles.find((r) => AGENTS[r])
|
||||||
|
return key ? { key, ...AGENTS[key] } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initials for a person, so a human entry reads as a name too. */
|
||||||
|
export function initialsOf(name) {
|
||||||
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (!parts.length) return '—'
|
||||||
|
return (parts[0][0] + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase()
|
||||||
|
}
|
||||||
@ -68,6 +68,37 @@ export class ZinoClient {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── The support desk ────────────────────────────────────────────────────
|
||||||
|
Not app-scoped: these are the platform's own agent routes, and the token
|
||||||
|
this console already holds is accepted on them as-is. A conversation is a
|
||||||
|
session; a question is a message on it. */
|
||||||
|
|
||||||
|
/** This operator's conversations with the desk, newest first. */
|
||||||
|
deskSessions(agentId) {
|
||||||
|
return this.request('GET', `/api/agent-sessions?agent_id=${agentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a new conversation. */
|
||||||
|
deskStartSession(agentId) {
|
||||||
|
return this.request('POST', '/api/agent-sessions', { agent_id: agentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The transcript of one conversation. */
|
||||||
|
deskMessages(sessionId) {
|
||||||
|
return this.request('GET', `/api/agent-sessions/${sessionId}/messages`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask. Answers with the rows the backend persisted for the turn —
|
||||||
|
* { user_message, call_api_message, agent_message } — not a bare string.
|
||||||
|
*
|
||||||
|
* These take twenty to forty seconds: the desk runs real queries against the
|
||||||
|
* book before it answers. The caller has to say so, or the panel looks hung.
|
||||||
|
*/
|
||||||
|
deskAsk(sessionId, content) {
|
||||||
|
return this.request('POST', `/api/agent-sessions/${sessionId}/messages`, { content })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* org_id must be a STRING. The gateway rejects a number with
|
* org_id must be a STRING. The gateway rejects a number with
|
||||||
* "cannot unmarshal number into Go struct field LoginRequest.org_id".
|
* "cannot unmarshal number into Go struct field LoginRequest.org_id".
|
||||||
|
|||||||
@ -13,24 +13,88 @@ export const WORKFLOW = 'zk_wf_lead'
|
|||||||
*
|
*
|
||||||
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
|
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
|
||||||
* because the sidebar has to render its ORDER, and the API returns states as a
|
* because the sidebar has to render its ORDER, and the API returns states as a
|
||||||
* set with no canonical sequence. Keep in step with 02_workflow.sql.
|
* set with no canonical sequence. Keep in step with 53_v2_state_model.sql.
|
||||||
|
*
|
||||||
|
* `kind` answers "who is holding this lead", which is the question an operator
|
||||||
|
* actually has — not "where is it in the process":
|
||||||
|
*
|
||||||
|
* needs a person has to act, and nothing moves until they do
|
||||||
|
* customer waiting on someone outside the business
|
||||||
|
* auto an AI employee is carrying it; `doing` is what to say meanwhile
|
||||||
|
* waiting real, but not yet — the renewal is too far off to work
|
||||||
|
* end terminal
|
||||||
|
*
|
||||||
|
* `need` names the queue by what it wants done. A queue called "Upload
|
||||||
|
* documents" answers "is anything waiting on me?"; one called "Document
|
||||||
|
* Pending" describes where the lead sits and leaves the operator to work it out.
|
||||||
|
*
|
||||||
|
* `name` MUST match workflow.tbl_wf_states.name exactly — the queue filter, the
|
||||||
|
* sidebar tally and the action lookup all resolve a lead through this string
|
||||||
|
* against current_state_name. A workflow rename not mirrored here does NOT
|
||||||
|
* error: the queue returns nothing, correctly, for a name no lead is in. That
|
||||||
|
* is the failure mode this table has, and it is silent.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The support desk this console can ask questions of.
|
||||||
|
*
|
||||||
|
* A Support App on the platform: an AI desk bound to app 536 that reads the
|
||||||
|
* deployed config and the live book through read-only tools and answers with
|
||||||
|
* evidence. It is org-scoped and lives outside this app, so its id is config
|
||||||
|
* here rather than something the console can derive.
|
||||||
|
*
|
||||||
|
* Access is granted per user on the platform (usersystem.tbl_org_user_agents)
|
||||||
|
* and the console additionally gates the panel to ops — see ASK_DESK_ROLES.
|
||||||
|
* Both have to agree; the platform's grant is the one that actually enforces.
|
||||||
|
*/
|
||||||
|
export const SUPPORT_AGENT_ID = 29648
|
||||||
|
export const ASK_DESK_ROLES = ['ops_admin']
|
||||||
|
|
||||||
export const STAGES = [
|
export const STAGES = [
|
||||||
{ uid: 'zk-state-new', name: 'New Lead', kind: 'work' },
|
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
|
||||||
{ uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' },
|
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
|
||||||
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' },
|
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' },
|
||||||
{ uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' },
|
// The principal stall in the workflow, and the one that had no stage until
|
||||||
{ uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' },
|
// 7 September: a lead waited for its three documents inside "Contacted", so
|
||||||
{ uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' },
|
// the queue that most needed watching was the one that did not exist.
|
||||||
{ uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' },
|
{ uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' },
|
||||||
// The only human queue in the whole machine.
|
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer', doing: 'Waiting for the customer to reply on WhatsApp' },
|
||||||
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' },
|
// The one queue where the machine stops and a person decides.
|
||||||
{ uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' },
|
{ uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' },
|
||||||
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' },
|
// `approval` marks the one queue that is a person's SIGN-OFF rather than a
|
||||||
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' },
|
// piece of work: confirming money has arrived. It is locked to ops_admin in
|
||||||
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
|
// the workflow — no agent and no other role can perform it — because an
|
||||||
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
|
// employee that can mark a premium received can put a customer on risk for a
|
||||||
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
|
// policy nobody paid for. It gets its own band on the overview so the role
|
||||||
|
// that owns it does not have to find it in a list of things mostly handled
|
||||||
|
// by somebody else.
|
||||||
|
// WHERE THE AI PUTS DOWN WHAT IT CANNOT HONESTLY FINISH. An AI employee has
|
||||||
|
// no way to raise a ticket and no way to send a message; the only move it
|
||||||
|
// has for "a person should take this" is Flag for Review, and it lands here
|
||||||
|
// with the reason it wrote. Nothing is running on these leads.
|
||||||
|
{ uid: 'zk-state-review', name: 'Needs a Person', kind: 'needs', need: 'Flagged by the AI', by: 'operations' },
|
||||||
|
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations', approval: 'ops_admin', approvalLabel: 'Premium awaiting your confirmation', approvalNote: 'Cover cannot start until the premium is received — section 64VB. Only operations can confirm it.' },
|
||||||
|
{ uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' },
|
||||||
|
// Nurture. There is no scheduler yet, so Resume Outreach is a button and it
|
||||||
|
// is the only thing that wakes a parked lead — do not present this as a
|
||||||
|
// stage with nothing to do.
|
||||||
|
{ uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Nurture', by: 'the renewal calendar' },
|
||||||
|
{ uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' },
|
||||||
|
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
|
||||||
|
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the sidebar groups those. Fourteen flat stages answer "what is the
|
||||||
|
* process?" — the question nobody signing in has. These answer "is anything
|
||||||
|
* waiting on me?".
|
||||||
|
*/
|
||||||
|
export const NAV_GROUPS = [
|
||||||
|
// The sidebar answers "is anything waiting on me?", so a group is WHO holds
|
||||||
|
// the work, not where it sits in the process. A person's queues and the
|
||||||
|
// customer's queues both need a human to look, so they share one heading.
|
||||||
|
{ label: 'Needs you', kinds: ['needs', 'customer'] },
|
||||||
|
{ label: 'Scheduled', kinds: ['waiting'] },
|
||||||
|
{ label: 'History', kinds: ['end'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Channel presentation. source_channel is data on the instance, never a branch. */
|
/** Channel presentation. source_channel is data on the instance, never a branch. */
|
||||||
@ -60,30 +124,106 @@ export const DV_LEAD = 'zk-dv-lead'
|
|||||||
* the platform allows it, the console never offers it, and nothing errors.
|
* the platform allows it, the console never offers it, and nothing errors.
|
||||||
* Collect Documents shipped in that state for one round.
|
* Collect Documents shipped in that state for one round.
|
||||||
*/
|
*/
|
||||||
export const ACTIONS = {
|
/**
|
||||||
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
|
* What each activity IS at a stage, not merely who may press it.
|
||||||
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }],
|
*
|
||||||
'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' },
|
* `do` the expected next step here. Usually one; Referred has two,
|
||||||
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
|
* because clear and decline are a decision pair rather than a
|
||||||
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }],
|
* step and an alternative to it.
|
||||||
'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
|
* `again` a bounded loop — a retry, a reminder, a re-quote. Legitimate,
|
||||||
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }],
|
* never the thing to do next.
|
||||||
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }],
|
* `force` an AI employee's own job, offered to a person ONLY so a
|
||||||
'zk-state-accepted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' },
|
* stalled lead can be pushed by hand. Presenting these as
|
||||||
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }],
|
* actions is what made the app read as a control panel: six
|
||||||
'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }],
|
* equal buttons where five are recovery levers.
|
||||||
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
|
* `exit` Mark Lost. Always reachable, never a step. Four stages used
|
||||||
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
|
* to offer a partner agent this and nothing else, which told
|
||||||
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
|
* them their only option was to give up on a lead the system
|
||||||
'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Ops' }],
|
* was actively working.
|
||||||
// 'Payment webhook' was aspirational — there is no payment integration on the
|
*/
|
||||||
// platform and none is planned here. Collection happens on the insurer's own
|
const STATE_ACTIVITIES = {
|
||||||
// rails; ops records the reference. See 35_reframe_to_what_exists.sql.
|
'zk-state-new': [
|
||||||
'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' },
|
{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' },
|
||||||
{ uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage' }],
|
],
|
||||||
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }],
|
'zk-state-qualified': [
|
||||||
|
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
|
||||||
|
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-contacted': [
|
||||||
|
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
|
||||||
|
],
|
||||||
|
// The one stage whose next step is a person's: three documents have to
|
||||||
|
// be found and uploaded. Everything else here is the AI's own chain.
|
||||||
|
'zk-state-docs': [
|
||||||
|
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
|
||||||
|
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' },
|
||||||
|
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
|
||||||
|
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
|
||||||
|
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },
|
||||||
|
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-quoted': [
|
||||||
|
// The customer accepts from their own phone: their WhatsApp reply is
|
||||||
|
// routed to Customer Reply, Engage reads it, and Engage performs this.
|
||||||
|
// Kept reachable — role 'force', folded away — because a customer who
|
||||||
|
// says yes on a CALL still needs somebody to record it, and because a
|
||||||
|
// reply Engage judged ambiguous has to be actionable by a person.
|
||||||
|
{ uid: 'zk-act-accept', label: 'Record the acceptance by hand', by: 'you, on consent', role: 'force' },
|
||||||
|
{ uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' },
|
||||||
|
{ uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' },
|
||||||
|
{ uid: 'zk-act-answer-customer', label: 'Reply to the customer', by: 'you, on WhatsApp', role: 'again' },
|
||||||
|
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' },
|
||||||
|
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-referred': [
|
||||||
|
{ uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' },
|
||||||
|
{ uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' },
|
||||||
|
{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-review': [
|
||||||
|
{ uid: 'zk-act-review-resume', label: 'Send back to the workflow', by: 'you', role: 'do' },
|
||||||
|
{ uid: 'zk-act-review-refer', label: 'Send to underwriting', by: 'you', role: 'do' },
|
||||||
|
],
|
||||||
|
'zk-state-payment': [
|
||||||
|
{ uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' },
|
||||||
|
// Not offered: the chase is a SCHEDULED activity now (86), booked 24h
|
||||||
|
// out by Request Premium and re-booked at 48h to a cap of three. It
|
||||||
|
// carries zero roles so the scheduler can perform it at all, which means
|
||||||
|
// a button here would 403 for everyone. To actually reach the customer,
|
||||||
|
// use Reply to the customer — that sends something.
|
||||||
|
{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-issued': [
|
||||||
|
{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' },
|
||||||
|
],
|
||||||
|
'zk-state-parked': [
|
||||||
|
{ uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark Lost is not tied to one state: a lead can be dropped from anywhere before
|
||||||
|
* the policy issues, and every role that files a lead may do it. Appended rather
|
||||||
|
* than written into every entry so there is one place to change it, and so a
|
||||||
|
* stage added above cannot silently lose it.
|
||||||
|
*
|
||||||
|
* It carries role 'exit' precisely so it is never rendered as a step. It is the
|
||||||
|
* way out, not the way on.
|
||||||
|
*/
|
||||||
|
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'whoever holds the lead', role: 'exit' }
|
||||||
|
|
||||||
|
const DROPPABLE = [
|
||||||
|
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
||||||
|
'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ACTIONS = Object.fromEntries(
|
||||||
|
[...new Set([...Object.keys(STATE_ACTIVITIES), ...DROPPABLE])].map((uid) => [
|
||||||
|
uid,
|
||||||
|
[...(STATE_ACTIVITIES[uid] ?? []), ...(DROPPABLE.includes(uid) ? [DROP] : [])],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entry points surfaced in the console.
|
* Entry points surfaced in the console.
|
||||||
*
|
*
|
||||||
@ -100,3 +240,337 @@ export const ENTRY = [
|
|||||||
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
|
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
|
||||||
note: 'POSP or broker sourcing a lead. Everything after this happens without them.' },
|
note: 'POSP or broker sourcing a lead. Everything after this happens without them.' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Product lines, as stored on the instance and as they should be read. */
|
||||||
|
export const PRODUCTS = {
|
||||||
|
motor: 'Motor',
|
||||||
|
sme_package: 'SME Package',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which upload slot belongs to which product line — and, for the two slots that
|
||||||
|
* are conditional, when the condition holds.
|
||||||
|
*
|
||||||
|
* The Collect Documents activity serves BOTH lines from one form: it returns all
|
||||||
|
* eleven slots to every lead, so a motor renewal was being asked for a Udyam
|
||||||
|
* certificate and a stock statement. Nothing on the platform decides otherwise —
|
||||||
|
* `field_rules` comes back as `[]` for this activity, so there is no server-side
|
||||||
|
* visibility to honour.
|
||||||
|
*
|
||||||
|
* This is therefore a MIRROR, in the same sense as STAGES and ACTIONS above, and
|
||||||
|
* carries the same hazard: a slot added to the activity but not added here is
|
||||||
|
* shown to every product, and one renamed here stops matching and reverts to the
|
||||||
|
* same. The durable home for this is `field_rules` on the activity; when those
|
||||||
|
* are seeded, ActivityForm should read them and this table should go.
|
||||||
|
*
|
||||||
|
* Every slot is optional in the workflow (`mandatory: false` on all eleven), so
|
||||||
|
* hiding one cannot make a submission fail validation.
|
||||||
|
*/
|
||||||
|
export const DOC_SLOTS = {
|
||||||
|
// Motor.
|
||||||
|
doc_rc: { line: 'motor' },
|
||||||
|
doc_prev_policy: { line: 'motor' },
|
||||||
|
// "Break-in only" per the policy. The flag DOES exist now — 49 derives
|
||||||
|
// break_in at filing on all three doors — so the slot can honour its own
|
||||||
|
// caveat instead of being shown to every motor renewal.
|
||||||
|
doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' },
|
||||||
|
|
||||||
|
// SME.
|
||||||
|
doc_gst_cert: { line: 'sme' },
|
||||||
|
doc_udyam_cert: { line: 'sme' },
|
||||||
|
doc_premises_proof: { line: 'sme' },
|
||||||
|
doc_stock_statement: { line: 'sme' },
|
||||||
|
doc_premises_photos: { line: 'sme' },
|
||||||
|
// Audited accounts are only read when business interruption is on the risk.
|
||||||
|
doc_financials: { line: 'sme',
|
||||||
|
when: (lead) => (lead.sme_sections || []).includes('business_interruption') },
|
||||||
|
|
||||||
|
// Both lines.
|
||||||
|
doc_pan: { line: 'both' },
|
||||||
|
// Identity is only re-evidenced when KYC actually referred; asking up front
|
||||||
|
// collects an Aadhaar the file does not need.
|
||||||
|
doc_address_proof: { line: 'both', when: (lead) => lead.kyc_outcome === 'refer' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields that belong to one product line but do not say so in their name.
|
||||||
|
* Everything else is classified by prefix: `motor_*` is motor, `sme_*` is SME.
|
||||||
|
* A prefix rule rather than a list, so a field added to the workflow tomorrow is
|
||||||
|
* classified without anyone remembering to come back here.
|
||||||
|
*/
|
||||||
|
export const LINE_FIELDS = {
|
||||||
|
gstin: 'sme',
|
||||||
|
udyam_no: 'sme',
|
||||||
|
// entity_name is labelled "Business Name" on the form. A company-owned
|
||||||
|
// vehicle does have an owner with a name, so this is not strictly an SME
|
||||||
|
// field — but asking a motor renewal for a business name puts an empty box
|
||||||
|
// on the shortest form in the app, and the answer is already carried by
|
||||||
|
// customer_name in every motor lead we have. It is optional, so hiding it
|
||||||
|
// cannot block a submission, and an SME lead still gets it.
|
||||||
|
entity_name: 'sme',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields the form STAMPS rather than asks.
|
||||||
|
*
|
||||||
|
* Each is resolved by the prefill pipeline from the signed-in identity —
|
||||||
|
* Arjun IS Crestline Insurance Advisors, submitting through the agency door —
|
||||||
|
* so all three arrived filled and read-only-in-spirit, and the New lead form
|
||||||
|
* opened with three of its eleven boxes already answered by the system. That
|
||||||
|
* is three boxes of noise in front of the four that actually need a person.
|
||||||
|
*
|
||||||
|
* HIDDEN FROM THE FORM, STILL SENT. These are not decorative: source_channel
|
||||||
|
* is mandatory, and partner_code and rm_or_agent_id are what Intake attributes
|
||||||
|
* the lead on. So they are filtered at RENDER only — `fields`, which drives
|
||||||
|
* both validation and the payload, still holds them, and their prefilled
|
||||||
|
* values submit exactly as before. Filtering them out of `fields` instead
|
||||||
|
* would drop the attribution and 400 on the mandatory channel.
|
||||||
|
*/
|
||||||
|
export const STAMPED_FIELDS = new Set([
|
||||||
|
'source_channel',
|
||||||
|
'partner_code',
|
||||||
|
'rm_or_agent_id',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Whether a field is stamped by prefill and so should not be drawn. */
|
||||||
|
export function fieldIsStamped(fieldId) {
|
||||||
|
return STAMPED_FIELDS.has(baseFieldId(fieldId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields the workflow computes for itself, which a form must therefore not ask
|
||||||
|
* for. Matched on the base id, so every per-activity suffix is covered.
|
||||||
|
*
|
||||||
|
* `documents_status` is the one that matters. It gates the AND-join that wakes
|
||||||
|
* Engage after an upload (55), and it was rendered as a dropdown — asking the
|
||||||
|
* person who had just attached three files to tell the system what it could
|
||||||
|
* see. Left unset, the lead sits in Document Pending with everything attached
|
||||||
|
* and nothing happening, and it reads as the AI having stalled. It is derived
|
||||||
|
* in the trigger now (75), from the files themselves.
|
||||||
|
*
|
||||||
|
* `documents_notes` goes with it: the same script writes what is still missing,
|
||||||
|
* and a person overwriting that would be arguing with the file list.
|
||||||
|
*/
|
||||||
|
export const DERIVED_FIELDS = new Set([
|
||||||
|
'documents_status',
|
||||||
|
'documents_notes',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field ids carry a per-form suffix — `doc_rc` arrives as `doc_rc_2`, `pan`
|
||||||
|
* as `pan_3` — so a field is matched on the id with that suffix removed. No
|
||||||
|
* underlying field id ends in a number, which is what makes this safe.
|
||||||
|
*/
|
||||||
|
export function baseFieldId(id) {
|
||||||
|
return String(id).replace(/_\d+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which product line a field belongs to: 'motor', 'sme', or 'both'. */
|
||||||
|
export function fieldLine(fieldId) {
|
||||||
|
const id = baseFieldId(fieldId)
|
||||||
|
if (DOC_SLOTS[id]) return DOC_SLOTS[id].line
|
||||||
|
if (LINE_FIELDS[id]) return LINE_FIELDS[id]
|
||||||
|
if (id.startsWith('motor_')) return 'motor'
|
||||||
|
if (id.startsWith('sme_')) return 'sme'
|
||||||
|
return 'both'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line a lead is on, in the vocabulary fieldLine answers in, or null when it
|
||||||
|
* cannot be told.
|
||||||
|
*
|
||||||
|
* Null matters: treating an unknown line as SME would quietly drop the RC and
|
||||||
|
* the expiring policy from Collect Documents on a lead whose product has not
|
||||||
|
* been stamped yet, with nothing on screen to say a slot was hidden. Not knowing
|
||||||
|
* means filtering nothing.
|
||||||
|
*/
|
||||||
|
export function leadLine(lead) {
|
||||||
|
if (lead?.product_line === 'motor') return 'motor'
|
||||||
|
if (lead?.product_line === 'sme_package') return 'sme'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an activity field should be offered for this lead — its line has to
|
||||||
|
* match, and a conditional upload slot has to have its condition hold.
|
||||||
|
*
|
||||||
|
* Everything is shown when there is no instance yet: an INIT form has no product
|
||||||
|
* line to filter on.
|
||||||
|
*/
|
||||||
|
export function fieldApplies(fieldId, lead) {
|
||||||
|
// Computed by the workflow — never asked for, on any form, whether or not
|
||||||
|
// there is a lead behind it.
|
||||||
|
if (DERIVED_FIELDS.has(baseFieldId(fieldId))) return false
|
||||||
|
if (!lead) return true
|
||||||
|
const on = leadLine(lead)
|
||||||
|
if (!on) return true
|
||||||
|
const line = fieldLine(fieldId)
|
||||||
|
if (line !== 'both' && line !== on) return false
|
||||||
|
const slot = DOC_SLOTS[baseFieldId(fieldId)]
|
||||||
|
return slot?.when ? slot.when(lead) : true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A STAGE IS NOT ALWAYS ONE SITUATION.
|
||||||
|
*
|
||||||
|
* Document Pending covers two that could not look more different to whoever is
|
||||||
|
* watching. Before the upload a person has to act and nothing moves until they
|
||||||
|
* do; after it the lead stays in the same state while Engage captures the risk,
|
||||||
|
* the Advisor recommends cover and the rating engine prices it — three AI steps,
|
||||||
|
* two to five minutes, no one to chase.
|
||||||
|
*
|
||||||
|
* Rendered from `current_state_name` alone both read as "waiting on the partner
|
||||||
|
* agent", so a lead that had just been served showed an Upload documents button
|
||||||
|
* and sat in the Action-required queue, and the AI work behind it was invisible.
|
||||||
|
* The state machine is right to hold one state here — nothing has been decided
|
||||||
|
* yet — so the distinction belongs in the read model, once, rather than in each
|
||||||
|
* screen's own guess.
|
||||||
|
*
|
||||||
|
* `documents_status` is what separates them. The workflow derives it from the
|
||||||
|
* files themselves (75), so it says what is actually attached rather than what
|
||||||
|
* anyone claimed.
|
||||||
|
*/
|
||||||
|
export function phaseOf(stage, lead) {
|
||||||
|
if (!stage || !lead) return stage
|
||||||
|
|
||||||
|
// Quote Presented has the same shape as Document Pending: the state holds
|
||||||
|
// still while the situation underneath it changes completely.
|
||||||
|
//
|
||||||
|
// Before the customer answers, the lead is genuinely with them and nothing
|
||||||
|
// is running. After they accept, KYC and the underwriting screen run inside
|
||||||
|
// the SAME state — the lead does not move until underwriting clears — so a
|
||||||
|
// page saying "waiting for the customer to reply" sat directly above a panel
|
||||||
|
// saying "accepted by the customer, KYC and underwriting are running". Both
|
||||||
|
// were rendered from the same instant; only one was true.
|
||||||
|
if (stage.uid === 'zk-state-quoted' && lead.acceptance_ref && !lead.uw_outcome) {
|
||||||
|
return {
|
||||||
|
...stage,
|
||||||
|
kind: 'auto',
|
||||||
|
doing: lead.kyc_outcome ? 'Screening the risk for underwriting' : 'Verifying KYC',
|
||||||
|
by: 'KYC & Evidence',
|
||||||
|
after: 'customer accepted',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') {
|
||||||
|
return {
|
||||||
|
...stage,
|
||||||
|
kind: 'auto',
|
||||||
|
doing: 'Reading the documents and pricing the cover',
|
||||||
|
by: 'Engage AI, then the Advisor',
|
||||||
|
// Kept so a screen can say WHY this is not the queue it looks like.
|
||||||
|
after: 'documents received',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why an automated stage has stopped, when it has.
|
||||||
|
*
|
||||||
|
* The AI chain behind Document Pending is honest about refusing: the rating
|
||||||
|
* engine writes `quoted_breakup` = "Not priced: no insured value (IDV)
|
||||||
|
* captured…" rather than inventing a premium, and Engage then declines to
|
||||||
|
* raise a quote it would have to fabricate. Both are the right call.
|
||||||
|
*
|
||||||
|
* What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a
|
||||||
|
* tab, the AI's task dies in a queue no operator can see, and the lead sits in
|
||||||
|
* Document Pending looking exactly like one whose documents never arrived —
|
||||||
|
* indefinitely, because nothing wakes the chain again. A workflow that stops
|
||||||
|
* for a good reason and a workflow that is broken must not look the same.
|
||||||
|
*
|
||||||
|
* Recovery is always the same shape: put the missing value in and let the
|
||||||
|
* chain re-run. Collect Documents carries the IDV field and re-performing it
|
||||||
|
* wakes Engage again, so the action is one the partner agent already holds.
|
||||||
|
*/
|
||||||
|
export function blockedOn(lead) {
|
||||||
|
if (!lead) return null
|
||||||
|
const breakup = String(lead.quoted_breakup || '')
|
||||||
|
if (breakup.startsWith('Not priced')) {
|
||||||
|
return {
|
||||||
|
what: breakup.replace(/^Not priced:\s*/, ''),
|
||||||
|
// Named rather than described: the operator has to find this field, and
|
||||||
|
// it is not where they would look for it.
|
||||||
|
fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.',
|
||||||
|
via: 'zk-act-collect-docs',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHEN AN AGENT STOPS, WHAT DOES A PERSON PRESS?
|
||||||
|
*
|
||||||
|
* Agents stall. Not often, but they do, and for reasons nothing in this app
|
||||||
|
* controls: the model provider slows to ninety seconds a call or returns a 502,
|
||||||
|
* a thinking budget runs out mid-sentence, a late webhook wakes the wrong
|
||||||
|
* employee. Watched from the console every one of those looks identical —
|
||||||
|
* a lead that simply stops — and the screen kept promising it would "complete
|
||||||
|
* within two minutes" indefinitely.
|
||||||
|
*
|
||||||
|
* There is no retry button in the platform, and none of these agents can be
|
||||||
|
* woken directly. But every one of them is woken BY AN ACTIVITY, so performing
|
||||||
|
* that activity again wakes it again. That is what this table holds: for a
|
||||||
|
* lead sitting in an automated step, the activity a person can perform to make
|
||||||
|
* the stalled agent run once more.
|
||||||
|
*
|
||||||
|
* `by` is who to expect to move afterwards, so the button says what will
|
||||||
|
* happen rather than just what it does.
|
||||||
|
*
|
||||||
|
* The choice within Document Pending depends on HOW FAR the chain got, because
|
||||||
|
* three agents work that state in sequence and the one to restart is the one
|
||||||
|
* that did not finish.
|
||||||
|
*/
|
||||||
|
export function nudgeFor(stage, lead) {
|
||||||
|
if (!stage || !lead) return null
|
||||||
|
|
||||||
|
switch (stage.uid) {
|
||||||
|
case 'zk-state-new':
|
||||||
|
return { uid: 'zk-act-qualify', label: 'Run the check again', by: 'Intake' }
|
||||||
|
|
||||||
|
case 'zk-state-qualified':
|
||||||
|
return { uid: 'zk-act-contact', label: 'Record the call outcome', by: 'Engage' }
|
||||||
|
|
||||||
|
case 'zk-state-contacted':
|
||||||
|
return { uid: 'zk-act-request-docs', label: 'Ask for the documents again', by: 'Engage' }
|
||||||
|
|
||||||
|
case 'zk-state-docs': {
|
||||||
|
// Engage captures, the Advisor recommends, the rating engine prices —
|
||||||
|
// in that order, each woken by the one before.
|
||||||
|
const line = lead.product_line === 'sme_package' ? 'zk-act-capture-sme' : 'zk-act-capture-motor'
|
||||||
|
if (!lead.motor_idv && !lead.sme_value_at_risk) {
|
||||||
|
return { uid: line, label: 'Capture the risk again', by: 'Engage' }
|
||||||
|
}
|
||||||
|
if (!lead.ai_recommended_cover) {
|
||||||
|
// The Advisor is the ONE step nobody may perform by hand — it is
|
||||||
|
// permitted to ai_advisor alone. Re-performing the activity that wakes
|
||||||
|
// it is the only way back, and it is the reason this table exists.
|
||||||
|
return { uid: line, label: 'Wake the Advisor', by: 'the Advisor' }
|
||||||
|
}
|
||||||
|
if (!lead.quoted_premium) {
|
||||||
|
return { uid: 'zk-act-quote', label: 'Build the quote again', by: 'the rating engine' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'zk-state-quoted': {
|
||||||
|
// Only after an acceptance — before that nothing is running and there is
|
||||||
|
// nothing to wake; the lead is with the customer.
|
||||||
|
if (!lead.acceptance_ref) return null
|
||||||
|
if (!lead.kyc_outcome) return { uid: 'zk-act-kyc', label: 'Run KYC again', by: 'KYC & Evidence' }
|
||||||
|
if (!lead.uw_outcome) return { uid: 'zk-act-uw-screen', label: 'Run the underwriting screen', by: 'KYC & Evidence' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'zk-state-issued':
|
||||||
|
return { uid: 'zk-act-onboard', label: 'Close the file', by: 'Engage' }
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long an automated step may sit before the console stops reassuring and
|
||||||
|
* starts offering the way out. Generous: an employee wake plus a slow model
|
||||||
|
* can legitimately take three or four minutes. */
|
||||||
|
export const STALL_AFTER_MS = 5 * 60 * 1000
|
||||||
|
|||||||
130
src/api/errors.js
Normal file
130
src/api/errors.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* The gateway's error shapes, turned into something an operator can act on.
|
||||||
|
*
|
||||||
|
* Five shapes matter, and they are told apart by status plus a phrase in the
|
||||||
|
* message — there is no error code to switch on. `kind` is for the caller to
|
||||||
|
* branch on ('stale' and 'gone' need the screen to do something); `title` and
|
||||||
|
* `detail` are what gets shown.
|
||||||
|
*
|
||||||
|
* Anything unrecognised keeps its own message: a wrong guess reads worse than
|
||||||
|
* the server's own words.
|
||||||
|
*/
|
||||||
|
export function describeError(err) {
|
||||||
|
const status = err?.status
|
||||||
|
const raw = String(err?.message ?? '')
|
||||||
|
const has = (...words) => words.every((w) => raw.toLowerCase().includes(w))
|
||||||
|
|
||||||
|
if (status === 404 && has('no record found')) {
|
||||||
|
return {
|
||||||
|
kind: 'gone',
|
||||||
|
title: 'This lead is no longer there.',
|
||||||
|
detail: 'It may have been removed since the queue was loaded. Taking you back to the list.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403 && has('not allowed in state')) {
|
||||||
|
return {
|
||||||
|
kind: 'stale',
|
||||||
|
// Deliberately not "the lead moved": the gateway returns this both when a
|
||||||
|
// lead advanced under the screen AND when the console offered an activity
|
||||||
|
// the state never allowed. Only one of those is a move, and claiming the
|
||||||
|
// wrong one sends the operator looking for something that did not happen.
|
||||||
|
title: 'That activity is not available at this stage.',
|
||||||
|
detail: 'Either the lead moved on while this was open, or it never allowed this. Refreshed to show what it does allow.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403) {
|
||||||
|
return {
|
||||||
|
kind: 'forbidden',
|
||||||
|
title: 'The workflow refused this.',
|
||||||
|
detail: 'This is the platform deciding, not the console: the signed-in role does not hold this activity.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field validation runs BEFORE the permission check, so a disallowed
|
||||||
|
// submission carrying bad data arrives here as a 400 and not a 403.
|
||||||
|
if (status === 400 && has('validation failed')) {
|
||||||
|
// "field(unknown), field_2(required)" is the unsuffixed-key mistake, and it
|
||||||
|
// is a bug in this console rather than anything the operator did.
|
||||||
|
if (has('unknown') && has('required')) {
|
||||||
|
return {
|
||||||
|
kind: 'bug',
|
||||||
|
title: 'The console sent a field name the workflow does not know.',
|
||||||
|
detail: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (has('enum')) {
|
||||||
|
return {
|
||||||
|
kind: 'invalid',
|
||||||
|
title: 'One of the choices is not one the workflow accepts.',
|
||||||
|
detail: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kind: 'invalid', title: 'The workflow rejected this submission.', detail: raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
return { kind: 'auth', title: 'The session has expired.', detail: 'Sign in again to continue.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: 'unknown', title: raw || 'Something went wrong.', detail: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the workflow's field-validation reply into something a person can act
|
||||||
|
* on, using the form's own labels.
|
||||||
|
*
|
||||||
|
* The server answers with the machine keys and a bare reason:
|
||||||
|
*
|
||||||
|
* validation failed for activity zk-act-init-agent: product_line_4(required)
|
||||||
|
*
|
||||||
|
* Shown raw, that asks an operator to know what a field_s_id is, that `_4` is a
|
||||||
|
* disambiguating suffix rather than part of a name, and that "required" is a
|
||||||
|
* rule and not a value. All three are ours to know, not theirs.
|
||||||
|
*
|
||||||
|
* `fields` is the schema array, so the reply is rendered in the same words the
|
||||||
|
* form used a line above it — "Product Line", not product_line_4. A key the
|
||||||
|
* schema does not carry keeps its raw name: a wrong label is worse than an
|
||||||
|
* ugly one, because the operator goes looking for a field that is not there.
|
||||||
|
*
|
||||||
|
* Returns null when the message is not a field-validation reply, so callers
|
||||||
|
* can fall through to describeError.
|
||||||
|
*/
|
||||||
|
export function describeValidation(rawMessage, fields = []) {
|
||||||
|
const raw = String(rawMessage ?? '')
|
||||||
|
if (!/validation failed/i.test(raw)) return null
|
||||||
|
|
||||||
|
const label = (key) =>
|
||||||
|
fields.find((f) => f.id === key)?.name ??
|
||||||
|
// Same suffix rule the prefill seeding uses: the activity key may carry a
|
||||||
|
// numeric tail the global name does not.
|
||||||
|
fields.find((f) => String(f.id).replace(/_\d+$/, '') === key)?.name ??
|
||||||
|
key
|
||||||
|
|
||||||
|
const reasons = {
|
||||||
|
required: (n) => `${n} is required`,
|
||||||
|
unknown: (n) => `${n} is not a field this form accepts`,
|
||||||
|
enum: (n) => `${n} is not one of the available choices`,
|
||||||
|
invalid: (n) => `${n} is not valid`,
|
||||||
|
type: (n) => `${n} is the wrong kind of value`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = []
|
||||||
|
const re = /([A-Za-z0-9_.]+)\s*\(([^)]+)\)/g
|
||||||
|
let m
|
||||||
|
while ((m = re.exec(raw)) !== null) {
|
||||||
|
const name = label(m[1])
|
||||||
|
const why = String(m[2]).toLowerCase().trim()
|
||||||
|
const key = Object.keys(reasons).find((k) => why.includes(k))
|
||||||
|
items.push(key ? reasons[key](name) : `${name}: ${why}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) return null
|
||||||
|
return {
|
||||||
|
kind: 'validation',
|
||||||
|
// One problem reads as a sentence; several read as a list.
|
||||||
|
title: items.length === 1 ? items[0] : 'Some details are missing or not accepted.',
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/api/holder.js
Normal file
36
src/api/holder.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { phaseOf } from './config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is holding a lead right now, said the way the colour rule says it:
|
||||||
|
* blue = the AI, amber = a person, teal = the customer, grey = nobody (closed).
|
||||||
|
*
|
||||||
|
* Derived from phaseOf, so it agrees with the sidebar tallies and the lead
|
||||||
|
* header. `label` is the short form for a table cell: "AI · Intake",
|
||||||
|
* "Underwriter", "Customer", "Closed".
|
||||||
|
*/
|
||||||
|
const AI_NAMES = [
|
||||||
|
['intake', 'Intake'], ['engage', 'Engage'], ['advisor', 'Advisor'],
|
||||||
|
['kyc', 'KYC'], ['voice', 'Meera'], ['rating', 'Rating'],
|
||||||
|
]
|
||||||
|
|
||||||
|
function tidy(by) {
|
||||||
|
const s = String(by || '').replace(/^(the|an|a)\s+/i, '').trim()
|
||||||
|
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function holderOf(stageDef, row) {
|
||||||
|
if (!stageDef) return { tone: 'grey', label: '—' }
|
||||||
|
const ph = phaseOf(stageDef, row) || stageDef
|
||||||
|
switch (ph.kind) {
|
||||||
|
case 'auto': {
|
||||||
|
const by = String(ph.by || '').toLowerCase()
|
||||||
|
const hit = AI_NAMES.find(([k]) => by.includes(k))
|
||||||
|
return { tone: 'blue', label: 'AI · ' + (hit ? hit[1] : 'Engage') }
|
||||||
|
}
|
||||||
|
case 'customer': return { tone: 'teal', label: 'Customer' }
|
||||||
|
case 'needs': return { tone: 'amber', label: tidy(ph.by) }
|
||||||
|
case 'waiting': return { tone: 'grey', label: 'Calendar' }
|
||||||
|
case 'end': return { tone: 'grey', label: 'Closed' }
|
||||||
|
default: return { tone: 'grey', label: tidy(ph.by) }
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/api/permissions.js
Normal file
159
src/api/permissions.js
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { ACTIONS, STAGES } from './config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may perform what.
|
||||||
|
*
|
||||||
|
* A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same
|
||||||
|
* sense as STAGES and ACTIONS: the console needs it to decide what to draw,
|
||||||
|
* and the API returns a refusal rather than a capability list.
|
||||||
|
*
|
||||||
|
* ── This is presentation, never enforcement ──────────────────────────────
|
||||||
|
* The workflow refuses server-side on every submission, and that refusal is
|
||||||
|
* the real control. Nothing here can grant anything; a role added here that
|
||||||
|
* the platform does not recognise gets a 403 the moment it submits. What this
|
||||||
|
* buys is that an operator is not shown four buttons they will be refused for
|
||||||
|
* pressing — which on a demo reads as a broken app rather than as a fence.
|
||||||
|
*
|
||||||
|
* Keep in step with the permissions table. A role REMOVED there but left here
|
||||||
|
* shows a button that 403s: recoverable and visible. A role ADDED there but
|
||||||
|
* missing here hides a button that would have worked: invisible, and the
|
||||||
|
* failure mode worth watching for.
|
||||||
|
*/
|
||||||
|
export const ACTIVITY_ROLES = {
|
||||||
|
'zk-act-init-agent': ['ops_admin', 'partner_agent'],
|
||||||
|
'zk-act-init-bank': ['ops_admin', 'bank_rm'],
|
||||||
|
'zk-act-init-direct': ['ops_admin', 'csr_direct'],
|
||||||
|
|
||||||
|
'zk-act-qualify': ['ops_admin', 'ai_intake'],
|
||||||
|
'zk-act-contact': ['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-request-docs': ['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
'zk-act-capture-motor':['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-capture-sme': ['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-advise': ['ai_advisor'],
|
||||||
|
'zk-act-quote': ['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
// Ops only. The customer's questions are normally Engage's, and this is
|
||||||
|
// the way a person answers one Engage declined — through the same WhatsApp
|
||||||
|
// thread, so the reply lands on the lead file instead of somewhere nobody
|
||||||
|
// can find it later.
|
||||||
|
'zk-act-answer-customer': ['ops_admin'],
|
||||||
|
'zk-act-kyc': ['ops_admin', 'ai_kyc'],
|
||||||
|
'zk-act-uw-screen': ['ops_admin', 'ai_kyc'],
|
||||||
|
'zk-act-uw-prepare': ['ai_uw_referral'],
|
||||||
|
|
||||||
|
// The only exclusive permission in the application. Ops runs everything
|
||||||
|
// else and cannot touch these two — a referral is cleared by an underwriter
|
||||||
|
// or it is not cleared.
|
||||||
|
'zk-act-uw-clear': ['sme_underwriter'],
|
||||||
|
'zk-act-uw-decline': ['sme_underwriter'],
|
||||||
|
|
||||||
|
'zk-act-payment': ['ops_admin', 'ai_engage'],
|
||||||
|
// Zero roles in the workflow since 86 — the scheduler performs it, and a
|
||||||
|
// scheduled activity with any role list fails silently. Left here as an
|
||||||
|
// empty list so the mirror still records that the activity exists.
|
||||||
|
'zk-act-nudge': [],
|
||||||
|
'zk-act-realise': ['ops_admin'],
|
||||||
|
'zk-act-onboard': ['ops_admin', 'ai_engage'],
|
||||||
|
'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
|
||||||
|
// The AI's one way of saying "a person should take this". Every employee
|
||||||
|
// holds it, and so does ops — a person can park a lead for the same reasons.
|
||||||
|
'zk-act-flag-review': ['ops_admin', 'ai_intake', 'ai_engage', 'ai_kyc', 'ai_advisor', 'ai_uw_referral'],
|
||||||
|
// And the two ways back out of Needs a Person. The underwriter is here too:
|
||||||
|
// a flag they are sent is theirs to route, not only ops'.
|
||||||
|
'zk-act-review-resume':['ops_admin', 'sme_underwriter'],
|
||||||
|
'zk-act-review-refer': ['ops_admin', 'sme_underwriter'],
|
||||||
|
'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
|
||||||
|
// ── The one place this mirror deliberately DISAGREES with the workflow ──
|
||||||
|
//
|
||||||
|
// Both of these carry NO roles in tbl_wf_activity_permissions, which the
|
||||||
|
// platform reads as "open to anyone". That is not generosity: the scheduler
|
||||||
|
// performs them as the synthetic `system` actor, and the trigger's own
|
||||||
|
// perform step ignores allow_system_perform, so zero roles is the only
|
||||||
|
// configuration under which a scheduled activity can run at all. See
|
||||||
|
// 63_scheduled_activities_rbac.sql.
|
||||||
|
//
|
||||||
|
// Open to the SYSTEM is not the same as open to everyone signing in. Left
|
||||||
|
// literal, an underwriter is offered "Send Document Reminder" on a queue
|
||||||
|
// they have no business in. So the UI narrows them to the roles that would
|
||||||
|
// plausibly chase by hand. The workflow still accepts either from anyone —
|
||||||
|
// this hides a button, it does not close a door.
|
||||||
|
'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roles that belong to an AI employee. They never sign in. */
|
||||||
|
export const AI_ROLES = new Set([
|
||||||
|
'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function rolesOf(user) {
|
||||||
|
const r = user?.roles
|
||||||
|
if (Array.isArray(r)) return r.filter(Boolean).map(String)
|
||||||
|
if (Array.isArray(user?.role_assignments)) {
|
||||||
|
return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An empty role list means the activity is open to any signed-in user — that
|
||||||
|
* is what the platform's RBAC does with no roles attached, so mirroring it
|
||||||
|
* here keeps the two from disagreeing.
|
||||||
|
*/
|
||||||
|
export function canPerform(roles, activityUid) {
|
||||||
|
const allowed = ACTIVITY_ROLES[activityUid]
|
||||||
|
if (!allowed) return true // unknown activity: show it, let the API decide
|
||||||
|
if (allowed.length === 0) return true
|
||||||
|
return roles.some((r) => allowed.includes(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The activities this user may actually run from a given stage. */
|
||||||
|
export function actionsFor(roles, stageUid) {
|
||||||
|
return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activities that are available almost everywhere, or that no person runs.
|
||||||
|
*
|
||||||
|
* They must not count towards "does this role have business in this queue".
|
||||||
|
* Mark Lost is offered in eight stages to every agent role, and the two
|
||||||
|
* scheduled activities carry no roles at all — which the platform reads as
|
||||||
|
* open. Left in the test, between them they make every queue look relevant
|
||||||
|
* to everybody, which is the whole thing this is trying to avoid.
|
||||||
|
*/
|
||||||
|
const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which queues a role has any business WORKING IN.
|
||||||
|
*
|
||||||
|
* Terminal stages stay visible to everyone — closed business is reporting,
|
||||||
|
* not work, and hiding it would leave an underwriter unable to see what
|
||||||
|
* became of a file they declined. Everything else appears only where the
|
||||||
|
* role can do something substantive.
|
||||||
|
*
|
||||||
|
* This gates the SIDEBAR, not the data. The overview still counts the whole
|
||||||
|
* book for every role, because an agent tracking leads they filed is a
|
||||||
|
* reasonable thing to want and a queue they cannot act in is not.
|
||||||
|
*/
|
||||||
|
export function visibleStages(roles) {
|
||||||
|
// Operations runs the desk. They cannot clear a referral — that stays
|
||||||
|
// exclusive to the underwriter — but the underwriting queue is theirs to
|
||||||
|
// watch, and an ops console that hides a queue because it holds one
|
||||||
|
// activity they may not press is hiding the wrong thing.
|
||||||
|
if (roles.includes('ops_admin')) return STAGES
|
||||||
|
|
||||||
|
return STAGES.filter((s) => {
|
||||||
|
if (s.kind === 'end') return true
|
||||||
|
return (ACTIONS[s.uid] || [])
|
||||||
|
.filter((a) => !AMBIENT.has(a.uid))
|
||||||
|
.some((a) => canPerform(roles, a.uid))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this user can file a lead at all, and through which doors. */
|
||||||
|
export function entryDoorsFor(roles, entries) {
|
||||||
|
return entries.filter((e) => canPerform(roles, e.uid))
|
||||||
|
}
|
||||||
109
src/api/portfolio.jsx
Normal file
109
src/api/portfolio.jsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useZino } from './provider.jsx'
|
||||||
|
import { RV_LEADS, STAGES, phaseOf } from './config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole book, fetched ONCE and shared.
|
||||||
|
*
|
||||||
|
* The sidebar used to carry no counts, on the reasoning that a tally could only
|
||||||
|
* come from a second full list call that would then disagree with the queue's
|
||||||
|
* own total. That reasoning was right about the cost and wrong about the
|
||||||
|
* conclusion: a sidebar with no numbers means the only way to learn whether
|
||||||
|
* anything is waiting on you is to click all nine queues, which is the one
|
||||||
|
* question a console's navigation exists to answer.
|
||||||
|
*
|
||||||
|
* So there is no second call. The overview already fetched the whole book on a
|
||||||
|
* timer; that fetch moves here and both surfaces read it, which is one call
|
||||||
|
* fewer than before and makes the two agree by construction.
|
||||||
|
*
|
||||||
|
* Bounded at 200 rows, as the overview always was. Beyond that the honest
|
||||||
|
* answer is an aggregate endpoint rather than a bigger limit, and the counts
|
||||||
|
* would need to say they are partial — worth knowing before this app meets a
|
||||||
|
* real book.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PortfolioContext = createContext(null)
|
||||||
|
const EMPTY = []
|
||||||
|
|
||||||
|
export function PortfolioProvider({ children }) {
|
||||||
|
const { client, user } = useZino()
|
||||||
|
// Identity, not just presence: switching user must not show the previous
|
||||||
|
// one's book while the new fetch is in flight.
|
||||||
|
const who = user?.id ?? user?.email ?? null
|
||||||
|
// Seeded from `who` rather than set inside the effect. Nothing here writes
|
||||||
|
// state synchronously during a render pass — a signed-out shell is 'ready'
|
||||||
|
// and empty from the first frame, and the first fetch only ever moves it
|
||||||
|
// forwards, so there is no loading flash and no cascading render.
|
||||||
|
const [state, setState] = useState(() => ({
|
||||||
|
status: who ? 'loading' : 'ready', rows: EMPTY, at: null, error: null,
|
||||||
|
}))
|
||||||
|
const cancelled = useRef(false)
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!who) return Promise.resolve()
|
||||||
|
return client.recordView(RV_LEADS, { limit: 200 })
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled.current) return
|
||||||
|
setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? EMPTY, at: new Date(), error: null })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled.current) return
|
||||||
|
// A failed poll leaves a good book on screen. Only a first load is an error.
|
||||||
|
setState((p) => (p.status === 'ready' ? p : { status: 'error', rows: EMPTY, at: null, error: err }))
|
||||||
|
})
|
||||||
|
}, [client, who])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelled.current = false
|
||||||
|
load()
|
||||||
|
const id = setInterval(() => { if (document.visibilityState === 'visible') load() }, 30000)
|
||||||
|
return () => { cancelled.current = true; clearInterval(id) }
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ ...state, reload: load }), [state, load])
|
||||||
|
return <PortfolioContext.Provider value={value}>{children}</PortfolioContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePortfolio() {
|
||||||
|
const ctx = useContext(PortfolioContext)
|
||||||
|
if (!ctx) throw new Error('usePortfolio must be used inside PortfolioProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000
|
||||||
|
const daysToExpiry = (v) => {
|
||||||
|
if (!v) return null
|
||||||
|
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
|
||||||
|
return isNaN(d) ? null : Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-stage tallies for the sidebar.
|
||||||
|
*
|
||||||
|
* `waiting` is the number that goes on the badge: leads whose PHASE still needs
|
||||||
|
* a person. `working` is the rest — in the state, carried by an agent, not
|
||||||
|
* anyone's task. Badging the state total told an operator four leads needed
|
||||||
|
* them when two did, which is how a queue stops being believed.
|
||||||
|
*
|
||||||
|
* `urgent` marks a queue holding a renewal that has lapsed or expires within a
|
||||||
|
* week. It is the only reason to look at one queue before another, and it was
|
||||||
|
* invisible until you opened each one.
|
||||||
|
*/
|
||||||
|
export function useStageCounts() {
|
||||||
|
const { rows, status } = usePortfolio()
|
||||||
|
return useMemo(() => {
|
||||||
|
const out = {}
|
||||||
|
for (const s of STAGES) out[s.uid] = { total: 0, waiting: 0, working: 0, urgent: 0 }
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = STAGES.find((x) => x.name === r.current_state_name)
|
||||||
|
if (!s) continue
|
||||||
|
const t = out[s.uid]
|
||||||
|
t.total += 1
|
||||||
|
if (s.kind !== 'end' && phaseOf(s, r).kind === 'auto') t.working += 1
|
||||||
|
else if (s.kind !== 'end') t.waiting += 1
|
||||||
|
const d = daysToExpiry(r.renewal_due_date)
|
||||||
|
if (s.kind !== 'end' && d !== null && d <= 7) t.urgent += 1
|
||||||
|
}
|
||||||
|
return { counts: out, ready: status === 'ready' }
|
||||||
|
}, [rows, status])
|
||||||
|
}
|
||||||
216
src/api/thread.js
Normal file
216
src/api/thread.js
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { AGENTS } from './agents.js'
|
||||||
|
import { baseFieldId } from './config.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WHATSAPP THREAD, BUILT ONCE.
|
||||||
|
*
|
||||||
|
* The conversation was being reconstructed twice — the timeline read it per
|
||||||
|
* audit row, the dialog read it per field — and the two disagreed. The trail
|
||||||
|
* printed one entry per turn, so a four-message exchange took more of the page
|
||||||
|
* than the entire underwriting chain, and a message the workflow recorded from
|
||||||
|
* inside a trigger (the payment request, the issuance note) appeared in the
|
||||||
|
* dialog and nowhere in the trail. One builder, two readers, one answer.
|
||||||
|
*
|
||||||
|
* WHY A THREAD IS NOT A LIST OF ROWS. The audit trail records activities; the
|
||||||
|
* conversation is a thing that persists ACROSS them. Three facts make it awkward
|
||||||
|
* and they are all handled here rather than in each screen's own guess:
|
||||||
|
*
|
||||||
|
* 1. The customer's turns and ours are separate activities — `customer-reply`
|
||||||
|
* is performed by the channel, `answer-customer` by Engage — so a turn is
|
||||||
|
* a FIELD on a row, not a row.
|
||||||
|
* 2. Two of our sends are not chat activities at all. Request Premium and the
|
||||||
|
* issuance step compose their message inside a trigger and write it to
|
||||||
|
* `customer_answer` alongside their own work (see 98/99/100 in the seed
|
||||||
|
* SQL). They belong in the thread and stay on their own step in the trail.
|
||||||
|
* 3. One submission is recorded up to three times — the trigger commit, its
|
||||||
|
* repeat, and the settle — so the same sentence arrives three times.
|
||||||
|
*
|
||||||
|
* `episodes` is what makes this renderable as a timeline entry. The thread is
|
||||||
|
* one conversation, but it happens in bursts: the customer argues about the
|
||||||
|
* quote, then KYC and underwriting run for twenty minutes, then the premium
|
||||||
|
* request reopens the same thread. An episode is one burst. Collapsing the
|
||||||
|
* whole thread into a single entry would date the payment exchange to the
|
||||||
|
* moment of the quote objection and destroy the chronology the trail exists
|
||||||
|
* for; leaving every turn as its own entry is what it did before. A burst is
|
||||||
|
* the unit that is both honest and short.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The two activities that ARE the thread — one turn each, either direction. */
|
||||||
|
export const CHAT_ACTS = new Set(['zk-act-customer-reply', 'zk-act-answer-customer'])
|
||||||
|
|
||||||
|
const INBOUND = 'customer_reply'
|
||||||
|
const OUTBOUND = 'customer_answer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields the thread OWNS. A step that writes one is not asked to print it
|
||||||
|
* as well — the message belongs to the conversation, and quoting it on the step
|
||||||
|
* too is how the payment request came to say the same sentence twice.
|
||||||
|
*/
|
||||||
|
export const THREAD_FIELDS = new Set([INBOUND, OUTBOUND])
|
||||||
|
|
||||||
|
/** Bookkeeping rows. They interrupt nothing: the platform writing a field is
|
||||||
|
* not a step that broke off a conversation. */
|
||||||
|
const SYSTEM_ACT = 'DATA_UPDATE'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last resort for a step whose activity_name did not come back — without it the
|
||||||
|
* trail prints a raw slug like "zk-act-doc-reminder" in the middle of an
|
||||||
|
* otherwise readable story. A guess, but one that reads as English.
|
||||||
|
*/
|
||||||
|
export function prettyUid(uid) {
|
||||||
|
if (!uid) return 'Step'
|
||||||
|
return String(uid)
|
||||||
|
.replace(/^zk-act-/, '')
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to call a step. The server's own name first; the slug, tidied, after. */
|
||||||
|
export function stepName(row) {
|
||||||
|
return row?.activity_name || prettyUid(row?.activity_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fields[]` is the platform's typed rendering of a submission; `data` is the
|
||||||
|
* raw fallback for a row the workflow could not resolve.
|
||||||
|
*/
|
||||||
|
function fieldsOf(row) {
|
||||||
|
return Array.isArray(row.fields) && row.fields.length
|
||||||
|
? row.fields
|
||||||
|
: Object.entries(row.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who sent an outbound turn. Usually Engage; sometimes a person answering by
|
||||||
|
* hand, which must not be attributed to the machine. */
|
||||||
|
function senderOf(row) {
|
||||||
|
const key = (row.user_roles || []).find((r) => AGENTS[r]) || null
|
||||||
|
return { agentKey: key, by: key ? AGENTS[key].short : (row.user_name || 'Zurich Kotak') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The turns carried by one audit row, in field order. */
|
||||||
|
function turnsInRow(row) {
|
||||||
|
const out = []
|
||||||
|
for (const f of fieldsOf(row)) {
|
||||||
|
const base = baseFieldId(f.field_id)
|
||||||
|
if (!THREAD_FIELDS.has(base)) continue
|
||||||
|
const text = typeof f.value === 'string' ? f.value.trim() : ''
|
||||||
|
if (!text) continue
|
||||||
|
out.push({ side: base === INBOUND ? 'them' : 'us', text })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thread, its bursts, and which audit row carries what.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* turns every message, oldest first
|
||||||
|
* episodes contiguous bursts, each with the steps that preceded it
|
||||||
|
* byRow row id -> { episodeId, sent, received }, so a step that sent a
|
||||||
|
* message can link into the thread instead of quoting it
|
||||||
|
*/
|
||||||
|
export function buildThread(rows) {
|
||||||
|
const empty = { turns: [], episodes: [], byRow: new Map() }
|
||||||
|
if (!Array.isArray(rows) || !rows.length) return empty
|
||||||
|
|
||||||
|
// Sorted on (timestamp, id). One submission writes three rows in the same
|
||||||
|
// second, so a timestamp alone leaves their order to the sort's stability and
|
||||||
|
// puts a reply before the message it answers whenever the two land together.
|
||||||
|
const ordered = [...rows].sort((a, b) => {
|
||||||
|
const t = String(a.created_at).localeCompare(String(b.created_at))
|
||||||
|
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const turns = []
|
||||||
|
const episodes = []
|
||||||
|
const byRow = new Map()
|
||||||
|
let current = null
|
||||||
|
// The steps seen since the last message, and whether any of them broke off
|
||||||
|
// the conversation. `pending` accumulates even before the first message so
|
||||||
|
// that the first episode can drop it: there is nothing to bridge yet.
|
||||||
|
let pending = []
|
||||||
|
let broke = false
|
||||||
|
|
||||||
|
for (const row of ordered) {
|
||||||
|
const mine = turnsInRow(row)
|
||||||
|
|
||||||
|
if (!mine.length) {
|
||||||
|
// A chat activity that recorded no text is not a step that interrupted
|
||||||
|
// anything — it is an empty row. Bookkeeping is not a step either.
|
||||||
|
if (row.activity_id === SYSTEM_ACT || CHAT_ACTS.has(row.activity_id)) continue
|
||||||
|
if (current) broke = true
|
||||||
|
const name = stepName(row)
|
||||||
|
if (name && !pending.includes(name)) pending.push(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const who = senderOf(row)
|
||||||
|
for (const t of mine) {
|
||||||
|
const prev = turns[turns.length - 1]
|
||||||
|
// De-duped against the PREVIOUS turn only. A customer who asks the same
|
||||||
|
// thing twice because the first went unanswered has said it twice, and a
|
||||||
|
// thread that showed it once would hide exactly the impatience an
|
||||||
|
// operator needs to see. Only an immediate repeat is the platform
|
||||||
|
// recording one submission three times.
|
||||||
|
if (prev && prev.side === t.side && prev.text === t.text) continue
|
||||||
|
|
||||||
|
if (!current || broke) {
|
||||||
|
current = {
|
||||||
|
id: `ep${episodes.length + 1}`,
|
||||||
|
turns: [],
|
||||||
|
// What ran between this burst and the one before it. Empty on the
|
||||||
|
// first, which begins the conversation rather than resuming it.
|
||||||
|
after: episodes.length ? pending : [],
|
||||||
|
anchorRowId: row.id,
|
||||||
|
anchorIsChat: CHAT_ACTS.has(row.activity_id),
|
||||||
|
}
|
||||||
|
episodes.push(current)
|
||||||
|
broke = false
|
||||||
|
}
|
||||||
|
pending = []
|
||||||
|
|
||||||
|
const turn = {
|
||||||
|
key: `${row.id}-${t.side}-${turns.length}`,
|
||||||
|
side: t.side,
|
||||||
|
text: t.text,
|
||||||
|
at: row.created_at,
|
||||||
|
rowId: row.id,
|
||||||
|
episodeId: current.id,
|
||||||
|
agentKey: t.side === 'us' ? who.agentKey : null,
|
||||||
|
by: t.side === 'us' ? who.by : 'the customer',
|
||||||
|
}
|
||||||
|
current.turns.push(turn)
|
||||||
|
turns.push(turn)
|
||||||
|
|
||||||
|
const tally = byRow.get(row.id) || { episodeId: current.id, sent: 0, received: 0 }
|
||||||
|
if (t.side === 'us') tally.sent += 1
|
||||||
|
else tally.received += 1
|
||||||
|
byRow.set(row.id, tally)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ep of episodes) {
|
||||||
|
ep.count = ep.turns.length
|
||||||
|
ep.from = ep.turns[0].at
|
||||||
|
ep.to = ep.turns[ep.turns.length - 1].at
|
||||||
|
// The last exchange, which is where the conversation GOT to — what an
|
||||||
|
// operator scanning the trail is actually looking for.
|
||||||
|
ep.preview = ep.turns.slice(-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { turns, episodes, byRow }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Verify KYC and Underwriting Screen", "A, B +2 more" — a bridge, not a list.
|
||||||
|
* Used for the steps that interrupted a conversation and for the voices in it.
|
||||||
|
*/
|
||||||
|
export function listOf(names, max = 2) {
|
||||||
|
if (!names || !names.length) return ''
|
||||||
|
if (names.length <= max) {
|
||||||
|
return names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||||
|
}
|
||||||
|
return `${names.slice(0, max).join(', ')} +${names.length - max} more`
|
||||||
|
}
|
||||||
BIN
src/assets/fonts/source-sans-3-latin-400-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-400-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-500-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-500-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-600-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-600-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-700-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-700-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-400-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-500-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-600-normal.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2
Normal file
BIN
src/assets/fonts/source-sans-3-latin-ext-700-normal.woff2
Normal file
Binary file not shown.
@ -1,82 +1,398 @@
|
|||||||
.af { display: flex; flex-direction: column; gap: 18px; }
|
.af {
|
||||||
.af__loading { color: var(--zk-muted); font-size: .86rem; }
|
display: flex;
|
||||||
.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
|
flex-direction: column;
|
||||||
.af__field { display: flex; flex-direction: column; gap: 5px; }
|
gap: 20px;
|
||||||
.af__field--wide { grid-column: 1 / -1; }
|
}
|
||||||
|
|
||||||
|
.af__loading {
|
||||||
|
color: var(--zk-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(248px, 1fr));
|
||||||
|
gap: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__field--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.af__field > span {
|
.af__field > span {
|
||||||
font-size: .74rem; font-weight: 500; color: var(--zk-muted);
|
font-size: var(--fs-2xs);
|
||||||
letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.af__req {
|
.af__req {
|
||||||
font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
font-style: normal;
|
||||||
color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
|
font-size: var(--fs-3xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-blue);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
}
|
}
|
||||||
.af input, .af select, .af textarea {
|
|
||||||
font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px;
|
.af input,
|
||||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
|
.af select,
|
||||||
|
.af textarea {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color var(--t-fast), box-shadow var(--t-fast), background var(--t-fast);
|
||||||
}
|
}
|
||||||
.af input:focus, .af select:focus, .af textarea:focus {
|
|
||||||
outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue);
|
.af input:hover,
|
||||||
|
.af select:hover,
|
||||||
|
.af textarea:hover {
|
||||||
|
border-color: var(--zk-blue-light);
|
||||||
}
|
}
|
||||||
.af textarea { resize: vertical; }
|
|
||||||
.af__multi { display: flex; flex-wrap: wrap; gap: 6px; }
|
.af input:focus,
|
||||||
|
.af select:focus,
|
||||||
|
.af textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--zk-blue);
|
||||||
|
box-shadow: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af select {
|
||||||
|
appearance: none;
|
||||||
|
padding-right: 34px;
|
||||||
|
/* The caret is drawn rather than loaded: an inline data URI keeps the select
|
||||||
|
consistent across platforms without another asset to serve. */
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%235d6162' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
background-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.af textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 84px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__multi {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.af__chip {
|
.af__chip {
|
||||||
font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
|
font: inherit;
|
||||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
font-size: var(--fs-2xs);
|
||||||
|
padding: 6px 13px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast), box-shadow var(--t-fast);
|
||||||
}
|
}
|
||||||
.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); }
|
|
||||||
.af__actions { display: flex; justify-content: flex-end; gap: 10px; }
|
.af__chip:hover {
|
||||||
|
border-color: var(--zk-blue-light);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__chip.is-on {
|
||||||
|
background: var(--grad-blue);
|
||||||
|
border-color: var(--zk-blue);
|
||||||
|
color: var(--zk-white);
|
||||||
|
box-shadow: var(--sh-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.af__submit {
|
.af__submit {
|
||||||
font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
|
font: inherit;
|
||||||
cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 10px 26px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0;
|
||||||
|
background: var(--grad-blue);
|
||||||
|
color: var(--zk-white);
|
||||||
|
box-shadow: var(--sh-sm);
|
||||||
|
transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast);
|
||||||
}
|
}
|
||||||
.af__submit:disabled { opacity: .55; cursor: default; }
|
|
||||||
|
.af__submit:hover:not(:disabled) {
|
||||||
|
background: var(--grad-blue-hover);
|
||||||
|
box-shadow: var(--sh-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__submit:active:not(:disabled) {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: var(--sh-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.af__ghost {
|
.af__ghost {
|
||||||
font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
|
font: inherit;
|
||||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
font-size: var(--fs-xs);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.af__ghost:hover {
|
||||||
|
border-color: var(--zk-grey);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
|
||||||
.af__err {
|
.af__err {
|
||||||
background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
|
background: var(--zk-danger-tint);
|
||||||
border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
|
border: 1px solid var(--zk-danger-line);
|
||||||
|
border-left: 3px solid var(--zk-danger);
|
||||||
|
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
animation: zk-rise 0.2s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__err strong {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
background: rgba(211, 47, 47, 0.1);
|
||||||
|
color: var(--zk-danger-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.af__err p {
|
||||||
|
margin: 7px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; }
|
|
||||||
.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); }
|
|
||||||
|
|
||||||
.af__auto {
|
.af__auto {
|
||||||
font-size: .88rem; padding: 8px 10px; border-radius: 4px; min-height: 37px;
|
font-size: var(--fs-xs);
|
||||||
border: 1px dashed var(--zk-line); background: var(--zk-tint); color: var(--zk-muted);
|
padding: 10px 12px;
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
border-radius: var(--r-md);
|
||||||
|
min-height: 41px;
|
||||||
|
border: 1px dashed var(--zk-line);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.af__auto span {
|
.af__auto span {
|
||||||
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
font-size: var(--fs-3xs);
|
||||||
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--zk-white);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- file / ocr ---- */
|
/* ---- file / ocr ---- */
|
||||||
.ff { display: flex; flex-direction: column; gap: 6px; }
|
.ff {
|
||||||
.ff__input { display: none; }
|
display: flex;
|
||||||
.ff__btn {
|
flex-direction: column;
|
||||||
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
|
gap: 8px;
|
||||||
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
|
|
||||||
color: var(--zk-blue-dark); text-align: center;
|
|
||||||
}
|
}
|
||||||
.ff__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
|
|
||||||
.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; }
|
.ff__input {
|
||||||
.ff__got {
|
display: none;
|
||||||
font-size: .78rem; color: var(--zk-muted);
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ff__drop {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px dashed var(--zk-blue-light);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
text-align: center;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
.ff__drop svg { width: 20px; height: 20px; }
|
||||||
|
.ff__drop strong { font-size: var(--fs-2xs); font-weight: 500; }
|
||||||
|
.ff__drop span { font-size: var(--fs-3xs); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.ff__drop:hover:not(:disabled) {
|
||||||
|
background: var(--zk-white);
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--zk-blue);
|
||||||
|
}
|
||||||
|
.ff__drop:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
|
.ff__drop:disabled { opacity: 0.65; cursor: default; border-style: solid; }
|
||||||
|
|
||||||
|
/* Uploaded: the file as a fact, not a call to action. */
|
||||||
|
.ff__file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 9px 9px 11px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-white);
|
||||||
|
}
|
||||||
|
.ff__file > svg { width: 15px; height: 15px; flex: none; color: var(--zk-blue-dark); }
|
||||||
|
|
||||||
|
.ff__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__replace {
|
||||||
|
flex: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-3xs);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.ff__replace:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
|
||||||
|
.ff__replace:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
|
|
||||||
.ff__read {
|
.ff__read {
|
||||||
border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
|
border: 1px solid var(--zk-blue-light);
|
||||||
border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
|
border-left-width: 3px;
|
||||||
|
background: var(--zk-tint);
|
||||||
|
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
animation: zk-rise 0.22s var(--ease) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ff__readlabel {
|
.ff__readlabel {
|
||||||
font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
|
font-size: var(--fs-3xs);
|
||||||
color: var(--zk-blue-dark); margin-bottom: 2px;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
.ff__read div { font-size: .8rem; color: var(--zk-ink); }
|
|
||||||
.ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; }
|
.ff__read div {
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ff__read em {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
text-transform: capitalize;
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
.ff__err {
|
.ff__err {
|
||||||
font-size: .78rem; color: var(--zk-danger);
|
font-size: var(--fs-2xs);
|
||||||
background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
|
color: var(--zk-danger-ink);
|
||||||
|
background: var(--zk-danger-tint);
|
||||||
|
border: 1px solid var(--zk-danger-line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 8px 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A field the form is waiting on. Marked on the label rather than the input so
|
||||||
|
the name is highlighted too — an operator scanning a wide form is looking for
|
||||||
|
the LABEL they missed, not for a red box. */
|
||||||
|
.af__field.is-missing > span { color: var(--zk-danger-ink); }
|
||||||
|
.af__field.is-missing input,
|
||||||
|
.af__field.is-missing select,
|
||||||
|
.af__field.is-missing textarea {
|
||||||
|
border-color: var(--zk-danger-line);
|
||||||
|
background: var(--zk-danger-tint);
|
||||||
|
}
|
||||||
|
.af__field.is-missing input:focus,
|
||||||
|
.af__field.is-missing select:focus,
|
||||||
|
.af__field.is-missing textarea:focus {
|
||||||
|
border-color: var(--zk-danger);
|
||||||
|
outline-color: var(--zk-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The form talking about itself, or relaying a field-level refusal. Distinct
|
||||||
|
from af__err, which is for a failure the operator cannot fix by typing. */
|
||||||
|
.af__err--soft {
|
||||||
|
background: var(--zk-danger-tint);
|
||||||
|
border: 1px solid var(--zk-danger-line);
|
||||||
|
color: var(--zk-danger-ink);
|
||||||
|
}
|
||||||
|
.af__err--soft ul { margin: 6px 0 0; padding-left: 18px; }
|
||||||
|
.af__err--soft li { margin: 2px 0; }
|
||||||
|
|
||||||
|
/* ---- form sections ---- */
|
||||||
|
.af__sec + .af__sec { margin-top: 22px; }
|
||||||
|
|
||||||
|
.af__sech {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: var(--fs-3xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uploads size to their content and never stretch to the tallest card in the
|
||||||
|
row, so a document that has been read does not pull its neighbours down. */
|
||||||
|
.af__grid--docs { align-items: start; }
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js'
|
||||||
|
import { describeError, describeValidation } from '../api/errors.js'
|
||||||
import FileField from './FileField.jsx'
|
import FileField from './FileField.jsx'
|
||||||
import './ActivityForm.css'
|
import './ActivityForm.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML input each workflow data_type maps to. `phone` and `email` were both
|
||||||
|
* falling through to plain text, which costs the keyboard on a phone and the
|
||||||
|
* browser's own validation everywhere.
|
||||||
|
*/
|
||||||
|
const INPUT_TYPES = {
|
||||||
|
number: 'number',
|
||||||
|
date: 'date',
|
||||||
|
phone: 'tel',
|
||||||
|
email: 'email',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders whatever /view/form-screens returns for an activity — labels, types,
|
* Renders whatever /view/form-screens returns for an activity — labels, types,
|
||||||
* select options and which fields are mandatory — and submits it straight back.
|
* select options and which fields are mandatory — and submits it straight back.
|
||||||
@ -11,12 +25,26 @@ import './ActivityForm.css'
|
|||||||
* activity in Studio, redeploy, and it appears here with no code change. That
|
* activity in Studio, redeploy, and it appears here with no code change. That
|
||||||
* is the point: the workflow is the source of truth, and a hardcoded form would
|
* is the point: the workflow is the source of truth, and a hardcoded form would
|
||||||
* quietly drift from it.
|
* quietly drift from it.
|
||||||
|
*
|
||||||
|
* The ONE thing filtered here is which upload slots apply to the lead's product
|
||||||
|
* line. Collect Documents serves motor and SME from a single form and the
|
||||||
|
* activity carries no `field_rules`, so without this a motor renewal is asked
|
||||||
|
* for a Udyam certificate. See DOC_SLOTS in api/config.js — including why that
|
||||||
|
* table should stop existing once the rules are seeded on the activity.
|
||||||
*/
|
*/
|
||||||
export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) {
|
export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) {
|
||||||
const { client } = useZino()
|
const { client } = useZino()
|
||||||
const [schema, setSchema] = useState(null)
|
const [schema, setSchema] = useState(null)
|
||||||
const [values, setValues] = useState({})
|
const [values, setValues] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
// Which required fields were empty on the last attempt. Kept separate from
|
||||||
|
// `error`, because this is the form talking about itself rather than the
|
||||||
|
// server refusing something.
|
||||||
|
const [missing, setMissing] = useState([])
|
||||||
|
// Field ids filled from the lead record rather than typed. OCR may overwrite
|
||||||
|
// these — the document is more authoritative than a copy of the record — but
|
||||||
|
// must never overwrite something a person typed.
|
||||||
|
const seededRef = useRef(new Set())
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -29,26 +57,191 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
|||||||
// The server resolved a prefill pipeline for this activity — it stamps
|
// The server resolved a prefill pipeline for this activity — it stamps
|
||||||
// the channel from the door and the agent from the signed-in user, so
|
// the channel from the door and the agent from the signed-in user, so
|
||||||
// the form never asks for either. Seed the inputs with what it sent.
|
// the form never asks for either. Seed the inputs with what it sent.
|
||||||
|
//
|
||||||
|
// The two sides key differently, and matching only on f.id is why this
|
||||||
|
// silently did nothing. A form field is the ACTIVITY key, which carries
|
||||||
|
// a numeric suffix because a workflow version may use one global on
|
||||||
|
// several activities — partner_code_3, rm_or_agent_id_3,
|
||||||
|
// source_channel_4. The pipeline's fieldMapping names the GLOBAL —
|
||||||
|
// partner_code, rm_or_agent_id, source_channel. Neither is wrong; they
|
||||||
|
// are different names for the same field, and nothing between them
|
||||||
|
// reconciles it.
|
||||||
|
//
|
||||||
|
// So: exact id first, then the field's uid, then the global name with
|
||||||
|
// the suffix stripped. Accepting all three means this keeps working
|
||||||
|
// whichever convention a pipeline is authored in, rather than breaking
|
||||||
|
// again the next time one is written the other way.
|
||||||
const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
|
const pre = s.prefill_data || s.prefillData || s.field_defaults || {}
|
||||||
|
const seed = {}
|
||||||
if (pre && typeof pre === 'object') {
|
if (pre && typeof pre === 'object') {
|
||||||
const seed = {}
|
for (const f of s.fields) {
|
||||||
for (const f of s.fields) if (pre[f.id] !== undefined) seed[f.id] = pre[f.id]
|
const base = baseFieldId(f.id ?? '')
|
||||||
if (Object.keys(seed).length) setValues(seed)
|
const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined)
|
||||||
|
if (v !== undefined && v !== null && v !== '') seed[f.id] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors
|
||||||
|
// fourteen fields the record carries — registration, make and model,
|
||||||
|
// previous insurer, expiry, policy number, PAN — so the OCR can write
|
||||||
|
// into them. Rendered blank, they read as fourteen more things to type.
|
||||||
|
// Seed each from the lead by its base key, so an agent uploading for
|
||||||
|
// KA01MF6618 sees KA01MF6618 already there.
|
||||||
|
//
|
||||||
|
// Server prefill wins, then anything already typed. Files and generated
|
||||||
|
// ids are never seeded — a file reference is not a value to copy, and an
|
||||||
|
// id_gen is the platform's to issue. Only on an existing lead: an INIT
|
||||||
|
// form has no record behind it.
|
||||||
|
const seededFromLead = new Set()
|
||||||
|
if (lead && instanceId) {
|
||||||
|
for (const f of s.fields) {
|
||||||
|
if (seed[f.id] !== undefined) continue
|
||||||
|
if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue
|
||||||
|
const v = lead[baseFieldId(f.id ?? '')]
|
||||||
|
if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') {
|
||||||
|
seed[f.id] = v
|
||||||
|
seededFromLead.add(f.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seededRef.current = seededFromLead
|
||||||
|
|
||||||
|
if (Object.keys(seed).length) setValues(seed)
|
||||||
})
|
})
|
||||||
.catch((e) => { if (!dead) setError(e) })
|
.catch((e) => { if (!dead) setError(e) })
|
||||||
return () => { dead = true }
|
return () => { dead = true }
|
||||||
|
// `lead` is READ here but deliberately not a dependency. It is the parent's
|
||||||
|
// polled record object, so its identity changes on every refresh; listing it
|
||||||
|
// would re-run this effect, re-fetch the schema and call setValues(seed) —
|
||||||
|
// discarding whatever the operator had typed, every few seconds, mid-form.
|
||||||
|
// The seed is a one-time starting point, not a subscription.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [client, activityUid, instanceId])
|
}, [client, activityUid, instanceId])
|
||||||
|
|
||||||
function set(id, v) { setValues((p) => ({ ...p, [id]: v })) }
|
function set(id, v) {
|
||||||
|
// Typed by hand: from here on it outranks any document.
|
||||||
|
seededRef.current.delete(id)
|
||||||
|
setValues((p) => ({ ...p, [id]: v }))
|
||||||
|
// Clear the complaint as soon as the field is filled. Leaving a red field
|
||||||
|
// marked after it has been corrected teaches people to ignore the marking.
|
||||||
|
setMissing((p) => (p.includes(id) ? p.filter((x) => x !== id) : p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed before the early returns below use it, and before submit: a field
|
||||||
|
// that was never offered must never be sent.
|
||||||
|
//
|
||||||
|
// If the lead's line would hide EVERY field, the filter is not removing noise
|
||||||
|
// any more — it is removing the activity. Capture Motor Risk is twelve motor
|
||||||
|
// fields and is runnable from Contacted whatever the product is, so on an SME
|
||||||
|
// lead this would otherwise render a form with nothing in it and a live Submit
|
||||||
|
// button. Show the activity whole and let the operator see what it is asking.
|
||||||
|
//
|
||||||
|
// THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no
|
||||||
|
// lead yet, so `fieldApplies` had nothing to filter on and every entry form
|
||||||
|
// asked a motor renewal for a business name. But the product line IS on that
|
||||||
|
// form, three boxes up — the person has already said "Motor" by the time the
|
||||||
|
// question is drawn. So visibility reads the live answer first and the saved
|
||||||
|
// lead second, and the form narrows as it is filled.
|
||||||
|
const effLead = (() => {
|
||||||
|
const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line')
|
||||||
|
const chosen = live ? values[live.id] : undefined
|
||||||
|
if (!chosen) return lead
|
||||||
|
return { ...(lead || {}), product_line: chosen }
|
||||||
|
})()
|
||||||
|
|
||||||
|
const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : []
|
||||||
|
// Filtering must never hide a field the workflow requires: `submit` sends only
|
||||||
|
// what is rendered, so a hidden mandatory field becomes a 400 naming something
|
||||||
|
// that is not on screen and cannot be filled. Hiding everything is the same
|
||||||
|
// failure in the large — it removes the activity rather than its noise, and
|
||||||
|
// Capture Motor Risk is twelve motor fields that stay runnable on an SME lead.
|
||||||
|
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false
|
||||||
|
const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn an /ocr-extract response into form values.
|
||||||
|
*
|
||||||
|
* THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the
|
||||||
|
* ocr_config's `extraction_fields[].key` — `reg_no`, `engine_cc`, `fuel` —
|
||||||
|
* because that is what the vision prompt was asked to produce. The form's
|
||||||
|
* fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight
|
||||||
|
* in, as they were, every extracted value landed on a key no field renders:
|
||||||
|
* the read succeeded, the panel said so, and nothing filled.
|
||||||
|
*
|
||||||
|
* The bridge is already on the field. `ocr_config.field_mappings` maps
|
||||||
|
* extraction_key -> target_field (the GLOBAL name), and the form field is that
|
||||||
|
* global plus a per-form suffix. So: key -> target -> the field whose base id
|
||||||
|
* matches. Same suffix rule as prefill and the same trap, in a third place.
|
||||||
|
*/
|
||||||
|
function applyExtraction(ocrField, extracted) {
|
||||||
|
const maps = ocrField?.properties?.ocr_config?.field_mappings
|
||||||
|
?? ocrField?.ocr_config?.field_mappings ?? []
|
||||||
|
const all = schema?.fields ?? []
|
||||||
|
|
||||||
|
// extraction_key -> form field id
|
||||||
|
const target = {}
|
||||||
|
for (const m of maps) {
|
||||||
|
if (!m?.extraction_key || !m?.target_field) continue
|
||||||
|
const hit = all.find((f) => f.id === m.target_field)
|
||||||
|
?? all.find((f) => baseFieldId(f.id) === m.target_field)
|
||||||
|
if (hit) target[m.extraction_key] = hit.id
|
||||||
|
}
|
||||||
|
|
||||||
|
setValues((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const [key, val] of Object.entries(extracted)) {
|
||||||
|
if (val === null || val === undefined || String(val) === '') continue
|
||||||
|
// An unmapped key may still name a field directly on forms whose
|
||||||
|
// extraction keys ARE the field names.
|
||||||
|
const id = target[key]
|
||||||
|
?? all.find((f) => f.id === key)?.id
|
||||||
|
?? all.find((f) => baseFieldId(f.id) === key)?.id
|
||||||
|
if (!id) continue
|
||||||
|
// Never overwrite something a person typed. A value we copied off the
|
||||||
|
// lead is fair game — the document is the better source for it.
|
||||||
|
const isBlank = next[id] === undefined || next[id] === ''
|
||||||
|
if (isBlank || seededRef.current.has(id)) {
|
||||||
|
next[id] = val
|
||||||
|
seededRef.current.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(v) {
|
||||||
|
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Check here rather than letting the workflow do it. The server's answer is
|
||||||
|
// correct and unreadable — "product_line_4(required)" — and it costs a round
|
||||||
|
// trip to be told something this form already knew. id_gen is issued
|
||||||
|
// server-side and is never the operator's to fill.
|
||||||
|
const gaps = fields.filter((f) => f.mandatory && f.data_type !== 'id_gen' && isEmpty(values[f.id]))
|
||||||
|
if (gaps.length) {
|
||||||
|
setMissing(gaps.map((f) => f.id))
|
||||||
|
setError(null)
|
||||||
|
// Put the first offender on screen. On a form this wide the empty field
|
||||||
|
// is often above the fold and the message below it. Scrolling to the
|
||||||
|
// LABEL rather than focusing an input works for every field type,
|
||||||
|
// including the file and OCR widgets that render no input at all.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document
|
||||||
|
.querySelector('.af__field.is-missing')
|
||||||
|
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMissing([])
|
||||||
setBusy(true); setError(null)
|
setBusy(true); setError(null)
|
||||||
// Send only fields the activity defines. A submission is schema-validated
|
// Send only fields the activity defines. A submission is schema-validated
|
||||||
// and an unknown field is fatal, so empties are dropped rather than sent.
|
// and an unknown field is fatal, so empties are dropped rather than sent.
|
||||||
const payload = {}
|
const payload = {}
|
||||||
for (const f of schema.fields) {
|
for (const f of fields) {
|
||||||
// id_gen is issued server-side and stripped from the submission. Sending
|
// id_gen is issued server-side and stripped from the submission. Sending
|
||||||
// it would be forging a reference the platform owns.
|
// it would be forging a reference the platform owns.
|
||||||
if (f.data_type === 'id_gen') continue
|
if (f.data_type === 'id_gen') continue
|
||||||
@ -63,41 +256,50 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
|||||||
onDone?.(res)
|
onDone?.(res)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err)
|
setError(err)
|
||||||
|
// The lead moved under the form. Nothing the operator can fix by reading —
|
||||||
|
// tell the page to re-fetch so the actions on offer are the real ones.
|
||||||
|
if (describeError(err).kind === 'stale') onStale?.()
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !schema) return <div className="af__err">Could not load the form — {error.status} {error.message}</div>
|
if (error && !schema) return <div className="af__err">Could not load the form — {describeError(error).title}</div>
|
||||||
if (!schema) return <p className="af__loading">Loading the form…</p>
|
if (!schema) return <p className="af__loading">Loading the form…</p>
|
||||||
|
|
||||||
return (
|
// Stamped fields are in `fields` — they validate and they submit — but they
|
||||||
<form className="af" onSubmit={submit}>
|
// are not drawn. See STAMPED_FIELDS. A stamped field that came back EMPTY is
|
||||||
<div className="af__grid">
|
// drawn anyway: source_channel is mandatory, so a prefill that did not
|
||||||
{schema.fields.map((f) => {
|
// resolve would otherwise fail validation against a box that is not on the
|
||||||
const opts = f.properties?.options || []
|
// screen and cannot be filled.
|
||||||
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
const visible = fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id]))
|
||||||
return (
|
|
||||||
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
|
/* DOCUMENTS GET THEIR OWN ROW. In one flat auto-fit grid the uploads sat
|
||||||
|
beside plain inputs, and an upload that has read a document is several
|
||||||
|
times taller than a text box — so a row carried three tall OCR cards and
|
||||||
|
three short fields, and the form went ragged with the inputs stranded up
|
||||||
|
beside the buttons. Split in two, each grid is internally one height. */
|
||||||
|
const isDoc = (f) => f.data_type === 'file' || f.data_type === 'ocr'
|
||||||
|
const docFields = visible.filter(isDoc)
|
||||||
|
const dataFields = visible.filter((f) => !isDoc(f))
|
||||||
|
|
||||||
|
const renderField = (f) => {
|
||||||
|
const opts = f.properties?.options || []
|
||||||
|
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={f.uid}
|
||||||
|
className={'af__field'
|
||||||
|
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
|
||||||
|
+ (missing.includes(f.id) ? ' is-missing' : '')}
|
||||||
|
>
|
||||||
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
||||||
|
|
||||||
{f.data_type === 'file' || f.data_type === 'ocr' ? (
|
{f.data_type === 'file' || f.data_type === 'ocr' ? (
|
||||||
<FileField
|
<FileField
|
||||||
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
|
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
|
||||||
onChange={(refs) => set(f.id, refs)}
|
onChange={(refs) => set(f.id, refs)}
|
||||||
onExtract={(fields) => {
|
onExtract={(fields) => applyExtraction(f, fields)}
|
||||||
// The document answered questions the form was about to ask.
|
|
||||||
// Only fill what is still blank — never overwrite something a
|
|
||||||
// person typed with something a model read.
|
|
||||||
setValues((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
for (const [k, val] of Object.entries(fields)) {
|
|
||||||
if (val === null || val === undefined || String(val) === '') continue
|
|
||||||
if (next[k] === undefined || next[k] === '') next[k] = val
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : f.data_type === 'id_gen' ? (
|
) : f.data_type === 'id_gen' ? (
|
||||||
<div className="af__auto">
|
<div className="af__auto">
|
||||||
@ -123,24 +325,72 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
|||||||
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
|
type={INPUT_TYPES[f.data_type] ?? 'text'}
|
||||||
value={v} onChange={(e) => set(f.id, e.target.value)}
|
value={v} onChange={(e) => set(f.id, e.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
return (
|
||||||
<div className="af__err">
|
<form className="af" onSubmit={submit}>
|
||||||
<strong>{error.status}</strong> {error.message}
|
{docFields.length ? (
|
||||||
{error.status === 403 || String(error.message).includes('permission denied') ? (
|
<section className="af__sec">
|
||||||
<p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p>
|
<h4 className="af__sech">Documents</h4>
|
||||||
|
<div className="af__grid af__grid--docs">{docFields.map(renderField)}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dataFields.length ? (
|
||||||
|
<section className="af__sec">
|
||||||
|
{docFields.length ? <h4 className="af__sech">Details</h4> : null}
|
||||||
|
<div className="af__grid">{dataFields.map(renderField)}</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* The form's own complaint, before anything is sent. */}
|
||||||
|
{missing.length ? (
|
||||||
|
<div className="af__err af__err--soft" role="alert">
|
||||||
|
<strong>
|
||||||
|
{missing.length === 1
|
||||||
|
? `${fields.find((f) => f.id === missing[0])?.name ?? 'A field'} is required`
|
||||||
|
: 'Some required details are missing'}
|
||||||
|
</strong>
|
||||||
|
{missing.length > 1 ? (
|
||||||
|
<ul>
|
||||||
|
{missing.map((id) => (
|
||||||
|
<li key={id}>{fields.find((f) => f.id === id)?.name ?? id}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{error ? (() => {
|
||||||
|
// A field-validation reply is rendered in the form's own words. The
|
||||||
|
// status code is dropped with it: "400" tells an operator nothing they
|
||||||
|
// can act on, and it is the first thing they read.
|
||||||
|
const bad = describeValidation(error?.message, fields)
|
||||||
|
if (bad) {
|
||||||
|
return (
|
||||||
|
<div className="af__err af__err--soft" role="alert">
|
||||||
|
<strong>{bad.title}</strong>
|
||||||
|
{bad.items.length > 1 ? (
|
||||||
|
<ul>{bad.items.map((t) => <li key={t}>{t}</li>)}</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const said = describeError(error)
|
||||||
|
return (
|
||||||
|
<div className="af__err" role="alert">
|
||||||
|
<strong>{said.title}</strong>
|
||||||
|
{said.detail ? <p>{said.detail}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})() : null}
|
||||||
|
|
||||||
<div className="af__actions">
|
<div className="af__actions">
|
||||||
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
|
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
|
||||||
<button type="submit" className="af__submit" disabled={busy}>
|
<button type="submit" className="af__submit" disabled={busy}>
|
||||||
|
|||||||
91
src/components/AgentCard.css
Normal file
91
src/components/AgentCard.css
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/* The agent dialog. Namespaced `agc`, not `card`: the screens use `.card`
|
||||||
|
for every panel, and a bare `.card { max-height }` here was silently
|
||||||
|
capping the lead tables at 720px while their rows kept rendering. */
|
||||||
|
.agc { max-height: min(82vh, 720px); }
|
||||||
|
|
||||||
|
.agc__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px 20px 16px 22px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agc__disc { width: 38px !important; height: 38px !important; font-size: var(--fs-xs) !important; flex: none; }
|
||||||
|
|
||||||
|
.agc__head > div { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.agc__head h3 { margin: 0 0 3px; font-size: var(--fs-md); font-weight: 500; color: var(--zk-ink); }
|
||||||
|
.agc__head p { margin: 0; font-size: var(--fs-2xs); line-height: 1.45; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.agc__head button {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.agc__head button svg { width: 14px; height: 14px; }
|
||||||
|
.agc__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||||
|
.agc__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.agc__body { overflow-y: auto; padding: 18px 22px 20px; }
|
||||||
|
|
||||||
|
.agc__body section + section { margin-top: 20px; }
|
||||||
|
|
||||||
|
.agc__body h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: var(--fs-3xs);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agc__body p { margin: 0; font-size: var(--fs-xs); line-height: 1.55; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.agc__tools { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.agc__tools li {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-white);
|
||||||
|
}
|
||||||
|
.agc__tools code {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
}
|
||||||
|
.agc__tools span { font-size: var(--fs-2xs); line-height: 1.5; color: var(--zk-muted); }
|
||||||
|
|
||||||
|
/* "No tools" is the most reassuring fact about an agent writing into an
|
||||||
|
insurance file. An empty section would read as missing data instead. */
|
||||||
|
.agc__none {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
border: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agc__kb { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.agc__kb li {
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
padding: 4px 11px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agc__foot {
|
||||||
|
margin-top: 20px !important;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
font-size: var(--fs-2xs) !important;
|
||||||
|
color: var(--zk-grey) !important;
|
||||||
|
}
|
||||||
112
src/components/AgentCard.jsx
Normal file
112
src/components/AgentCard.jsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { AGENTS } from '../api/agents.js'
|
||||||
|
import './AgentCard.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this agent is, and what it can actually reach.
|
||||||
|
*
|
||||||
|
* The question anybody asks the first time they watch an AI do a step of their
|
||||||
|
* job is not "how does it work" — it is "what is it ALLOWED to touch". That
|
||||||
|
* answer is short here and it is reassuring, so it should be one click away
|
||||||
|
* rather than something a demo has to be trusted on: four of the five hold no
|
||||||
|
* tools at all and reason from the policy wordings; the only tool in the whole
|
||||||
|
* roster reads a partner registry.
|
||||||
|
*
|
||||||
|
* It is also the answer that stopped being true once, silently. Intake held a
|
||||||
|
* tool that could telephone customers, its charter never mentioned it, and it
|
||||||
|
* used it on every lead believing it was a duplicate check. Nobody could see
|
||||||
|
* that from any screen. This is the screen that would have shown it.
|
||||||
|
*/
|
||||||
|
export default function AgentCard({ agentKey, onClose }) {
|
||||||
|
const a = AGENTS[agentKey]
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!a) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg agc"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={a.full}
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="agc__head">
|
||||||
|
<span className={`who__disc who__disc--${a.tone} agc__disc`} aria-hidden="true">{a.initials}</span>
|
||||||
|
<div>
|
||||||
|
<h3>{a.full}</h3>
|
||||||
|
<p>{a.does.charAt(0).toUpperCase() + a.does.slice(1)}.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agc__body">
|
||||||
|
<section>
|
||||||
|
<h4>When it runs</h4>
|
||||||
|
<p>{a.wakes}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>What it can reach</h4>
|
||||||
|
{a.tools.length ? (
|
||||||
|
<ul className="agc__tools">
|
||||||
|
{a.tools.map((t) => (
|
||||||
|
<li key={t.name}>
|
||||||
|
<code>{t.name}</code>
|
||||||
|
<span>{t.does}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
/* Stated positively rather than left blank. "No tools" is the
|
||||||
|
single most reassuring fact about an agent that writes into an
|
||||||
|
insurance file, and an empty section reads as missing data. */
|
||||||
|
<p className="agc__none">
|
||||||
|
No tools. It reads the lead and the policy wordings, and writes its answer
|
||||||
|
back into the workflow — nothing else.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>What it reads</h4>
|
||||||
|
<ul className="agc__kb">
|
||||||
|
{a.knowledge.map((k) => <li key={k}>{k}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="agc__foot">
|
||||||
|
Every step it takes is recorded against the lead, with what it wrote and why.
|
||||||
|
It cannot confirm a premium, issue a policy or telephone anybody.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/components/AgentChip.css
Normal file
44
src/components/AgentChip.css
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
.who { display: inline-flex; align-items: center; gap: 7px; min-width: 0; }
|
||||||
|
|
||||||
|
/* A soft disc in the worker's colour: blue for the AI at large, amber for KYC,
|
||||||
|
teal for the Advisor, grey initials for a person. */
|
||||||
|
.who__disc {
|
||||||
|
position: relative; display: grid; place-items: center; flex: none;
|
||||||
|
width: 22px; height: 22px; border-radius: 50%;
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: .02em; user-select: none;
|
||||||
|
background: var(--zk-tint-blue); color: var(--zk-blue);
|
||||||
|
}
|
||||||
|
/* xs is the label on a chat bubble, where the disc sits beside 12.5px type. */
|
||||||
|
.who--xs { gap: 5px; }
|
||||||
|
.who--xs .who__disc { width: 16px; height: 16px; font-size: 8.5px; }
|
||||||
|
.who--xs .who__name { font-size: var(--fs-3xs); }
|
||||||
|
.who--xs .who__tag { font-size: 10px; padding: 0 4px; }
|
||||||
|
.who--md .who__disc { width: 36px; height: 36px; font-size: 13px; }
|
||||||
|
.who--lg .who__disc { width: 40px; height: 40px; font-size: 14px; }
|
||||||
|
|
||||||
|
.who__disc--blue, .who__disc--violet, .who__disc--plum { background: var(--zk-tint-blue); color: var(--zk-blue); }
|
||||||
|
.who__disc--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
|
||||||
|
.who__disc--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
|
||||||
|
.who__disc--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.who__glyph { width: 52%; height: 52%; }
|
||||||
|
.who__init { font-weight: 700; }
|
||||||
|
|
||||||
|
.who__name { font-size: var(--fs-sm); font-weight: 600; color: var(--zk-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.who__tag {
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: .04em;
|
||||||
|
color: var(--zk-blue); background: var(--zk-tint-blue);
|
||||||
|
padding: 1px 6px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.who--btn {
|
||||||
|
font: inherit; cursor: pointer; padding: 2px 8px 2px 2px;
|
||||||
|
border: 1px solid transparent; border-radius: var(--r-pill); background: transparent;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.who--btn:hover { background: var(--zk-tint); border-color: var(--zk-line); }
|
||||||
|
.who--btn:hover .who__i { opacity: 1; }
|
||||||
|
.who--btn:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
|
.who__i { width: 12px; height: 12px; flex: none; color: var(--zk-grey); opacity: 0; transition: opacity .12s; }
|
||||||
|
|
||||||
|
.who__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
84
src/components/AgentChip.jsx
Normal file
84
src/components/AgentChip.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { AGENTS, initialsOf } from '../api/agents.js'
|
||||||
|
import './AgentChip.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TWO SYMBOLS, ONE CONTRAST: a sparkle for the machines, a person for the
|
||||||
|
* people.
|
||||||
|
*
|
||||||
|
* The sparkle is the settled 2026 signal for AI — the mark Claude, Gemini and
|
||||||
|
* Copilot all use — so an agent's disc reads as "made by AI" without a legend,
|
||||||
|
* in the agent's own colour. A person's disc carries a plain head-and-shoulders
|
||||||
|
* glyph, so a human step reads as human at the same glance. The contrast is the
|
||||||
|
* whole message: which of these did the machine do, and which did a person.
|
||||||
|
* Who exactly is said by the name beside the disc and by the colour.
|
||||||
|
*/
|
||||||
|
function SparkleGlyph() {
|
||||||
|
return (
|
||||||
|
<svg className="who__glyph" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M8 .8c.25 2.4.9 3.9 1.9 4.9 1 1 2.5 1.65 4.9 1.9v.8c-2.4.25-3.9.9-4.9 1.9-1 1-1.65 2.5-1.9 4.9h-.8c-.25-2.4-.9-3.9-1.9-4.9-1-1-2.5-1.65-4.9-1.9v-.8c2.4-.25 3.9-.9 4.9-1.9 1-1 1.65-2.5 1.9-4.9z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who did this — as a face, not a slug.
|
||||||
|
*
|
||||||
|
* A disc with initials, coloured per agent and consistent everywhere. The
|
||||||
|
* point is recognition at a glance down a timeline: the operator should see
|
||||||
|
* that one worker made four consecutive entries without reading four names.
|
||||||
|
*
|
||||||
|
* People get a disc too, in grey. A trail where the agents are decorated and
|
||||||
|
* the humans are plain text reads as though the machines are the important
|
||||||
|
* ones, which is the wrong way round on a screen whose job is oversight.
|
||||||
|
*/
|
||||||
|
export default function AgentChip({ agentKey, name, size = 'sm', showName = true, onOpen }) {
|
||||||
|
const a = agentKey ? AGENTS[agentKey] : null
|
||||||
|
const label = a ? a.short : (name || 'System')
|
||||||
|
const tone = a ? a.tone : 'grey'
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{/* THE DISC SAYS WHICH WORKER; THE SPARKLE SAYS IT IS NOT A PERSON.
|
||||||
|
Initials alone do not carry that — "EN" reads as a colleague's
|
||||||
|
badge, and on a screen whose entire claim is that the work was done
|
||||||
|
autonomously, the one fact every entry must state is which entries
|
||||||
|
were. The sparkle is the settled convention for machine-generated
|
||||||
|
work across current interfaces, so it needs no legend.
|
||||||
|
|
||||||
|
Only agents get it. A trail where a human's disc also carried one
|
||||||
|
would say nothing at all. */}
|
||||||
|
<span className={`who__disc who__disc--${tone}`} aria-hidden="true">
|
||||||
|
{/* A machine wears the sparkle; a person wears their initials. */}
|
||||||
|
{a ? <SparkleGlyph /> : <span className="who__init">{initialsOf(name)}</span>}
|
||||||
|
</span>
|
||||||
|
{showName ? <span className="who__name">{label}</span> : null}
|
||||||
|
{showName && a ? <small className="who__tag">AI</small> : null}
|
||||||
|
{a ? <span className="who__sr"> (AI)</span> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only an AGENT opens a card. A person's disc is a label — there is nothing
|
||||||
|
// to explain about a human being that this app is entitled to show.
|
||||||
|
if (a && onOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`who who--${size} who--btn`}
|
||||||
|
onClick={() => onOpen(agentKey)}
|
||||||
|
title={`${a.full} — what it does and what it can reach`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
<svg className="who__i" viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<circle cx="7" cy="7" r="5.6" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M7 6.2v3.4M7 4.3v.9" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`who who--${size}`} title={a ? `${a.full} — ${a.does}` : name || 'System'}>
|
||||||
|
{body}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
src/components/AskDesk.css
Normal file
200
src/components/AskDesk.css
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/* Ask the desk — full screen.
|
||||||
|
|
||||||
|
It was a 560px side panel, which was wrong for what the desk actually
|
||||||
|
returns: a timeline of fifteen steps, a table of every stalled lead, a
|
||||||
|
paragraph naming both PAN numbers. Reading those down a gutter while the
|
||||||
|
page behind sat unused was the panel fighting the content. Full screen, one
|
||||||
|
readable column, and the conversation list alongside. */
|
||||||
|
|
||||||
|
.desk {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--zk-tint);
|
||||||
|
animation: desk-in .16s var(--ease);
|
||||||
|
}
|
||||||
|
@keyframes desk-in { from { opacity: .4; } to { opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .desk { animation: none; } }
|
||||||
|
|
||||||
|
/* ── Bar ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.desk__bar {
|
||||||
|
flex: none; display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 12px 22px; background: var(--zk-navy); color: #fff;
|
||||||
|
}
|
||||||
|
.desk__id { min-width: 0; flex: 1; }
|
||||||
|
.desk__id h2 { margin: 0; font-size: var(--fs-lg); font-weight: 700; }
|
||||||
|
.desk__id p { margin: 2px 0 0; font-size: var(--fs-2xs); opacity: .72; }
|
||||||
|
.desk__x, .desk__rails {
|
||||||
|
flex: none; width: 34px; height: 34px; display: grid; place-items: center;
|
||||||
|
border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.desk__x svg, .desk__rails svg { width: 18px; height: 18px; }
|
||||||
|
.desk__x:hover, .desk__rails:hover { background: rgba(255,255,255,.14); }
|
||||||
|
.desk__x:focus-visible, .desk__rails:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255,255,255,.3); }
|
||||||
|
.desk__rails { display: none; margin-left: -6px; } /* narrow screens only */
|
||||||
|
.desk__new {
|
||||||
|
flex: none; font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
|
||||||
|
padding: 7px 15px; border-radius: var(--r-pill);
|
||||||
|
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
|
||||||
|
}
|
||||||
|
.desk__new:hover { background: rgba(255,255,255,.12); }
|
||||||
|
|
||||||
|
/* ── Frame ───────────────────────────────────────────────────────────────── */
|
||||||
|
.desk__body { flex: 1; min-height: 0; display: flex; }
|
||||||
|
|
||||||
|
.desk__rail {
|
||||||
|
flex: none; width: 260px; background: #fff; border-right: 1px solid var(--zk-line);
|
||||||
|
overflow-y: auto; padding: 14px 12px; display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.desk__railh {
|
||||||
|
font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
color: var(--zk-grey); padding: 4px 8px 8px;
|
||||||
|
}
|
||||||
|
.desk__railnone { margin: 4px 8px; font-size: var(--fs-2xs); color: var(--zk-grey); line-height: 1.5; }
|
||||||
|
.desk__sess {
|
||||||
|
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||||
|
padding: 9px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent;
|
||||||
|
font: inherit; color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.desk__sess:hover { background: var(--zk-tint); }
|
||||||
|
.desk__sess.is-on { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||||
|
.desk__sesst {
|
||||||
|
display: block; font-size: var(--fs-xs); font-weight: 600; line-height: 1.35;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.desk__sessw { display: block; font-size: var(--fs-3xs); color: var(--zk-grey); margin-top: 3px; }
|
||||||
|
|
||||||
|
.desk__main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.desk__feed { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 24px 8px; }
|
||||||
|
/* One column, near 70ch, because the answers are prose and prose has a measure. */
|
||||||
|
.desk__col { max-width: 760px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── Turns ───────────────────────────────────────────────────────────────── */
|
||||||
|
.desk__turn { margin-bottom: 26px; }
|
||||||
|
.desk__turn--user { display: flex; justify-content: flex-end; }
|
||||||
|
.desk__q {
|
||||||
|
margin: 0; max-width: 78%; padding: 11px 16px;
|
||||||
|
border-radius: var(--r-md) var(--r-md) var(--r-xs) var(--r-md);
|
||||||
|
background: var(--zk-navy); color: #fff; font-size: var(--fs-sm); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.desk__a { font-size: var(--fs-sm); color: var(--zk-ink); }
|
||||||
|
.desk__a p { margin: 0 0 11px; line-height: 1.6; }
|
||||||
|
.desk__a h4 { margin: 20px 0 8px; font-size: var(--fs-md); font-weight: 700; }
|
||||||
|
.desk__a ul, .desk__a ol { margin: 0 0 12px; padding-left: 22px; }
|
||||||
|
.desk__a li { margin-bottom: 5px; line-height: 1.55; }
|
||||||
|
.desk__a blockquote {
|
||||||
|
margin: 0 0 12px; padding: 9px 15px; border-left: 3px solid var(--zk-amber-bar);
|
||||||
|
background: var(--zk-amber-tint); border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
}
|
||||||
|
.desk__a code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em;
|
||||||
|
background: var(--zk-tint); padding: 1px 5px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk__tablewrap { overflow-x: auto; margin: 0 0 14px; }
|
||||||
|
.desk__a table { border-collapse: collapse; width: 100%; font-size: var(--fs-xs); }
|
||||||
|
.desk__a th {
|
||||||
|
text-align: left; font-weight: 600; color: var(--zk-grey); white-space: nowrap;
|
||||||
|
padding: 8px 14px 8px 0; border-bottom: 1px solid var(--zk-line);
|
||||||
|
}
|
||||||
|
.desk__a td { padding: 8px 14px 8px 0; border-bottom: 1px solid var(--zk-line-soft); }
|
||||||
|
.desk__a th:last-child, .desk__a td:last-child { padding-right: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.desk__a tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
.desk__tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
|
.desk__tools span {
|
||||||
|
font-size: var(--fs-3xs); color: var(--zk-muted);
|
||||||
|
background: var(--zk-line-soft); padding: 2px 9px; border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
|
.desk__empty { padding: 32px 0 8px; }
|
||||||
|
.desk__empty h3 { margin: 0 0 6px; font-size: var(--fs-xl); font-weight: 700; color: var(--zk-ink); }
|
||||||
|
.desk__empty > p { margin: 0 0 20px; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 60ch; }
|
||||||
|
.desk__sugg { display: flex; flex-direction: column; gap: 9px; align-items: flex-start; }
|
||||||
|
.desk__sugg button {
|
||||||
|
font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer;
|
||||||
|
padding: 10px 16px; border-radius: var(--r-sm);
|
||||||
|
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.desk__sugg button:hover:not(:disabled) { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); color: var(--zk-navy); }
|
||||||
|
.desk__sugg button:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
.desk__wait { display: flex; align-items: center; gap: 10px; font-size: var(--fs-xs); color: var(--zk-muted); }
|
||||||
|
.desk__dots { display: inline-flex; gap: 4px; }
|
||||||
|
.desk__dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--zk-blue); animation: desk-blink 1.2s infinite; }
|
||||||
|
.desk__dots i:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.desk__dots i:nth-child(3) { animation-delay: .4s; }
|
||||||
|
@keyframes desk-blink { 0%,60%,100% { opacity: .25; } 30% { opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .desk__dots i { animation: none; opacity: .6; } }
|
||||||
|
|
||||||
|
.desk__err {
|
||||||
|
border: 1px solid var(--zk-danger-line); background: var(--zk-danger-tint);
|
||||||
|
border-radius: var(--r-md); padding: 12px 15px; display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.desk__err strong { font-size: var(--fs-sm); color: var(--zk-danger-ink); }
|
||||||
|
.desk__err span { font-size: var(--fs-2xs); color: var(--zk-muted); }
|
||||||
|
|
||||||
|
/* ── Ask ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.desk__ask { flex: none; border-top: 1px solid var(--zk-line); background: #fff; padding: 14px 24px 18px; }
|
||||||
|
.desk__askcol { display: flex; gap: 12px; align-items: flex-end; }
|
||||||
|
.desk__ask textarea {
|
||||||
|
flex: 1; resize: none; font: inherit; font-size: var(--fs-sm); color: var(--zk-ink);
|
||||||
|
padding: 12px 14px; border: 1px solid var(--zk-line); border-radius: var(--r-sm);
|
||||||
|
background: #fff; min-height: 46px; max-height: 160px;
|
||||||
|
}
|
||||||
|
.desk__ask textarea:focus { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
.desk__ask textarea:disabled { background: var(--zk-tint); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
/* ── Chart drawn from the answer's own table ─────────────────────────────── */
|
||||||
|
.dchart {
|
||||||
|
margin: 0 0 14px; padding: 15px 18px;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
|
||||||
|
}
|
||||||
|
.dchart__head {
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
font-size: var(--fs-3xs); color: var(--zk-grey); margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.dchart__toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--zk-line); border-radius: var(--r-pill); overflow: hidden; }
|
||||||
|
.dchart__toggle button {
|
||||||
|
font: inherit; font-size: var(--fs-3xs); font-weight: 600; cursor: pointer;
|
||||||
|
padding: 4px 12px; border: 0; background: #fff; color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.dchart__toggle button.is-on { background: var(--zk-navy); color: #fff; }
|
||||||
|
|
||||||
|
.dchart__bars { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.dchart__row { display: grid; grid-template-columns: minmax(96px, 30%) 1fr auto; gap: 12px; align-items: center; }
|
||||||
|
.dchart__lab { font-size: var(--fs-2xs); color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dchart__track { height: 16px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
|
||||||
|
.dchart__fill { display: block; height: 100%; border-radius: 3px; }
|
||||||
|
.dchart__val { font-size: var(--fs-2xs); font-variant-numeric: tabular-nums; color: var(--zk-muted); white-space: nowrap; }
|
||||||
|
.dchart__cap { margin: 10px 0 0; font-size: var(--fs-3xs); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.dchart__donut { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.dchart__donut svg { width: 160px; height: 160px; flex: none; }
|
||||||
|
.dchart__total { font-size: 21px; font-weight: 700; fill: var(--zk-ink); font-variant-numeric: tabular-nums; }
|
||||||
|
.dchart__totlab { font-size: 10px; fill: var(--zk-grey); letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.dchart__key { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.dchart__key li { display: grid; grid-template-columns: 10px 1fr auto auto; gap: 10px; align-items: center; font-size: var(--fs-2xs); }
|
||||||
|
.dchart__sw { width: 10px; height: 10px; border-radius: 2px; }
|
||||||
|
.dchart__keylab { color: var(--zk-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dchart__keyval { color: var(--zk-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.dchart__keypct { color: var(--zk-grey); font-variant-numeric: tabular-nums; min-width: 34px; text-align: right; }
|
||||||
|
|
||||||
|
/* ── Narrow ──────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.desk__rails { display: grid; }
|
||||||
|
.desk__id p { display: none; }
|
||||||
|
.desk__new { font-size: 0; padding: 8px; }
|
||||||
|
.desk__new::before { content: '+'; font-size: 17px; font-weight: 700; }
|
||||||
|
.desk__rail {
|
||||||
|
position: absolute; top: 58px; bottom: 0; left: 0; z-index: 2;
|
||||||
|
transform: translateX(-100%); transition: transform .18s var(--ease);
|
||||||
|
box-shadow: 8px 0 28px rgba(11,42,91,.14);
|
||||||
|
}
|
||||||
|
.desk__rail.is-open { transform: none; }
|
||||||
|
.desk__body { position: relative; }
|
||||||
|
.desk__feed { padding: 20px 16px 8px; }
|
||||||
|
.desk__ask { padding: 12px 16px 16px; }
|
||||||
|
.dchart__row { grid-template-columns: minmax(72px, 34%) 1fr auto; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .desk__rail { transition: none; } }
|
||||||
344
src/components/AskDesk.jsx
Normal file
344
src/components/AskDesk.jsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { SUPPORT_AGENT_ID } from '../api/config.js'
|
||||||
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import { describeError } from '../api/errors.js'
|
||||||
|
import DeskChart, { chartableFrom } from './DeskChart.jsx'
|
||||||
|
import './AskDesk.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the desk — the support desk, full screen.
|
||||||
|
*
|
||||||
|
* A support desk on the platform, bound to this app, that reads the deployed
|
||||||
|
* config and the live book through read-only tools and answers with evidence.
|
||||||
|
* A different kind of thing from the five AI employees: they DO the work and
|
||||||
|
* write to the workflow; this one only reads, to answer a person's question.
|
||||||
|
* Nothing it says changes a lead.
|
||||||
|
*
|
||||||
|
* Full screen rather than a side panel, because the answers are long — a
|
||||||
|
* timeline, a table of every stalled lead — and reading them in a 560px gutter
|
||||||
|
* meant scrolling a column while the page behind it sat unused. The
|
||||||
|
* conversation list comes with it: an investigation is worth returning to, and
|
||||||
|
* every conversation opens a ticket on the platform whether or not anyone
|
||||||
|
* reads it back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
'How many leads are in each stage right now?',
|
||||||
|
'Which leads have not moved in over 12 hours?',
|
||||||
|
'What reason did the AI give for each underwriting referral?',
|
||||||
|
'Total written premium, and how many policies were issued?',
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ── The desk answers in markdown. This renders the five things it uses:
|
||||||
|
headings, lists, tables, bold and inline code. Anything else falls through
|
||||||
|
as text, which is the right failure — an unrendered asterisk is legible; a
|
||||||
|
crashed panel is not. ── */
|
||||||
|
function inline(text, key) {
|
||||||
|
return String(text).split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((p, i) => {
|
||||||
|
if (p.startsWith('**') && p.endsWith('**')) return <strong key={`${key}-${i}`}>{p.slice(2, -2)}</strong>
|
||||||
|
if (p.startsWith('`') && p.endsWith('`')) return <code key={`${key}-${i}`}>{p.slice(1, -1)}</code>
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
|
||||||
|
const isRow = (line) => /^\s*\|.*\|\s*$/.test(line)
|
||||||
|
const isRule = (line) => /^\s*\|[\s:|-]+\|\s*$/.test(line)
|
||||||
|
|
||||||
|
/** Parses one answer into blocks, so a table can be drawn as well as printed. */
|
||||||
|
function parse(text) {
|
||||||
|
const lines = String(text || '').split('\n')
|
||||||
|
const out = []
|
||||||
|
let list = null
|
||||||
|
let rows = null
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (!list) return
|
||||||
|
out.push({ t: list.ordered ? 'ol' : 'ul', items: list.items })
|
||||||
|
list = null
|
||||||
|
}
|
||||||
|
const flushTable = () => {
|
||||||
|
if (!rows) return
|
||||||
|
out.push({ t: 'table', head: rows[0], body: rows.slice(1) })
|
||||||
|
rows = null
|
||||||
|
}
|
||||||
|
const flush = () => { flushTable(); flushList() }
|
||||||
|
|
||||||
|
lines.forEach((raw) => {
|
||||||
|
const line = raw.trimEnd()
|
||||||
|
const bullet = line.match(/^\s*[-*]\s+(.*)$/)
|
||||||
|
const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/)
|
||||||
|
const heading = line.match(/^#{1,4}\s+(.*)$/)
|
||||||
|
const quote = line.match(/^\s*>\s?(.*)$/)
|
||||||
|
|
||||||
|
if (isRow(line)) {
|
||||||
|
if (!isRule(line)) { flushList(); if (!rows) rows = []; rows.push(cells(line)) }
|
||||||
|
} else if (rows && line.trim() === '') {
|
||||||
|
/* hold the table open across a blank line */
|
||||||
|
} else if (bullet) {
|
||||||
|
flushTable()
|
||||||
|
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] } }
|
||||||
|
list.items.push(bullet[1])
|
||||||
|
} else if (numbered) {
|
||||||
|
flushTable()
|
||||||
|
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] } }
|
||||||
|
list.items.push(numbered[1])
|
||||||
|
} else if (heading) {
|
||||||
|
flush(); out.push({ t: 'h', text: heading[1] })
|
||||||
|
} else if (quote) {
|
||||||
|
flush(); out.push({ t: 'quote', text: quote[1] })
|
||||||
|
} else if (line.trim() === '') {
|
||||||
|
flush()
|
||||||
|
} else {
|
||||||
|
flush(); out.push({ t: 'p', text: line })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function Answer({ text }) {
|
||||||
|
const blocks = useMemo(() => parse(text), [text])
|
||||||
|
// Chart type is per-table and remembered while the answer is on screen.
|
||||||
|
const [kinds, setKinds] = useState({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((b, i) => {
|
||||||
|
if (b.t === 'h') return <h4 key={i}>{inline(b.text, i)}</h4>
|
||||||
|
if (b.t === 'p') return <p key={i}>{inline(b.text, i)}</p>
|
||||||
|
if (b.t === 'quote') return <blockquote key={i}>{inline(b.text, i)}</blockquote>
|
||||||
|
if (b.t === 'ul') return <ul key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ul>
|
||||||
|
if (b.t === 'ol') return <ol key={i}>{b.items.map((t, j) => <li key={j}>{inline(t, `${i}-${j}`)}</li>)}</ol>
|
||||||
|
if (b.t === 'table') {
|
||||||
|
const chart = chartableFrom(b.head, b.body)
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
{chart ? (
|
||||||
|
<DeskChart
|
||||||
|
data={chart}
|
||||||
|
kind={kinds[i] || 'bar'}
|
||||||
|
onKind={(k) => setKinds((p) => ({ ...p, [i]: k }))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="desk__tablewrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr>{b.head.map((c, j) => <th key={j}>{inline(c, `h${j}`)}</th>)}</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{b.body.map((r, ri) => (
|
||||||
|
<tr key={ri}>{r.map((c, ci) => <td key={ci}>{inline(c, `${ri}-${ci}`)}</td>)}</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tools a turn ran — the desk's working, not just its answer. */
|
||||||
|
function toolsOf(row) {
|
||||||
|
const raw = row?.metadata?.tool_calls ?? row?.metadata?.api_calls ?? row?.metadata?.tools
|
||||||
|
return (Array.isArray(raw) ? raw : [])
|
||||||
|
.map((t) => (typeof t === 'string' ? t : t?.name || t?.tool_name)).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = (iso) => {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d)) return ''
|
||||||
|
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return mins + 'm ago'
|
||||||
|
if (mins < 1440) return Math.round(mins / 60) + 'h ago'
|
||||||
|
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AskDesk({ onClose }) {
|
||||||
|
const { client } = useZino()
|
||||||
|
const [sessions, setSessions] = useState(null)
|
||||||
|
const [sessionId, setSessionId] = useState(null)
|
||||||
|
const [rows, setRows] = useState([])
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [asking, setAsking] = useState(false)
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
const [railOpen, setRailOpen] = useState(false)
|
||||||
|
const feedRef = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
const loadSessions = useCallback(() => {
|
||||||
|
client.deskSessions(SUPPORT_AGENT_ID)
|
||||||
|
.then((r) => setSessions(Array.isArray(r) ? r : (r?.data ?? [])))
|
||||||
|
.catch(() => setSessions([]))
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
useEffect(() => { loadSessions() }, [loadSessions])
|
||||||
|
|
||||||
|
// Escape closes, because a full-screen panel with no way out by keyboard is
|
||||||
|
// a trap for anyone who opened it by accident.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape' && !asking) onClose?.() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose, asking])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = feedRef.current
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}, [rows, asking])
|
||||||
|
|
||||||
|
const openSession = useCallback((id) => {
|
||||||
|
setSessionId(id); setRows([]); setErr(null); setRailOpen(false)
|
||||||
|
client.deskMessages(id)
|
||||||
|
.then((r) => {
|
||||||
|
const msgs = Array.isArray(r) ? r : (r?.data ?? [])
|
||||||
|
setRows(msgs
|
||||||
|
.filter((m) => m.role === 'user' || m.role === 'agent')
|
||||||
|
.map((m) => ({ key: 'm' + m.id, role: m.role === 'user' ? 'user' : 'agent', content: m.content, tools: [] })))
|
||||||
|
})
|
||||||
|
.catch(setErr)
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
const newSession = useCallback(async () => {
|
||||||
|
setErr(null); setRows([]); setSessionId(null); setRailOpen(false)
|
||||||
|
try {
|
||||||
|
const s = await client.deskStartSession(SUPPORT_AGENT_ID)
|
||||||
|
setSessionId(s?.id ?? s?.data?.id ?? null)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
} catch (e) { setErr(e) }
|
||||||
|
}, [client])
|
||||||
|
|
||||||
|
// One conversation is opened on arrival, so the first question needs no setup.
|
||||||
|
useEffect(() => { newSession() }, [newSession])
|
||||||
|
|
||||||
|
const ask = useCallback(async (question) => {
|
||||||
|
const q = String(question ?? '').trim()
|
||||||
|
if (!q || !sessionId || asking) return
|
||||||
|
setErr(null); setText('')
|
||||||
|
setRows((prev) => [...prev, { key: 'q' + Date.now(), role: 'user', content: q }])
|
||||||
|
setAsking(true)
|
||||||
|
try {
|
||||||
|
const res = await client.deskAsk(sessionId, q)
|
||||||
|
const answer = res?.agent_message
|
||||||
|
setRows((prev) => [...prev, {
|
||||||
|
key: 'a' + (answer?.id ?? Date.now()),
|
||||||
|
role: 'agent',
|
||||||
|
content: answer?.content ?? 'The desk answered with nothing at all.',
|
||||||
|
tools: toolsOf(res?.call_api_message),
|
||||||
|
}])
|
||||||
|
loadSessions()
|
||||||
|
} catch (e) { setErr(e) } finally {
|
||||||
|
setAsking(false); inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [client, sessionId, asking, loadSessions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desk" role="dialog" aria-label="Ask the desk" aria-modal="true">
|
||||||
|
<header className="desk__bar">
|
||||||
|
<button
|
||||||
|
type="button" className="desk__rails" aria-label="Conversations"
|
||||||
|
aria-expanded={railOpen} onClick={() => setRailOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 4.5h12M3 9h12M3 13.5h8" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
|
||||||
|
</button>
|
||||||
|
<div className="desk__id">
|
||||||
|
<h2>Ask the desk</h2>
|
||||||
|
<p>Reads the book and answers with evidence. It cannot change anything.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="desk__new" onClick={newSession}>New question</button>
|
||||||
|
<button type="button" className="desk__x" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M4.5 4.5l9 9M13.5 4.5l-9 9" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="desk__body">
|
||||||
|
<aside className={'desk__rail' + (railOpen ? ' is-open' : '')} aria-label="Past conversations">
|
||||||
|
<span className="desk__railh">Conversations</span>
|
||||||
|
{sessions === null ? <p className="desk__railnone">Loading…</p>
|
||||||
|
: sessions.length === 0 ? <p className="desk__railnone">Nothing yet. Every question you ask is kept here.</p>
|
||||||
|
: sessions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id} type="button"
|
||||||
|
className={'desk__sess' + (s.id === sessionId ? ' is-on' : '')}
|
||||||
|
onClick={() => openSession(s.id)}
|
||||||
|
>
|
||||||
|
<span className="desk__sesst">{s.title || 'Untitled question'}</span>
|
||||||
|
<span className="desk__sessw">{when(s.created_at)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="desk__main">
|
||||||
|
<div className="desk__feed" ref={feedRef}>
|
||||||
|
<div className="desk__col">
|
||||||
|
{rows.length === 0 && !asking ? (
|
||||||
|
<div className="desk__empty">
|
||||||
|
<h3>What would you like to know?</h3>
|
||||||
|
<p>Ask about a lead, a queue, or the book as a whole. Anything countable comes back as a table — and a chart.</p>
|
||||||
|
<div className="desk__sugg">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button key={s} type="button" disabled={!sessionId} onClick={() => ask(s)}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.key} className={'desk__turn desk__turn--' + r.role}>
|
||||||
|
{r.role === 'user'
|
||||||
|
? <p className="desk__q">{r.content}</p>
|
||||||
|
: (
|
||||||
|
<div className="desk__a">
|
||||||
|
<Answer text={r.content} />
|
||||||
|
{r.tools?.length ? (
|
||||||
|
<div className="desk__tools">
|
||||||
|
{r.tools.map((t, i) => <span key={t + i}>{t}</span>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{asking ? (
|
||||||
|
<div className="desk__turn desk__turn--agent">
|
||||||
|
<div className="desk__wait">
|
||||||
|
<span className="desk__dots" aria-hidden="true"><i /><i /><i /></span>
|
||||||
|
Reading the book — this takes half a minute.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<div className="desk__err" role="alert">
|
||||||
|
<strong>{describeError(err).title}</strong>
|
||||||
|
<span>{err?.message}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="desk__ask" onSubmit={(e) => { e.preventDefault(); ask(text) }}>
|
||||||
|
<div className="desk__col desk__askcol">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef} value={text} rows={1}
|
||||||
|
placeholder={sessionId ? 'Ask a question…' : 'Opening a conversation…'}
|
||||||
|
disabled={!sessionId || asking}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter sends; Shift+Enter is a new line. A question is
|
||||||
|
// usually one line, and reaching for a button every time is
|
||||||
|
// friction on the thing this panel exists for.
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(text) }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn--primary" disabled={!sessionId || asking || !text.trim()}>Ask</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/components/CallTranscript.jsx
Normal file
118
src/components/CallTranscript.jsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import './Conversation.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The call, turn by turn.
|
||||||
|
*
|
||||||
|
* The voice agent hands the workflow a full transcript when a call ends, and
|
||||||
|
* until now Engage read it, wrote three sentences of summary, and the words
|
||||||
|
* themselves were dropped. So a customer said "I think it's 12,000 rupees" and
|
||||||
|
* "lower premium", and the file held one AI's paraphrase of that.
|
||||||
|
*
|
||||||
|
* It matters more here than it would elsewhere: `consent_artefact =
|
||||||
|
* verbal_call` is recorded from what the agent HEARD, and it is the artefact
|
||||||
|
* justifying every later contact. A colleague reviewing that months later
|
||||||
|
* should see the customer's own words.
|
||||||
|
*
|
||||||
|
* Rendered like the WhatsApp thread on purpose — agent right, caller left —
|
||||||
|
* because it is the same kind of thing and should not need learning twice.
|
||||||
|
*/
|
||||||
|
function turnsFrom(text) {
|
||||||
|
if (!text || typeof text !== 'string') return []
|
||||||
|
const out = []
|
||||||
|
// "Agent: ..." / "Caller: ..." with a turn continuing until the next label.
|
||||||
|
// Split on the labels rather than on newlines: a single turn wraps, and
|
||||||
|
// splitting per line would shatter one sentence into four bubbles.
|
||||||
|
const re = /^(Agent|Caller|Customer|User|Assistant):\s*/i
|
||||||
|
for (const raw of text.split('\n')) {
|
||||||
|
const line = raw.replace(/\s+$/, '')
|
||||||
|
if (!line.trim()) continue
|
||||||
|
const m = line.match(re)
|
||||||
|
if (m) {
|
||||||
|
const who = m[1].toLowerCase()
|
||||||
|
out.push({
|
||||||
|
side: (who === 'agent' || who === 'assistant') ? 'us' : 'them',
|
||||||
|
text: line.slice(m[0].length).trim(),
|
||||||
|
})
|
||||||
|
} else if (out.length) {
|
||||||
|
out[out.length - 1].text += (out[out.length - 1].text ? '\n' : '') + line.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.filter((t) => t.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallTranscript({ text, audio, name, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
const turns = turnsFrom(text)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg conv"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Call transcript"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="prose__head">
|
||||||
|
<h3>The call with {name || 'the customer'}</h3>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audio ? (
|
||||||
|
<div className="conv__audio">
|
||||||
|
{/* Listen along with the transcript. preload=none so opening the
|
||||||
|
dialog does not pull a multi-MB WAV until the user hits play. */}
|
||||||
|
<audio controls preload="none" src={audio}>
|
||||||
|
Your browser cannot play this recording.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="conv__body">
|
||||||
|
{turns.length ? turns.map((t, i) => (
|
||||||
|
<div key={i} className={'bub bub--' + t.side}>
|
||||||
|
<p>{t.text}</p>
|
||||||
|
<span>{t.side === 'us' ? 'Meera' : (name || 'the customer')}</span>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
/* A transcript that will not parse is still evidence, so it is
|
||||||
|
shown raw rather than replaced with "nothing to display". */
|
||||||
|
<pre className="conv__raw">{text}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="conv__note">
|
||||||
|
Recorded by the voice agent as the call happened. This is what was said —
|
||||||
|
the call notes in the trail are Engage's reading of it, which is a
|
||||||
|
different thing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/ChainOfThought.css
Normal file
32
src/components/ChainOfThought.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.cot { margin-top: 8px; }
|
||||||
|
.cot__finding { margin: 0 0 8px; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); max-width: 64ch; }
|
||||||
|
|
||||||
|
/* The handle is a link, in the row of actions under the entry. */
|
||||||
|
.cot__toggle {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 0;
|
||||||
|
border: 0; background: none; cursor: pointer;
|
||||||
|
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
|
||||||
|
}
|
||||||
|
.cot__toggle:hover { text-decoration: underline; }
|
||||||
|
.cot__toggle:focus-visible { outline: none; box-shadow: var(--ring); border-radius: 4px; }
|
||||||
|
.cot__brain { display: none; }
|
||||||
|
.cot__ct { font-weight: 600; }
|
||||||
|
.cot__caret { width: 11px; height: 11px; transition: transform .18s var(--ease); opacity: .7; }
|
||||||
|
.cot__caret.is-open { transform: rotate(90deg); }
|
||||||
|
|
||||||
|
/* The reasoning, as a flat card with a stepped rail. */
|
||||||
|
.cot__steps {
|
||||||
|
list-style: none; margin: 10px 0 0; padding: 14px 18px 10px;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
|
||||||
|
}
|
||||||
|
.cot__step { position: relative; display: grid; grid-template-columns: 84px 1fr; gap: 12px; padding: 8px 0 8px 16px; }
|
||||||
|
.cot__step::before {
|
||||||
|
content: ''; position: absolute; left: 2px; top: 11px; width: 9px; height: 9px;
|
||||||
|
border-radius: 50%; background: #fff; border: 2px solid var(--zk-blue); z-index: 1;
|
||||||
|
}
|
||||||
|
.cot__step:not(:last-child)::after { content: ''; position: absolute; left: 5.5px; top: 20px; bottom: -6px; width: 1.5px; background: var(--zk-blue-light); }
|
||||||
|
.cot__mark { font-size: var(--fs-3xs); font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-blue); padding-top: 2px; }
|
||||||
|
.cot__b { font-size: var(--fs-xs); line-height: 1.5; color: var(--zk-muted); overflow-wrap: anywhere; }
|
||||||
|
.cot__b b { color: var(--zk-ink); font-weight: 600; }
|
||||||
|
.cot__foot { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.cot__conf { font-size: 12px; font-weight: 700; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 10px; border-radius: var(--r-pill); }
|
||||||
87
src/components/ChainOfThought.jsx
Normal file
87
src/components/ChainOfThought.jsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import './ChainOfThought.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an AI employee reached a decision, step by step.
|
||||||
|
*
|
||||||
|
* This is the trust surface. A person signing off on — or overriding — a
|
||||||
|
* machine's decision needs the answer to one question first: WHY did it do
|
||||||
|
* that. Before this, the reasoning existed (the employees write it as workflow
|
||||||
|
* fields, so it is already in the audit trail) but the console showed only the
|
||||||
|
* conclusion. An operator either trusted it blind or went digging. Neither is
|
||||||
|
* oversight.
|
||||||
|
*
|
||||||
|
* The chain reads the way the employee's own record reads: what it REASONED and
|
||||||
|
* what it DECIDED. It is assembled from real fields already on the step — the
|
||||||
|
* employee's own words for the reasoning, the activity it committed and the
|
||||||
|
* state it moved the lead to for the decision — never narrated after the fact.
|
||||||
|
* A step may carry more than one reasoning field (attribution AND eligibility,
|
||||||
|
* say); each is its own rung.
|
||||||
|
*
|
||||||
|
* The finding — the first sentence, since the employees write the conclusion
|
||||||
|
* first — is lifted out and stays visible, so the "why" can be read without
|
||||||
|
* opening the chain.
|
||||||
|
*/
|
||||||
|
function findingOf(text) {
|
||||||
|
const m = String(text).match(/^(.{24,200}?[.!?])(\s|$)/)
|
||||||
|
return m ? m[1].trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChainOfThought({ reasoning, decided, confidence }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const primary = reasoning.length ? reasoning[0][1] : ''
|
||||||
|
const finding = findingOf(primary)
|
||||||
|
|
||||||
|
const steps = reasoning.length + 1 // each reasoning rung, plus Decided
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cot">
|
||||||
|
{finding ? <p className="cot__finding">{finding}</p> : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cot__toggle"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<svg className="cot__brain" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M6 2.5a2 2 0 0 0-2 2 2 2 0 0 0-1 3.7A2 2 0 0 0 4 11.5a2 2 0 0 0 2 2M10 2.5a2 2 0 0 1 2 2 2 2 0 0 1 1 3.7 2 2 0 0 1-1 3.3 2 2 0 0 1-2 2M8 3v10"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
See reasoning
|
||||||
|
<span className="cot__ct">· {steps} step{steps === 1 ? '' : 's'}</span>
|
||||||
|
<svg className={'cot__caret' + (open ? ' is-open' : '')} viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<ol className="cot__steps">
|
||||||
|
{reasoning.map(([label, text], i) => (
|
||||||
|
<li className="cot__step" key={label}>
|
||||||
|
<span className="cot__mark">{i === 0 ? 'Reasoned' : label}</span>
|
||||||
|
<div className="cot__b">{text}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className="cot__step">
|
||||||
|
<span className="cot__mark">Decided</span>
|
||||||
|
<div className="cot__b">
|
||||||
|
<b>{decided.what}</b>
|
||||||
|
{decided.stage ? <> — moved to <b>{decided.stage}</b></> : null}
|
||||||
|
{confidence != null ? (
|
||||||
|
<div className="cot__foot">
|
||||||
|
<span className="cot__conf">
|
||||||
|
{Math.round(confidence * (confidence <= 1 ? 100 : 1))}% confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
168
src/components/ClampText.css
Normal file
168
src/components/ClampText.css
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
.clamp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamp__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: var(--clamp-lines, 3);
|
||||||
|
line-clamp: var(--clamp-lines, 3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamp__text--short {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamp__more {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--zk-blue);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamp__more:hover {
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clamp__more svg {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
transition: transform var(--t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The headline lifted out of an AI paragraph. Slightly heavier than the body
|
||||||
|
and never clamped — it is one sentence by construction, and the point of it
|
||||||
|
is that the finding can be read without opening anything. */
|
||||||
|
.clamp__lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.clamp__lead + .clamp__text { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* The finding, lifted out of an AI paragraph. Heavier than the body and never
|
||||||
|
clamped — it is one sentence by construction, and the whole point is that it
|
||||||
|
can be read without opening anything. */
|
||||||
|
.clamp__lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.clamp__lead + .clamp__text { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ── The full text, over the page ─────────────────────────────────────────
|
||||||
|
Expanding inline was the wrong shape for the audit rail: 320px wide, one
|
||||||
|
entry per step, and a 1,500-character rationale pushed a lead's whole
|
||||||
|
history off the screen to read one sentence of it. */
|
||||||
|
.prose__scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(23, 31, 40, 0.42);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
animation: zk-fade 0.14s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose__dlg {
|
||||||
|
width: min(680px, 100%);
|
||||||
|
max-height: min(76vh, 720px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--zk-white);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
box-shadow: 0 24px 60px -12px rgba(23, 31, 40, 0.3);
|
||||||
|
outline: none;
|
||||||
|
animation: zk-rise 0.18s var(--ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px 14px 24px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose__head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose__head button {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-sm, 6px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.prose__head button svg { width: 14px; height: 14px; }
|
||||||
|
.prose__head button:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||||
|
.prose__head button:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.prose__body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 24px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose__body p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.62;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.prose__body p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* The finding, kept at the top and kept emphasised: a reader who opened this
|
||||||
|
to check one thing should not have to find it again. */
|
||||||
|
.prose__lead {
|
||||||
|
font-size: var(--fs-sm) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--zk-ink) !important;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zk-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.prose__scrim, .prose__dlg { animation: none; }
|
||||||
|
}
|
||||||
128
src/components/ClampText.jsx
Normal file
128
src/components/ClampText.jsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import './ClampText.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a block of AI prose into a finding and its working.
|
||||||
|
*
|
||||||
|
* The employees write the conclusion first and the evidence after it —
|
||||||
|
* "POSP-77341 is active and empanelled for motor." then four sentences of
|
||||||
|
* registry detail. So the first sentence already IS the summary, and lifting it
|
||||||
|
* out gives one without inventing text or asking the model for a second field
|
||||||
|
* it would have to be trusted to keep in step with the first.
|
||||||
|
*
|
||||||
|
* Returns null when the split would be useless: no sentence terminator, a first
|
||||||
|
* sentence so long it is the paragraph again, or one so short it is a fragment
|
||||||
|
* rather than a finding. Those fall back to plain folding.
|
||||||
|
*/
|
||||||
|
function splitLead(value) {
|
||||||
|
const m = value.match(/^(.{40,220}?[.!?])(\s+)(\S[\s\S]*)$/)
|
||||||
|
if (!m) return null
|
||||||
|
return { lead: m[1].trim(), rest: m[3].trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full text, in a dialog.
|
||||||
|
*
|
||||||
|
* The reasoning used to expand INLINE, and in the audit rail — 320px wide, one
|
||||||
|
* entry per step — a 1,500-character rationale pushed the rest of the lead's
|
||||||
|
* history off the screen to read one sentence of it. Nobody reads a paragraph
|
||||||
|
* in a sidebar. It opens over the page instead: the finding stays on the line,
|
||||||
|
* the working is one click away and one Escape back.
|
||||||
|
*/
|
||||||
|
function ProseDialog({ title, lead, body, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
// The page behind must not scroll under the dialog.
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
// Focus goes back to the button that opened this, not to the top of the
|
||||||
|
// document — otherwise a keyboard reader loses their place in the trail.
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title || 'Full text'}
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="prose__head">
|
||||||
|
<h3>{title || 'In full'}</h3>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="prose__body">
|
||||||
|
{/* The finding stays at the top and stays emphasised — a reader who
|
||||||
|
opened this to check one thing should not have to find it again. */}
|
||||||
|
{lead ? <p className="prose__lead">{lead}</p> : null}
|
||||||
|
{body.split(/\n{2,}/).map((para, i) => <p key={i}>{para}</p>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prose, reduced to its point.
|
||||||
|
*
|
||||||
|
* The AI employees write at length — a recommendation rationale runs past 1,500
|
||||||
|
* characters — and a screen that prints all of it is a document. Short text is
|
||||||
|
* shown as it is; anything longer shows its finding and puts the working behind
|
||||||
|
* one click.
|
||||||
|
*
|
||||||
|
* Whether to fold is decided on length, not by measuring the rendered box: a
|
||||||
|
* ref-and-measure pass would reflow on every resize to answer a question the
|
||||||
|
* string itself already answers.
|
||||||
|
*/
|
||||||
|
export default function ClampText({ text, title, lines = 3, threshold = 150 }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const value = String(text)
|
||||||
|
|
||||||
|
if (value.length <= threshold) return <p className="clamp__text clamp__text--short">{value}</p>
|
||||||
|
|
||||||
|
const split = splitLead(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="clamp">
|
||||||
|
{split
|
||||||
|
? <p className="clamp__lead">{split.lead}</p>
|
||||||
|
: <p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>}
|
||||||
|
<button type="button" className="clamp__more" onClick={() => setOpen(true)}>
|
||||||
|
{split ? 'Read the reasoning' : 'Read in full'}
|
||||||
|
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<ProseDialog
|
||||||
|
title={title}
|
||||||
|
lead={split?.lead}
|
||||||
|
body={split ? split.rest : value}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
src/components/Conversation.css
Normal file
152
src/components/Conversation.css
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
.conv { max-height: min(80vh, 760px); }
|
||||||
|
|
||||||
|
.conv__body {
|
||||||
|
/* The offset parent for the scroll-to-an-exchange in Conversation.jsx. */
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 22px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv__empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theirs left, ours right: the arrangement everybody reads without being told,
|
||||||
|
which is the whole reason to render a thread rather than a log. */
|
||||||
|
.bub {
|
||||||
|
max-width: 78%;
|
||||||
|
padding: 9px 13px 7px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bub p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Direct children only, and not the attribution row: the chip in there brings
|
||||||
|
its own spans, and CallTranscript still labels its turns with a plain one. */
|
||||||
|
.bub > span:not(.bub__by) {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Who said it, and whether they are a person. */
|
||||||
|
.bub__by {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.bub__by time {
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
/* The disc's tint and the bubble's tint are the same token, so a chip dropped
|
||||||
|
into a bubble loses its disc entirely. White puts it back. */
|
||||||
|
.bub .who__disc { background: #fff; }
|
||||||
|
|
||||||
|
.bub--them {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bub--us {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-blue-dark);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv__note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 22px 18px;
|
||||||
|
border-top: 1px solid var(--zk-line-soft);
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A transcript the parser could not split into turns is still evidence.
|
||||||
|
Shown raw rather than replaced with "nothing to display". */
|
||||||
|
.conv__raw {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The call recording, played from inside the transcript dialog. */
|
||||||
|
.conv__audio {
|
||||||
|
padding: 12px 20px 4px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.conv__audio audio {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "7 messages · 2 exchanges" — the size of the thread, on the header. */
|
||||||
|
.conv__sub {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One burst of messages. The bubbles take their left/right alignment from the
|
||||||
|
flex column they sit in, so an episode has to be one too. */
|
||||||
|
.conv__ep {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opened from the trail, the exchange you arrived from is marked — and then
|
||||||
|
the mark fades, because a permanent tint on one part of a thread reads as a
|
||||||
|
state something is in rather than as "you came from here". */
|
||||||
|
.conv__ep.is-anchor { animation: conv-found 2.4s var(--ease) forwards; }
|
||||||
|
@keyframes conv-found {
|
||||||
|
0%, 45% { box-shadow: 0 0 0 6px var(--zk-teal-tint); background: var(--zk-teal-tint); }
|
||||||
|
100% { box-shadow: 0 0 0 6px transparent; background: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THE PAUSE, ACCOUNTED FOR. Two bursts twenty minutes and four workflow steps
|
||||||
|
apart read as one continuous exchange without this — so a reply about the
|
||||||
|
premium appears to answer the payment link that followed it. */
|
||||||
|
.conv__gap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 6px 2px;
|
||||||
|
font-size: var(--fs-3xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
|
.conv__gap::before,
|
||||||
|
.conv__gap::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.conv__gap span { flex: none; }
|
||||||
173
src/components/Conversation.jsx
Normal file
173
src/components/Conversation.jsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { Fragment, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import AgentChip from './AgentChip.jsx'
|
||||||
|
import { buildThread, listOf } from '../api/thread.js'
|
||||||
|
import './Conversation.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WhatsApp thread, whole, scrollable, in the order it was said.
|
||||||
|
*
|
||||||
|
* The trail's job is what happened in what order; this is the other question —
|
||||||
|
* what was actually said — and it is one conversation even though the workflow
|
||||||
|
* touches it from several places. Theirs on the left, ours on the right, oldest
|
||||||
|
* first, newest in view when it opens.
|
||||||
|
*
|
||||||
|
* THE THREAD IS BUILT ONCE, in api/thread.js, and shared with the timeline
|
||||||
|
* entry that opens it. It used to be reassembled here from the raw fields,
|
||||||
|
* which is how the two came to disagree about how many messages there were.
|
||||||
|
*
|
||||||
|
* WHAT THE GAPS ARE. The conversation runs in bursts: the customer argues about
|
||||||
|
* the premium, then KYC and the underwriting screen run, then the payment
|
||||||
|
* request reopens the same thread twenty minutes later. Read as one flat list
|
||||||
|
* those bursts run together and the reply to a quote appears to answer the
|
||||||
|
* payment link. So the workflow steps that happened in between are named on the
|
||||||
|
* divider, and the thread reads as what it is — one conversation with pauses in
|
||||||
|
* it that somebody can account for.
|
||||||
|
*
|
||||||
|
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
|
||||||
|
* than left for someone to discover. The console can only show what the
|
||||||
|
* workflow RECORDS, so anything sent from inside a trigger node and never
|
||||||
|
* written to a field is invisible here however certainly the customer received
|
||||||
|
* it. Both payment-path messages used to be exactly that (98, 99); 100 writes
|
||||||
|
* them to `customer_answer` and they appear. One send remains absent: the
|
||||||
|
* quote, which is a registered WhatsApp template rather than text we compose.
|
||||||
|
*
|
||||||
|
* A thread that quietly omitted any of this would be worse than one that says
|
||||||
|
* which parts it holds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function at(ts) {
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (isNaN(d)) return ''
|
||||||
|
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long the thread was quiet, and what the workflow did meanwhile. */
|
||||||
|
function gapOf(prev, ep) {
|
||||||
|
const a = Date.parse(prev.to)
|
||||||
|
const b = Date.parse(ep.from)
|
||||||
|
const mins = isNaN(a) || isNaN(b) ? null : Math.round((b - a) / 60000)
|
||||||
|
const howLong = mins == null || mins < 1 ? 'Moments later'
|
||||||
|
: mins === 1 ? 'A minute later'
|
||||||
|
: mins < 60 ? `${mins} minutes later`
|
||||||
|
: mins < 120 ? 'An hour later'
|
||||||
|
: mins < 1440 ? `${Math.round(mins / 60)} hours later`
|
||||||
|
: mins < 2880 ? 'The next day'
|
||||||
|
: `${Math.round(mins / 1440)} days later`
|
||||||
|
return ep.after.length ? `${howLong} — after ${listOf(ep.after)}` : howLong
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Conversation({ rows, name, anchor, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const bodyRef = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
const { turns, episodes } = buildThread(rows)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opened from an exchange in the trail, it opens AT that exchange; opened
|
||||||
|
* from the lead header, it opens at the newest message, which is where a
|
||||||
|
* chat is read from.
|
||||||
|
*
|
||||||
|
* scrollTop rather than scrollIntoView: this element is inside a dialog over
|
||||||
|
* a frozen page, and scrollIntoView walks every scrollable ancestor to get
|
||||||
|
* there. `.conv__body` is the offset parent (see the CSS), so the sum is
|
||||||
|
* exactly the distance wanted.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const body = bodyRef.current
|
||||||
|
if (!body) return
|
||||||
|
const target = anchor ? body.querySelector(`[data-ep="${anchor}"]`) : null
|
||||||
|
body.scrollTop = target ? Math.max(0, target.offsetTop - 12) : body.scrollHeight
|
||||||
|
}, [anchor])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg conv"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="WhatsApp conversation"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="prose__head">
|
||||||
|
<div>
|
||||||
|
<h3>WhatsApp with {name || 'the customer'}</h3>
|
||||||
|
{turns.length ? (
|
||||||
|
<p className="conv__sub">
|
||||||
|
{turns.length} message{turns.length === 1 ? '' : 's'}
|
||||||
|
{episodes.length > 1 ? ` · ${episodes.length} exchanges` : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="conv__body" ref={bodyRef}>
|
||||||
|
{episodes.length ? episodes.map((ep, i) => (
|
||||||
|
<Fragment key={ep.id}>
|
||||||
|
{i > 0 ? (
|
||||||
|
<div className="conv__gap">
|
||||||
|
<span>{gapOf(episodes[i - 1], ep)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={'conv__ep' + (anchor === ep.id ? ' is-anchor' : '')}
|
||||||
|
data-ep={ep.id}
|
||||||
|
>
|
||||||
|
{ep.turns.map((t) => (
|
||||||
|
<div key={t.key} className={'bub bub--' + t.side}>
|
||||||
|
<p>{t.text}</p>
|
||||||
|
<span className="bub__by">
|
||||||
|
{/* Named AND marked, both directions. It stamped only the
|
||||||
|
time, so a thread read back months later could not say
|
||||||
|
which of our replies a person wrote and which Engage
|
||||||
|
did — and a bare "Engage" reads as a colleague. The
|
||||||
|
sparkle disc and the AI tag are how the rest of this
|
||||||
|
console says "machine"; a person keeps their initials. */}
|
||||||
|
{t.side === 'them'
|
||||||
|
? <AgentChip name={name || 'the customer'} size="xs" />
|
||||||
|
: <AgentChip agentKey={t.agentKey} name={t.by} size="xs" />}
|
||||||
|
<time dateTime={t.at}>{at(t.at)}</time>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)) : (
|
||||||
|
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="conv__note">
|
||||||
|
Shows the customer'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,
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/components/DeskChart.jsx
Normal file
136
src/components/DeskChart.jsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* A chart drawn from the desk's own answer.
|
||||||
|
*
|
||||||
|
* The platform has no chart widget and the desk holds no widget tools — its
|
||||||
|
* fourteen tools are plain API reads, so everything it says comes back as text.
|
||||||
|
* What it DOES do, reliably, when asked anything countable, is answer with a
|
||||||
|
* markdown table. So the chart here is not a second source: it is that table,
|
||||||
|
* drawn. Every bar can be checked against the row beside it.
|
||||||
|
*
|
||||||
|
* Inline SVG rather than a charting library: two shapes, one scale, and a
|
||||||
|
* library would be more bytes than the console's whole chart vocabulary.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A cell like "₹3.96 L", "69%", "1,204" or "12" as a number, or null. */
|
||||||
|
function toNumber(raw) {
|
||||||
|
const s = String(raw ?? '').trim()
|
||||||
|
if (!s) return null
|
||||||
|
// Indian short forms carry their own multiplier and would otherwise sort as 3.96 < 12.
|
||||||
|
const cr = /([\d.,]+)\s*Cr/i.exec(s)
|
||||||
|
if (cr) return parseFloat(cr[1].replace(/,/g, '')) * 1e7
|
||||||
|
const lakh = /([\d.,]+)\s*L\b/i.exec(s)
|
||||||
|
if (lakh) return parseFloat(lakh[1].replace(/,/g, '')) * 1e5
|
||||||
|
const plain = s.replace(/[₹$,%\s]/g, '').replace(/,/g, '')
|
||||||
|
const n = Number(plain)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can this table be drawn? It needs a label column, one numeric column, and at
|
||||||
|
* least two rows — one bar is a fact, not a chart.
|
||||||
|
*/
|
||||||
|
/* A totals row is the sum of the others, so charting it draws one bar as long
|
||||||
|
as all the rest put together, or a slice that swallows half the ring. The
|
||||||
|
desk adds one to most count tables. */
|
||||||
|
const isTotalRow = (label) => /^\**\s*(total|sum|overall|all)\b/i.test(String(label ?? '').trim())
|
||||||
|
|
||||||
|
export function chartableFrom(head, allRows) {
|
||||||
|
const rows = allRows.filter((r) => !isTotalRow(r[0]))
|
||||||
|
if (!head?.length || rows.length < 2) return null
|
||||||
|
// The rightmost column that parses as a number on most rows is the measure.
|
||||||
|
for (let c = head.length - 1; c >= 1; c--) {
|
||||||
|
const vals = rows.map((r) => toNumber(r[c]))
|
||||||
|
const good = vals.filter((v) => v !== null && v >= 0)
|
||||||
|
if (good.length >= Math.max(2, Math.ceil(rows.length * 0.8))) {
|
||||||
|
const points = rows
|
||||||
|
.map((r, i) => ({ label: String(r[0] ?? '').trim(), value: vals[i], raw: String(r[c] ?? '') }))
|
||||||
|
.filter((p) => p.label && p.value !== null)
|
||||||
|
if (points.length < 2) return null
|
||||||
|
if (points.every((p) => p.value === 0)) return null
|
||||||
|
return { measure: head[c], points }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The console's colour rule has meaning attached to it, so a chart of arbitrary
|
||||||
|
categories cannot borrow it. This is a neutral sequence in the same family —
|
||||||
|
ordered, so the biggest slice is always the deepest. */
|
||||||
|
const SERIES = ['#1F55A6', '#4E7FC4', '#7CA0D4', '#137A66', '#4FA08D', '#A86A0B', '#D99A2B', '#7A8699']
|
||||||
|
|
||||||
|
function Bars({ points, measure }) {
|
||||||
|
const max = Math.max(...points.map((p) => p.value))
|
||||||
|
return (
|
||||||
|
<div className="dchart__bars">
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<div className="dchart__row" key={p.label + i}>
|
||||||
|
<span className="dchart__lab" title={p.label}>{p.label}</span>
|
||||||
|
<span className="dchart__track">
|
||||||
|
<span className="dchart__fill" style={{ width: Math.max(1.5, (p.value / max) * 100) + '%', background: SERIES[i % SERIES.length] }} />
|
||||||
|
</span>
|
||||||
|
<span className="dchart__val">{p.raw}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="dchart__cap">{measure}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Donut({ points, measure }) {
|
||||||
|
const total = points.reduce((t, p) => t + p.value, 0)
|
||||||
|
if (!total) return <Bars points={points} measure={measure} />
|
||||||
|
const R = 62, C = 2 * Math.PI * R
|
||||||
|
let offset = 0
|
||||||
|
return (
|
||||||
|
<div className="dchart__donut">
|
||||||
|
<svg viewBox="0 0 160 160" role="img" aria-label={`${measure} by share`}>
|
||||||
|
<g transform="rotate(-90 80 80)">
|
||||||
|
{points.map((p, i) => {
|
||||||
|
const frac = p.value / total
|
||||||
|
const dash = `${frac * C} ${C - frac * C}`
|
||||||
|
const el = (
|
||||||
|
<circle
|
||||||
|
key={p.label + i} cx="80" cy="80" r={R} fill="none"
|
||||||
|
stroke={SERIES[i % SERIES.length]} strokeWidth="26"
|
||||||
|
strokeDasharray={dash} strokeDashoffset={-offset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
offset += frac * C
|
||||||
|
return el
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<text x="80" y="76" textAnchor="middle" className="dchart__total">{total.toLocaleString('en-IN')}</text>
|
||||||
|
<text x="80" y="94" textAnchor="middle" className="dchart__totlab">total</text>
|
||||||
|
</svg>
|
||||||
|
<ul className="dchart__key">
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<li key={p.label + i}>
|
||||||
|
<span className="dchart__sw" style={{ background: SERIES[i % SERIES.length] }} />
|
||||||
|
<span className="dchart__keylab">{p.label}</span>
|
||||||
|
<span className="dchart__keyval">{p.raw}</span>
|
||||||
|
<span className="dchart__keypct">{Math.round((p.value / total) * 100)}%</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeskChart({ data, kind, onKind }) {
|
||||||
|
const { points, measure } = data
|
||||||
|
// A share only means something when the parts make a whole. Counts by stage
|
||||||
|
// do; a column of average premiums does not, and a donut of averages is a
|
||||||
|
// lie told in colour — so the toggle offers it, and bars stay the default.
|
||||||
|
return (
|
||||||
|
<figure className="dchart">
|
||||||
|
<figcaption className="dchart__head">
|
||||||
|
<span>{measure} · {points.length} rows</span>
|
||||||
|
<span className="dchart__toggle" role="group" aria-label="Chart type">
|
||||||
|
<button type="button" className={kind === 'bar' ? 'is-on' : ''} onClick={() => onKind('bar')}>Bars</button>
|
||||||
|
<button type="button" className={kind === 'donut' ? 'is-on' : ''} onClick={() => onKind('donut')}>Share</button>
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
{kind === 'donut' ? <Donut points={points} measure={measure} /> : <Bars points={points} measure={measure} />}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -61,20 +61,40 @@ export default function FileField({ field, value, instanceId, activityUid, onCha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const name = files.length ? (files[0].original_name || files[0].uuid) : ''
|
||||||
|
const open = () => inputRef.current?.click()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ff">
|
<div className="ff">
|
||||||
<input
|
<input
|
||||||
ref={inputRef} type="file" className="ff__input"
|
ref={inputRef} type="file" className="ff__input"
|
||||||
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
|
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="ff__btn" disabled={Boolean(busy)}
|
|
||||||
onClick={() => inputRef.current?.click()}>
|
|
||||||
{busy || (files.length ? 'Replace file' : isOcr ? 'Upload — we read it for you' : 'Upload')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{files.length ? (
|
{/* Two states, not one button that changes its words. Nothing uploaded is
|
||||||
<div className="ff__got">{files[0].original_name || files[0].uuid}</div>
|
an invitation — a dashed target that says what it takes. Something
|
||||||
) : null}
|
uploaded is a fact — the file, named, with a quiet way to swap it. The
|
||||||
|
old single pink slab stayed the same size and weight either way, which
|
||||||
|
made a finished upload shout as loudly as an empty one. */}
|
||||||
|
{files.length && !busy ? (
|
||||||
|
<div className="ff__file">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M9 1.8H4.4a1 1 0 0 0-1 1v10.4a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V5.4Zm0 0V5.4h3.6"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="ff__name" title={name}>{name}</span>
|
||||||
|
<button type="button" className="ff__replace" onClick={open}>Replace</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="ff__drop" disabled={Boolean(busy)} onClick={open}>
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M10 13.5V4.2m0 0L6.6 7.6M10 4.2l3.4 3.4M3.5 13v2a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5v-2"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<strong>{busy || (isOcr ? 'Upload — we read it for you' : 'Upload document')}</strong>
|
||||||
|
{busy ? null : <span>PDF, PNG or JPG</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{extracted ? (
|
{extracted ? (
|
||||||
<div className="ff__read">
|
<div className="ff__read">
|
||||||
|
|||||||
65
src/components/LeadFileDialog.css
Normal file
65
src/components/LeadFileDialog.css
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/* Wide and tall: this is the whole record, and squeezing thirteen groups into
|
||||||
|
a prose-width dialog would only move the crowding rather than remove it. */
|
||||||
|
.lfd { width: min(1180px, 100%); max-height: min(90vh, 940px); }
|
||||||
|
|
||||||
|
.lfd__sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lfd__tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid var(--zk-line-soft);
|
||||||
|
background: var(--zk-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ctrl-F was how people read the open version. Behind a click that stops
|
||||||
|
working, so the drawer brings its own. */
|
||||||
|
.lfd__find {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
background: var(--zk-white);
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lfd__find:focus-within { border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue); }
|
||||||
|
.lfd__find svg { width: 14px; height: 14px; flex: none; color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.lfd__find input {
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lfd__find input::-webkit-search-cancel-button { cursor: pointer; }
|
||||||
|
|
||||||
|
.lfd__count {
|
||||||
|
flex: none;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lfd__body { overflow-y: auto; }
|
||||||
|
.lfd__body .file360 { padding: 18px 22px 24px; }
|
||||||
|
|
||||||
|
.lfd__none {
|
||||||
|
margin: 0;
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--zk-grey);
|
||||||
|
}
|
||||||
166
src/components/LeadFileDialog.jsx
Normal file
166
src/components/LeadFileDialog.jsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import ClampText from './ClampText.jsx'
|
||||||
|
import './LeadFileDialog.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything on the lead, on demand.
|
||||||
|
*
|
||||||
|
* This used to sit open on the page. Fifty-seven fields across thirteen groups
|
||||||
|
* pushed the audit trail — the part anyone actually reads — a screen and a half
|
||||||
|
* down, and most of those fields are reference material: nobody opens a lead to
|
||||||
|
* find out its source channel. The page now carries the handful that matter for
|
||||||
|
* where the lead IS, and the complete record lives one click away in here.
|
||||||
|
*
|
||||||
|
* NOTHING IS LOST AND NOTHING IS BURIED. The groups, the order and the values
|
||||||
|
* are the same ones the page rendered before — this receives them already
|
||||||
|
* computed, so the two can never disagree. The trigger says how many fields are
|
||||||
|
* behind it, because a control that hides fifty-seven things should say so.
|
||||||
|
*
|
||||||
|
* The filter is the point of doing this as a drawer rather than a collapse.
|
||||||
|
* Ctrl-F is what people were using on the open version; with the record behind
|
||||||
|
* a click that stops working, so the drawer brings its own. It matches the
|
||||||
|
* label AND the value, so "KA01" finds the vehicle and "POSP" finds the
|
||||||
|
* partner, and it says how many groups it hid rather than silently emptying.
|
||||||
|
*/
|
||||||
|
export default function LeadFileDialog({ groups, labels, label, money, fileHref, longAt, onClose }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const total = groups.reduce((n, [, shown]) => n + shown.length, 0)
|
||||||
|
|
||||||
|
const shownGroups = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
if (!term) return groups
|
||||||
|
return groups
|
||||||
|
.map(([title, rows]) => [
|
||||||
|
title,
|
||||||
|
rows.filter(([k, v]) => {
|
||||||
|
const l = String(labels[k] ?? label(k)).toLowerCase()
|
||||||
|
return l.includes(term) || String(v).toLowerCase().includes(term)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.filter(([, rows]) => rows.length)
|
||||||
|
}, [groups, q, labels, label])
|
||||||
|
|
||||||
|
const found = shownGroups.reduce((n, [, rows]) => n + rows.length, 0)
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg lfd"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Lead file"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="prose__head">
|
||||||
|
<div>
|
||||||
|
<h3>Lead file</h3>
|
||||||
|
<p className="lfd__sub">
|
||||||
|
Everything captured, grouped by the step that captured it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lfd__tools">
|
||||||
|
<label className="lfd__find">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<circle cx="7" cy="7" r="4.2" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M10.2 10.2 13.5 13.5" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Find a field or a value…"
|
||||||
|
aria-label="Filter the lead file"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="lfd__count">
|
||||||
|
{q.trim() ? `${found} of ${total} fields` : `${total} fields`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lfd__body">
|
||||||
|
{shownGroups.length ? (
|
||||||
|
<div className="file360">
|
||||||
|
{shownGroups.map(([title, shown]) => {
|
||||||
|
const facts = shown.filter(([, v, files]) => files.length || v.length <= longAt)
|
||||||
|
const notes = shown.filter(([, v, files]) => !files.length && v.length > longAt)
|
||||||
|
return (
|
||||||
|
<section className="fgroup" key={title}>
|
||||||
|
<h3>{title}<span>{shown.length}</span></h3>
|
||||||
|
|
||||||
|
{facts.length ? (
|
||||||
|
<dl className="props">
|
||||||
|
{facts.map(([k, v, files]) => (
|
||||||
|
<div className="prop" key={k}>
|
||||||
|
<dt>{labels[k] ?? label(k)}</dt>
|
||||||
|
{files.length ? (
|
||||||
|
<dd className="prop__files">
|
||||||
|
{files.map((f) => (
|
||||||
|
<a key={f.uuid} className="prop__file" href={fileHref(f)}
|
||||||
|
target="_blank" rel="noreferrer">
|
||||||
|
{f.original_name || 'Document'}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
) : (
|
||||||
|
<dd className={money.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{notes.length ? (
|
||||||
|
<div className="fnotes">
|
||||||
|
{notes.map(([k, v]) => (
|
||||||
|
<div className="fnote" key={k}>
|
||||||
|
<span className="fnote__l">{labels[k] ?? label(k)}</span>
|
||||||
|
<ClampText text={v} title={labels[k] ?? label(k)} lines={2} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="lfd__none">
|
||||||
|
Nothing on this lead matches “{q.trim()}”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/NewLeadDialog.css
Normal file
17
src/components/NewLeadDialog.css
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/* Wider than the prose dialogs — this holds a four-column form grid, not a
|
||||||
|
paragraph — and allowed to grow taller, because a form that scrolls its own
|
||||||
|
Submit off the bottom is worse than a tall dialog. */
|
||||||
|
.nld { width: min(880px, 100%); max-height: min(88vh, 860px); }
|
||||||
|
|
||||||
|
.nld__sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nld__body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 24px 22px;
|
||||||
|
}
|
||||||
112
src/components/NewLeadDialog.jsx
Normal file
112
src/components/NewLeadDialog.jsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import ActivityForm from './ActivityForm.jsx'
|
||||||
|
import { ENTRY } from '../api/config.js'
|
||||||
|
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||||
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import './NewLeadDialog.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filing a lead, over the page rather than on one of its own.
|
||||||
|
*
|
||||||
|
* This was a route. Pressing New lead left whatever you were reading — a queue
|
||||||
|
* you were working through, a lead you were half way down — filed the form,
|
||||||
|
* and landed you on the new lead's file, three navigations from where you
|
||||||
|
* started. Filing a lead is not a destination; it is four fields and a Submit,
|
||||||
|
* and it belongs over the work rather than instead of it. Cancel now puts you
|
||||||
|
* back exactly where you were, because you never left.
|
||||||
|
*
|
||||||
|
* The door chooser stays: the entry activities have different permissions and
|
||||||
|
* the bank one lands the instance further along. With one door — which is
|
||||||
|
* today — it is skipped rather than shown as a list of one.
|
||||||
|
*
|
||||||
|
* Modal furniture is the same as the conversation and reasoning dialogs:
|
||||||
|
* scrim click, Escape, focus trapped in and restored out, page scroll frozen.
|
||||||
|
* A fourth hand-rolled variant of that would be a fourth chance to get the
|
||||||
|
* keyboard wrong.
|
||||||
|
*/
|
||||||
|
export default function NewLeadDialog({ onClose }) {
|
||||||
|
const { user } = useZino()
|
||||||
|
const doors = entryDoorsFor(rolesOf(user), ENTRY)
|
||||||
|
const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const ref = useRef(null)
|
||||||
|
const restore = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restore.current = document.activeElement
|
||||||
|
ref.current?.focus()
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="prose__dlg nld"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="New lead"
|
||||||
|
tabIndex={-1}
|
||||||
|
ref={ref}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="prose__head">
|
||||||
|
<div>
|
||||||
|
<h3>{entry ? entry.label : 'New lead'}</h3>
|
||||||
|
<p className="nld__sub">
|
||||||
|
{entry
|
||||||
|
? entry.note
|
||||||
|
: 'Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close">
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="nld__body">
|
||||||
|
{!doors.length ? (
|
||||||
|
<p className="empty">Your role does not create leads.</p>
|
||||||
|
) : !entry ? (
|
||||||
|
<div className="doors">
|
||||||
|
{doors.map((e) => (
|
||||||
|
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
||||||
|
<strong>{e.label}</strong>
|
||||||
|
<span className="chip">{e.channel}</span>
|
||||||
|
<p>{e.note}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ActivityForm
|
||||||
|
activityUid={entry.uid}
|
||||||
|
onCancel={onClose}
|
||||||
|
onDone={(res) => {
|
||||||
|
const id = res?.instance_id || res?.data?.instance_id
|
||||||
|
// Carry the workflow's own message across the navigation —
|
||||||
|
// "Lead received — Intake is qualifying it" is the answer to
|
||||||
|
// "did that work?", and it is only said once.
|
||||||
|
onClose()
|
||||||
|
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/StageRail.css
Normal file
36
src/components/StageRail.css
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/* The journey, across the top of the lead. Eight steps, one row. */
|
||||||
|
.rail {
|
||||||
|
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
|
||||||
|
margin: 0 0 18px; padding: 20px 28px 18px;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-md); background: #fff;
|
||||||
|
}
|
||||||
|
.rail ol { display: flex; align-items: flex-start; list-style: none; margin: 0; padding: 0; flex: 1; min-width: 0; }
|
||||||
|
.rail__s { position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px; flex: 1; min-width: 0; text-align: center; }
|
||||||
|
|
||||||
|
.rail__dot {
|
||||||
|
width: 16px; height: 16px; border-radius: 50%; flex: none; z-index: 1;
|
||||||
|
background: var(--zk-navy); border: 3px solid #fff; box-shadow: 0 0 0 1px var(--zk-navy);
|
||||||
|
transition: background var(--t-fast), box-shadow var(--t-fast);
|
||||||
|
}
|
||||||
|
.rail__l { font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||||
|
|
||||||
|
.rail__bar { position: absolute; top: 7px; left: 50%; width: 100%; height: 2px; background: var(--zk-line); }
|
||||||
|
.rail__bar.is-done { background: var(--zk-navy); }
|
||||||
|
|
||||||
|
.is-todo .rail__dot { background: #fff; box-shadow: 0 0 0 1px var(--zk-line); }
|
||||||
|
.is-todo .rail__l { color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.is-here .rail__l { font-weight: 700; color: var(--zk-navy); }
|
||||||
|
.is-here.tone-teal .rail__dot { background: var(--zk-teal); box-shadow: 0 0 0 1px var(--zk-teal), 0 0 0 5px var(--zk-teal-tint); }
|
||||||
|
.is-here.tone-blue .rail__dot { background: var(--zk-blue); box-shadow: 0 0 0 1px var(--zk-blue), 0 0 0 5px var(--zk-tint-blue); }
|
||||||
|
.is-here.tone-blue .rail__l { color: var(--zk-blue); }
|
||||||
|
.is-here.tone-navy .rail__dot { box-shadow: 0 0 0 1px var(--zk-navy), 0 0 0 5px var(--zk-tint-blue); }
|
||||||
|
|
||||||
|
.rail__off {
|
||||||
|
flex: none; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-amber);
|
||||||
|
background: var(--zk-amber-tint); border: 1px solid var(--zk-amber-line);
|
||||||
|
padding: 6px 14px; border-radius: var(--r-pill);
|
||||||
|
}
|
||||||
|
.rail__off.is-closed { color: var(--zk-muted); background: var(--zk-line-soft); border-color: var(--zk-line); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) { .rail__l { display: none; } .rail { padding: 14px 18px; } }
|
||||||
84
src/components/StageRail.jsx
Normal file
84
src/components/StageRail.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { STAGES } from '../api/config.js'
|
||||||
|
import './StageRail.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this lead has been, where it is, and what is left.
|
||||||
|
*
|
||||||
|
* The page said the stage in one chip in the corner and nothing about the
|
||||||
|
* journey, so "how far along is this?" — the first question anyone asks about
|
||||||
|
* a lead — could only be answered by knowing the workflow by heart.
|
||||||
|
*
|
||||||
|
* PASSED IS READ FROM THE AUDIT, NOT ASSUMED FROM THE ORDER. A lead can skip
|
||||||
|
* (documents uploaded from Qualified carried one straight past Contacted) and
|
||||||
|
* can go backwards (a callback returns it to Awaiting Contact). Colouring
|
||||||
|
* everything left of the current stage would have claimed steps that never
|
||||||
|
* happened. The trail knows which states this lead actually entered; this
|
||||||
|
* reads that.
|
||||||
|
*
|
||||||
|
* The side states — Parked, Referred, Lost, Declined — are deliberately NOT on
|
||||||
|
* the rail. They are departures from the path rather than points along it, and
|
||||||
|
* putting them in a row would suggest every lead passes through. When the lead
|
||||||
|
* is in one, the rail says so at the end instead.
|
||||||
|
*/
|
||||||
|
const PATH = [
|
||||||
|
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
||||||
|
'zk-state-quoted', 'zk-state-payment', 'zk-state-issued', 'zk-state-onboarded',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Short enough to fit eight across; the queue names are written for a sidebar. */
|
||||||
|
const SHORT = {
|
||||||
|
'zk-state-new': 'Filed',
|
||||||
|
'zk-state-qualified': 'Called',
|
||||||
|
'zk-state-contacted': 'Contacted',
|
||||||
|
'zk-state-docs': 'Documents',
|
||||||
|
'zk-state-quoted': 'Quoted',
|
||||||
|
'zk-state-payment': 'Payment',
|
||||||
|
'zk-state-issued': 'Issued',
|
||||||
|
'zk-state-onboarded': 'Onboarded',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StageRail({ audit, currentName }) {
|
||||||
|
const current = STAGES.find((s) => s.name === currentName)
|
||||||
|
const visited = new Set(
|
||||||
|
(audit || [])
|
||||||
|
.map((r) => r.execution_state)
|
||||||
|
.filter((v) => PATH.includes(v)),
|
||||||
|
)
|
||||||
|
if (current) visited.add(current.uid)
|
||||||
|
|
||||||
|
const currentIndex = PATH.indexOf(current?.uid)
|
||||||
|
// The current step takes its holder's colour: teal when the lead is closed
|
||||||
|
// (issued, onboarded), blue while the AI has it, navy otherwise.
|
||||||
|
const hereTone = current?.kind === 'end' ? 'teal' : current?.kind === 'auto' ? 'blue' : 'navy'
|
||||||
|
// A lead sitting somewhere off the path — Parked, Referred, Lost, Declined.
|
||||||
|
const aside = current && currentIndex === -1 ? current : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rail" aria-label="Progress">
|
||||||
|
<ol>
|
||||||
|
{PATH.map((uid, i) => {
|
||||||
|
const been = visited.has(uid)
|
||||||
|
const here = uid === current?.uid
|
||||||
|
// Ahead of a lead that has left the path is unknowable — a referred
|
||||||
|
// risk may never see payment — so nothing after it is called "to do".
|
||||||
|
const state = here ? 'here' : been ? 'done' : 'todo'
|
||||||
|
return (
|
||||||
|
<li key={uid} className={'rail__s is-' + state + (here ? ' tone-' + hereTone : '')}>
|
||||||
|
<span className="rail__dot" aria-hidden="true" />
|
||||||
|
<span className="rail__l">{SHORT[uid]}</span>
|
||||||
|
{i < PATH.length - 1 ? (
|
||||||
|
<span className={'rail__bar' + (visited.has(PATH[i + 1]) ? ' is-done' : '')} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{aside ? (
|
||||||
|
<div className={'rail__off' + (aside.kind === 'end' ? ' is-closed' : '')}>
|
||||||
|
{aside.kind === 'end' ? 'Ended in' : 'Now in'} <strong>{aside.name}</strong>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,39 +1,134 @@
|
|||||||
.tl { list-style: none; margin: 0; padding: 0 0 0 4px; }
|
.tl { list-style: none; margin: 0; padding: 0; }
|
||||||
.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; }
|
.tl__loading, .tl__err { color: var(--zk-muted); font-size: var(--fs-xs); padding: 14px 2px; }
|
||||||
.tl__err { color: var(--zk-danger); }
|
.tl__err { color: var(--zk-danger-ink); }
|
||||||
|
|
||||||
.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); }
|
/* One event: a 36px avatar gutter, then the body. A thin connector runs down
|
||||||
.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; }
|
the gutter between avatars. */
|
||||||
|
.tl__ev { display: grid; grid-template-columns: 36px 1fr; gap: 14px; position: relative; padding-bottom: 22px; }
|
||||||
|
.tl__ev::before { content: ''; position: absolute; left: 17px; top: 36px; bottom: 0; width: 2px; background: var(--zk-line-soft); }
|
||||||
|
.tl__ev:last-child::before { display: none; }
|
||||||
|
.tl__ev:last-child { padding-bottom: 6px; }
|
||||||
|
.tl__ev--sys { opacity: .75; }
|
||||||
|
|
||||||
.tl__dot {
|
.tl__gutter { width: 36px; height: 36px; display: grid; place-items: center; position: relative; z-index: 1; }
|
||||||
position: absolute; left: -7px; top: 3px; width: 12px; height: 12px;
|
.tl__gutter .who--btn { padding: 0; border: 0; }
|
||||||
border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey);
|
.tl__disc {
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
font-weight: 700; font-size: 13px;
|
||||||
|
background: var(--zk-line-soft); color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
/* Machine work is blue, people are amber, the platform itself is grey — so a
|
.tl__disc--sys { font-size: 18px; color: var(--zk-grey); }
|
||||||
file can be read at a glance for how much of it was done by hand. */
|
|
||||||
.tl__item--ai .tl__dot { border-color: var(--zk-blue); background: var(--zk-blue); }
|
|
||||||
.tl__item--human .tl__dot { border-color: #b5761f; background: var(--zk-white); }
|
|
||||||
.tl__item--sys .tl__dot { border-color: var(--zk-line); background: var(--zk-line); }
|
|
||||||
|
|
||||||
.tl__body { display: flex; flex-direction: column; gap: 3px; }
|
.tl__body { min-width: 0; display: flex; flex-direction: column; }
|
||||||
.tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; }
|
.tl__line1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; min-height: 36px; }
|
||||||
.tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); }
|
.tl__what { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
|
||||||
.tl__who {
|
.tl__what--wa { display: inline-flex; align-items: center; gap: 6px; color: var(--zk-teal); }
|
||||||
font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap;
|
.tl__what--wa svg { width: 15px; height: 15px; }
|
||||||
|
.tl__ev--ai.is-live .tl__what { color: var(--zk-blue); }
|
||||||
|
.tl__to { font-size: var(--fs-xs); color: var(--zk-muted); padding: 2px 10px; border-radius: var(--r-pill); background: var(--zk-line-soft); }
|
||||||
|
.tl__to::before { content: '→ '; color: var(--zk-grey); }
|
||||||
|
.tl__n { font-size: var(--fs-2xs); color: var(--zk-muted); padding: 1px 8px; border-radius: var(--r-pill); background: var(--zk-line-soft); font-variant-numeric: tabular-nums; }
|
||||||
|
.tl__when { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
|
||||||
|
|
||||||
|
.tl__by { font-size: 14.5px; color: var(--zk-muted); margin-top: -2px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.tl__byname { font-weight: 600; color: var(--zk-ink); }
|
||||||
|
.tl__tag { font-size: 12px; font-weight: 700; letter-spacing: .04em; color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 6px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* What was said — a quote block. */
|
||||||
|
.tl__say { margin-top: 8px; padding: 10px 14px; border-left: 2px solid var(--zk-line); border-radius: 0 var(--r-sm) var(--r-sm) 0; background: var(--zk-tint); max-width: 72ch; }
|
||||||
|
.tl__saylabel { display: block; font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); margin-bottom: 6px; }
|
||||||
|
.tl__say p { margin: 0; font-size: 15.5px; line-height: 1.5; color: var(--zk-ink); }
|
||||||
|
|
||||||
|
/* Figures the step wrote — money, as chips. */
|
||||||
|
.tl__figs { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
||||||
|
.tl__figs span { display: inline-flex; gap: 8px; align-items: baseline; padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: 14.5px; font-variant-numeric: tabular-nums; }
|
||||||
|
.tl__figs em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; }
|
||||||
|
|
||||||
|
/* Documents received, as chips. */
|
||||||
|
.tl__docs { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14.5px; }
|
||||||
|
.tl__docshead { color: var(--zk-grey); }
|
||||||
|
.tl__doclist { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.tl__doc {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
|
||||||
|
font-size: 14.5px; color: var(--zk-ink); text-decoration: none;
|
||||||
}
|
}
|
||||||
.tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
|
.tl__doc svg { width: 13px; height: 13px; color: var(--zk-blue); }
|
||||||
.tl__who--human { background: #f7efe2; color: #7a5a12; }
|
.tl__doc:hover { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
|
||||||
.tl__who--sys { background: var(--zk-tint); color: var(--zk-muted); }
|
|
||||||
.tl__stage { font-size: .78rem; color: var(--zk-blue); }
|
|
||||||
.tl__when { font-size: .72rem; color: var(--zk-grey); }
|
|
||||||
|
|
||||||
.tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); }
|
/* The action row: links, in blue. */
|
||||||
.tl__saylabel {
|
.tl__acts { display: flex; gap: 18px; margin-top: 8px; font-size: var(--fs-xs); align-items: baseline; flex-wrap: wrap; }
|
||||||
display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase;
|
.tl__link { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; padding: 0; cursor: pointer; }
|
||||||
color: var(--zk-grey); margin-bottom: 2px;
|
.tl__link:hover { text-decoration: underline; }
|
||||||
|
.tl__wrote > summary { cursor: pointer; list-style: none; font-size: var(--fs-xs); font-weight: 500; color: var(--zk-muted); }
|
||||||
|
.tl__wrote > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tl__wrote > summary::after { content: ' ▾'; color: var(--zk-grey); }
|
||||||
|
.tl__wrote[open] > summary::after { content: ' ▴'; }
|
||||||
|
.tl__wrote dl { margin: 8px 0 0; padding: 10px 12px; border: 1px solid var(--zk-line-soft); border-radius: var(--r-sm); background: var(--zk-tint); display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
|
||||||
|
.tl__wrote dt { flex: none; width: 42%; font-size: var(--fs-2xs); color: var(--zk-grey); overflow-wrap: anywhere; }
|
||||||
|
.tl__wrote dd { margin: 0; flex: 1; min-width: 0; font-size: var(--fs-2xs); color: var(--zk-ink); overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
/* System updates, folded behind a pill. */
|
||||||
|
.tl__toggle {
|
||||||
|
display: inline-block; margin: 6px 0 14px; font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
|
||||||
|
padding: 6px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); background: #fff; cursor: pointer;
|
||||||
}
|
}
|
||||||
.tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; }
|
.tl__toggle:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||||
|
|
||||||
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; }
|
/* ── THE WHATSAPP EXCHANGE, COLLAPSED ───────────────────────────────────────
|
||||||
.tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; }
|
One entry for a burst of messages: who spoke, how many there were, the last
|
||||||
.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; }
|
two of them, and the whole thread behind a click. The card IS the control —
|
||||||
|
clicking anywhere in it opens the conversation, which is how every timeline
|
||||||
|
of this shape behaves and what stops the entry needing its own link row. */
|
||||||
|
.tl__disc--wa { background: var(--zk-teal-tint); color: var(--zk-teal); }
|
||||||
|
.tl__disc--wa svg { width: 17px; height: 17px; }
|
||||||
|
|
||||||
|
/* "The same thread, picked up after Verify KYC and Underwriting Screen." */
|
||||||
|
.tl__wacont { margin: 4px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.tl__wa {
|
||||||
|
display: block; width: 100%; max-width: 72ch; margin-top: 10px;
|
||||||
|
padding: 12px 14px 10px; text-align: left; cursor: pointer;
|
||||||
|
font: inherit; color: inherit; background: #fff;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-md);
|
||||||
|
transition: border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.tl__wa:hover { border-color: var(--zk-blue-light); }
|
||||||
|
.tl__wa:hover .tl__wafoot { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* The participants, as chips. A person's disc carries their initials, an
|
||||||
|
agent's carries the sparkle and an AI tag — which is the whole point of the
|
||||||
|
line: one of these is not a colleague. */
|
||||||
|
.tl__wavoices { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 1px; }
|
||||||
|
.tl__wavoices .who--btn { margin-left: -2px; }
|
||||||
|
|
||||||
|
.tl__wapre { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.tl__wamsg {
|
||||||
|
max-width: 88%; padding: 7px 11px 6px;
|
||||||
|
border-radius: 12px; background: var(--zk-line-soft);
|
||||||
|
}
|
||||||
|
.tl__wamsg-who { display: block; margin-bottom: 3px; }
|
||||||
|
/* Same reason as in the dialog: the disc's tint and the bubble's are one
|
||||||
|
token, so the disc vanishes into it without this. */
|
||||||
|
.tl__wamsg .who__disc { background: #fff; }
|
||||||
|
/* Two lines of each message, no more. A preview that grows with whatever
|
||||||
|
somebody typed has stopped being a preview. */
|
||||||
|
.tl__wamsg-txt {
|
||||||
|
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14.5px; line-height: 1.45; color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
/* Theirs left, ours right — the same arrangement as the thread itself, so the
|
||||||
|
preview is recognisably a piece of the conversation it opens. */
|
||||||
|
.tl__wamsg--them { align-self: flex-start; border-bottom-left-radius: 4px; }
|
||||||
|
.tl__wamsg--us {
|
||||||
|
align-self: flex-end; background: var(--zk-tint-blue); border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tl__wafoot {
|
||||||
|
display: flex; align-items: center; gap: 4px; margin-top: 9px;
|
||||||
|
font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue);
|
||||||
|
}
|
||||||
|
.tl__wafoot svg { width: 11px; height: 11px; }
|
||||||
|
|||||||
@ -1,120 +1,675 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { STAGES } from '../api/config.js'
|
import AgentChip from './AgentChip.jsx'
|
||||||
|
import ClampText from './ClampText.jsx'
|
||||||
|
import ChainOfThought from './ChainOfThought.jsx'
|
||||||
|
import { AGENTS, initialsOf } from '../api/agents.js'
|
||||||
|
import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
|
||||||
|
import { CHAT_ACTS, THREAD_FIELDS, buildThread, listOf, stepName } from '../api/thread.js'
|
||||||
import './Timeline.css'
|
import './Timeline.css'
|
||||||
|
|
||||||
/** Which employee holds which role — used to badge an entry as machine work. */
|
/* The roster lives in api/agents.js now — one short name, one colour and one
|
||||||
const AI_ROLES = {
|
set of initials per employee, so the same worker looks the same everywhere
|
||||||
ai_intake: 'Intake & Attribution',
|
it appears. This file only needs to know which roles are agents. */
|
||||||
ai_engage: 'Engage',
|
|
||||||
ai_kyc: 'KYC & Evidence',
|
|
||||||
ai_advisor: 'Advisor',
|
|
||||||
ai_uw_referral: 'Underwriting Referral',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fields worth surfacing per activity — an agent's reasoning, a rule's
|
* The fields worth surfacing per activity — an agent's reasoning, a rule's
|
||||||
* output, a call's notes. Everything else stays in the file below; a timeline
|
* output, a call's notes. Everything else stays in the file below; a timeline
|
||||||
* that shows every field is a table, not a story.
|
* that shows every field is a table, not a story.
|
||||||
|
*
|
||||||
|
* Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's
|
||||||
|
* field ids, which carry a per-form suffix — the call notes arrive as
|
||||||
|
* `contact_notes_2`, the document request as `documents_notes_3`. Matching the
|
||||||
|
* global ids directly, as this did, meant almost nothing ever matched: the
|
||||||
|
* timeline showed a bare list of activity names, and the one narrative line
|
||||||
|
* that did appear was an accident (a DATA_UPDATE row happens to write the
|
||||||
|
* unsuffixed key).
|
||||||
*/
|
*/
|
||||||
const NARRATIVE = [
|
/* AN AI STEP'S REASONING, in the order it is worth reading. These become the
|
||||||
|
"Reasoned" rung of the chain of thought — the employee's own words for why
|
||||||
|
it did what it did — rather than loose quote blocks. The first present one
|
||||||
|
is the finding shown without opening the chain. */
|
||||||
|
const REASONING = [
|
||||||
['attribution_reason', 'Attribution'],
|
['attribution_reason', 'Attribution'],
|
||||||
['eligibility_reason', 'Eligibility'],
|
['eligibility_reason', 'Eligibility'],
|
||||||
['dedupe_match_ref', 'Duplicate of'],
|
['ai_recommendation_rationale', 'Cover advice'],
|
||||||
['contact_notes', 'Call'],
|
['kyc_mismatch_notes', 'KYC mismatch'],
|
||||||
['ai_recommendation_rationale', 'Recommendation'],
|
// A clean KYC leaves the mismatch note empty, so the step showed no chain at
|
||||||
['quoted_breakup', 'How the premium was reached'],
|
// all. kyc_ref is what the KYC employee actually recorded — the document
|
||||||
['kyc_mismatch_notes', 'KYC'],
|
// package it matched the identity against — so it stands in as the finding.
|
||||||
|
['kyc_ref', 'KYC check'],
|
||||||
['referral_analysis', 'Referral analysis'],
|
['referral_analysis', 'Referral analysis'],
|
||||||
|
// Underwriting writes its reasoning into uw_referral_reason (the screen and
|
||||||
|
// prepare steps); uw_decision_notes is only the clear/decline note. The map
|
||||||
|
// knew the second and not the first, so the substantive UW decision — the
|
||||||
|
// one the reader most wants the reasoning for — showed as a bare heading.
|
||||||
|
['uw_referral_reason', 'Underwriting'],
|
||||||
['uw_decision_notes', 'Underwriting decision'],
|
['uw_decision_notes', 'Underwriting decision'],
|
||||||
|
['contact_notes', 'Call'],
|
||||||
['documents_notes', 'Documents'],
|
['documents_notes', 'Documents'],
|
||||||
|
// The payment-nudge employee records why it chose to follow up now (or hold).
|
||||||
|
['nudge_notes', 'Payment follow-up'],
|
||||||
|
['quoted_breakup', 'How the premium was reached'],
|
||||||
|
['lost_reason', 'Why it was dropped'],
|
||||||
|
['resume_note', 'Why now'],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/* WHAT WAS SAID IS NO LONGER QUOTED ON THE STEP.
|
||||||
|
*
|
||||||
|
* `customer_reply` and `customer_answer` used to be rendered here, which is
|
||||||
|
* what made every WhatsApp turn its own entry in the trail: five headings, five
|
||||||
|
* actors and five quote boxes for one exchange about a premium. They belong to
|
||||||
|
* the THREAD now (api/thread.js), which owns them for both the collapsed entry
|
||||||
|
* below and the dialog. Leaving them here as well would print the payment
|
||||||
|
* request's own message on the Request Premium step and again in the exchange
|
||||||
|
* it opened. */
|
||||||
|
const CONVERSATION = [
|
||||||
|
['dedupe_match_ref', 'Duplicate of'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const NARRATIVE = [...REASONING, ...CONVERSATION]
|
||||||
|
|
||||||
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
|
||||||
|
|
||||||
function when(ts) {
|
/**
|
||||||
if (!ts) return ''
|
* Fields already shown elsewhere in the entry, so the "what it wrote" list
|
||||||
|
* does not repeat them: the narrative prose above it, the money figures below
|
||||||
|
* it, the thread that owns the messages, the document chips, and the platform's
|
||||||
|
* own bookkeeping.
|
||||||
|
*/
|
||||||
|
const SHOWN_ELSEWHERE = new Set([
|
||||||
|
...NARRATIVE.map(([k]) => k), ...MONEY, ...THREAD_FIELDS, '_system',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** A value as one short line. Long prose is already in the narrative block, so
|
||||||
|
* anything here is a field value, not a paragraph. */
|
||||||
|
function short(f) {
|
||||||
|
const v = f.value
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const named = v
|
||||||
|
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
|
||||||
|
.filter(Boolean)
|
||||||
|
return named.length ? named.join(', ') : null
|
||||||
|
}
|
||||||
|
if (typeof v === 'object') return null
|
||||||
|
const t = String(v).trim()
|
||||||
|
if (!t) return null
|
||||||
|
// A value long enough to be prose belongs in the narrative block, not in a
|
||||||
|
// list of what changed — truncating it here would show half a sentence and
|
||||||
|
// teach the reader that this list is unreliable.
|
||||||
|
return t.length > 90 ? t.slice(0, 88) + '…' : t
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document slots, in the order they are worth reading. Labels are shorter than
|
||||||
|
* the workflow's own — "Registration Certificate (RC)" is a chip, not a form
|
||||||
|
* label — and a slot missing from here still renders under its server label. */
|
||||||
|
const DOC_LABELS = {
|
||||||
|
doc_rc: 'RC',
|
||||||
|
doc_prev_policy: 'Expiring policy',
|
||||||
|
doc_pan: 'PAN',
|
||||||
|
doc_gst_cert: 'GST certificate',
|
||||||
|
doc_udyam_cert: 'Udyam certificate',
|
||||||
|
doc_address_proof: 'Address proof',
|
||||||
|
doc_premises_proof: 'Premises proof',
|
||||||
|
doc_stock_statement: 'Stock statement',
|
||||||
|
doc_premises_photos: 'Premises photos',
|
||||||
|
doc_vehicle_photos: 'Vehicle photos',
|
||||||
|
doc_financials: 'Financials',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_TYPES = new Set(['file', 'ocr'])
|
||||||
|
|
||||||
|
/** The absolute stamp and the relative one, kept apart: a collapsed exchange
|
||||||
|
* spans two moments and has only one "ago". */
|
||||||
|
function absAt(ts) {
|
||||||
const d = new Date(ts)
|
const d = new Date(ts)
|
||||||
|
if (isNaN(d)) return ''
|
||||||
|
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function agoAt(ts) {
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (isNaN(d)) return ''
|
||||||
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
const mins = Math.round((Date.now() - d.getTime()) / 60000)
|
||||||
const rel = mins < 1 ? 'just now'
|
return mins < 1 ? 'just now'
|
||||||
: mins < 60 ? `${mins}m ago`
|
: mins < 60 ? `${mins}m ago`
|
||||||
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
: mins < 1440 ? `${Math.round(mins / 60)}h ago`
|
||||||
: `${Math.round(mins / 1440)}d ago`
|
: `${Math.round(mins / 1440)}d ago`
|
||||||
return `${d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} · ${rel}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timeline({ instanceId }) {
|
function when(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
const abs = absAt(ts)
|
||||||
|
return abs ? `${abs} · ${agoAt(ts)}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An exchange happens over a stretch of time rather than at an instant, and the
|
||||||
|
* stamp has to say so — dated only at its first message, an entry reads as
|
||||||
|
* though the reply visible inside it arrived before it was written.
|
||||||
|
*
|
||||||
|
* The "ago" is measured from the LAST message: how stale the conversation is,
|
||||||
|
* which is the operationally useful half.
|
||||||
|
*/
|
||||||
|
function spanAt(from, to) {
|
||||||
|
if (!from) return ''
|
||||||
|
if (!to || to === from) return when(from)
|
||||||
|
const a = new Date(from)
|
||||||
|
const b = new Date(to)
|
||||||
|
if (isNaN(a) || isNaN(b)) return when(from)
|
||||||
|
const hm = (d) => d.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const day = (d) => d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' })
|
||||||
|
const head = a.toDateString() === b.toDateString()
|
||||||
|
? `${day(a)}, ${hm(a)} – ${hm(b)}`
|
||||||
|
: `${day(a)} – ${day(b)}`
|
||||||
|
return `${head} · ${agoAt(to)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An uploaded file, as the platform stores it. */
|
||||||
|
function filesIn(value) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value.filter((f) => f && typeof f === 'object' && f.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The WhatsApp mark, on the disc and in the heading. */
|
||||||
|
function WaGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M8 1.6a6.3 6.3 0 0 0-5.4 9.5L1.7 14.4l3.4-.9A6.3 6.3 0 1 0 8 1.6z"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||||
|
<path d="M5.7 5.6c.5-.1.7.1.9.5l.3.7c.1.2 0 .4-.1.5l-.3.3c-.1.1-.2.3-.1.4a3.4 3.4 0 0 0 1.6 1.6c.2.1.3 0 .4-.1l.3-.3c.2-.2.3-.2.5-.1l.8.4c.4.2.5.4.4.8-.1.5-.6.9-1.1.9-1.6 0-4-2.4-4-4 0-.5.3-1 .8-1.1z"
|
||||||
|
fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who spoke in an exchange — as CHIPS, not as a sentence.
|
||||||
|
*
|
||||||
|
* "Priya Raghavan and Engage" reads as two colleagues. On a screen whose whole
|
||||||
|
* claim is that the work was done autonomously, the one fact every line has to
|
||||||
|
* state is which of the two is a machine, and a bare name states the opposite.
|
||||||
|
*
|
||||||
|
* Every other entry in the trail already says it the same way — the agent's
|
||||||
|
* disc carries a sparkle, its name carries an AI tag, a person's disc carries
|
||||||
|
* their initials — so this is the existing convention reaching somewhere it was
|
||||||
|
* missing rather than a new one.
|
||||||
|
*
|
||||||
|
* The customer comes first: they are the reason the exchange exists.
|
||||||
|
*/
|
||||||
|
function voicesOf(ep, customerName) {
|
||||||
|
const who = []
|
||||||
|
if (ep.turns.some((t) => t.side === 'them')) {
|
||||||
|
who.push({ key: 'them', agentKey: null, name: customerName || 'the customer' })
|
||||||
|
}
|
||||||
|
for (const t of ep.turns) {
|
||||||
|
if (t.side !== 'us') continue
|
||||||
|
// Keyed on the agent, or on the name for a person answering by hand: ops
|
||||||
|
// replying in the thread is a person and must never wear the sparkle.
|
||||||
|
const key = t.agentKey || `person:${t.by}`
|
||||||
|
if (!who.some((w) => w.key === key)) who.push({ key, agentKey: t.agentKey, name: t.by })
|
||||||
|
}
|
||||||
|
return who
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label on a preview bubble: the same chip, small, and never a button —
|
||||||
|
* the card it sits in is already one, and a button inside a button is not. */
|
||||||
|
function TurnWho({ turn, customerName }) {
|
||||||
|
return turn.side === 'them'
|
||||||
|
? <AgentChip name={customerName || 'the customer'} size="xs" />
|
||||||
|
: <AgentChip agentKey={turn.agentKey} name={turn.by} size="xs" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `rows` is owned by the lead page and refreshed on its poll, so the trail
|
||||||
|
* keeps up with a lead that five agents are working through in three minutes.
|
||||||
|
* This component fetched them itself once on mount and never again.
|
||||||
|
*/
|
||||||
|
export default function Timeline({ rows, customerName, onOpenAgent, onOpenChat, onOpenCall }) {
|
||||||
const { client } = useZino()
|
const { client } = useZino()
|
||||||
const [rows, setRows] = useState(null)
|
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
|
||||||
const [err, setErr] = useState(null)
|
// anything. They are the bulk of a busy trail and they are hidden until asked
|
||||||
|
// for — the story is what people and agents did.
|
||||||
|
const [showSys, setShowSys] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let dead = false
|
|
||||||
client.audit(instanceId)
|
|
||||||
.then((r) => { if (!dead) setRows(Array.isArray(r) ? r : (r?.data ?? [])) })
|
|
||||||
.catch((e) => { if (!dead) setErr(e) })
|
|
||||||
return () => { dead = true }
|
|
||||||
}, [client, instanceId])
|
|
||||||
|
|
||||||
if (err) return <div className="tl__err">Could not load the timeline — {err.status} {err.message}</div>
|
|
||||||
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
if (!rows) return <p className="tl__loading">Loading the timeline…</p>
|
||||||
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
|
if (!rows.length) return <p className="tl__loading">Nothing has happened yet.</p>
|
||||||
|
|
||||||
|
// The conversation, assembled once and shared with the dialog. `episodes` are
|
||||||
|
// its bursts; `byRow` says which steps put a message on WhatsApp themselves.
|
||||||
|
const { episodes, byRow } = buildThread(rows)
|
||||||
|
|
||||||
// Oldest first: a timeline reads forwards.
|
// Oldest first: a timeline reads forwards.
|
||||||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||||||
|
|
||||||
|
// ONE SUBMISSION WRITES SEVERAL ROWS. The platform records each stage of a
|
||||||
|
// submission separately, told apart by execution_state:
|
||||||
|
//
|
||||||
|
// zk-act-qualify TRIGGER_PERFORMED the trigger's commit
|
||||||
|
// zk-act-qualify TRIGGER_PERFORMED ...again, on the settle path
|
||||||
|
// zk-act-qualify zk-state-qualified the one that moved the lead
|
||||||
|
//
|
||||||
|
// Rendered literally that is "Qualify Lead" three times in a row, which
|
||||||
|
// reads as the AI having done the same thing three times. Only the row
|
||||||
|
// carrying a real state means anything to somebody reading the file.
|
||||||
|
//
|
||||||
|
// Collapsed on (same activity, within fifteen seconds) rather than on the
|
||||||
|
// execution_state alone, because a genuine repeat has to survive: Collect
|
||||||
|
// Documents really is performed twice on a lead whose first upload was
|
||||||
|
// incomplete, and those are minutes apart, not milliseconds.
|
||||||
|
//
|
||||||
|
// The kept row takes the EARLIEST timestamp — when the operator acted — and
|
||||||
|
// whichever state and payload is actually populated.
|
||||||
|
const isStage = (v) => STAGES.some((s) => s.uid === v)
|
||||||
|
const SAME_SUBMISSION_MS = 15000
|
||||||
|
|
||||||
|
const merged = []
|
||||||
|
for (const r of ordered) {
|
||||||
|
// Look BACK for a match rather than only at the previous entry: the
|
||||||
|
// platform interleaves a DATA_UPDATE row between the two halves of one
|
||||||
|
// submission, so the rows to merge are near each other in time but not
|
||||||
|
// adjacent in the list.
|
||||||
|
let at = -1
|
||||||
|
for (let i = merged.length - 1; i >= 0; i--) {
|
||||||
|
if (Date.parse(r.created_at) - Date.parse(merged[i].created_at) >= SAME_SUBMISSION_MS) break
|
||||||
|
if (merged[i].activity_id === r.activity_id) { at = i; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (at >= 0) {
|
||||||
|
const prev = merged[at]
|
||||||
|
merged[at] = {
|
||||||
|
...prev,
|
||||||
|
// The settle row is the one that names the resulting stage.
|
||||||
|
execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
|
||||||
|
// Whichever row actually carries the submission and the AI's working.
|
||||||
|
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
|
||||||
|
fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
|
||||||
|
user_name: prev.user_name || r.user_name,
|
||||||
|
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
|
||||||
|
created_at: prev.created_at,
|
||||||
|
// Every raw row folded in here. The thread is keyed on RAW ids — a
|
||||||
|
// message may have been recorded on the second row of a submission
|
||||||
|
// whose first row won the merge — so a lookup has to try all of them.
|
||||||
|
mergedIds: [...(prev.mergedIds || [prev.id]), r.id],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.push(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastStage = null
|
||||||
|
|
||||||
|
/** One step: what it was, who took it, what it wrote. */
|
||||||
|
const stepEntry = (r, ids) => {
|
||||||
|
const roles = r.user_roles || []
|
||||||
|
// SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it,
|
||||||
|
// so an AI's own field write matched the agent roster, was classed as its work,
|
||||||
|
// and appeared in the trail as an entry titled "Data updated" — twice,
|
||||||
|
// while the toggle below still offered to reveal two others. The row's
|
||||||
|
// activity id is what says it is bookkeeping; the roles say who triggered
|
||||||
|
// the bookkeeping, which is a different question.
|
||||||
|
const isSystem = r.activity_id === 'DATA_UPDATE'
|
||||||
|
const aiRole = roles.find((x) => AGENTS[x])
|
||||||
|
const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value view. `fields[]` is the platform's own typed rendering of the
|
||||||
|
* submission — one entry per configured field with its label, data type and
|
||||||
|
* value — and it is what makes a file field knowable as a file. `data` is
|
||||||
|
* the raw untyped fallback for a row the workflow could not resolve.
|
||||||
|
*/
|
||||||
|
const fields = Array.isArray(r.fields) && r.fields.length
|
||||||
|
? r.fields
|
||||||
|
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v }))
|
||||||
|
|
||||||
|
const byBase = new Map()
|
||||||
|
for (const f of fields) {
|
||||||
|
const base = baseFieldId(f.field_id)
|
||||||
|
// `_system` is the platform's own marker on every submission.
|
||||||
|
if (base === '_system') continue
|
||||||
|
if (!byBase.has(base)) byBase.set(base, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was attached. This is the answer to "show me that the documents
|
||||||
|
// were captured": the names, from the submission that carried them, each
|
||||||
|
// one openable.
|
||||||
|
const docs = []
|
||||||
|
for (const [base, f] of byBase) {
|
||||||
|
if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue
|
||||||
|
for (const file of filesIn(f.value)) {
|
||||||
|
docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log Contact is where the call lands. Offer the words themselves beside
|
||||||
|
// the summary, because they are not the same thing.
|
||||||
|
const hasCall = r.activity_id === 'zk-act-contact' && Boolean(byBase.get('call_transcript')?.value)
|
||||||
|
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
||||||
|
// A self-loop — Capture Motor Risk runs inside Document Pending and settles
|
||||||
|
// back into it — is not a move, and printing "→ Document Pending" against
|
||||||
|
// four consecutive entries reads as the lead bouncing.
|
||||||
|
const moved = stage && stage.uid !== lastStage
|
||||||
|
if (stage) lastStage = stage.uid
|
||||||
|
|
||||||
|
// A step that composed a WhatsApp message inside its own trigger — Request
|
||||||
|
// Premium, the issuance note. The text lives in the thread; the step says
|
||||||
|
// it sent one and links there rather than quoting what is already shown.
|
||||||
|
const chat = ids.map((id) => byRow.get(id)).find(Boolean)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: r.id ?? `${r.activity_id}-${r.created_at}`,
|
||||||
|
kind,
|
||||||
|
hasCall,
|
||||||
|
sent: chat?.sent ?? 0,
|
||||||
|
chatEpisode: chat?.episodeId ?? null,
|
||||||
|
agentKey: aiRole || null,
|
||||||
|
actor: aiRole ? AGENTS[aiRole].full : (r.user_name || 'System'),
|
||||||
|
what: isSystem ? 'Data updated' : stepName(r),
|
||||||
|
stage: moved ? stage : null,
|
||||||
|
when: when(r.created_at),
|
||||||
|
docs,
|
||||||
|
// The reasoning rungs of the chain of thought — the employee's own text.
|
||||||
|
reasoning: REASONING
|
||||||
|
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||||||
|
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
||||||
|
// Conversation lines stay as plain quotes.
|
||||||
|
conversation: CONVERSATION
|
||||||
|
.map(([k, label]) => [label, byBase.get(k)?.value])
|
||||||
|
.filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
|
||||||
|
// The decision's confidence, when the step recorded it — ai_recommendation_confidence
|
||||||
|
// is a real field the advice step writes into the audit data, so it needs
|
||||||
|
// no backend change to reach here.
|
||||||
|
confidence: (() => {
|
||||||
|
const c = byBase.get('ai_recommendation_confidence')?.value
|
||||||
|
const n = c == null ? null : Number(c)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
})(),
|
||||||
|
figures: [...byBase]
|
||||||
|
.filter(([base, f]) => MONEY.has(base) && f.value)
|
||||||
|
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EVERYTHING THIS STEP WROTE.
|
||||||
|
*
|
||||||
|
* Twelve fields were rendered as prose and four as money; the other
|
||||||
|
* hundred-odd were invisible. So Capture Motor Risk — which writes engine
|
||||||
|
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
|
||||||
|
* add-ons — showed as a heading and a timestamp, and the only way to see
|
||||||
|
* what an agent had actually decided was to open the Lead file and guess
|
||||||
|
* which values came from that step.
|
||||||
|
*
|
||||||
|
* This is not the model's internal reasoning; that is recorded on the row
|
||||||
|
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
|
||||||
|
* It is the next best and arguably more useful thing: the decision it
|
||||||
|
* committed, field by field, against the step that made it.
|
||||||
|
*/
|
||||||
|
wrote: [...byBase]
|
||||||
|
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
|
||||||
|
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
|
||||||
|
.filter(([, v]) => v !== null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CONVERSATION IS ONE ENTRY, NOT NINE.
|
||||||
|
*
|
||||||
|
* Every WhatsApp turn used to be its own step in the trail — "WhatsApp",
|
||||||
|
* "WhatsApp", "WhatsApp" — each with a heading, an actor, a quote box and an
|
||||||
|
* Open thread link, so a four-message exchange about a premium took more of
|
||||||
|
* the story than the entire underwriting chain.
|
||||||
|
*
|
||||||
|
* An exchange is now one entry: who spoke, how many messages, the last two of
|
||||||
|
* them, and the whole thread one click away. It carries NO single actor, which
|
||||||
|
* is the trap the previous attempt fell into — collapsing turns while keeping
|
||||||
|
* the first speaker's name printed the customer's words under Engage's chip.
|
||||||
|
* Here both sides are named on their own message and neither owns the entry.
|
||||||
|
*
|
||||||
|
* ONE ENTRY PER BURST, not one for the whole thread. The quote objection and
|
||||||
|
* the payment exchange are the same conversation resumed, but they are twenty
|
||||||
|
* minutes and four workflow steps apart. Folding them into a single entry
|
||||||
|
* would date the payment messages to the moment of the objection and place
|
||||||
|
* them above the underwriting that actually preceded them — and saying what
|
||||||
|
* happened in what order is the trail's one job. So each burst sits where it
|
||||||
|
* happened, the later ones marked as a continuation, and every one of them
|
||||||
|
* opens the same complete thread.
|
||||||
|
*/
|
||||||
|
const entries = []
|
||||||
|
const placed = new Set()
|
||||||
|
|
||||||
|
const waEntry = (ep) => ({ key: 'wa-' + ep.id, kind: 'wa', ep })
|
||||||
|
|
||||||
|
for (const r of merged) {
|
||||||
|
const ids = r.mergedIds || [r.id]
|
||||||
|
const opens = episodes.filter((e) => !placed.has(e.id) && ids.includes(e.anchorRowId))
|
||||||
|
|
||||||
|
if (CHAT_ACTS.has(r.activity_id)) {
|
||||||
|
// The row IS a turn, not a step, so it never appears as one. Its exchange
|
||||||
|
// takes its place — once, where the exchange began.
|
||||||
|
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push(stepEntry(r, ids))
|
||||||
|
// A step whose own message reopened the thread sits directly above the
|
||||||
|
// exchange it started.
|
||||||
|
for (const e of opens) { entries.push(waEntry(e)); placed.add(e.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guard, not a case: an exchange whose anchor row did not survive the merge
|
||||||
|
// would otherwise vanish from the trail entirely.
|
||||||
|
for (const e of episodes) if (!placed.has(e.id)) entries.push(waEntry(e))
|
||||||
|
|
||||||
|
const sysCount = entries.filter((it) => it.kind === 'sys').length
|
||||||
|
const visible = showSys ? entries : entries.filter((it) => it.kind !== 'sys')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ol className="tl">
|
<>
|
||||||
{ordered.map((r, i) => {
|
{sysCount ? (
|
||||||
const roles = r.user_roles || []
|
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
||||||
const aiRole = roles.find((x) => AI_ROLES[x])
|
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
||||||
const isSystem = r.activity_id === 'DATA_UPDATE'
|
</button>
|
||||||
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
|
) : null}
|
||||||
const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
|
|
||||||
const stage = STAGES.find((s) => s.uid === r.execution_state)
|
|
||||||
const d = r.data || {}
|
|
||||||
|
|
||||||
const narrative = NARRATIVE
|
<ol className="tl">
|
||||||
.map(([k, label]) => [label, d[k]])
|
{visible.map((it) => (
|
||||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
|
it.kind === 'wa' ? (
|
||||||
|
<li key={it.key} className="tl__ev tl__ev--wa">
|
||||||
|
<span className="tl__gutter" aria-hidden="true">
|
||||||
|
<span className="tl__disc tl__disc--wa"><WaGlyph /></span>
|
||||||
|
</span>
|
||||||
|
<div className="tl__body">
|
||||||
|
<div className="tl__line1">
|
||||||
|
<h4 className="tl__what tl__what--wa">
|
||||||
|
<WaGlyph />
|
||||||
|
WhatsApp
|
||||||
|
</h4>
|
||||||
|
{/* A resumed exchange says so on its own line, so nobody
|
||||||
|
reads it as a second conversation out of nowhere. */}
|
||||||
|
{it.ep.after.length ? <span className="tl__n">continued</span> : null}
|
||||||
|
<span className="tl__n">
|
||||||
|
{it.ep.count} message{it.ep.count === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<span className="tl__when">{spanAt(it.ep.from, it.ep.to)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tl__wavoices">
|
||||||
|
{voicesOf(it.ep, customerName).map((w) => (
|
||||||
|
<AgentChip
|
||||||
|
key={w.key}
|
||||||
|
agentKey={w.agentKey}
|
||||||
|
name={w.name}
|
||||||
|
size="sm"
|
||||||
|
onOpen={w.agentKey ? onOpenAgent : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
const figures = Object.entries(d)
|
{it.ep.after.length ? (
|
||||||
.filter(([k, v]) => MONEY.has(k) && v)
|
<p className="tl__wacont">
|
||||||
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')])
|
The same thread, picked up after {listOf(it.ep.after)}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
return (
|
{/* The last exchange, as a thread rather than a quote block —
|
||||||
<li key={r.id ?? i} className={'tl__item tl__item--' + kind}>
|
and the whole card opens the conversation, which is how
|
||||||
<span className="tl__dot" aria-hidden="true" />
|
every timeline of this shape behaves. */}
|
||||||
<div className="tl__body">
|
<button
|
||||||
<div className="tl__head">
|
type="button"
|
||||||
<strong className="tl__what">
|
className="tl__wa"
|
||||||
{isSystem ? 'Data updated' : (r.activity_name || r.activity_id)}
|
onClick={() => onOpenChat?.(it.ep.id)}
|
||||||
</strong>
|
aria-label={`Open the WhatsApp conversation — ${it.ep.count} messages`}
|
||||||
<span className={'tl__who tl__who--' + kind}>
|
>
|
||||||
{kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor}
|
<span className="tl__wapre">
|
||||||
</span>
|
{it.ep.preview.map((t) => (
|
||||||
{stage ? <span className="tl__stage">→ {stage.name}</span> : null}
|
<span key={t.key} className={'tl__wamsg tl__wamsg--' + t.side}>
|
||||||
|
<span className="tl__wamsg-who">
|
||||||
|
<TurnWho turn={t} customerName={customerName} />
|
||||||
|
</span>
|
||||||
|
<span className="tl__wamsg-txt">{t.text}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="tl__wafoot">
|
||||||
|
{it.ep.count > it.ep.preview.length
|
||||||
|
? `Open the conversation — all ${it.ep.count} messages`
|
||||||
|
: 'Open the conversation'}
|
||||||
|
<svg viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M4 2.5 8 6l-4 3.5" fill="none" stroke="currentColor" strokeWidth="1.6"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={it.key} className={'tl__ev tl__ev--' + it.kind}>
|
||||||
|
{/* The worker, as a face in the gutter. Recognition down a column
|
||||||
|
beats reading five names to notice it was one agent throughout. */}
|
||||||
|
<span className="tl__gutter" aria-hidden="true">
|
||||||
|
{it.kind === 'sys' ? (
|
||||||
|
<span className="tl__disc tl__disc--sys">·</span>
|
||||||
|
) : it.agentKey ? (
|
||||||
|
<AgentChip agentKey={it.agentKey} size="md" showName={false} onOpen={onOpenAgent} />
|
||||||
|
) : (
|
||||||
|
<span className="tl__disc tl__disc--hu">{initialsOf(it.actor)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="tl__body">
|
||||||
|
<div className="tl__line1">
|
||||||
|
<h4 className="tl__what">{it.what}</h4>
|
||||||
|
{it.stage ? <span className="tl__to">{it.stage.name}</span> : null}
|
||||||
|
<span className="tl__when">{it.when}</span>
|
||||||
|
</div>
|
||||||
|
<div className="tl__by">
|
||||||
|
{it.kind === 'sys'
|
||||||
|
? <span className="tl__byname">System</span>
|
||||||
|
: it.agentKey
|
||||||
|
? <><span className="tl__byname">{it.actor}</span><small className="tl__tag">AI</small></>
|
||||||
|
: <span className="tl__byname">{it.actor}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="tl__when">{when(r.created_at)}</div>
|
|
||||||
|
|
||||||
{narrative.map(([label, v]) => (
|
{/* What was received, named and openable. The count is stated
|
||||||
|
rather than left to be inferred from a list — "3 documents
|
||||||
|
received" is the sentence the operator is looking for. */}
|
||||||
|
{it.docs.length ? (
|
||||||
|
<div className="tl__docs">
|
||||||
|
<span className="tl__docshead">
|
||||||
|
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
|
||||||
|
</span>
|
||||||
|
<div className="tl__doclist">
|
||||||
|
{it.docs.map((d) => (
|
||||||
|
<a
|
||||||
|
key={d.uuid}
|
||||||
|
className="tl__doc"
|
||||||
|
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title={d.original_name}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round" />
|
||||||
|
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
{d.slot}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* THE CHAIN OF THOUGHT. For an AI step, the reasoning is not a
|
||||||
|
loose quote — it is how the employee reached the decision, so
|
||||||
|
it renders as the chain: what it checked, what it reasoned,
|
||||||
|
what it decided. This is the answer to "why did it do that",
|
||||||
|
which is the first thing a person needs before trusting or
|
||||||
|
overriding a machine. */}
|
||||||
|
{it.kind === 'ai' && it.reasoning.length ? (
|
||||||
|
<ChainOfThought
|
||||||
|
reasoning={it.reasoning}
|
||||||
|
confidence={it.confidence}
|
||||||
|
decided={{ what: it.what, stage: it.stage ? it.stage.name : null }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* A human step's reason, and the AI step's reasoning when it is
|
||||||
|
not the story's actor (rare), stay as a plain quote. */}
|
||||||
|
{it.kind !== 'ai'
|
||||||
|
? it.reasoning.map(([label, v]) => (
|
||||||
|
<div className="tl__say" key={label}>
|
||||||
|
<span className="tl__saylabel">{label}</span>
|
||||||
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{it.conversation.map(([label, v]) => (
|
||||||
<div className="tl__say" key={label}>
|
<div className="tl__say" key={label}>
|
||||||
<span className="tl__saylabel">{label}</span>
|
<span className="tl__saylabel">{label}</span>
|
||||||
<p>{String(v)}</p>
|
<ClampText text={String(v)} title={`${label} — ${it.what}`} lines={2} threshold={120} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{figures.length ? (
|
{it.figures.length ? (
|
||||||
<div className="tl__figs">
|
<div className="tl__figs">
|
||||||
{figures.map(([k, v]) => (
|
{it.figures.map(([k, v]) => (
|
||||||
<span key={k}><em>{k}</em> {v}</span>
|
<span key={k}><em>{k}</em> {v}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="tl__acts">
|
||||||
|
{it.hasCall && onOpenCall ? (
|
||||||
|
<button type="button" className="tl__link" onClick={onOpenCall}>Hear the call</button>
|
||||||
|
) : null}
|
||||||
|
{/* This step put something on WhatsApp itself. The message is in
|
||||||
|
the thread with the rest of the conversation, not quoted
|
||||||
|
here a second time. */}
|
||||||
|
{it.sent && onOpenChat ? (
|
||||||
|
<button type="button" className="tl__link" onClick={() => onOpenChat(it.chatEpisode)}>
|
||||||
|
{it.sent === 1 ? 'Sent 1 message on WhatsApp' : `Sent ${it.sent} messages on WhatsApp`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{/* Folded, because most steps write a lot and the trail has to
|
||||||
|
stay readable as a story. Open it and the step becomes an
|
||||||
|
itemised account of what it decided. */}
|
||||||
|
{it.wrote.length ? (
|
||||||
|
<details className="tl__wrote">
|
||||||
|
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
|
||||||
|
<dl>
|
||||||
|
{it.wrote.map(([k, v]) => (
|
||||||
|
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
221
src/index.css
221
src/index.css
@ -1,74 +1,177 @@
|
|||||||
/* ── Zurich Kotak brand typeface ───────────────────────────── */
|
/* ── Typeface: Source Sans 3, self-hosted ───────────────────────────────────
|
||||||
@font-face {
|
Bundled from src/assets so Vite resolves the URLs under the sub-path mount;
|
||||||
font-family: 'Zurich Sans';
|
an absolute /fonts/ path would 404 behind the BASE_HREF rewrite. */
|
||||||
src: url('./assets/fonts/ZurichSans-Light.otf') format('opentype');
|
@font-face { font-family: 'Source Sans 3'; font-weight: 400; font-display: swap;
|
||||||
font-weight: 300;
|
src: url('./assets/fonts/source-sans-3-latin-400-normal.woff2') format('woff2'),
|
||||||
font-style: normal;
|
url('./assets/fonts/source-sans-3-latin-ext-400-normal.woff2') format('woff2'); }
|
||||||
font-display: swap;
|
@font-face { font-family: 'Source Sans 3'; font-weight: 500; font-display: swap;
|
||||||
}
|
src: url('./assets/fonts/source-sans-3-latin-500-normal.woff2') format('woff2'),
|
||||||
@font-face {
|
url('./assets/fonts/source-sans-3-latin-ext-500-normal.woff2') format('woff2'); }
|
||||||
font-family: 'Zurich Sans';
|
@font-face { font-family: 'Source Sans 3'; font-weight: 600; font-display: swap;
|
||||||
src: url('./assets/fonts/ZurichSans-Regular.otf') format('opentype');
|
src: url('./assets/fonts/source-sans-3-latin-600-normal.woff2') format('woff2'),
|
||||||
font-weight: 400;
|
url('./assets/fonts/source-sans-3-latin-ext-600-normal.woff2') format('woff2'); }
|
||||||
font-style: normal;
|
@font-face { font-family: 'Source Sans 3'; font-weight: 700; font-display: swap;
|
||||||
font-display: swap;
|
src: url('./assets/fonts/source-sans-3-latin-700-normal.woff2') format('woff2'),
|
||||||
}
|
url('./assets/fonts/source-sans-3-latin-ext-700-normal.woff2') format('woff2'); }
|
||||||
@font-face {
|
|
||||||
font-family: 'Zurich Sans';
|
|
||||||
src: url('./assets/fonts/ZurichSans-Medium.otf') format('opentype');
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Brand palette lifted from zurichkotak.com */
|
/* ── PALETTE — Lead Desk redesign ───────────────────────────────────────
|
||||||
--zk-blue: #2167ae;
|
Colour MEANS something here, and the meaning is fixed on every screen:
|
||||||
--zk-blue-dark: #1a5490;
|
blue = the AI holds it amber = a person holds it
|
||||||
--zk-blue-deep: #1a528b;
|
teal = the customer holds it red = risk, and nothing else
|
||||||
--zk-navy: #23366f;
|
Navy is the masthead and the brand; blue is the one action colour.
|
||||||
--zk-blue-mid: #5495cf;
|
The token NAMES keep their history — the whole app references --zk-blue
|
||||||
--zk-blue-light: #91bfe3;
|
and friends — so remapping the values here re-themes every surface. */
|
||||||
--zk-cyan: #c7ebf9;
|
--zk-navy: #0B2A5B;
|
||||||
|
--zk-navy-2: #12386F;
|
||||||
|
--zk-blue: #1F55A6; /* primary action; "with the AI" */
|
||||||
|
--zk-blue-dark: #12386F;
|
||||||
|
--zk-blue-deep: #0B2A5B;
|
||||||
|
--zk-blue-mid: #4E7FC4;
|
||||||
|
--zk-blue-light: #C3D3EC; /* hairline / ring */
|
||||||
|
--zk-cyan: #E4ECF9; /* blue-soft */
|
||||||
|
--zk-tint-blue: #E4ECF9; /* blue-soft */
|
||||||
|
|
||||||
--zk-ink: #2b2e31;
|
--zk-teal: #137A66; /* the customer; issued */
|
||||||
--zk-muted: #5d6162;
|
--zk-teal-ink: #0F6353;
|
||||||
--zk-grey: #a6adaf;
|
--zk-teal-tint: #DDF1EC;
|
||||||
--zk-line: #dde4e3;
|
--zk-teal-line: #B8DDD3;
|
||||||
--zk-line-soft: #eceeef;
|
--zk-good: #137A66;
|
||||||
--zk-tint: #f4f9fc;
|
|
||||||
--zk-tint-blue: #ebf3fb;
|
|
||||||
--zk-white: #fff;
|
|
||||||
--zk-danger: #d32f2f;
|
|
||||||
|
|
||||||
--font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif;
|
--zk-amber: #A86A0B; /* a person holds it */
|
||||||
|
--zk-amber-ink: #8A5609;
|
||||||
|
--zk-amber-tint: #FBF0DC;
|
||||||
|
--zk-amber-line: #EAD6AE;
|
||||||
|
--zk-amber-bar: #D99A2B; /* the brighter amber for bars and legends */
|
||||||
|
|
||||||
|
--zk-danger: #C8102E; /* Kotak red: risk only */
|
||||||
|
--zk-danger-ink: #A30D25;
|
||||||
|
--zk-danger-tint: #FBE7EA;
|
||||||
|
--zk-danger-line: #F1C5CC;
|
||||||
|
|
||||||
|
--zk-ink: #152238;
|
||||||
|
--zk-muted: #4B5A72; /* ink-2 */
|
||||||
|
--zk-grey: #7A8699; /* ink-3 */
|
||||||
|
--zk-line: #D9DFE9;
|
||||||
|
--zk-line-soft: #E9EDF3;
|
||||||
|
--zk-tint: #F2F4F8; /* the canvas */
|
||||||
|
--zk-sunk: #E9EDF3;
|
||||||
|
--zk-white: #FFFFFF;
|
||||||
|
--zk-card: #FFFFFF;
|
||||||
|
|
||||||
|
/* ── Shape ────────────────────────────────────────────────────────────
|
||||||
|
Flat. Cards are white on the grey canvas with a 1px line and a 10px
|
||||||
|
radius; nothing floats, nothing casts a shadow. */
|
||||||
|
--r-xs: 6px;
|
||||||
|
--r-sm: 8px;
|
||||||
|
--r-md: 10px;
|
||||||
|
--r-lg: 10px;
|
||||||
|
--r-xl: 12px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
--sh-xs: none;
|
||||||
|
--sh-sm: none;
|
||||||
|
--sh-md: none;
|
||||||
|
--sh-lg: 0 12px 32px -12px rgba(21, 34, 56, 0.28); /* dialogs only */
|
||||||
|
--sh-accent: none;
|
||||||
|
|
||||||
|
--ring: 0 0 0 3px rgba(31, 85, 166, 0.18);
|
||||||
|
|
||||||
|
--ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--t-fast: 0.14s var(--ease);
|
||||||
|
--t: 0.2s var(--ease);
|
||||||
|
|
||||||
|
--grad-blue: var(--zk-blue);
|
||||||
|
--grad-blue-hover: var(--zk-navy-2);
|
||||||
|
|
||||||
|
/* ── TYPE SCALE — on a 15px root, so the rem steps land on the design's
|
||||||
|
pixel sizes: 12.5 eyebrows, 13.5 small, 14 labels, 15 body, 17 card
|
||||||
|
titles, 20 tab figures, 28 page title / KPI, 44 the one risk number. */
|
||||||
|
--fs-3xs: 0.8333rem; /* 12.5px — uppercase eyebrows, table heads */
|
||||||
|
--fs-2xs: 0.9rem; /* 13.5px — meta, pills, secondary lines */
|
||||||
|
--fs-xs: 0.9333rem; /* 14px — labels */
|
||||||
|
--fs-sm: 1rem; /* 15px — body */
|
||||||
|
--fs-md: 1.0667rem; /* 16px — emphasis */
|
||||||
|
--fs-lg: 1.1333rem; /* 17px — card titles */
|
||||||
|
--fs-xl: 1.3333rem; /* 20px — tab figures, small headings */
|
||||||
|
--fs-2xl: 1.8667rem; /* 28px — page title, KPI figures */
|
||||||
|
--fs-3xl: 2.9333rem; /* 44px — renewals at risk */
|
||||||
|
|
||||||
|
--lh-tight: 1.15;
|
||||||
|
--lh-snug: 1.35;
|
||||||
|
--lh-normal: 1.4;
|
||||||
|
|
||||||
|
--fw-normal: 400;
|
||||||
|
--fw-medium: 500;
|
||||||
|
--fw-semi: 600;
|
||||||
|
--fw-bold: 700;
|
||||||
|
--fw-x: 700;
|
||||||
|
|
||||||
|
--sp-1: 4px;
|
||||||
|
--sp-2: 8px;
|
||||||
|
--sp-3: 12px;
|
||||||
|
--sp-4: 16px;
|
||||||
|
--sp-5: 20px;
|
||||||
|
--sp-6: 24px;
|
||||||
|
--sp-7: 32px;
|
||||||
|
--sp-8: 40px;
|
||||||
|
--sp-9: 56px;
|
||||||
|
|
||||||
|
--font-display: 'Source Sans 3', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-zurich: 'Source Sans 3', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
font-family: var(--font-zurich);
|
font-family: var(--font-zurich);
|
||||||
font-size: 16px;
|
/* Absolute on purpose: every --fs-* token is rem and sizing the root from
|
||||||
line-height: 1.5;
|
one of them would be circular. */
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: var(--lh-normal);
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
background: var(--zk-white);
|
background: var(--zk-tint);
|
||||||
|
font-feature-settings: 'tnum' 1;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* { box-sizing: border-box; }
|
||||||
box-sizing: border-box;
|
|
||||||
|
html, body, #root { height: 100%; }
|
||||||
|
|
||||||
|
body { margin: 0; background: var(--zk-tint); }
|
||||||
|
|
||||||
|
button, input, select, textarea { font: inherit; color: inherit; }
|
||||||
|
|
||||||
|
/* One focus treatment for the whole console. */
|
||||||
|
:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 2px; }
|
||||||
|
|
||||||
|
::selection { background: var(--zk-tint-blue); color: var(--zk-navy); }
|
||||||
|
|
||||||
|
* { scrollbar-width: thin; scrollbar-color: var(--zk-line) transparent; }
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border: 3px solid transparent; border-radius: var(--r-pill);
|
||||||
|
background-clip: content-box; background-color: var(--zk-line);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background-color: var(--zk-grey); }
|
||||||
|
|
||||||
|
@keyframes zk-rise {
|
||||||
|
/* Opacity only — a transform here composites text onto a GPU layer and it
|
||||||
|
renders soft in Chrome on Linux. */
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes zk-shimmer {
|
||||||
|
from { background-position: -420px 0; }
|
||||||
|
to { background-position: 420px 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
@media (prefers-reduced-motion: reduce) {
|
||||||
body,
|
*, *::before, *::after {
|
||||||
#root {
|
animation-duration: 0.001ms !important;
|
||||||
height: 100%;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
body {
|
}
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +1,209 @@
|
|||||||
.shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--zk-tint); }
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--zk-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Masthead ─────────────────────────────────────────────────────────────── */
|
||||||
.shell__top {
|
.shell__top {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
position: sticky;
|
||||||
gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white);
|
top: 0;
|
||||||
border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10;
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: var(--zk-navy);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.shell__brand { display: flex; align-items: center; gap: 14px; }
|
|
||||||
.shell__brand img { height: 30px; width: auto; display: block; }
|
|
||||||
.shell__brand div { display: flex; flex-direction: column; line-height: 1.25; padding-left: 14px; border-left: 1px solid var(--zk-line); }
|
|
||||||
.shell__brand strong { font-size: .95rem; font-weight: 500; color: var(--zk-ink); }
|
|
||||||
.shell__brand span { font-size: .74rem; color: var(--zk-muted); }
|
|
||||||
|
|
||||||
.shell__who { display: flex; align-items: center; gap: 16px; }
|
.mast__burger {
|
||||||
.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; }
|
display: none; /* a phone-only control; see the media block */
|
||||||
.shell__user strong { font-size: .85rem; font-weight: 500; }
|
width: 34px; height: 34px; flex: none;
|
||||||
.shell__user span { font-size: .72rem; color: var(--zk-muted); }
|
place-items: center; margin-left: -6px;
|
||||||
.shell__signout {
|
border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer;
|
||||||
font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer;
|
|
||||||
background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line);
|
|
||||||
}
|
}
|
||||||
.shell__signout:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
.mast__burger svg { width: 20px; height: 20px; }
|
||||||
|
.mast__burger:hover { background: rgba(255, 255, 255, .12); }
|
||||||
|
.mast__burger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
|
||||||
|
|
||||||
|
/* Half the bar's height, which is where a 3.7:1 lockup sits without crowding
|
||||||
|
the app name beside it. The asset carries its own rounded plate and the
|
||||||
|
words "ZURICH kotak / General Insurance", so nothing is set in type next to
|
||||||
|
it — that was the same sentence twice. The radius matches the artwork's own
|
||||||
|
corners so no hairline shows at the edge. */
|
||||||
|
.mast__logo { flex: none; display: block; height: 28px; width: auto; border-radius: 6px; }
|
||||||
|
|
||||||
|
.mast__sep { width: 1px; height: 26px; background: rgba(255, 255, 255, .22); }
|
||||||
|
|
||||||
|
.mast__app { font-size: 17px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.mast__app span { font-weight: 400; opacity: .7; margin-left: 8px; font-size: 15px; }
|
||||||
|
|
||||||
|
.mast__user { margin-left: auto; }
|
||||||
|
|
||||||
|
.shell__scrim {
|
||||||
|
display: none;
|
||||||
|
position: fixed; inset: 56px 0 0 0; z-index: 35;
|
||||||
|
border: 0; padding: 0; background: rgba(11, 42, 91, .35); cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Frame ────────────────────────────────────────────────────────────────── */
|
||||||
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
|
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; }
|
||||||
|
|
||||||
.shell__nav {
|
.shell__nav {
|
||||||
width: 244px; flex: none; background: var(--zk-white);
|
position: sticky;
|
||||||
border-right: 1px solid var(--zk-line); padding: 18px 12px 28px;
|
top: 56px;
|
||||||
display: flex; flex-direction: column; gap: 1px;
|
align-self: flex-start;
|
||||||
}
|
width: 232px;
|
||||||
.shell__navlabel {
|
flex: none;
|
||||||
margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500;
|
min-height: calc(100vh - 56px);
|
||||||
letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey);
|
max-height: calc(100vh - 56px);
|
||||||
}
|
overflow-y: auto;
|
||||||
.shell__navlabel--closed { margin-top: 22px; }
|
overscroll-behavior: contain;
|
||||||
|
background: #fff;
|
||||||
.shell__stage {
|
border-right: 1px solid var(--zk-line);
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
padding: 20px 12px;
|
||||||
padding: 8px 10px; border-radius: 4px; text-decoration: none;
|
display: flex;
|
||||||
color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent;
|
flex-direction: column;
|
||||||
}
|
gap: 2px;
|
||||||
.shell__stage:hover { background: var(--zk-tint); }
|
|
||||||
.shell__stage.is-active { background: var(--zk-tint-blue); border-left-color: var(--zk-blue); color: var(--zk-blue-dark); font-weight: 500; }
|
|
||||||
/* The one queue where the machine stops and a person decides. */
|
|
||||||
.shell__stage.is-human .shell__stagename { font-weight: 500; }
|
|
||||||
.shell__badge {
|
|
||||||
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
|
|
||||||
padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
|
.nav__h {
|
||||||
|
margin: 0;
|
||||||
.shell__add {
|
padding: 18px 12px 6px;
|
||||||
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
|
font-size: var(--fs-3xs);
|
||||||
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
|
font-weight: 600;
|
||||||
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--zk-grey);
|
||||||
}
|
}
|
||||||
.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
|
.nav__group { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|
||||||
|
.nav__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.nav__link:hover { background: var(--zk-tint); color: var(--zk-ink); }
|
||||||
|
.nav__link.is-on {
|
||||||
|
background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-navy);
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: inset 3px 0 0 var(--zk-blue);
|
||||||
|
}
|
||||||
|
.nav__name { min-width: 0; line-height: 1.35; }
|
||||||
|
|
||||||
|
/* The count is who is being waited on: red a person urgently, amber the
|
||||||
|
customer, grey the calendar or history. */
|
||||||
|
.nav__n {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--zk-line-soft);
|
||||||
|
color: var(--zk-muted);
|
||||||
|
}
|
||||||
|
.nav__n--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
|
||||||
|
.nav__n--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
|
||||||
|
.nav__n--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
|
||||||
|
|
||||||
|
/* Closed folds. The summary IS a nav row; a chevron says it opens. */
|
||||||
|
.nav__fold > summary { list-style: none; }
|
||||||
|
.nav__fold > summary::-webkit-details-marker { display: none; }
|
||||||
|
.nav__fold > summary .nav__name::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px; height: 5px;
|
||||||
|
margin: 0 8px 1px 0;
|
||||||
|
border-right: 1.5px solid var(--zk-grey);
|
||||||
|
border-bottom: 1.5px solid var(--zk-grey);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transition: transform var(--t-fast);
|
||||||
|
}
|
||||||
|
.nav__fold[open] > summary .nav__name::before { transform: rotate(45deg); }
|
||||||
|
.nav__sub { display: flex; flex-direction: column; gap: 2px; padding-left: 12px; }
|
||||||
|
|
||||||
|
/* ── Canvas ───────────────────────────────────────────────────────────────── */
|
||||||
|
.shell__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--zk-tint);
|
||||||
|
padding: 28px 32px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
/* ── PHONE ──────────────────────────────────────────────────────────────
|
||||||
|
The masthead keeps one line and one line only: the mark, the app name,
|
||||||
|
the person. Everything secondary goes, because a 56px bar that wraps is
|
||||||
|
a bar that clips, which is what it did.
|
||||||
|
|
||||||
|
The rail becomes a drawer over the page rather than a block above it —
|
||||||
|
nine queues stacked at the top meant scrolling past the whole menu on
|
||||||
|
every screen before reaching a single lead. */
|
||||||
|
.shell__top { gap: 12px; padding: 0 14px; }
|
||||||
|
.mast__burger { display: grid; }
|
||||||
|
.mast__sep,
|
||||||
|
.mast__app span { display: none; }
|
||||||
|
/* "General Insurance" is inside the artwork, so it cannot be dropped on its
|
||||||
|
own the way the type lockup's second line was. The mark shrinks instead. */
|
||||||
|
.mast__logo { height: 24px; }
|
||||||
|
.mast__app { font-size: 15.5px; }
|
||||||
|
|
||||||
|
.shell__body { flex-direction: row; }
|
||||||
|
.shell__nav {
|
||||||
|
position: fixed; top: 56px; left: 0; bottom: 0; z-index: 40;
|
||||||
|
width: 268px; max-width: 82vw; min-height: 0; max-height: none;
|
||||||
|
border-right: 1px solid var(--zk-line);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .22s var(--ease);
|
||||||
|
overflow-y: auto; overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.shell__nav.is-open { transform: none; box-shadow: 0 12px 40px rgba(11, 42, 91, .22); }
|
||||||
|
.shell__scrim { display: block; }
|
||||||
|
.shell__main { padding: 20px 16px 48px; min-width: 0; flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
/* Below this the app name and the wordmark cannot both fit beside the user
|
||||||
|
menu without one of them truncating. The mark wins — it is the brand. */
|
||||||
|
.mast__app { display: none; }
|
||||||
|
.um__id { display: none; }
|
||||||
|
.um__caret { display: none; }
|
||||||
|
.um__trigger { padding: 4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.shell__nav { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The support desk, reachable from anywhere in the console. Ops only — the
|
||||||
|
button is not drawn for anyone else, and the platform refuses them anyway. */
|
||||||
|
.mast__desk {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 14px; border-radius: var(--r-pill);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .28); background: transparent; color: #fff;
|
||||||
|
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
|
}
|
||||||
|
.mast__desk svg { width: 14px; height: 14px; }
|
||||||
|
.mast__desk:hover { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .5); }
|
||||||
|
.mast__desk:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
|
||||||
|
/* With the desk button present it owns the auto margin, so the user menu must
|
||||||
|
not also claim one or the two separate to opposite ends. */
|
||||||
|
.mast__desk ~ .mast__user { margin-left: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) { .mast__desk span, .mast__desk { font-size: 0; gap: 0; padding: 8px; } .mast__desk svg { font-size: initial; } }
|
||||||
|
|||||||
@ -1,75 +1,159 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
import { ASK_DESK_ROLES, NAV_GROUPS, STAGES } from '../api/config.js'
|
||||||
|
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
|
import { useStageCounts } from '../api/portfolio.jsx'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { STAGES } from '../api/config.js'
|
import AskDesk from '../components/AskDesk.jsx'
|
||||||
|
import UserMenu from './UserMenu.jsx'
|
||||||
import logo from '../assets/brand/zurich_logo.webp'
|
import logo from '../assets/brand/zurich_logo.webp'
|
||||||
import './Shell.css'
|
import './Shell.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sidebar IS the pipeline, in the order a lead moves — so the shape of the
|
* The frame: a navy masthead, a white rail, a grey canvas.
|
||||||
* process is legible before any record is opened. The one human queue is marked
|
*
|
||||||
* as such, because it is the only place the machine stops.
|
* The rail groups the pipeline by WHO IS HOLDING each lead — a person, the
|
||||||
|
* customer, the calendar — because "is anything waiting on me?" is the question
|
||||||
|
* anyone signing in actually has. The count on each row is what needs a person;
|
||||||
|
* its colour says how urgently, on the one colour rule the whole console keeps:
|
||||||
|
* red for risk, amber for a person, teal for the customer, blue for the AI.
|
||||||
|
*
|
||||||
|
* Closed lives under History and stays folded: it is reporting, not work.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** The badge colour for a queue: the colour of whoever is being waited on. */
|
||||||
|
function tone(stage) {
|
||||||
|
if (stage.kind === 'needs') return 'red'
|
||||||
|
if (stage.kind === 'customer') return 'amber'
|
||||||
|
return 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Shell() {
|
export default function Shell() {
|
||||||
const { user, signOut } = useZino()
|
const { user } = useZino()
|
||||||
const work = STAGES.filter((s) => s.kind !== 'end')
|
const roles = rolesOf(user)
|
||||||
const closed = STAGES.filter((s) => s.kind === 'end')
|
const stages = visibleStages(roles)
|
||||||
|
const { counts, ready } = useStageCounts()
|
||||||
|
// On a phone the rail becomes a drawer. It is closed on every navigation,
|
||||||
|
// because a menu that stays open over the page you just chose is a menu you
|
||||||
|
// have to dismiss twice. Desktop never reads this — the rail is always there.
|
||||||
|
const [navOpen, setNavOpen] = useState(false)
|
||||||
|
// The support desk reads the WHOLE book — every partner, every premium — so
|
||||||
|
// it is an ops tool, not something a partner agent may open. The platform
|
||||||
|
// enforces this too (the desk is assigned per user); this only decides
|
||||||
|
// whether the button is drawn.
|
||||||
|
const [deskOpen, setDeskOpen] = useState(false)
|
||||||
|
const canAskDesk = ASK_DESK_ROLES.some((r) => roles.includes(r))
|
||||||
|
// Closing on the link rather than on the route: a menu that stays open over
|
||||||
|
// the page you just chose is a menu you have to dismiss twice. Keyed off the
|
||||||
|
// anchor so opening the Closed fold — which is not navigation — leaves it up.
|
||||||
|
const closeIfLink = (e) => { if (e.target.closest('a')) setNavOpen(false) }
|
||||||
|
|
||||||
|
const count = (s) => counts[s.uid] || { total: 0, waiting: 0, working: 0, urgent: 0 }
|
||||||
|
|
||||||
|
// "All leads" carries the open book, not the whole book.
|
||||||
|
const openTotal = STAGES
|
||||||
|
.filter((s) => s.kind !== 'end')
|
||||||
|
.reduce((t, s) => t + count(s).total, 0)
|
||||||
|
|
||||||
|
const row = (s) => {
|
||||||
|
const c = count(s)
|
||||||
|
const n = s.kind === 'end' ? c.total : c.waiting
|
||||||
|
const hint = c.working ? `${c.working} being worked by the AI` : undefined
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={s.uid}
|
||||||
|
to={`/stage/${s.uid}`}
|
||||||
|
title={hint}
|
||||||
|
className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}
|
||||||
|
>
|
||||||
|
<span className="nav__name">{s.need ?? s.name}</span>
|
||||||
|
{ready ? <span className={`nav__n nav__n--${n ? tone(s) : 'grey'}`}>{n}</span> : null}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell__top">
|
<header className="shell__top">
|
||||||
<div className="shell__brand">
|
<button
|
||||||
<img src={logo} alt="Zurich Kotak General Insurance" />
|
type="button" className="mast__burger"
|
||||||
<div>
|
aria-label={navOpen ? 'Close the menu' : 'Open the menu'} aria-expanded={navOpen}
|
||||||
<strong>Lead Desk</strong>
|
onClick={() => setNavOpen((o) => !o)}
|
||||||
<span>Lead to Policy</span>
|
>
|
||||||
</div>
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||||
</div>
|
{navOpen
|
||||||
<div className="shell__who">
|
? <path d="M5 5l10 10M15 5L5 15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
<div className="shell__user">
|
: <path d="M3 6h14M3 10h14M3 14h14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />}
|
||||||
<strong>{user?.name || user?.email || 'Signed in'}</strong>
|
</svg>
|
||||||
{/* The role is shown because RBAC is the demo: what this person can
|
</button>
|
||||||
and cannot do is the point, so who they are signed in as should
|
{/* The lockup as ARTWORK, not as type. It was set in Source Sans with
|
||||||
never be a guess. */}
|
a drawn Z, which is a passable imitation of a brand and not the
|
||||||
<span>{(user?.roles || []).join(', ') || user?.email}</span>
|
brand: the real mark's proportions, its weight contrast and its
|
||||||
</div>
|
plate are all in the asset. Bundled from src/assets so Vite
|
||||||
<button type="button" className="shell__signout" onClick={signOut}>
|
rewrites the URL and it resolves under the sub-path mount. */}
|
||||||
Sign out
|
<img className="mast__logo" src={logo} alt="Zurich Kotak General Insurance" />
|
||||||
|
<span className="mast__sep" aria-hidden="true" />
|
||||||
|
<div className="mast__app">Lead Desk <span>Lead to policy</span></div>
|
||||||
|
{canAskDesk ? (
|
||||||
|
<button type="button" className="mast__desk" onClick={() => setDeskOpen(true)}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.3" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
Ask the desk
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : null}
|
||||||
|
<div className="mast__user"><UserMenu /></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="shell__body">
|
<div className="shell__body">
|
||||||
<nav className="shell__nav" aria-label="Pipeline">
|
{/* The scrim only exists while the drawer is open, and only on a phone:
|
||||||
<NavLink to="/add" className="shell__add">+ Add a lead</NavLink>
|
tapping the page you can see should put the menu away. */}
|
||||||
<p className="shell__navlabel">Pipeline</p>
|
{navOpen ? <button type="button" className="shell__scrim" aria-label="Close the menu" onClick={() => setNavOpen(false)} /> : null}
|
||||||
{work.map((s) => (
|
<nav className={'shell__nav' + (navOpen ? ' is-open' : '')} aria-label="Pipeline" onClick={closeIfLink}>
|
||||||
<NavLink
|
<NavLink to="/" end className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
|
||||||
key={s.uid}
|
<span className="nav__name">Overview</span>
|
||||||
to={`/stage/${s.uid}`}
|
</NavLink>
|
||||||
className={({ isActive }) =>
|
<NavLink to="/stage/all" className={({ isActive }) => 'nav__link' + (isActive ? ' is-on' : '')}>
|
||||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
|
<span className="nav__name">All leads</span>
|
||||||
}
|
{ready ? <span className="nav__n nav__n--grey">{openTotal}</span> : null}
|
||||||
>
|
</NavLink>
|
||||||
<span className="shell__stagename">{s.name}</span>
|
|
||||||
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
|
{NAV_GROUPS.map((group) => {
|
||||||
</NavLink>
|
const inGroup = stages.filter((s) => group.kinds.includes(s.kind))
|
||||||
))}
|
if (!inGroup.length) return null
|
||||||
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
|
|
||||||
{closed.map((s) => (
|
if (group.kinds.includes('end')) {
|
||||||
<NavLink
|
const closed = inGroup.reduce((t, s) => t + count(s).total, 0)
|
||||||
key={s.uid}
|
return (
|
||||||
to={`/stage/${s.uid}`}
|
<div className="nav__group" key={group.label}>
|
||||||
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
|
<h6 className="nav__h">{group.label}</h6>
|
||||||
>
|
<details className="nav__fold">
|
||||||
<span className="shell__stagename">{s.name}</span>
|
<summary className="nav__link">
|
||||||
</NavLink>
|
<span className="nav__name">Closed</span>
|
||||||
))}
|
{ready ? <span className="nav__n nav__n--grey">{closed}</span> : null}
|
||||||
|
</summary>
|
||||||
|
<div className="nav__sub">{inGroup.map(row)}</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nav__group" key={group.label}>
|
||||||
|
<h6 className="nav__h">{group.label}</h6>
|
||||||
|
{inGroup.map(row)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="shell__main">
|
<main className="shell__main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{deskOpen ? <AskDesk onClose={() => setDeskOpen(false)} /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/layout/UserMenu.css
Normal file
59
src/layout/UserMenu.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.um { position: relative; }
|
||||||
|
|
||||||
|
/* The trigger sits on the navy masthead, so it is white type on navy and the
|
||||||
|
avatar inverts: a white disc with navy initials. */
|
||||||
|
.um__trigger {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px 5px 5px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #fff;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.um__trigger:hover, .um__trigger.is-open { background: rgba(255, 255, 255, .10); }
|
||||||
|
.um__trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255, 255, 255, .28); }
|
||||||
|
|
||||||
|
.um__avatar {
|
||||||
|
flex: none; width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: #fff; color: var(--zk-navy);
|
||||||
|
font-size: 13px; font-weight: 700; letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.um__avatar--lg { width: 40px; height: 40px; font-size: 15px; background: var(--zk-tint-blue); color: var(--zk-navy); }
|
||||||
|
|
||||||
|
.um__id { display: flex; flex-direction: column; text-align: left; line-height: 1.25; }
|
||||||
|
.um__id strong { font-size: 15px; font-weight: 600; }
|
||||||
|
.um__id > span { font-size: 12.5px; opacity: .72; }
|
||||||
|
|
||||||
|
.um__caret { width: 12px; height: 12px; flex: none; opacity: .6; transition: transform var(--t); }
|
||||||
|
.um__trigger.is-open .um__caret { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* The menu drops onto the white canvas below the bar. */
|
||||||
|
.um__menu {
|
||||||
|
position: absolute; top: calc(100% + 10px); right: 0; z-index: 40;
|
||||||
|
min-width: 300px; padding: 7px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: #fff; color: var(--zk-ink);
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
box-shadow: var(--sh-lg);
|
||||||
|
animation: zk-rise .16s var(--ease) both;
|
||||||
|
}
|
||||||
|
.um__head { display: flex; align-items: flex-start; gap: 12px; padding: 12px 11px 14px; }
|
||||||
|
.um__headid { display: flex; flex-direction: column; gap: 1px; line-height: 1.35; min-width: 0; }
|
||||||
|
.um__headid strong { font-size: var(--fs-sm); font-weight: 600; }
|
||||||
|
.um__headid span { font-size: var(--fs-2xs); color: var(--zk-muted); overflow-wrap: anywhere; }
|
||||||
|
.um__headid .um__roles {
|
||||||
|
margin-top: 4px; align-self: flex-start;
|
||||||
|
font-size: var(--fs-2xs); font-weight: 600;
|
||||||
|
color: var(--zk-navy); background: var(--zk-tint-blue);
|
||||||
|
border-radius: var(--r-pill); padding: 2px 9px;
|
||||||
|
}
|
||||||
|
.um__item {
|
||||||
|
display: block; width: 100%; text-align: left; cursor: pointer;
|
||||||
|
font: inherit; font-size: var(--fs-sm); padding: 10px 11px;
|
||||||
|
border-radius: var(--r-sm); background: none; border: 0;
|
||||||
|
border-top: 1px solid var(--zk-line-soft); color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.um__item:hover, .um__item:focus-visible { outline: none; background: var(--zk-tint); color: var(--zk-navy); }
|
||||||
92
src/layout/UserMenu.jsx
Normal file
92
src/layout/UserMenu.jsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import './UserMenu.css'
|
||||||
|
|
||||||
|
/** First letters of the first two words — a name is all we have for an avatar. */
|
||||||
|
function initials(user) {
|
||||||
|
const from = user?.name || user?.email || ''
|
||||||
|
const parts = from.replace(/@.*$/, '').split(/[\s._-]+/).filter(Boolean)
|
||||||
|
if (!parts.length) return '?'
|
||||||
|
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in identity, with sign-out folded inside it.
|
||||||
|
*
|
||||||
|
* The role stays visible on the trigger rather than only inside the menu: RBAC is
|
||||||
|
* the demo, so who you are signed in as should never take a click to find out.
|
||||||
|
*/
|
||||||
|
export default function UserMenu() {
|
||||||
|
const { user, signOut } = useZino()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapRef = useRef(null)
|
||||||
|
const triggerRef = useRef(null)
|
||||||
|
|
||||||
|
// Close on an outside click or Escape. Bound only while open, so the shell
|
||||||
|
// costs nothing at rest.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onDown(e) {
|
||||||
|
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
||||||
|
}
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key !== 'Escape') return
|
||||||
|
setOpen(false)
|
||||||
|
triggerRef.current?.focus()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const name = user?.name || user?.email || 'Signed in'
|
||||||
|
// Role slugs read as titles: partner_agent -> Partner agent.
|
||||||
|
const roles = (user?.roles || [])
|
||||||
|
// `sme_underwriter` → "SME underwriter": the acronyms the roles here use stay upper.
|
||||||
|
// `sme_underwriter` → "SME underwriter". Ops is a word, not an acronym.
|
||||||
|
.map((r) => String(r).replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||||
|
.replace(/\b(sme|uw|rm|posp|csr|kyc)\b/gi, (m) => m.toUpperCase()))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="um" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={'um__trigger' + (open ? ' is-open' : '')}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="um__avatar" aria-hidden="true">{initials(user)}</span>
|
||||||
|
<span className="um__id">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
<span>{roles || user?.email}</span>
|
||||||
|
</span>
|
||||||
|
<svg className="um__caret" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.4"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="um__menu" role="menu">
|
||||||
|
<div className="um__head">
|
||||||
|
<span className="um__avatar um__avatar--lg" aria-hidden="true">{initials(user)}</span>
|
||||||
|
<div className="um__headid">
|
||||||
|
<strong>{name}</strong>
|
||||||
|
{user?.email ? <span>{user.email}</span> : null}
|
||||||
|
{roles ? <span className="um__roles">{roles}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -68,7 +68,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: var(--zk-cyan);
|
color: var(--zk-cyan);
|
||||||
font-size: 13px;
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@ -81,7 +81,7 @@
|
|||||||
.login__headline {
|
.login__headline {
|
||||||
/* Zurich Kotak set their hero in Zurich Sans Light */
|
/* Zurich Kotak set their hero in Zurich Sans Light */
|
||||||
margin: 26px 0 0;
|
margin: 26px 0 0;
|
||||||
font-size: 44px;
|
font-size: var(--fs-3xl);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
@ -104,7 +104,7 @@
|
|||||||
margin: 16px 0 0;
|
margin: 16px 0 0;
|
||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
font-size: 16px;
|
font-size: var(--fs-md);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
.login__features strong {
|
.login__features strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 15px;
|
font-size: var(--fs-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.005em;
|
letter-spacing: 0.005em;
|
||||||
}
|
}
|
||||||
@ -152,7 +152,7 @@
|
|||||||
.login__features em {
|
.login__features em {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-size: 14px;
|
font-size: var(--fs-xs);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: rgba(255, 255, 255, 0.74);
|
color: rgba(255, 255, 255, 0.74);
|
||||||
@ -178,7 +178,7 @@
|
|||||||
|
|
||||||
.login__title {
|
.login__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 30px;
|
font-size: var(--fs-2xl);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
color: var(--zk-navy);
|
color: var(--zk-navy);
|
||||||
@ -186,7 +186,7 @@
|
|||||||
|
|
||||||
.login__subtitle {
|
.login__subtitle {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 14px;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
@ -201,7 +201,7 @@
|
|||||||
.login__field span {
|
.login__field span {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
font-size: 13px;
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
}
|
}
|
||||||
@ -212,7 +212,7 @@
|
|||||||
border: 1px solid var(--zk-line);
|
border: 1px solid var(--zk-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--zk-white);
|
background: var(--zk-white);
|
||||||
font-size: 14px;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--zk-ink);
|
color: var(--zk-ink);
|
||||||
transition:
|
transition:
|
||||||
@ -232,7 +232,7 @@
|
|||||||
|
|
||||||
.login__error {
|
.login__error {
|
||||||
margin: -4px 0 0;
|
margin: -4px 0 0;
|
||||||
font-size: 13px;
|
font-size: var(--fs-2xs);
|
||||||
color: var(--zk-danger);
|
color: var(--zk-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,7 +247,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--zk-blue);
|
background: var(--zk-blue);
|
||||||
color: var(--zk-white);
|
color: var(--zk-white);
|
||||||
font-size: 14px;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
@ -277,7 +277,7 @@
|
|||||||
.login__powered {
|
.login__powered {
|
||||||
margin: 26px 0 0;
|
margin: 26px 0 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: var(--fs-2xs);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--zk-grey);
|
color: var(--zk-grey);
|
||||||
}
|
}
|
||||||
@ -296,11 +296,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login__headline {
|
.login__headline {
|
||||||
font-size: 36px;
|
font-size: var(--fs-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login__lede {
|
.login__lede {
|
||||||
font-size: 15px;
|
font-size: var(--fs-sm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,19 @@ export function requireConfigValue(key) {
|
|||||||
*
|
*
|
||||||
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
|
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
|
||||||
* resolves to "./".
|
* resolves to "./".
|
||||||
|
*
|
||||||
|
* It reads the TAG, not `document.baseURI`. With no <base> in the document —
|
||||||
|
* which is every `npm run dev` session, where the placeholder is still an HTML
|
||||||
|
* comment — baseURI falls back to the current page URL, so the basename became
|
||||||
|
* whatever deep path happened to be loaded. Open /lead/16911 directly and every
|
||||||
|
* link then rendered under it: /lead/16911/lead/11411, and again on the next
|
||||||
|
* click. Unmounted means mounted at the root.
|
||||||
*/
|
*/
|
||||||
export function basePath() {
|
export function basePath() {
|
||||||
return new URL(document.baseURI).pathname
|
const tag = document.querySelector('base[href]')
|
||||||
|
if (!tag) return '/'
|
||||||
|
const path = new URL(tag.href, window.location.origin).pathname
|
||||||
|
// A relative href ("./") resolves against the current URL and would reintroduce
|
||||||
|
// exactly the same drift. Only an absolute mount path is a mount path.
|
||||||
|
return tag.getAttribute('href').startsWith('/') ? path : '/'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import ActivityForm from '../components/ActivityForm.jsx'
|
|
||||||
import { ENTRY } from '../api/config.js'
|
|
||||||
import './screens.css'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The three doors. Each is a separate INIT activity with its own permissions,
|
|
||||||
* and the bank one lands the instance further along because the bank already
|
|
||||||
* did CKYC. Whichever door is used, everything after it is identical.
|
|
||||||
*/
|
|
||||||
export default function AddLead() {
|
|
||||||
// One door surfaced: skip the chooser entirely rather than show a list of one.
|
|
||||||
const [entry, setEntry] = useState(ENTRY.length === 1 ? ENTRY[0] : null)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<header className="page__head">
|
|
||||||
<div>
|
|
||||||
<h1 className="page__title">Add a lead</h1>
|
|
||||||
<p className="page__sub">Three doors, one machine. The door decides who may submit and where it lands — nothing after that.</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{!entry ? (
|
|
||||||
<div className="doors">
|
|
||||||
{ENTRY.map((e) => (
|
|
||||||
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
|
||||||
<strong>{e.label}</strong>
|
|
||||||
<span className="chip">{e.channel}</span>
|
|
||||||
<p>{e.note}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="panel">
|
|
||||||
<div className="panel__head">
|
|
||||||
<div>
|
|
||||||
<h2 className="panel__title">{entry.label}</h2>
|
|
||||||
<p className="panel__sub">{entry.note}</p>
|
|
||||||
</div>
|
|
||||||
{ENTRY.length > 1 ? (
|
|
||||||
<button type="button" className="af__ghost" onClick={() => setEntry(null)}>Change door</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ActivityForm
|
|
||||||
activityUid={entry.uid}
|
|
||||||
onDone={(res) => {
|
|
||||||
const id = res?.instance_id || res?.data?.instance_id
|
|
||||||
if (id) navigate(`/lead/${id}`)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
461
src/screens/Overview.jsx
Normal file
461
src/screens/Overview.jsx
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { usePortfolio } from '../api/portfolio.jsx'
|
||||||
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
||||||
|
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
|
import { describeError } from '../api/errors.js'
|
||||||
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||||
|
import { holderOf } from '../api/holder.js'
|
||||||
|
import './screens.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portfolio overview — the landing screen.
|
||||||
|
*
|
||||||
|
* Everything is derived from ONE record-view call and computed here. That call
|
||||||
|
* now lives in PortfolioProvider and is shared with the sidebar's tallies, so
|
||||||
|
* the two cannot disagree and there is no second fetch. If the book outgrows a
|
||||||
|
* single page the answer is an aggregate endpoint, not a larger limit.
|
||||||
|
*
|
||||||
|
* Counts cover the WHOLE portfolio for every role, including queues the role
|
||||||
|
* cannot work in. Reporting and permission are different questions: an agent
|
||||||
|
* tracking leads they filed is reasonable, acting on them is not, and the
|
||||||
|
* sidebar already gates the second.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 86400000
|
||||||
|
const today = () => Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')
|
||||||
|
|
||||||
|
function daysToExpiry(v) {
|
||||||
|
if (!v) return null
|
||||||
|
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
|
||||||
|
return isNaN(d) ? null : Math.round((d - today()) / DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageInDays(v) {
|
||||||
|
if (!v) return null
|
||||||
|
const d = Date.parse(v)
|
||||||
|
return isNaN(d) ? null : Math.floor((Date.now() - d) / DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
|
||||||
|
|
||||||
|
/** Indian money, short. A dashboard tile has no room for eight digits. */
|
||||||
|
function inr(n) {
|
||||||
|
if (!n) return '₹0'
|
||||||
|
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
|
||||||
|
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
|
||||||
|
return '₹' + Math.round(n).toLocaleString('en-IN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The renewal-exposure buckets, as predicates on days-to-expiry, so the filter
|
||||||
|
and the three counts stay one definition. */
|
||||||
|
const EXPO_BUCKETS = {
|
||||||
|
lapsed: (d) => d < 0,
|
||||||
|
week: (d) => d >= 0 && d <= 7,
|
||||||
|
month: (d) => d > 7 && d <= 30,
|
||||||
|
}
|
||||||
|
const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' }
|
||||||
|
/* The renewal-exposure table is filterable on the columns it shows. Each field
|
||||||
|
knows how to match a row and how to describe itself as a chip. */
|
||||||
|
const PREM_RANGES = [
|
||||||
|
['lt10', 'Under ₹10,000', (p) => p > 0 && p < 10000],
|
||||||
|
['10-25', '₹10,000 – 25,000', (p) => p >= 10000 && p < 25000],
|
||||||
|
['25-50', '₹25,000 – 50,000', (p) => p >= 25000 && p < 50000],
|
||||||
|
['gt50', 'Over ₹50,000', (p) => p >= 50000],
|
||||||
|
]
|
||||||
|
const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn]))
|
||||||
|
|
||||||
|
|
||||||
|
export default function Overview() {
|
||||||
|
const { user } = useZino()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const roles = rolesOf(user)
|
||||||
|
const canFile = entryDoorsFor(roles, ENTRY).length > 0
|
||||||
|
// Filing a lead happens over this page, not instead of it — see
|
||||||
|
// NewLeadDialog. The overview behind it keeps its counts and its poll.
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
// Renewal-exposure dropdown filters. Empty string = no filter on that field.
|
||||||
|
const [fStage, setFStage] = useState('')
|
||||||
|
const [fExpiry, setFExpiry] = useState('')
|
||||||
|
const [fPrem, setFPrem] = useState('')
|
||||||
|
const state = usePortfolio()
|
||||||
|
|
||||||
|
const m = useMemo(() => {
|
||||||
|
const rows = state.rows
|
||||||
|
const byStage = {}
|
||||||
|
for (const r of rows) byStage[r.current_state_name || '—'] = (byStage[r.current_state_name || '—'] || 0) + 1
|
||||||
|
|
||||||
|
const endNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name))
|
||||||
|
const open = rows.filter((r) => !endNames.has(r.current_state_name))
|
||||||
|
const won = rows.filter((r) => r.current_state_name === 'Onboarded' || r.current_state_name === 'Policy Issued')
|
||||||
|
const lost = rows.filter((r) => r.current_state_name === 'Lost / Dropped' || r.current_state_name === 'Declined')
|
||||||
|
|
||||||
|
const closed = won.length + lost.length
|
||||||
|
|
||||||
|
// Written premium counts only policies that exist. A quote nobody accepted
|
||||||
|
// is pipeline, not production, and adding the two is how a dashboard ends
|
||||||
|
// up flattering itself.
|
||||||
|
const gwp = won.reduce((t, r) => t + num(r.quoted_premium), 0)
|
||||||
|
const commission = won.reduce((t, r) => t + num(r.commission_amount), 0)
|
||||||
|
// Policy Issued is written business AND not yet terminal, so it appears in
|
||||||
|
// `open` as well as in `won`. Summing both would count the same premium
|
||||||
|
// twice — once as pipeline, once as production. Pipeline is what is still
|
||||||
|
// to be placed, so anything already on risk comes out of it.
|
||||||
|
const wonIds = new Set(won.map((r) => r.instance_id ?? r.id))
|
||||||
|
const pipeline = open
|
||||||
|
.filter((r) => !wonIds.has(r.instance_id ?? r.id))
|
||||||
|
.reduce((t, r) => t + num(r.quoted_premium), 0)
|
||||||
|
|
||||||
|
const dated = open
|
||||||
|
.map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date), _age: ageInDays(r.created_at) }))
|
||||||
|
.filter((r) => r._d !== null)
|
||||||
|
|
||||||
|
const exposure = {
|
||||||
|
lapsed: dated.filter((r) => r._d < 0),
|
||||||
|
week: dated.filter((r) => r._d >= 0 && r._d <= 7),
|
||||||
|
month: dated.filter((r) => r._d > 7 && r._d <= 30),
|
||||||
|
}
|
||||||
|
|
||||||
|
const attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d)
|
||||||
|
|
||||||
|
// Queues a person has to clear, in the order the pipeline runs.
|
||||||
|
//
|
||||||
|
// The count is of leads a person actually has to act on — NOT of leads
|
||||||
|
// sitting in the state. Document Pending holds both: the ones waiting for
|
||||||
|
// an upload, and the ones whose upload arrived and are now being worked by
|
||||||
|
// three AI steps inside the same state. Counting the state told an operator
|
||||||
|
// that four leads needed them when two did, which is how a queue stops
|
||||||
|
// being believed. `working` is reported separately rather than dropped —
|
||||||
|
// the leads are still there, they are just not anyone's task.
|
||||||
|
const actionable = STAGES
|
||||||
|
.filter((s) => s.kind === 'needs' || s.kind === 'customer')
|
||||||
|
.map((s) => {
|
||||||
|
const here = open.filter((r) => r.current_state_name === s.name)
|
||||||
|
const working = here.filter((r) => phaseOf(s, r).kind === 'auto')
|
||||||
|
const waiting = here.filter((r) => phaseOf(s, r).kind !== 'auto')
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
n: waiting.length,
|
||||||
|
working: working.length,
|
||||||
|
oldest: waiting
|
||||||
|
.map((r) => ageInDays(r.created_at))
|
||||||
|
.filter((x) => x !== null)
|
||||||
|
.sort((a, b) => b - a)[0],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// The sign-off queues this ROLE owns. Empty for everyone else, so the band
|
||||||
|
// never appears to a partner agent who could not act on it anyway.
|
||||||
|
const approvals = actionable.filter((s) => s.approval && roles.includes(s.approval))
|
||||||
|
|
||||||
|
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
|
||||||
|
// Every open lead is in exactly one of three hands: a person's, the AI's,
|
||||||
|
// or the customer's. The page led with money, which is the outcome; this
|
||||||
|
// is the state, and it is the sentence the whole product exists to say.
|
||||||
|
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
|
||||||
|
let withAI = 0, withCustomer = 0, withPerson = 0
|
||||||
|
for (const r of open) {
|
||||||
|
const def = byStageDef.get(r.current_state_name)
|
||||||
|
if (!def) continue
|
||||||
|
const ph = phaseOf(def, r)
|
||||||
|
if (ph.kind === 'auto') withAI += 1
|
||||||
|
else if (ph.kind === 'customer') withCustomer += 1
|
||||||
|
else withPerson += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
|
||||||
|
withAI, withCustomer, withPerson,
|
||||||
|
conversion: closed ? Math.round((won.length / closed) * 100) : null,
|
||||||
|
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
|
||||||
|
}
|
||||||
|
}, [state.rows, roles])
|
||||||
|
|
||||||
|
const mine = new Set(visibleStages(roles).map((s) => s.uid))
|
||||||
|
|
||||||
|
// Stages present in the exposure set, for the Stage filter's options.
|
||||||
|
const expoStages = [...new Set(m.attention.map((r) => r.current_state_name).filter(Boolean))]
|
||||||
|
const anyFilter = Boolean(fStage || fExpiry || fPrem)
|
||||||
|
// The exposure table, narrowed by whichever dropdowns are set (AND).
|
||||||
|
const shown = m.attention.filter((r) =>
|
||||||
|
(!fStage || r.current_state_name === fStage) &&
|
||||||
|
(!fExpiry || (EXPO_BUCKETS[fExpiry] ? EXPO_BUCKETS[fExpiry](r._d) : true)) &&
|
||||||
|
(!fPrem || (PREM_MATCH[fPrem] ? PREM_MATCH[fPrem](num(r.quoted_premium)) : true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.status === 'error') {
|
||||||
|
const said = describeError(state.error)
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
||||||
|
<div className="notice">
|
||||||
|
<strong>{said.title}</strong>
|
||||||
|
{said.detail ? <p>{said.detail}</p> : null}
|
||||||
|
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'loading' && !state.rows.length) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
||||||
|
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page__title">Portfolio overview</h1>
|
||||||
|
<p className="page__sub">Motor and SME renewals, organisation-wide</p>
|
||||||
|
</div>
|
||||||
|
{/* The action sits with the thing it acts on. It was in the sidebar,
|
||||||
|
which is for moving between places — an action among destinations
|
||||||
|
is the one item that does not behave like its neighbours. */}
|
||||||
|
<div className="page__right">
|
||||||
|
{state.at ? (
|
||||||
|
<span className="stamp">
|
||||||
|
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{canFile ? (
|
||||||
|
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.7" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
New lead
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Who holds the work, right now. Three numbers, one sentence: this many
|
||||||
|
are being worked by the AI, this many are waiting on a person, this
|
||||||
|
many on a customer. It sits above the money because it is the claim
|
||||||
|
the money is evidence for. */}
|
||||||
|
{/* WHO HOLDS THE WORK, RIGHT NOW. Every open lead is in exactly one of
|
||||||
|
three hands — the AI's, a person's, the customer's — and that is the
|
||||||
|
sentence this console exists to say. The money below is evidence for
|
||||||
|
it. Risk stands apart, in the one colour reserved for it. */}
|
||||||
|
{(() => {
|
||||||
|
const total = m.open.length || 1
|
||||||
|
const pct = (n) => Math.max(n ? 2 : 0, Math.round((n / total) * 100))
|
||||||
|
const risk = m.exposure.lapsed.length + m.exposure.week.length
|
||||||
|
return (
|
||||||
|
<section className="hero">
|
||||||
|
<div className="card holders">
|
||||||
|
<div className="holders__top">
|
||||||
|
<b>{m.open.length}</b>
|
||||||
|
<span>open leads. Who is holding each one right now:</span>
|
||||||
|
</div>
|
||||||
|
<div className="bar" aria-hidden="true">
|
||||||
|
<div className="bar__blue" style={{ width: pct(m.withAI) + '%' }} />
|
||||||
|
<div className="bar__amber" style={{ width: pct(m.withPerson) + '%' }} />
|
||||||
|
<div className="bar__teal" style={{ width: pct(m.withCustomer) + '%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="legend">
|
||||||
|
<div className="legend__item legend__item--blue">
|
||||||
|
<b>{m.withAI}</b><strong>With the AI</strong>
|
||||||
|
<span>Being qualified, rated, checked or chased. Nothing for anyone to do.</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__item legend__item--amber">
|
||||||
|
<b>{m.withPerson}</b><strong>Waiting on a person</strong>
|
||||||
|
<span>A decision or an upload is owed by an agent, underwriter or ops.</span>
|
||||||
|
</div>
|
||||||
|
<div className="legend__item legend__item--teal">
|
||||||
|
<b>{m.withCustomer}</b><strong>With the customer</strong>
|
||||||
|
<span>A quote or a payment is theirs to answer.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={'card risk' + (risk ? '' : ' is-quiet')}>
|
||||||
|
<b>{risk}</b><strong>{risk === 1 ? 'renewal at risk' : 'renewals at risk'}</strong>
|
||||||
|
<div className="risk__rows">
|
||||||
|
<div><span>Already lapsed</span><em>{m.exposure.lapsed.length}</em></div>
|
||||||
|
<div><span>Expire within 7 days</span><em>{m.exposure.week.length}</em></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<section className="card kpis">
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Pipeline value</span>
|
||||||
|
<strong className="kpi__v">{inr(m.pipeline)}</strong>
|
||||||
|
<span className="kpi__s">quoted, not yet on risk</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Written premium</span>
|
||||||
|
<strong className="kpi__v">{inr(m.gwp)}</strong>
|
||||||
|
<span className="kpi__s">{m.won.length} {m.won.length === 1 ? 'policy' : 'policies'} issued</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Conversion</span>
|
||||||
|
<strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong>
|
||||||
|
<span className="kpi__s">of leads that closed</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Commission</span>
|
||||||
|
<strong className="kpi__v">{inr(m.commission)}</strong>
|
||||||
|
<span className="kpi__s">payable to partners</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Closed to date</span>
|
||||||
|
<strong className="kpi__v">{m.closed}</strong>
|
||||||
|
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SIGN-OFF, not work: the one queue that is a person's approval rather
|
||||||
|
than a task, shown to the role that owns it and to nobody else. */}
|
||||||
|
{m.approvals.map((s) => (
|
||||||
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
|
||||||
|
<strong className="appr__n">{s.n}</strong>
|
||||||
|
<span className="appr__t">{s.approvalLabel}<em>{s.approvalNote}</em></span>
|
||||||
|
<span className="appr__go">{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest} days` : 'review') : 'nothing waiting'}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<section className="cols">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card__hd">
|
||||||
|
<h3>Waiting on a person</h3>
|
||||||
|
<p>{m.actionable.reduce((t, s) => t + s.n, 0)} leads, oldest first</p>
|
||||||
|
</div>
|
||||||
|
<div className="queue">
|
||||||
|
{[...m.actionable].sort((a, b) => (b.oldest ?? -1) - (a.oldest ?? -1)).map((s) => (
|
||||||
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'queue__row' + (s.n ? '' : ' is-clear')}>
|
||||||
|
<b className="queue__n">{s.n}</b>
|
||||||
|
<div>
|
||||||
|
<div className="queue__t">{s.need ?? s.name}</div>
|
||||||
|
<div className="queue__who">
|
||||||
|
<span className={'dot dot--' + (s.kind === 'customer' ? 'teal' : 'amber')} />
|
||||||
|
{String(s.by).replace(/^(the|an|a)\s+/i, '').replace(/^./, (c) => c.toUpperCase())}
|
||||||
|
{!mine.has(s.uid) ? <span className="queue__ro" title="View only for your role">view</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="queue__age">
|
||||||
|
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest} day${s.oldest === 1 ? '' : 's'}` : ''}
|
||||||
|
{s.working ? <em>{s.working} more with the AI</em> : null}
|
||||||
|
</div>
|
||||||
|
<span className="btn btn--ghost btn--small">Open</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card__hd"><h3>Pipeline by stage</h3><p>{m.open.length} open</p></div>
|
||||||
|
<div className="dist">
|
||||||
|
{STAGES.filter((s) => s.kind !== 'end').map((s) => {
|
||||||
|
const n = m.byStage[s.name] || 0
|
||||||
|
const tone = s.kind === 'auto' ? 'blue' : s.kind === 'customer' ? 'teal' : s.kind === 'needs' ? 'amber' : 'grey'
|
||||||
|
return (
|
||||||
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
|
||||||
|
<span className="dist__l">{s.name}</span>
|
||||||
|
<span className="dist__bar" aria-hidden="true">
|
||||||
|
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + tone} />
|
||||||
|
</span>
|
||||||
|
<b className="dist__n">{n}</b>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="dist__sec">Closed · {m.closed}</div>
|
||||||
|
{STAGES.filter((s) => s.kind === 'end').map((s) => {
|
||||||
|
const n = m.byStage[s.name] || 0
|
||||||
|
const max = Math.max(1, ...STAGES.filter((x) => x.kind === 'end').map((x) => m.byStage[x.name] || 0))
|
||||||
|
return (
|
||||||
|
<Link key={s.uid} to={`/stage/${s.uid}`} className="dist__r is-dim">
|
||||||
|
<span className="dist__l">{s.name}</span>
|
||||||
|
<span className="dist__bar" aria-hidden="true">
|
||||||
|
<span style={{ width: (n / max) * 100 + '%' }} className="dist__f dist__f--grey" />
|
||||||
|
</span>
|
||||||
|
<b className="dist__n">{n}</b>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card exp">
|
||||||
|
<div className="card__hd"><h3>Renewal exposure</h3><p>open leads by time to expiry</p></div>
|
||||||
|
<div className="exp__tabs">
|
||||||
|
<div className="tab tab--red"><b>{m.exposure.lapsed.length}</b>lapsed</div>
|
||||||
|
<div className="tab tab--amber"><b>{m.exposure.week.length}</b>within 7 days</div>
|
||||||
|
<div className="tab"><b>{m.exposure.month.length}</b>8 to 30 days</div>
|
||||||
|
<div className="exp__filters">
|
||||||
|
<label className="xfd__f">
|
||||||
|
<select value={fStage} onChange={(e) => setFStage(e.target.value)} aria-label="Stage">
|
||||||
|
<option value="">All stages</option>
|
||||||
|
{expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="xfd__f">
|
||||||
|
<select value={fExpiry} onChange={(e) => setFExpiry(e.target.value)} aria-label="Time to expiry">
|
||||||
|
<option value="">Any time</option>
|
||||||
|
{Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="xfd__f">
|
||||||
|
<select value={fPrem} onChange={(e) => setFPrem(e.target.value)} aria-label="Premium">
|
||||||
|
<option value="">Any premium</option>
|
||||||
|
{PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{anyFilter ? (
|
||||||
|
<button type="button" className="xfd__clear" onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>Clear</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.length ? (
|
||||||
|
<div className="gridwrap">
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th>Holding it</th><th className="num">Premium</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.slice(0, anyFilter ? 100 : 8).map((r) => {
|
||||||
|
const id = r.instance_id ?? r.id
|
||||||
|
const def = STAGES.find((s) => s.name === r.current_state_name)
|
||||||
|
const h = holderOf(def, r)
|
||||||
|
const tone = r._d < 0 ? 'red' : r._d <= 7 ? 'amber' : 'grey'
|
||||||
|
return (
|
||||||
|
<tr key={id} className="grid__row" tabIndex={0} role="link"
|
||||||
|
onClick={() => navigate(`/lead/${id}`)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) } }}
|
||||||
|
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}>
|
||||||
|
<td>
|
||||||
|
<span className="grid__name" style={{ fontSize: 'var(--fs-sm)' }}>{r.customer_name || r.lead_ref || `#${id}`}</span>
|
||||||
|
<div className="grid__sub">{r.lead_ref || ''}</div>
|
||||||
|
</td>
|
||||||
|
<td><span className={'pill pill--' + tone}>
|
||||||
|
{r._d < 0 ? Math.abs(r._d) + (r._d === -1 ? ' day' : ' days') + ' overdue' : r._d === 0 ? 'today' : r._d + (r._d === 1 ? ' day' : ' days')}
|
||||||
|
</span></td>
|
||||||
|
<td>{r.current_state_name}</td>
|
||||||
|
<td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
|
||||||
|
<td className="num">{r.quoted_premium ? '₹' + Math.round(num(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="empty" style={{ margin: '0 20px 20px' }}>
|
||||||
|
{anyFilter ? 'No open leads match these filters.' : 'No renewals due within 30 days.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
|
import { ENTRY, RV_LEADS, STAGES, phaseOf } from '../api/config.js'
|
||||||
|
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||||
|
import { describeError } from '../api/errors.js'
|
||||||
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
||||||
|
import { holderOf } from '../api/holder.js'
|
||||||
|
|
||||||
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
||||||
* for "when exactly", the relative for "is this going stale". */
|
* for "when exactly", the relative for "is this going stale". */
|
||||||
@ -20,6 +24,33 @@ function added(ts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is an automated stage actually moving?
|
||||||
|
*
|
||||||
|
* The agents carry six of the nine stages, and a lead sitting in one is in
|
||||||
|
* exactly one of two states: being worked right now, or stuck. The row showed
|
||||||
|
* neither, so a lead three hours into "Calling the customer" looked identical
|
||||||
|
* to one thirty seconds in — which is the failure this console exists to catch.
|
||||||
|
*
|
||||||
|
* The threshold is generous on purpose. An employee wake takes a minute or two
|
||||||
|
* and a scheduled retry can be hours out, so `stalled` means "longer than any
|
||||||
|
* normal step", not "longer than average".
|
||||||
|
*/
|
||||||
|
function progress(row, stage) {
|
||||||
|
if (!stage || stage.kind !== 'auto') return null
|
||||||
|
// `updated_at` ONLY. Falling back to created_at dates the measure from when
|
||||||
|
// the lead was filed rather than from its last step, so every lead older than
|
||||||
|
// half an hour reports "stalled" — an alarm on every row is an alarm on none.
|
||||||
|
// The view returns the column since 76; before that it was silently absent,
|
||||||
|
// which is exactly how that fallback got there.
|
||||||
|
const t = Date.parse(row.updated_at)
|
||||||
|
if (isNaN(t)) return null
|
||||||
|
const mins = (Date.now() - t) / 60000
|
||||||
|
if (mins < 3) return { state: 'working', label: stage.doing }
|
||||||
|
if (mins < 30) return { state: 'waiting', label: stage.doing }
|
||||||
|
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
|
||||||
|
}
|
||||||
|
|
||||||
/** Days to expiry, and how alarmed to be about it. This is a renewal book —
|
/** Days to expiry, and how alarmed to be about it. This is a renewal book —
|
||||||
* the countdown is the most operationally useful number in the row. */
|
* the countdown is the most operationally useful number in the row. */
|
||||||
function expiry(dateStr) {
|
function expiry(dateStr) {
|
||||||
@ -41,107 +72,263 @@ import './screens.css'
|
|||||||
*/
|
*/
|
||||||
export default function Pipeline() {
|
export default function Pipeline() {
|
||||||
const { stageUid } = useParams()
|
const { stageUid } = useParams()
|
||||||
const { client } = useZino()
|
const navigate = useNavigate()
|
||||||
const stage = STAGES.find((s) => s.uid === stageUid)
|
const { client, user } = useZino()
|
||||||
|
const canFile = entryDoorsFor(rolesOf(user), ENTRY).length > 0
|
||||||
|
// Over the queue rather than away from it: filing a lead used to cost you
|
||||||
|
// your place in the list you were working through.
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
// "all" is not a stage — it is every lead in one list, so an operator
|
||||||
|
// wanting to find one does not have to guess which queue it is in.
|
||||||
|
//
|
||||||
|
// IT MEANS ALL. This view used to drop closed leads, so a lead that was lost
|
||||||
|
// or onboarded disappeared from the page called "All leads" — the one place
|
||||||
|
// somebody goes when they cannot find something. A lead that ended is still
|
||||||
|
// a lead, and "where did it go?" is exactly the question this page exists to
|
||||||
|
// answer. Closed rows are shown, and dimmed, and can be hidden by choice.
|
||||||
|
const isAll = stageUid === 'all'
|
||||||
|
const [hideClosed, setHideClosed] = useState(false)
|
||||||
|
// Find-a-lead. Client-side, over the page already fetched.
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const stage = isAll
|
||||||
|
? { uid: 'all', name: 'All leads', kind: 'all', by: 'everyone' }
|
||||||
|
: STAGES.find((s) => s.uid === stageUid)
|
||||||
|
|
||||||
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
|
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setState({ status: 'loading', rows: [], error: null })
|
|
||||||
client
|
// `quiet` is what makes polling bearable: the first load may show a skeleton,
|
||||||
.recordView(RV_LEADS, {
|
// a refresh may not — dropping back to the loading state every 30 seconds
|
||||||
limit: 100,
|
// would flash the whole queue away under whoever is reading it.
|
||||||
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
|
function fetchRows(quiet) {
|
||||||
})
|
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
|
||||||
.then((res) => {
|
return client
|
||||||
if (cancelled) return
|
.recordView(RV_LEADS, {
|
||||||
const rows = res?.data ?? res?.rows ?? res?.records ?? []
|
limit: isAll ? 200 : 100,
|
||||||
setState({ status: 'ready', rows, error: null })
|
// No stage filter on the all view. Closed leads are dropped below
|
||||||
})
|
// rather than in the query: one request beats three negations, and
|
||||||
.catch((err) => {
|
// 200 covers a demo book comfortably.
|
||||||
if (cancelled) return
|
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
|
||||||
setState({ status: 'error', rows: [], error: err })
|
})
|
||||||
})
|
.then((res) => {
|
||||||
return () => { cancelled = true }
|
if (cancelled) return
|
||||||
}, [client, stageUid, stage?.name])
|
let rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||||
|
// Nothing is dropped here any more. Whether closed leads are shown
|
||||||
|
// is a choice made below, on data already fetched — a filter that
|
||||||
|
// throws rows away at the query cannot be undone by a toggle.
|
||||||
|
// total_count is the size of the QUEUE; rows is one page of at most
|
||||||
|
// 100 of it. Counting the page would quietly under-report a busy stage.
|
||||||
|
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
|
||||||
|
setState({ status: 'ready', rows, total, error: null })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return
|
||||||
|
// A failed refresh must not throw away a queue that is already on
|
||||||
|
// screen; only a failed first load is an error state.
|
||||||
|
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], total: 0, error: err }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRows(false)
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === 'visible') fetchRows(true)
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
|
}, [client, stageUid, stage?.name, isAll])
|
||||||
|
|
||||||
|
// Split rather than filtered at the query, so the header can say how many
|
||||||
|
// are closed instead of silently showing fewer than the page claims.
|
||||||
|
const CLOSED = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
|
||||||
|
const closedCount = isAll ? state.rows.filter((r) => CLOSED.has(r.current_state_name)).length : 0
|
||||||
|
const needle = q.trim().toLowerCase()
|
||||||
|
const shownRows = (isAll && hideClosed
|
||||||
|
? state.rows.filter((r) => !CLOSED.has(r.current_state_name))
|
||||||
|
: state.rows
|
||||||
|
).filter((r) => !needle || [r.customer_name, r.lead_ref, r.vehicle_reg, r.entity_name, r.customer_phone]
|
||||||
|
.some((v) => String(v || '').toLowerCase().includes(needle)))
|
||||||
|
|
||||||
|
// Inside a person-gated queue the rows split in two: the ones that are that
|
||||||
|
// person's turn, and the ones the AI is still working inside the same state.
|
||||||
|
// Two group rows say so; a flat list read as one queue when it was two.
|
||||||
|
const grouped = !isAll && (stage.kind === 'needs' || stage.kind === 'customer')
|
||||||
|
const yourTurn = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind !== 'auto') : shownRows
|
||||||
|
const withAI = grouped ? shownRows.filter((r) => phaseOf(stage, r).kind === 'auto') : []
|
||||||
|
|
||||||
if (!stage) return <p className="empty">Unknown stage.</p>
|
if (!stage) return <p className="empty">Unknown stage.</p>
|
||||||
|
|
||||||
|
function renderRows(rows) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const id = r.instance_id ?? r.id
|
||||||
|
const exp = expiry(r.renewal_due_date)
|
||||||
|
const add = added(r.updated_at || r.created_at)
|
||||||
|
// What is really happening in the state, not just its name —
|
||||||
|
// a lead whose documents are in is being worked by the AI, not
|
||||||
|
// waiting on the agent whose queue it is sitting in.
|
||||||
|
const def = STAGES.find((x) => x.name === r.current_state_name)
|
||||||
|
const rowStage = phaseOf(def, r)
|
||||||
|
const prog = progress(r, rowStage)
|
||||||
|
const h = holderOf(def, r)
|
||||||
|
const done = CLOSED.has(r.current_state_name)
|
||||||
|
const dueTone = exp ? (exp.tone === 'lapsed' ? 'red' : exp.tone === 'urgent' ? 'amber' : '') : ''
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={id}
|
||||||
|
className={'grid__row'
|
||||||
|
+ (prog?.state === 'stalled' ? ' is-stalled' : '')
|
||||||
|
+ (done ? ' is-done' : '')}
|
||||||
|
onClick={() => navigate(`/lead/${id}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="link"
|
||||||
|
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className="grid__name">{r.customer_name || '—'}</span>
|
||||||
|
<div className="grid__sub">
|
||||||
|
{r.lead_ref || `#${id}`}
|
||||||
|
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
|
||||||
|
{r.vehicle_reg ? ` · ${r.vehicle_reg}` : r.entity_name ? ` · ${r.entity_name}` : ''}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{prog?.state === 'stalled' ? (
|
||||||
|
<><span className="stall">Stalled</span><div className="grid__sub">{prog.label}</div></>
|
||||||
|
) : (
|
||||||
|
<>{r.current_state_name || '—'}{prog ? <div className="grid__sub">{prog.label}</div> : null}</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{exp
|
||||||
|
? <span className={'due' + (dueTone ? ' due--' + dueTone : '')}><b>{exp.label}</b><small>{exp.on}</small></span>
|
||||||
|
: <span className="grid__sub">—</span>}
|
||||||
|
</td>
|
||||||
|
<td><span className="who"><span className={'dot dot--' + h.tone} />{h.label}</span></td>
|
||||||
|
<td className="num">{r.quoted_premium ? '₹' + Math.round(Number(r.quoted_premium)).toLocaleString('en-IN') : '—'}</td>
|
||||||
|
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page__title">{stage.name}</h1>
|
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
||||||
<p className="page__sub">
|
<p className="page__sub">
|
||||||
{stage.kind === 'human'
|
{isAll
|
||||||
? 'The only queue in the machine that waits for a person.'
|
? 'Every lead not yet closed, across all stages.'
|
||||||
: 'Handled by the agents; shown here for visibility.'}
|
: stage.kind === 'auto'
|
||||||
|
? `Automated · ${stage.doing.toLowerCase()}`
|
||||||
|
: stage.kind === 'end'
|
||||||
|
? 'Closed — retained for reporting.'
|
||||||
|
: stage.kind === 'waiting'
|
||||||
|
? 'Held until the renewal window opens. Re-enters outreach automatically.'
|
||||||
|
: `Pending action by ${stage.by}.`}
|
||||||
|
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{state.status === 'ready' ? (
|
{canFile ? (
|
||||||
<span className="page__count">{state.rows.length}</span>
|
<div className="page__right">
|
||||||
|
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="1.7" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
New lead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{state.status === 'loading' ? <p className="empty">Loading…</p> : null}
|
{state.status === 'loading' ? (
|
||||||
|
<div className="skel" aria-busy="true" aria-label="Loading the queue">
|
||||||
{state.status === 'error' ? (
|
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
|
||||||
<div className="notice">
|
|
||||||
<strong>This queue has no record view yet.</strong>
|
|
||||||
<p>
|
|
||||||
The console reads every queue from the <code>{RV_LEADS}</code> record view.
|
|
||||||
It is not seeded yet, so there is nothing to list. Everything else —
|
|
||||||
sign-in, routing and the pipeline itself — is live.
|
|
||||||
</p>
|
|
||||||
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && state.rows.length === 0 ? (
|
{state.status === 'error' ? (
|
||||||
<p className="empty">No leads at this stage.</p>
|
<div className="notice">
|
||||||
|
<strong>Unable to load this stage.</strong>
|
||||||
|
<p>The lead list did not return. Other functions are unaffected; retry before escalating.</p>
|
||||||
|
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && state.rows.length > 0 ? (
|
{state.status === 'ready' ? (
|
||||||
<table className="grid">
|
<div className="card">
|
||||||
<thead>
|
<div className="tools">
|
||||||
<tr>
|
<label className="search">
|
||||||
<th>Lead</th><th>Customer</th><th>Channel</th>
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
|
<circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||||
<th>Attribution</th><th>Added</th>
|
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
</tr>
|
</svg>
|
||||||
</thead>
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a lead, a registration, a reference" aria-label="Find a lead" />
|
||||||
<tbody>
|
</label>
|
||||||
{state.rows.map((r) => {
|
{/* Offered only where there is something to hide, and OFF by
|
||||||
const id = r.instance_id ?? r.id
|
default: this page's job is that nothing disappears from it. */}
|
||||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
{isAll && closedCount ? (
|
||||||
const exp = expiry(r.renewal_due_date)
|
<label className={'tools__toggle' + (hideClosed ? ' is-on' : '')}>
|
||||||
const add = added(r.created_at)
|
<input type="checkbox" checked={hideClosed} onChange={(ev) => setHideClosed(ev.target.checked)} />
|
||||||
return (
|
<i aria-hidden="true" />
|
||||||
<tr key={id}>
|
Hide {closedCount} closed
|
||||||
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
|
</label>
|
||||||
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
|
) : null}
|
||||||
<td><span className="chip">{ch.label}</span></td>
|
<span className="tools__count">
|
||||||
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
{shownRows.length} {shownRows.length === 1 ? 'lead' : 'leads'}
|
||||||
<td>
|
{!isAll && state.total > shownRows.length && !needle ? ` of ${state.total}` : ''}
|
||||||
{exp
|
</span>
|
||||||
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
|
</div>
|
||||||
<div className="grid__sub">{exp.on}</div></>
|
|
||||||
: <span className="grid__sub">—</span>}
|
{shownRows.length === 0 ? (
|
||||||
</td>
|
<p className="empty" style={{ margin: 20 }}>
|
||||||
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
{needle ? 'No lead matches that.' : isAll ? 'No leads yet.' : 'No records in this stage.'}
|
||||||
<td>
|
</p>
|
||||||
{r.attribution_status === 'contested'
|
) : (
|
||||||
? <span className="chip chip--warn">Contested</span>
|
<div className="gridwrap">
|
||||||
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
|
<table className="grid">
|
||||||
</td>
|
<thead>
|
||||||
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
|
<tr>
|
||||||
</tr>
|
<th>Customer</th>
|
||||||
)
|
<th>Stage</th>
|
||||||
})}
|
<th>Renewal due</th>
|
||||||
</tbody>
|
<th>Holding it</th>
|
||||||
</table>
|
<th className="num">Premium</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped && withAI.length ? (
|
||||||
|
<tr className="grp-row"><td colSpan={6}>
|
||||||
|
<span className={'dot dot--' + (stage.kind === 'customer' ? 'teal' : 'amber')} />
|
||||||
|
{stage.kind === 'customer' ? 'With the customer' : 'Your turn'}<span>{yourTurn.length}</span>
|
||||||
|
</td></tr>
|
||||||
|
) : null}
|
||||||
|
{renderRows(yourTurn)}
|
||||||
|
{grouped && withAI.length ? (
|
||||||
|
<>
|
||||||
|
<tr className="grp-row"><td colSpan={6}>
|
||||||
|
<span className="dot dot--blue" />With the AI<span>{withAI.length} · nothing for anyone to do</span>
|
||||||
|
</td></tr>
|
||||||
|
{renderRows(withAI)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,109 +1,399 @@
|
|||||||
.page__head {
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
display: flex; align-items: flex-start; justify-content: space-between;
|
Lead Desk screens. Flat white cards on the grey canvas, one colour rule:
|
||||||
gap: 24px; margin-bottom: 20px;
|
blue = the AI holds it, amber = a person, teal = the customer, red = risk.
|
||||||
}
|
═══════════════════════════════════════════════════════════════════════════ */
|
||||||
.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); }
|
|
||||||
.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); }
|
|
||||||
.page__count {
|
|
||||||
font-size: 1.35rem; font-weight: 500; color: var(--zk-blue);
|
|
||||||
background: var(--zk-white); border: 1px solid var(--zk-line);
|
|
||||||
border-radius: 6px; padding: 6px 16px; font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty { color: var(--zk-muted); font-size: .88rem; padding: 28px 2px; }
|
/* ── Page head ──────────────────────────────────────────────────────────── */
|
||||||
|
.page__head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 22px; }
|
||||||
|
.page__lead { display: flex; align-items: flex-start; gap: 14px; min-width: 0; }
|
||||||
|
.page__title { margin: 0; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; color: var(--zk-ink); }
|
||||||
|
.page__sub { margin: 4px 0 0; font-size: var(--fs-sm); color: var(--zk-muted); }
|
||||||
|
.page__stage { color: var(--zk-grey); }
|
||||||
|
.page__stage::before { content: ' · '; }
|
||||||
|
.page__sub .idline, .idline { font-size: var(--fs-sm); color: var(--zk-grey); margin-top: 2px; }
|
||||||
|
.page__right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
.stamp { font-size: var(--fs-xs); color: var(--zk-grey); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── Buttons, pills, dots ───────────────────────────────────────────────── */
|
||||||
|
.btn, .newlead {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 16px; border-radius: var(--r-sm);
|
||||||
|
font: inherit; font-weight: 600; font-size: var(--fs-sm);
|
||||||
|
cursor: pointer; border: 1px solid transparent; text-decoration: none;
|
||||||
|
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
.btn svg, .newlead svg { width: 14px; height: 14px; }
|
||||||
|
.newlead, .btn--primary { background: var(--zk-blue); color: #fff; }
|
||||||
|
.newlead:hover, .btn--primary:hover { background: var(--zk-navy-2); }
|
||||||
|
.btn--navy { background: var(--zk-navy); color: #fff; }
|
||||||
|
.btn--navy:hover { background: var(--zk-navy-2); }
|
||||||
|
.btn--ghost { border-color: var(--zk-line); background: #fff; color: var(--zk-ink); }
|
||||||
|
.btn--ghost:hover { background: var(--zk-tint); border-color: var(--zk-grey); }
|
||||||
|
.btn--small { padding: 5px 12px; font-size: var(--fs-xs); }
|
||||||
|
.btn:focus-visible, .newlead:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
|
.btn:disabled { opacity: .55; cursor: default; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 2px 10px; border-radius: var(--r-pill);
|
||||||
|
font-size: var(--fs-2xs); font-weight: 600; line-height: 1.5; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill--red { background: var(--zk-danger-tint); color: var(--zk-danger); }
|
||||||
|
.pill--amber { background: var(--zk-amber-tint); color: var(--zk-amber); }
|
||||||
|
.pill--teal { background: var(--zk-teal-tint); color: var(--zk-teal); }
|
||||||
|
.pill--blue { background: var(--zk-tint-blue); color: var(--zk-blue); }
|
||||||
|
.pill--grey { background: var(--zk-line-soft); color: var(--zk-muted); }
|
||||||
|
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: none; }
|
||||||
|
.dot--blue { background: var(--zk-blue); }
|
||||||
|
.dot--amber { background: var(--zk-amber); }
|
||||||
|
.dot--teal { background: var(--zk-teal); }
|
||||||
|
.dot--red { background: var(--zk-danger); }
|
||||||
|
.dot--grey { background: var(--zk-grey); }
|
||||||
|
|
||||||
|
.who { display: inline-flex; align-items: center; gap: 7px; color: var(--zk-muted); white-space: nowrap; }
|
||||||
|
.muted { color: var(--zk-grey); }
|
||||||
|
.link { color: var(--zk-blue); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||||
|
.card { background: var(--zk-card); border: 1px solid var(--zk-line); border-radius: var(--r-md); }
|
||||||
|
.card__hd { display: flex; align-items: baseline; gap: 12px; padding: 16px 20px 12px; }
|
||||||
|
.card__hd h3 { margin: 0; font-size: var(--fs-lg); font-weight: 600; color: var(--zk-ink); }
|
||||||
|
.card__hd p { margin: 0; font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
.card__hd .card__right { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-muted); }
|
||||||
|
|
||||||
|
/* ── States: notice, empty, skeleton ────────────────────────────────────── */
|
||||||
.notice {
|
.notice {
|
||||||
background: var(--zk-white); border: 1px solid var(--zk-line);
|
background: #fff; border: 1px solid var(--zk-line); border-left: 3px solid var(--zk-blue);
|
||||||
border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px;
|
border-radius: var(--r-xs) var(--r-md) var(--r-md) var(--r-xs); padding: 18px 22px; max-width: 64ch;
|
||||||
max-width: 60ch;
|
|
||||||
}
|
}
|
||||||
.notice strong { display: block; font-weight: 500; margin-bottom: 6px; }
|
.notice strong { display: block; font-weight: 600; margin-bottom: 7px; }
|
||||||
.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; }
|
.notice p { margin: 0 0 6px; font-size: var(--fs-xs); color: var(--zk-muted); line-height: 1.55; }
|
||||||
.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; }
|
.notice__detail { font-size: var(--fs-2xs) !important; color: var(--zk-grey) !important; }
|
||||||
.notice code {
|
.empty {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em;
|
margin: 0; color: var(--zk-muted); font-size: var(--fs-xs); text-align: center;
|
||||||
background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
|
padding: 44px 24px; border-radius: var(--r-md); border: 1px dashed var(--zk-line); background: #fff;
|
||||||
border-radius: 3px; padding: 1px 4px;
|
}
|
||||||
|
.skel { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.skel__row {
|
||||||
|
height: 52px; border-radius: var(--r-md); border: 1px solid var(--zk-line-soft);
|
||||||
|
background: linear-gradient(90deg, #fff 0, var(--zk-tint) 40%, #fff 80%);
|
||||||
|
background-size: 840px 100%; animation: zk-shimmer 1.4s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
/* ── Overview: who holds the work ───────────────────────────────────────── */
|
||||||
width: 100%; border-collapse: collapse; background: var(--zk-white);
|
.hero { display: grid; grid-template-columns: 1fr 380px; gap: 18px; margin-bottom: 22px; }
|
||||||
border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem;
|
.holders { padding: 20px 24px 22px; }
|
||||||
|
.holders__top { display: flex; align-items: baseline; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.holders__top b { font-size: 30px; font-weight: 700; letter-spacing: -.01em; }
|
||||||
|
.holders__top span { font-size: var(--fs-md); color: var(--zk-muted); }
|
||||||
|
.bar { display: flex; height: 22px; border-radius: 6px; overflow: hidden; gap: 3px; margin-bottom: 16px; background: var(--zk-line-soft); }
|
||||||
|
.bar div { height: 100%; transition: width var(--t); }
|
||||||
|
.bar__blue { background: var(--zk-blue); } .bar__amber { background: var(--zk-amber-bar); } .bar__teal { background: var(--zk-teal); }
|
||||||
|
.legend { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
|
||||||
|
.legend__item { border-left: 3px solid; padding-left: 14px; }
|
||||||
|
.legend__item--blue { border-color: var(--zk-blue); } .legend__item--amber { border-color: var(--zk-amber-bar); } .legend__item--teal { border-color: var(--zk-teal); }
|
||||||
|
.legend__item b { display: block; font-size: 26px; font-weight: 700; line-height: 1.1; }
|
||||||
|
.legend__item strong { display: block; font-size: var(--fs-sm); font-weight: 600; margin-top: 3px; }
|
||||||
|
.legend__item span { display: block; font-size: var(--fs-xs); color: var(--zk-muted); margin-top: 2px; }
|
||||||
|
.risk { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); padding: 20px 22px; display: flex; flex-direction: column; }
|
||||||
|
.risk b { font-size: var(--fs-3xl); font-weight: 700; color: var(--zk-danger); line-height: 1; letter-spacing: -.02em; }
|
||||||
|
.risk strong { display: block; font-size: var(--fs-lg); font-weight: 600; margin-top: 6px; }
|
||||||
|
.risk__rows { margin-top: auto; padding-top: 14px; display: grid; gap: 6px; font-size: var(--fs-sm); }
|
||||||
|
.risk__rows div { display: flex; justify-content: space-between; }
|
||||||
|
.risk__rows em { font-style: normal; font-weight: 700; color: var(--zk-danger); }
|
||||||
|
.risk.is-quiet b { color: var(--zk-teal); } .risk.is-quiet { background: var(--zk-teal-tint); border-color: var(--zk-teal-line); }
|
||||||
|
.risk.is-quiet .risk__rows em { color: var(--zk-teal); }
|
||||||
|
|
||||||
|
/* ── KPI strip ──────────────────────────────────────────────────────────── */
|
||||||
|
.kpis { display: grid; grid-template-columns: repeat(5, 1fr); padding: 18px 0; margin-bottom: 22px; }
|
||||||
|
.kpi { padding: 0 24px; border-left: 1px solid var(--zk-line-soft); }
|
||||||
|
.kpi:first-child { border-left: 0; }
|
||||||
|
.kpi__l { display: block; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 500; }
|
||||||
|
.kpi__v { display: block; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; margin: 4px 0 2px; color: var(--zk-ink); }
|
||||||
|
.kpi__s { font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
/* ── Sign-off band (the role that confirms premium) ─────────────────────── */
|
||||||
|
.appr {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto; gap: 18px; align-items: center;
|
||||||
|
padding: 16px 22px; margin-bottom: 22px; text-decoration: none; color: inherit;
|
||||||
|
border-radius: var(--r-md); border: 1px solid var(--zk-amber-line); background: var(--zk-amber-tint);
|
||||||
}
|
}
|
||||||
|
.appr__n { font-size: 30px; font-weight: 700; color: var(--zk-amber); min-width: 40px; text-align: center; }
|
||||||
|
.appr__t { font-size: var(--fs-md); font-weight: 600; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.appr__t em { font-style: normal; font-size: var(--fs-xs); font-weight: 400; color: var(--zk-muted); }
|
||||||
|
.appr__go { font-size: var(--fs-xs); font-weight: 600; color: var(--zk-amber); }
|
||||||
|
.appr:not(.is-live) { background: #fff; border-color: var(--zk-line); }
|
||||||
|
.appr:not(.is-live) .appr__n, .appr:not(.is-live) .appr__go { color: var(--zk-grey); }
|
||||||
|
|
||||||
|
/* ── Two columns: queue + distribution ──────────────────────────────────── */
|
||||||
|
.cols { display: grid; grid-template-columns: 1fr 380px; gap: 18px; margin-bottom: 22px; align-items: stretch; }
|
||||||
|
/* The two cards share a height; the queue's rows spread to fill it so the
|
||||||
|
shorter card never ends in a block of empty canvas. */
|
||||||
|
.cols > .card { display: flex; flex-direction: column; }
|
||||||
|
.queue { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.queue__row { flex: 1; }
|
||||||
|
.queue { padding: 0 0 6px; }
|
||||||
|
.queue__row {
|
||||||
|
display: grid; grid-template-columns: 52px 1fr auto auto; gap: 14px; align-items: center;
|
||||||
|
padding: 14px 20px; border-top: 1px solid var(--zk-line-soft); text-decoration: none; color: inherit;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.queue__row:first-child { border-top: 0; }
|
||||||
|
.queue__row:hover { background: var(--zk-tint); }
|
||||||
|
.queue__n { font-size: 26px; font-weight: 700; text-align: center; color: var(--zk-ink); }
|
||||||
|
.queue__row.is-clear .queue__n { color: var(--zk-grey); }
|
||||||
|
.queue__t { font-size: var(--fs-md); font-weight: 600; }
|
||||||
|
.queue__who { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--zk-muted); margin-top: 2px; }
|
||||||
|
.queue__age { font-size: var(--fs-xs); color: var(--zk-grey); text-align: right; line-height: 1.35; }
|
||||||
|
.queue__age em { display: block; font-style: normal; color: var(--zk-blue); font-weight: 600; }
|
||||||
|
.queue__ro { font-size: var(--fs-3xs); color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: var(--r-pill); padding: 1px 8px; }
|
||||||
|
|
||||||
|
.dist { padding: 6px 20px 14px; }
|
||||||
|
.dist__r {
|
||||||
|
display: grid; grid-template-columns: 1fr 120px 34px; gap: 12px; align-items: center;
|
||||||
|
padding: 8px 0; font-size: var(--fs-sm); text-decoration: none; color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.dist__r:hover .dist__l { color: var(--zk-blue); }
|
||||||
|
.dist__r.is-zero, .dist__r.is-dim { color: var(--zk-grey); }
|
||||||
|
.dist__bar { height: 8px; background: var(--zk-line-soft); border-radius: 4px; overflow: hidden; }
|
||||||
|
.dist__f { display: block; height: 100%; border-radius: 4px; background: var(--zk-navy); transition: width var(--t); }
|
||||||
|
.dist__f--blue { background: var(--zk-blue); } .dist__f--amber { background: var(--zk-amber-bar); }
|
||||||
|
.dist__f--teal { background: var(--zk-teal); } .dist__f--grey { background: #AEB7C6; }
|
||||||
|
.dist__n { text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.dist__sec { padding: 12px 0 4px; font-size: var(--fs-2xs); color: var(--zk-grey); font-weight: 600; border-top: 1px solid var(--zk-line-soft); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* ── Renewal exposure ───────────────────────────────────────────────────── */
|
||||||
|
.exp__tabs { display: flex; align-items: center; gap: 10px; padding: 0 20px 14px; flex-wrap: wrap; }
|
||||||
|
.tab {
|
||||||
|
display: flex; align-items: baseline; gap: 8px; padding: 8px 14px;
|
||||||
|
border: 1px solid var(--zk-line); border-radius: var(--r-sm); font-size: var(--fs-sm); background: #fff;
|
||||||
|
}
|
||||||
|
.tab b { font-size: var(--fs-xl); font-weight: 700; }
|
||||||
|
.tab--red { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); } .tab--red b { color: var(--zk-danger); }
|
||||||
|
.tab--amber { border-color: var(--zk-amber-line); background: var(--zk-amber-tint); } .tab--amber b { color: var(--zk-amber); }
|
||||||
|
.exp__filters { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.xfd__f select {
|
||||||
|
font: inherit; font-size: var(--fs-xs); color: var(--zk-muted);
|
||||||
|
padding: 8px 30px 8px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm);
|
||||||
|
background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%237A8699' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat right 10px center;
|
||||||
|
-webkit-appearance: none; appearance: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.xfd__f select:hover { border-color: var(--zk-grey); }
|
||||||
|
.xfd__f select:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
|
.xfd__clear { font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); background: none; border: 0; cursor: pointer; padding: 8px 6px; }
|
||||||
|
.xfd__clear:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ── Tables ─────────────────────────────────────────────────────────────── */
|
||||||
|
.gridwrap { overflow-x: auto; }
|
||||||
|
.grid { width: 100%; border-collapse: collapse; }
|
||||||
.grid th {
|
.grid th {
|
||||||
text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em;
|
font-size: var(--fs-3xs); font-weight: 600; color: var(--zk-grey); text-align: left;
|
||||||
text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px;
|
padding: 10px 20px; border-top: 1px solid var(--zk-line-soft); border-bottom: 1px solid var(--zk-line-soft); background: #FAFBFD; white-space: nowrap;
|
||||||
background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; }
|
.grid td { padding: 12px 20px; border-bottom: 1px solid var(--zk-line-soft); font-size: var(--fs-sm); vertical-align: middle; }
|
||||||
.grid tbody tr:last-child td { border-bottom: none; }
|
.grid tr:last-child td { border-bottom: 0; }
|
||||||
.grid tbody tr:hover { background: var(--zk-tint); }
|
.grid th.num, .grid td.num { text-align: right; }
|
||||||
.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
|
.grid td.num { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; }
|
.grid__row { cursor: pointer; transition: background var(--t-fast); }
|
||||||
.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; }
|
.grid__row:hover td { background: var(--zk-tint); }
|
||||||
.grid__link:hover { text-decoration: underline; }
|
.grid__row:focus-visible { outline: none; } .grid__row:focus-visible td { background: var(--zk-tint-blue); }
|
||||||
|
.grid__name { display: block; font-size: var(--fs-md); font-weight: 600; color: var(--zk-ink); }
|
||||||
|
.grid__sub { font-size: var(--fs-2xs); color: var(--zk-grey); margin-top: 2px; }
|
||||||
|
.grid tr.is-done td { color: var(--zk-grey); }
|
||||||
|
.grid tr.is-done .grid__name { font-weight: 500; color: var(--zk-muted); }
|
||||||
|
.grid tr.is-stalled td:first-child { box-shadow: inset 3px 0 0 var(--zk-danger); }
|
||||||
|
|
||||||
.chip {
|
.due { display: flex; flex-direction: column; gap: 2px; }
|
||||||
display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px;
|
.due b { font-weight: 600; }
|
||||||
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
|
.due--amber b { color: var(--zk-amber); } .due--red b { color: var(--zk-danger); }
|
||||||
|
.due small { font-size: var(--fs-2xs); color: var(--zk-grey); }
|
||||||
|
.stall { display: inline-flex; align-items: center; gap: 7px; color: var(--zk-danger); font-weight: 600; font-size: var(--fs-xs); }
|
||||||
|
.stall::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--zk-danger); }
|
||||||
|
.grp-row td { background: #FAFBFD; padding: 10px 20px; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-muted); border-top: 1px solid var(--zk-line-soft); }
|
||||||
|
.grp-row td span { font-weight: 400; color: var(--zk-grey); margin-left: 8px; }
|
||||||
|
.grp-row td .dot { margin-right: 8px; vertical-align: 1px; }
|
||||||
|
.need { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.need span { font-size: var(--fs-2xs); padding: 2px 9px; border-radius: 6px; background: var(--zk-amber-tint); color: var(--zk-amber); font-weight: 600; }
|
||||||
|
.need span.need__ok { background: var(--zk-teal-tint); color: var(--zk-teal); }
|
||||||
|
.pager { display: flex; align-items: center; gap: 14px; padding: 14px 20px; border-top: 1px solid var(--zk-line-soft); font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
.pager__pg { margin-left: auto; display: flex; gap: 4px; }
|
||||||
|
.pager__pg button {
|
||||||
|
font: inherit; font-size: var(--fs-xs); padding: 4px 10px; border-radius: 6px; cursor: pointer;
|
||||||
|
border: 1px solid var(--zk-line); background: #fff; color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
|
.pager__pg button.is-on { background: var(--zk-navy); color: #fff; border-color: var(--zk-navy); }
|
||||||
|
.pager__pg button:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
/* ---- doors ---- */
|
/* ── List tools ─────────────────────────────────────────────────────────── */
|
||||||
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
.tools { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--zk-line-soft); flex-wrap: wrap; }
|
||||||
.door {
|
.search {
|
||||||
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px; min-width: 260px;
|
||||||
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
|
border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; color: var(--zk-grey);
|
||||||
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
|
|
||||||
}
|
}
|
||||||
.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
|
.search svg { width: 14px; height: 14px; flex: none; }
|
||||||
.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
|
.search input { border: 0; outline: 0; background: none; font: inherit; font-size: var(--fs-xs); color: var(--zk-ink); flex: 1; min-width: 0; }
|
||||||
.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
|
.search:focus-within { border-color: var(--zk-blue); box-shadow: var(--ring); }
|
||||||
.door .chip { align-self: flex-start; }
|
.fchip {
|
||||||
|
font: inherit; font-size: var(--fs-xs); color: var(--zk-muted); background: #fff;
|
||||||
/* ---- panel ---- */
|
padding: 7px 30px 7px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-pill); cursor: pointer;
|
||||||
.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
|
-webkit-appearance: none; appearance: none;
|
||||||
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%237A8699' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||||
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
|
background-repeat: no-repeat; background-position: right 10px center;
|
||||||
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
|
|
||||||
|
|
||||||
/* ---- actions ---- */
|
|
||||||
.acts { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
||||||
.act {
|
|
||||||
font: inherit; cursor: pointer; text-align: left; background: var(--zk-white);
|
|
||||||
border: 1px solid var(--zk-line); border-radius: 5px; padding: 10px 14px;
|
|
||||||
display: flex; flex-direction: column; gap: 2px; min-width: 190px;
|
|
||||||
}
|
}
|
||||||
.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
|
.fchip.is-on { border-color: var(--zk-navy); color: var(--zk-navy); font-weight: 600; background-color: var(--zk-tint-blue); }
|
||||||
.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
|
.fchip:focus-visible { outline: none; box-shadow: var(--ring); }
|
||||||
.act strong { font-size: .88rem; font-weight: 500; }
|
.tools__count { margin-left: auto; font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
.act__by { font-size: .72rem; color: var(--zk-muted); }
|
.tools__toggle { display: inline-flex; align-items: center; gap: 8px; font-size: var(--fs-xs); color: var(--zk-muted); cursor: pointer; user-select: none; }
|
||||||
.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
|
.tools__toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
.tools__toggle i { width: 34px; height: 20px; border-radius: 10px; background: var(--zk-line); position: relative; display: inline-block; transition: background var(--t-fast); }
|
||||||
|
.tools__toggle i::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left var(--t-fast); }
|
||||||
|
.tools__toggle.is-on i { background: var(--zk-blue); } .tools__toggle.is-on i::after { left: 16px; }
|
||||||
|
.tools__toggle:focus-within i { box-shadow: var(--ring); }
|
||||||
|
|
||||||
/* ---- lead file ---- */
|
/* ── Lead detail ────────────────────────────────────────────────────────── */
|
||||||
.lead__stage { text-align: right; }
|
.back {
|
||||||
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
|
width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--zk-line); background: #fff;
|
||||||
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
|
display: grid; place-items: center; color: var(--zk-muted); margin-top: 4px; cursor: pointer; flex: none;
|
||||||
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); }
|
|
||||||
.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; }
|
|
||||||
.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); }
|
|
||||||
.grp__row dt { font-size: .7rem; color: var(--zk-grey); text-transform: capitalize; }
|
|
||||||
.grp__row dd { margin: 0; font-size: .87rem; color: var(--zk-ink); }
|
|
||||||
.lead__back { margin-top: 18px; }
|
|
||||||
|
|
||||||
/* ---- renewal countdown ---- */
|
|
||||||
.due {
|
|
||||||
display: inline-block; font-size: .76rem; font-weight: 500;
|
|
||||||
padding: 2px 8px; border-radius: 3px; white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.due--lapsed { background: #fdeceb; color: #a3261f; border: 1px solid #f0c2bd; }
|
.back svg { width: 16px; height: 16px; }
|
||||||
.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; }
|
.back:hover { background: var(--zk-tint); border-color: var(--zk-grey); color: var(--zk-ink); }
|
||||||
.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); }
|
.state { display: flex; align-items: center; gap: 10px; font-size: var(--fs-sm); color: var(--zk-muted); }
|
||||||
.due--later { background: var(--zk-tint); color: var(--zk-muted); border: 1px solid var(--zk-line); }
|
.state .pill { font-size: var(--fs-xs); }
|
||||||
|
|
||||||
/* ---- lead header meta ---- */
|
.attn {
|
||||||
.lead__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; }
|
display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: center;
|
||||||
.lead__meta div { display: flex; flex-direction: column; gap: 1px; }
|
padding: 18px 22px; margin-bottom: 18px; border-radius: var(--r-md);
|
||||||
.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); }
|
border: 1px solid var(--zk-amber-line); background: var(--zk-amber-tint);
|
||||||
.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); }
|
}
|
||||||
|
.attn--red { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); }
|
||||||
|
.attn--blue { border-color: var(--zk-blue-light); background: var(--zk-tint-blue); }
|
||||||
|
.attn h3 { margin: 0; font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.attn h3 .dot { width: 10px; height: 10px; }
|
||||||
|
.attn p { margin: 6px 0 0; font-size: var(--fs-sm); color: var(--zk-muted); max-width: 78ch; line-height: 1.5; }
|
||||||
|
.attn__acts { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.attn__q { font: inherit; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 600; padding: 0 6px; background: none; border: 0; cursor: pointer; }
|
||||||
|
.attn__q:hover { color: var(--zk-navy); text-decoration: underline; }
|
||||||
|
|
||||||
|
.facts { display: grid; grid-template-columns: repeat(4, 1fr); padding: 18px 0; margin-bottom: 18px; }
|
||||||
|
.fact { padding: 0 24px; border-left: 1px solid var(--zk-line-soft); min-width: 0; }
|
||||||
|
.fact:first-child { border-left: 0; }
|
||||||
|
.fact__l { display: block; font-size: var(--fs-xs); color: var(--zk-muted); font-weight: 500; }
|
||||||
|
.fact__v { display: block; font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -.01em; line-height: 1.15; margin: 4px 0 2px; color: var(--zk-ink); }
|
||||||
|
.fact__v.is-none { color: var(--zk-grey); font-weight: 500; font-size: 22px; }
|
||||||
|
.fact__v.due--red { color: var(--zk-danger); } .fact__v.due--amber { color: var(--zk-amber); }
|
||||||
|
.fact__s { font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
.fact__conf { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.fact__conf .tr { flex: 1; height: 6px; background: var(--zk-line-soft); border-radius: 3px; overflow: hidden; }
|
||||||
|
.fact__conf .tr i { display: block; height: 100%; background: var(--zk-teal); border-radius: 3px; }
|
||||||
|
.fact__conf span { font-size: var(--fs-xs); color: var(--zk-grey); }
|
||||||
|
|
||||||
|
.detail { display: grid; grid-template-columns: 1fr 400px; gap: 18px; align-items: start; }
|
||||||
|
.detail__main { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.detail__side { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
/* The panels on the lead page are cards. */
|
||||||
|
.panel { background: var(--zk-card); border: 1px solid var(--zk-line); border-radius: var(--r-md); padding: 0 24px 20px; }
|
||||||
|
.panel__head { display: flex; align-items: baseline; gap: 12px; padding: 18px 0 6px; flex-wrap: wrap; }
|
||||||
|
.panel__title { margin: 0; font-size: 19px; font-weight: 700; color: var(--zk-ink); }
|
||||||
|
.panel__sub { margin: 0; font-size: var(--fs-2xs); color: var(--zk-grey); }
|
||||||
|
.panel__act {
|
||||||
|
margin-left: auto; display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-ink);
|
||||||
|
padding: 5px 12px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff; cursor: pointer;
|
||||||
|
}
|
||||||
|
.panel__act svg { width: 14px; height: 14px; }
|
||||||
|
.panel__act b { font-weight: 600; color: var(--zk-muted); }
|
||||||
|
.panel__act:hover { background: var(--zk-tint); }
|
||||||
|
|
||||||
|
/* Next step */
|
||||||
|
.step { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
|
||||||
|
.step__btn { justify-content: center;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
||||||
|
font: inherit; text-align: left; cursor: pointer;
|
||||||
|
padding: 12px 18px; border-radius: var(--r-sm); border: 1px solid var(--zk-navy); background: var(--zk-navy); color: #fff;
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.step__btn strong { font-size: var(--fs-sm); font-weight: 700; }
|
||||||
|
.step__btn span { font-size: var(--fs-2xs); opacity: .8; }
|
||||||
|
.step__btn:hover, .step__btn.is-open { background: var(--zk-navy-2); }
|
||||||
|
.step__btn--no { background: #fff; color: var(--zk-danger); border-color: var(--zk-danger-line); }
|
||||||
|
.step__btn--no:hover, .step__btn--no.is-open { background: var(--zk-danger-tint); }
|
||||||
|
.alt { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
|
.alt__note { font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-grey); margin-right: 4px; }
|
||||||
|
.alt__btn {
|
||||||
|
font: inherit; font-size: var(--fs-xs); font-weight: 600; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
padding: 7px 12px; border-radius: var(--r-sm); border: 1px solid var(--zk-line); background: #fff; color: var(--zk-ink);
|
||||||
|
}
|
||||||
|
.alt__btn em { font-style: normal; font-weight: 400; font-size: var(--fs-2xs); color: var(--zk-grey); }
|
||||||
|
.alt__btn:hover, .alt__btn.is-open { border-color: var(--zk-navy); background: var(--zk-tint-blue); color: var(--zk-navy); }
|
||||||
|
.alt__btn--exit { color: var(--zk-danger); } .alt__btn--exit:hover { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); color: var(--zk-danger); }
|
||||||
|
.stuck { margin-top: 14px; border-top: 1px solid var(--zk-line-soft); padding-top: 10px; }
|
||||||
|
.stuck > summary { cursor: pointer; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-muted); list-style: none; }
|
||||||
|
.stuck > summary::-webkit-details-marker { display: none; }
|
||||||
|
.stuck > summary::before { content: '▸ '; color: var(--zk-grey); }
|
||||||
|
.stuck[open] > summary::before { content: '▾ '; }
|
||||||
|
.stuck__why { margin: 10px 0 0; font-size: var(--fs-2xs); color: var(--zk-grey); max-width: 64ch; }
|
||||||
|
.acts__form { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--zk-line-soft); }
|
||||||
|
.said {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px;
|
||||||
|
border-radius: var(--r-md); border: 1px solid var(--zk-teal-line); background: var(--zk-teal-tint); color: var(--zk-teal-ink);
|
||||||
|
}
|
||||||
|
.said p { margin: 0; font-size: var(--fs-sm); flex: 1; }
|
||||||
|
.said button { font: inherit; font-size: 18px; line-height: 1; background: none; border: 0; cursor: pointer; color: inherit; opacity: .7; }
|
||||||
|
|
||||||
|
/* Side cards: worked by, and the facts */
|
||||||
|
.panel--crew { padding: 16px 20px 18px; }
|
||||||
|
.crew__l { display: block; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-grey); margin-bottom: 10px; }
|
||||||
|
.crew { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.glance { display: flex; flex-direction: column; }
|
||||||
|
.fgroup { padding-top: 14px; border-top: 1px solid var(--zk-line-soft); }
|
||||||
|
.fgroup:first-child { border-top: 0; padding-top: 6px; }
|
||||||
|
.fgroup h3 { margin: 0 0 4px; font-size: var(--fs-2xs); font-weight: 600; color: var(--zk-grey); display: flex; justify-content: space-between; }
|
||||||
|
.fgroup h3 span { font-weight: 500; }
|
||||||
|
.props { margin: 0; }
|
||||||
|
.prop { display: grid; grid-template-columns: 150px 1fr; gap: 12px; padding: 7px 0; font-size: var(--fs-sm); }
|
||||||
|
.prop dt { color: var(--zk-muted); }
|
||||||
|
.prop dd { margin: 0; min-width: 0; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.prop__num { font-variant-numeric: tabular-nums; }
|
||||||
|
.prop__files { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.prop__file { color: var(--zk-blue); font-weight: 600; text-decoration: none; display: inline-flex; gap: 6px; align-items: center; overflow-wrap: anywhere; }
|
||||||
|
.prop__file::before { content: ''; width: 12px; height: 14px; flex: none; border: 1.4px solid currentColor; border-radius: 2px; opacity: .7; }
|
||||||
|
.prop__file:hover { text-decoration: underline; }
|
||||||
|
.pending { display: flex; gap: 8px; flex-wrap: wrap; padding: 6px 0 4px; }
|
||||||
|
.pending span { font-size: var(--fs-2xs); padding: 3px 10px; border-radius: 6px; background: var(--zk-line-soft); color: var(--zk-muted); }
|
||||||
|
.glance__act {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; margin-top: 10px;
|
||||||
|
padding: 5px 10px 5px 8px; border: 1px solid var(--zk-line); border-radius: var(--r-sm); background: #fff;
|
||||||
|
cursor: pointer; font: inherit; font-size: var(--fs-2xs); font-weight: 600; color: var(--zk-blue);
|
||||||
|
}
|
||||||
|
.glance__act svg { width: 13px; height: 13px; }
|
||||||
|
.glance__act:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); }
|
||||||
|
.glance__rec { margin-top: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.glance__rec-l { font-size: var(--fs-3xs); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--zk-grey); }
|
||||||
|
.glance__rec audio { width: 100%; height: 36px; }
|
||||||
|
.glance__all {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; margin-top: 14px; padding: 10px 0 0;
|
||||||
|
border: 0; border-top: 1px solid var(--zk-line-soft); background: none; cursor: pointer;
|
||||||
|
font: inherit; font-size: var(--fs-xs); font-weight: 600; color: var(--zk-blue); text-align: left;
|
||||||
|
}
|
||||||
|
.glance__all svg { width: 14px; height: 14px; }
|
||||||
|
.glance__all b { margin-left: auto; font-weight: 500; color: var(--zk-grey); }
|
||||||
|
.glance__all:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.hero, .cols, .detail { grid-template-columns: 1fr; }
|
||||||
|
.kpis { grid-template-columns: repeat(2, 1fr); row-gap: 16px; }
|
||||||
|
.facts { grid-template-columns: repeat(2, 1fr); row-gap: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The AI's own words, when it flagged a lead. Quoted, because deciding what
|
||||||
|
to do next is a judgement made on the sentence it wrote. */
|
||||||
|
.attn__said {
|
||||||
|
margin: 8px 0 0 !important; padding: 8px 14px;
|
||||||
|
border-left: 2px solid var(--zk-amber-bar); background: #fff;
|
||||||
|
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||||
|
font-size: var(--fs-md) !important; color: var(--zk-ink) !important; max-width: 78ch;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user