UI Enhancements in recordsview and detailsview

This commit is contained in:
Likith K R 2026-09-02 15:33:26 +05:30
parent 8b58b2243e
commit ae548a3a10
15 changed files with 2384 additions and 373 deletions

129
CLAUDE.md Normal file
View File

@ -0,0 +1,129 @@
# 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 perform almost every step;
a human appears at exactly one queue, `Referred to Underwriting`.
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/layout/Shell.jsx` — header + the pipeline sidebar (the sidebar *is* `STAGES`).
- `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.
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.
**`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.
Keep both in step with the seed SQL.
## 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.
- On submit, `ActivityForm` sends only fields the activity defines, drops empties (an
unknown field is fatal to schema validation) and skips `id_gen` — that reference is
issued server-side.
- OCR results fill only fields still blank; never overwrite what a person typed with what
a model read.
## Deployment
Registered with frontgen by `sm2/custom-apps/zurich-kotak/05_frontgen_project.sql` (slug
`zurich-kotak`, repo_name `zurich_kotak`, org 84). A push to `main` builds and serves at
https://preview-dev.getzino.in/zurich-kotak/. The webhook fires only on a **new** push.
Demo logins (all password `ZurichKotak@2026`, org `84`) are listed in `README.md`; sign in
as `sanjay.uw@zurichkotak.example` to land in the one human queue.

View File

@ -1,82 +1,324 @@
.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: 0.86rem;
}
.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: 0.74rem;
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: 0.58rem;
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: 0.88rem;
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: 0.78rem;
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: 0.88rem;
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: 0.88rem;
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: 0.84rem;
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: 0.8rem;
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: 0.88rem;
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: 0.58rem;
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;
flex-direction: column;
gap: 8px;
}
.ff__input {
display: none;
}
.ff__btn { .ff__btn {
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer; font: inherit;
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue); font-size: 0.84rem;
color: var(--zk-blue-dark); text-align: center; font-weight: 500;
padding: 14px 14px;
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__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; } .ff__btn:hover:not(:disabled) {
background: var(--zk-white);
border-style: solid;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.ff__btn:disabled {
opacity: 0.6;
cursor: default;
border-style: solid;
}
.ff__got { .ff__got {
font-size: .78rem; color: var(--zk-muted); font-size: 0.78rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--zk-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.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: 0.6rem;
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: 0.8rem;
color: var(--zk-ink);
}
.ff__read em {
font-style: normal;
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 6px;
font-size: 0.72rem;
}
.ff__err { .ff__err {
font-size: .78rem; color: var(--zk-danger); font-size: 0.78rem;
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;
} }

View File

@ -0,0 +1,59 @@
.clamp {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.clamp__text {
margin: 0;
font-size: 0.86rem;
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.is-open .clamp__text {
display: block;
overflow: visible;
}
.clamp__more {
font: inherit;
font-size: 0.76rem;
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);
}
.clamp.is-open .clamp__more svg {
transform: rotate(180deg);
}

View File

@ -0,0 +1,34 @@
import { useState } from 'react'
import './ClampText.css'
/**
* Prose, folded.
*
* The AI employees write at length a recommendation rationale runs past 1,500
* characters and a lead file that prints all of it is a document, not a
* screen. Anything long opens as a few lines with the rest one click away, so
* the page can be scanned and still holds everything for whoever wants it.
*
* Whether to fold is decided on length, not on measuring the rendered box: a
* ref-and-measure pass would reflow on every resize to answer a question the
* string itself already answers.
*/
export default function ClampText({ text, lines = 3, threshold = 150 }) {
const [open, setOpen] = useState(false)
const value = String(text)
if (value.length <= threshold) return <p className="clamp__text clamp__text--short">{value}</p>
return (
<div className={'clamp' + (open ? ' is-open' : '')}>
<p className="clamp__text" style={{ '--clamp-lines': lines }}>{value}</p>
<button type="button" className="clamp__more" onClick={() => setOpen((o) => !o)}>
{open ? 'Show less' : 'Read more'}
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
)
}

View File

@ -1,39 +1,205 @@
.tl { list-style: none; margin: 0; padding: 0 0 0 4px; } .tl {
.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; } list-style: none;
.tl__err { color: var(--zk-danger); } margin: 0;
padding: 0 0 0 4px;
}
.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); } .tl__loading,
.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; } .tl__err {
color: var(--zk-muted);
font-size: 0.86rem;
padding: 14px 2px;
}
.tl__err {
color: var(--zk-danger-ink);
}
.tl__item {
position: relative;
padding: 0 0 18px 26px;
border-left: 2px solid var(--zk-line-soft);
}
.tl__item:last-child {
border-left-color: transparent;
padding-bottom: 4px;
}
.tl__dot { .tl__dot {
position: absolute; left: -7px; top: 3px; width: 12px; height: 12px; position: absolute;
border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey); left: -8px;
top: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-grey);
box-shadow: 0 0 0 4px var(--zk-white);
} }
/* Machine work is blue, people are amber, the platform itself is grey so a /* Machine work is blue, people are amber, the platform itself is grey so a
file can be read at a glance for how much of it was done by hand. */ 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--ai .tl__dot {
.tl__item--human .tl__dot { border-color: #b5761f; background: var(--zk-white); } border-color: var(--zk-blue);
.tl__item--sys .tl__dot { border-color: var(--zk-line); background: var(--zk-line); } background: var(--zk-blue);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(33, 103, 174, 0.12);
}
.tl__item--human .tl__dot {
border-color: var(--zk-amber);
background: var(--zk-white);
box-shadow: 0 0 0 4px var(--zk-white), 0 0 0 7px rgba(181, 118, 31, 0.14);
}
.tl__item--sys .tl__dot {
border-color: var(--zk-line);
background: var(--zk-line);
}
.tl__body {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 12px 8px;
margin-left: -12px;
border-radius: var(--r-md);
transition: background var(--t-fast);
}
.tl__item:hover .tl__body {
background: var(--zk-tint);
}
.tl__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
}
/* Who and when sit on their own line: in a 384px rail, badge-beside-title wraps
into a ragged block that is harder to scan than two clean rows. */
.tl__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
}
.tl__what {
font-size: 0.89rem;
font-weight: 500;
color: var(--zk-ink);
}
.tl__body { display: flex; flex-direction: column; gap: 3px; }
.tl__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 10px; }
.tl__what { font-size: .93rem; font-weight: 500; color: var(--zk-ink); }
.tl__who { .tl__who {
font-size: .68rem; letter-spacing: .04em; padding: 2px 7px; border-radius: 3px; white-space: nowrap; font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.03em;
padding: 3px 9px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.tl__who--ai {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
.tl__who--human {
background: var(--zk-amber-tint);
color: var(--zk-amber-ink);
}
.tl__who--sys {
background: var(--zk-tint);
color: var(--zk-muted);
border: 1px solid var(--zk-line);
}
.tl__stage {
font-size: 0.78rem;
color: var(--zk-blue);
}
.tl__when {
font-size: 0.72rem;
color: var(--zk-grey);
}
.tl__say {
margin-top: 8px;
padding: 8px 12px;
border-left: 2px solid var(--zk-blue-light);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: var(--zk-tint);
}
.tl__item:hover .tl__say {
background: var(--zk-white);
} }
.tl__who--ai { background: var(--zk-tint-blue); color: var(--zk-blue-dark); }
.tl__who--human { background: #f7efe2; color: #7a5a12; }
.tl__who--sys { background: var(--zk-tint); color: var(--zk-muted); }
.tl__stage { font-size: .78rem; color: var(--zk-blue); }
.tl__when { font-size: .72rem; color: var(--zk-grey); }
.tl__say { margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--zk-line-soft); }
.tl__saylabel { .tl__saylabel {
display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; display: block;
color: var(--zk-grey); margin-bottom: 2px; font-size: 0.62rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-grey);
margin-bottom: 3px;
} }
.tl__say p { margin: 0; font-size: .84rem; line-height: 1.55; color: var(--zk-muted); max-width: 78ch; }
.tl__figs { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 7px; } .tl__say p {
.tl__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; } margin: 0;
.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; } font-size: 0.84rem;
line-height: 1.55;
color: var(--zk-muted);
max-width: 78ch;
}
.tl__figs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.tl__figs span {
font-size: 0.8rem;
color: var(--zk-ink);
font-variant-numeric: tabular-nums;
padding: 4px 11px;
border-radius: var(--r-pill);
background: var(--zk-white);
border: 1px solid var(--zk-line);
}
.tl__figs em {
font-style: normal;
font-size: 0.68rem;
color: var(--zk-grey);
text-transform: capitalize;
margin-right: 5px;
}
/* ---- system noise ---- */
.tl__toggle {
font: inherit;
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
margin-bottom: 14px;
padding: 5px 12px;
border-radius: var(--r-pill);
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);
}
.tl__toggle:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
color: var(--zk-blue-dark);
}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import ClampText from './ClampText.jsx'
import { STAGES } from '../api/config.js' import { STAGES } from '../api/config.js'
import './Timeline.css' import './Timeline.css'
@ -47,6 +48,10 @@ export default function Timeline({ instanceId }) {
const { client } = useZino() const { client } = useZino()
const [rows, setRows] = useState(null) const [rows, setRows] = useState(null)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
// DATA_UPDATE entries are the platform writing fields, not anyone deciding
// 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(() => { useEffect(() => {
let dead = false let dead = false
@ -63,58 +68,73 @@ export default function Timeline({ instanceId }) {
// 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)))
return ( const items = ordered.map((r, i) => {
<ol className="tl">
{ordered.map((r, i) => {
const roles = r.user_roles || [] const roles = r.user_roles || []
const aiRole = roles.find((x) => AI_ROLES[x]) const aiRole = roles.find((x) => AI_ROLES[x])
const isSystem = r.activity_id === 'DATA_UPDATE' const isSystem = r.activity_id === 'DATA_UPDATE'
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human' const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
const actor = aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System')
const stage = STAGES.find((s) => s.uid === r.execution_state)
const d = r.data || {} const d = r.data || {}
return {
const narrative = NARRATIVE key: r.id ?? i,
kind,
actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : (r.activity_name || r.activity_id),
stage: STAGES.find((s) => s.uid === r.execution_state),
when: when(r.created_at),
narrative: NARRATIVE
.map(([k, label]) => [label, d[k]]) .map(([k, label]) => [label, d[k]])
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '') .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''),
figures: Object.entries(d)
const figures = Object.entries(d)
.filter(([k, v]) => MONEY.has(k) && v) .filter(([k, v]) => MONEY.has(k) && v)
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]) .map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]),
}
})
const sysCount = items.filter((it) => it.kind === 'sys').length
const visible = showSys ? items : items.filter((it) => it.kind !== 'sys')
return ( return (
<li key={r.id ?? i} className={'tl__item tl__item--' + kind}> <>
{sysCount ? (
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
</button>
) : null}
<ol className="tl">
{visible.map((it) => (
<li key={it.key} className={'tl__item tl__item--' + it.kind}>
<span className="tl__dot" aria-hidden="true" /> <span className="tl__dot" aria-hidden="true" />
<div className="tl__body"> <div className="tl__body">
<div className="tl__head"> <div className="tl__head">
<strong className="tl__what"> <strong className="tl__what">{it.what}</strong>
{isSystem ? 'Data updated' : (r.activity_name || r.activity_id)} {it.stage ? <span className="tl__stage"> {it.stage.name}</span> : null}
</strong> </div>
<span className={'tl__who tl__who--' + kind}> <div className="tl__meta">
{kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor} <span className={'tl__who tl__who--' + it.kind}>
</span> {it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor}
{stage ? <span className="tl__stage"> {stage.name}</span> : null} </span>
<span className="tl__when">{it.when}</span>
</div> </div>
<div className="tl__when">{when(r.created_at)}</div>
{narrative.map(([label, v]) => ( {it.narrative.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)} 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> </div>
</li> </li>
) ))}
})}
</ol> </ol>
</>
) )
} }

View File

@ -41,13 +41,48 @@
--zk-white: #fff; --zk-white: #fff;
--zk-danger: #d32f2f; --zk-danger: #d32f2f;
/* Amber and red accents already used for people / lapsed renewals, named so
they stop being magic hex literals scattered across four stylesheets. */
--zk-amber: #b5761f;
--zk-amber-ink: #93500f;
--zk-amber-tint: #fdf1e7;
--zk-amber-line: #f0c69a;
--zk-danger-ink: #a3261f;
--zk-danger-tint: #fdeceb;
--zk-danger-line: #f0c2bd;
/* Shape, depth, motion
One scale, used everywhere. The palette above is untouched; every shadow
below is the brand navy at low alpha, so depth reads as part of the brand
rather than as generic grey haze. */
--r-xs: 4px;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 14px;
--r-pill: 999px;
--sh-xs: 0 1px 2px rgba(35, 54, 111, 0.05);
--sh-sm: 0 1px 2px rgba(35, 54, 111, 0.05), 0 2px 6px -2px rgba(35, 54, 111, 0.07);
--sh-md: 0 1px 2px rgba(35, 54, 111, 0.05), 0 10px 24px -8px rgba(35, 54, 111, 0.14);
--sh-lg: 0 2px 6px rgba(35, 54, 111, 0.06), 0 20px 42px -14px rgba(35, 54, 111, 0.22);
--ring: 0 0 0 3px rgba(33, 103, 174, 0.16);
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--t-fast: 0.14s var(--ease);
--t: 0.2s var(--ease);
--grad-blue: linear-gradient(135deg, var(--zk-blue) 0%, var(--zk-blue-deep) 100%);
--grad-blue-hover: linear-gradient(135deg, var(--zk-blue-dark) 0%, var(--zk-navy) 100%);
--font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif; --font-zurich: 'Zurich Sans', Arial, Helvetica, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
font-family: var(--font-zurich); font-family: var(--font-zurich);
font-size: 16px; font-size: 16px;
line-height: 1.5; line-height: 1.5;
color: var(--zk-ink); color: var(--zk-ink);
background: var(--zk-white); background: var(--zk-tint);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@ -65,10 +100,75 @@ body,
body { body {
margin: 0; margin: 0;
background: var(--zk-tint);
} }
button, button,
input { input,
select,
textarea {
font: inherit; font: inherit;
color: inherit; color: inherit;
} }
/* One focus treatment for the whole console: a soft brand ring, never the
browser's default outline halfway through a card's rounded corner. */
:focus-visible {
outline: 2px solid var(--zk-blue);
outline-offset: 2px;
}
::selection {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}
/* Scrollbars, toned to the palette. Chrome/Safari take the ::-webkit rules,
Firefox the standard properties. */
* {
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 {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@keyframes zk-shimmer {
from { background-position: -420px 0; }
to { background-position: 420px 0; }
}
/* Motion is decoration here every animation is an entrance or a hover, so
dropping them costs nothing. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}

View File

@ -1,58 +1,288 @@
.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);
}
/* ── Top bar ───────────────────────────────────────────────── */
.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;
justify-content: space-between;
gap: 24px;
height: 66px;
padding: 0 28px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: saturate(180%) blur(14px);
-webkit-backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--zk-line);
box-shadow: var(--sh-xs);
} }
.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; } .shell__brand {
.shell__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; } display: flex;
.shell__user strong { font-size: .85rem; font-weight: 500; } align-items: center;
.shell__user span { font-size: .72rem; color: var(--zk-muted); } gap: 16px;
.shell__signout { min-width: 0;
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); }
.shell__body { display: flex; flex: 1; min-height: 0; align-items: stretch; } .shell__brand img {
height: 30px;
width: auto;
display: block;
}
.shell__brandtext {
display: flex;
flex-direction: column;
gap: 1px;
line-height: 1.2;
padding-left: 16px;
border-left: 1px solid var(--zk-line);
}
.shell__brandtext strong {
font-size: 0.95rem;
font-weight: 500;
letter-spacing: -0.005em;
color: var(--zk-ink);
}
.shell__brandtext span {
font-size: 0.7rem;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--zk-grey);
}
/* ── Body ──────────────────────────────────────────────────── */
.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: 66px;
display: flex; flex-direction: column; gap: 1px; align-self: flex-start;
width: 264px;
flex: none;
min-height: calc(100vh - 66px);
max-height: calc(100vh - 66px);
overflow-y: auto;
overscroll-behavior: contain;
background: var(--zk-white);
border-right: 1px solid var(--zk-line);
padding: 20px 14px 32px;
display: flex;
flex-direction: column;
} }
.shell__navlabel { .shell__navlabel {
margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500; margin: 0 0 10px;
letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); padding: 0 10px;
font-size: 0.64rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zk-grey);
}
.shell__navlabel--closed {
margin-top: 26px;
}
/* The stage list is drawn as a rail with a node per state: the sidebar should
look like the pipeline it represents, not like a list of links. */
.shell__stages {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
}
.shell__stages::before {
content: '';
position: absolute;
left: 17px;
top: 18px;
bottom: 18px;
width: 2px;
border-radius: var(--r-pill);
background: linear-gradient(180deg, var(--zk-line) 0%, var(--zk-line-soft) 100%);
} }
.shell__navlabel--closed { margin-top: 22px; }
.shell__stage { .shell__stage {
display: flex; align-items: center; justify-content: space-between; gap: 8px; position: relative;
padding: 8px 10px; border-radius: 4px; text-decoration: none; display: flex;
color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent; align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px 9px 38px;
border-radius: var(--r-md);
text-decoration: none;
color: var(--zk-muted);
font-size: 0.845rem;
transition: background var(--t-fast), color var(--t-fast), transform var(--t-fast);
} }
.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; } .shell__stage::before {
content: '';
position: absolute;
left: 12px;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: var(--zk-white);
border: 2px solid var(--zk-line);
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
}
.shell__stage:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
.shell__stage:hover::before {
border-color: var(--zk-blue-light);
}
.shell__stage.is-active {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
font-weight: 500;
}
.shell__stage.is-active::before {
background: var(--zk-blue);
border-color: var(--zk-blue);
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
}
/* The one queue where the machine stops and a person decides. */ /* The one queue where the machine stops and a person decides. */
.shell__stage.is-human .shell__stagename { font-weight: 500; } .shell__stage.is-human .shell__stagename {
font-weight: 500;
color: var(--zk-ink);
}
.shell__stage.is-human.is-active .shell__stagename {
color: var(--zk-blue-dark);
}
/* Stage names wrap rather than truncate: "Referred to Underwriting" is the one
queue a person has to find, and it is also the longest label. */
.shell__stagename {
min-width: 0;
line-height: 1.35;
}
.shell__badge { .shell__badge {
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; flex: none;
padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white); font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: var(--r-pill);
background: var(--grad-blue);
color: var(--zk-white);
box-shadow: var(--sh-xs);
} }
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; } .shell__main {
flex: 1;
min-width: 0;
width: 100%;
max-width: 1460px;
margin: 0 auto;
padding: 30px 34px 76px;
animation: zk-rise 0.28s var(--ease) both;
}
/* ── Primary action ────────────────────────────────────────── */
.shell__add { .shell__add {
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px; display: flex;
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500; align-items: center;
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue); justify-content: center;
gap: 8px;
margin: 0 2px 22px;
padding: 11px 14px;
border-radius: var(--r-md);
text-decoration: none;
font-size: 0.86rem;
font-weight: 500;
color: var(--zk-white);
background: var(--grad-blue);
box-shadow: var(--sh-sm);
transition: box-shadow var(--t), transform var(--t-fast), background var(--t);
}
.shell__add:hover {
background: var(--grad-blue-hover);
box-shadow: var(--sh-md);
transform: translateY(-1px);
}
.shell__add:active {
transform: none;
box-shadow: var(--sh-xs);
}
.shell__add svg {
width: 15px;
height: 15px;
flex: none;
}
/* ── Narrow viewports: the rail lies down and scrolls ──────── */
@media (max-width: 900px) {
.shell__body {
flex-direction: column;
}
.shell__nav {
position: static;
width: 100%;
min-height: 0;
max-height: none;
border-right: 0;
border-bottom: 1px solid var(--zk-line);
padding: 14px 16px 16px;
}
.shell__stages {
flex-direction: row;
overflow-x: auto;
padding-bottom: 4px;
}
/* Laid out horizontally the stages are a scrolling strip of pills, and a rail
behind them would just be a line struck through the row. */
.shell__stages::before {
display: none;
}
.shell__stage {
padding: 8px 14px;
white-space: nowrap;
background: var(--zk-white);
border: 1px solid var(--zk-line);
}
.shell__stage::before {
display: none;
}
.shell__main {
padding: 22px 18px 56px;
}
} }
.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }

View File

@ -1,6 +1,6 @@
import { NavLink, Outlet } from 'react-router-dom' import { NavLink, Outlet } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
import { STAGES } from '../api/config.js' import { STAGES } from '../api/config.js'
import UserMenu from './UserMenu.jsx'
import logo from '../assets/brand/zurich_logo.webp' import logo from '../assets/brand/zurich_logo.webp'
import './Shell.css' import './Shell.css'
@ -10,7 +10,6 @@ import './Shell.css'
* as such, because it is the only place the machine stops. * as such, because it is the only place the machine stops.
*/ */
export default function Shell() { export default function Shell() {
const { user, signOut } = useZino()
const work = STAGES.filter((s) => s.kind !== 'end') const work = STAGES.filter((s) => s.kind !== 'end')
const closed = STAGES.filter((s) => s.kind === 'end') const closed = STAGES.filter((s) => s.kind === 'end')
@ -19,29 +18,25 @@ export default function Shell() {
<header className="shell__top"> <header className="shell__top">
<div className="shell__brand"> <div className="shell__brand">
<img src={logo} alt="Zurich Kotak General Insurance" /> <img src={logo} alt="Zurich Kotak General Insurance" />
<div> <div className="shell__brandtext">
<strong>Lead Desk</strong> <strong>Lead Desk</strong>
<span>Lead to Policy</span> <span>Lead to Policy</span>
</div> </div>
</div> </div>
<div className="shell__who"> <UserMenu />
<div className="shell__user">
<strong>{user?.name || user?.email || 'Signed in'}</strong>
{/* The role is shown because RBAC is the demo: what this person can
and cannot do is the point, so who they are signed in as should
never be a guess. */}
<span>{(user?.roles || []).join(', ') || user?.email}</span>
</div>
<button type="button" className="shell__signout" onClick={signOut}>
Sign out
</button>
</div>
</header> </header>
<div className="shell__body"> <div className="shell__body">
<nav className="shell__nav" aria-label="Pipeline"> <nav className="shell__nav" aria-label="Pipeline">
<NavLink to="/add" className="shell__add">+ Add a lead</NavLink> <NavLink to="/add" className="shell__add">
<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>
Add a lead
</NavLink>
<p className="shell__navlabel">Pipeline</p> <p className="shell__navlabel">Pipeline</p>
<div className="shell__stages">
{work.map((s) => ( {work.map((s) => (
<NavLink <NavLink
key={s.uid} key={s.uid}
@ -54,7 +49,9 @@ export default function Shell() {
{s.kind === 'human' ? <span className="shell__badge">you</span> : null} {s.kind === 'human' ? <span className="shell__badge">you</span> : null}
</NavLink> </NavLink>
))} ))}
</div>
<p className="shell__navlabel shell__navlabel--closed">Closed</p> <p className="shell__navlabel shell__navlabel--closed">Closed</p>
<div className="shell__stages">
{closed.map((s) => ( {closed.map((s) => (
<NavLink <NavLink
key={s.uid} key={s.uid}
@ -64,6 +61,7 @@ export default function Shell() {
<span className="shell__stagename">{s.name}</span> <span className="shell__stagename">{s.name}</span>
</NavLink> </NavLink>
))} ))}
</div>
</nav> </nav>
<main className="shell__main"> <main className="shell__main">

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

@ -0,0 +1,161 @@
.um {
position: relative;
}
.um__trigger {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
padding: 5px 12px 5px 5px;
border-radius: var(--r-pill);
background: transparent;
border: 1px solid transparent;
color: inherit;
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.um__trigger:hover {
background: var(--zk-tint);
border-color: var(--zk-line);
}
.um__trigger.is-open {
background: var(--zk-tint-blue);
border-color: var(--zk-blue-light);
}
.um__trigger:focus-visible {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.um__avatar {
flex: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--grad-blue);
color: var(--zk-white);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.03em;
box-shadow: var(--sh-xs);
}
.um__avatar--lg {
width: 40px;
height: 40px;
font-size: 0.86rem;
}
.um__id {
display: flex;
flex-direction: column;
text-align: left;
line-height: 1.3;
}
.um__id strong {
font-size: 0.85rem;
font-weight: 500;
}
.um__id > span {
font-size: 0.7rem;
color: var(--zk-muted);
}
.um__caret {
width: 12px;
height: 12px;
flex: none;
color: var(--zk-grey);
transition: transform var(--t);
}
.um__trigger.is-open .um__caret {
transform: rotate(180deg);
}
.um__menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 40;
min-width: 300px;
padding: 7px;
border-radius: var(--r-lg);
background: var(--zk-white);
border: 1px solid var(--zk-line);
box-shadow: var(--sh-lg);
transform-origin: top right;
animation: zk-rise 0.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: 0.87rem;
font-weight: 500;
}
.um__headid span {
font-size: 0.72rem;
color: var(--zk-muted);
overflow-wrap: anywhere;
}
.um__headid .um__roles {
margin-top: 4px;
align-self: flex-start;
font-size: 0.68rem;
font-weight: 500;
color: var(--zk-blue-dark);
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: 0.82rem;
padding: 10px 11px;
border-radius: var(--r-sm);
background: none;
border: 0;
border-top: 1px solid var(--zk-line-soft);
color: var(--zk-ink);
transition: background var(--t-fast), color var(--t-fast);
}
.um__item:hover {
background: var(--zk-tint);
color: var(--zk-blue-dark);
}
.um__item:focus-visible {
outline: none;
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
}

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

@ -0,0 +1,86 @@
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'
const roles = (user?.roles || []).join(', ')
return (
<div className="um" ref={wrapRef}>
<button
ref={triggerRef}
type="button"
className={'um__trigger' + (open ? ' is-open' : '')}
onClick={() => setOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={open}
>
<span className="um__avatar" aria-hidden="true">{initials(user)}</span>
<span className="um__id">
<strong>{name}</strong>
<span>{roles || user?.email}</span>
</span>
<svg className="um__caret" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" fill="none" stroke="currentColor" strokeWidth="1.4"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open ? (
<div className="um__menu" role="menu">
<div className="um__head">
<span className="um__avatar um__avatar--lg" aria-hidden="true">{initials(user)}</span>
<div className="um__headid">
<strong>{name}</strong>
{user?.email ? <span>{user.email}</span> : null}
{roles ? <span className="um__roles">{roles}</span> : null}
</div>
</div>
<button type="button" role="menuitem" className="um__item" onClick={signOut}>
Sign out
</button>
</div>
) : null}
</div>
)
}

View File

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

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx' import ActivityForm from '../components/ActivityForm.jsx'
import ClampText from '../components/ClampText.jsx'
import Timeline from '../components/Timeline.jsx' import Timeline from '../components/Timeline.jsx'
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js' import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
import './screens.css' import './screens.css'
@ -49,6 +50,26 @@ const GROUPS = [
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']], ["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
] ]
/** Past this, a value is prose and gets folded rather than printed in full. */
const LONG = 150
/**
* Field ids are snake_case and several carry acronyms, which sentence-casing
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
* other word passes through.
*/
const WORDS = {
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
od: 'OD', tp: 'TP', gst: 'GST', idv: 'IDV', ncb: 'NCB', rm: 'RM', si: 'SI',
cc: 'CC', id: 'ID', no: 'no.', pct: '%', wip: 'WIP', bi: 'BI', ee: 'EE',
amc: 'AMC', km: 'km', posp: 'POSP',
}
function label(k) {
const words = k.split('_').map((w) => WORDS[w] ?? w).join(' ')
return words.charAt(0).toUpperCase() + words.slice(1)
}
function fmt(k, v) { function fmt(k, v) {
if (v === null || v === undefined || v === '') return null if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) return v.join(', ') if (Array.isArray(v)) return v.join(', ')
@ -59,9 +80,12 @@ function fmt(k, v) {
export default function Lead() { export default function Lead() {
const { instanceId } = useParams() const { instanceId } = useParams()
const { client } = useZino() const { client } = useZino()
const navigate = useNavigate()
const location = useLocation()
const [row, setRow] = useState(null) const [row, setRow] = useState(null)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const [open, setOpen] = useState(null) const [open, setOpen] = useState(null)
const [tab, setTab] = useState(0)
const load = useCallback(() => { const load = useCallback(() => {
setErr(null) setErr(null)
@ -75,6 +99,12 @@ export default function Lead() {
if (err) return <div className="notice"><strong>Could not load lead {instanceId}.</strong><p className="notice__detail">{err.status} {err.message}</p></div> if (err) return <div className="notice"><strong>Could not load lead {instanceId}.</strong><p className="notice__detail">{err.status} {err.message}</p></div>
if (!row) return <p className="empty">Loading</p> if (!row) return <p className="empty">Loading</p>
// Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS
.map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)])
.filter(([, shown]) => shown.length)
const active = Math.min(tab, Math.max(groups.length - 1, 0))
const stateName = row.current_state_name || '' const stateName = row.current_state_name || ''
const stage = STAGES.find((s) => s.name === stateName) const stage = STAGES.find((s) => s.name === stateName)
const actions = ACTIONS[stage?.uid] || [] const actions = ACTIONS[stage?.uid] || []
@ -82,16 +112,34 @@ export default function Lead() {
return ( return (
<section> <section>
<header className="page__head"> <header className="page__head">
<div className="page__lead">
{/* React Router stamps the first entry in a session with key 'default',
so a lead opened from its own URL has nothing to go back TO and
lands on the queue instead of leaving the app. */}
<button
type="button" className="backbtn" title="Back to the queue" aria-label="Back to the queue"
onClick={() => (location.key === 'default' ? navigate('/') : navigate(-1))}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M9.5 3.5 5 8l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7"
strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div> <div>
<h1 className="page__title">{row.lead_ref || `Lead ${instanceId}`}</h1> <h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
<p className="page__sub"> <p className="page__sub">
{row.customer_name}{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '} {row.lead_ref || `Lead ${instanceId}`}
{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
{row.product_line === 'motor' ? 'Motor' : 'SME Package'} {row.product_line === 'motor' ? 'Motor' : 'SME Package'}
</p> </p>
</div> </div>
</div>
<div className="lead__stage"> <div className="lead__stage">
<span className="lead__stagelabel">Stage</span> <span className="lead__stagelabel">Stage</span>
<strong>{stateName || '—'}</strong> <strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
{/* An end state runs no activity. That is worth one line next to the
stage, not a panel of its own where the actions would be. */}
{!actions.length ? <span className="lead__closed">Closed nothing runs from here</span> : null}
</div> </div>
</header> </header>
@ -102,12 +150,20 @@ export default function Lead() {
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd> <dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
</div> </div>
) : null })()} ) : null })()}
{row.quoted_premium ? (
<div className="lead__meta--money">
<dt>Quoted premium</dt>
<dd>{Number(row.quoted_premium).toLocaleString('en-IN')}</dd>
</div>
) : null}
{row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null} {row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null}
{row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null} {row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null}
{row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null} {row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null}
{row.updated_at ? <div><dt>Last activity</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null} {row.updated_at ? <div><dt>Last activity</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null}
</dl> </dl>
<div className="lead">
<div className="lead__main">
{actions.length ? ( {actions.length ? (
<div className="panel"> <div className="panel">
<div className="panel__head"> <div className="panel__head">
@ -140,42 +196,66 @@ export default function Lead() {
</div> </div>
) : null} ) : null}
</div> </div>
) : ( ) : null}
<div className="notice"><strong>Terminal.</strong><p>No activity runs from {stateName}.</p></div>
)}
{/* The flow's own detail. Each group is one click away rather than one
long scroll, and prose is folded to a few lines the page can be
scanned, and still holds everything for whoever wants it. */}
{groups.length ? (
<div className="panel"> <div className="panel">
<div className="panel__head"> <div className="panel__head">
<div> <div>
<h2 className="panel__title">What has happened</h2> <h2 className="panel__title">Flow details</h2>
<p className="panel__sub"> <p className="panel__sub">Everything captured on this lead, grouped in the order it was worked.</p>
Every step on this lead, in order, and who took it an AI employee,
a person, or the platform itself.
</p>
</div> </div>
</div> </div>
<Timeline instanceId={instanceId} /> <div className="tabs" role="tablist">
{groups.map(([title, shown], i) => (
<button
key={title} type="button" role="tab" aria-selected={i === active}
className={'tab' + (i === active ? ' is-on' : '')}
onClick={() => setTab(i)}
>
{title}<span className="tab__n">{shown.length}</span>
</button>
))}
</div> </div>
<dl className="props">
{GROUPS.map(([title, keys]) => { {groups[active][1].map(([k, v]) => (
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null) v.length > LONG ? (
if (!shown.length) return null <div className="prop prop--note" key={k}>
return ( <dt>{label(k)}</dt>
<div className="grp" key={title}> <dd><ClampText text={v} /></dd>
<h3 className="grp__title">{title}</h3>
<dl className="grp__list">
{shown.map(([k, v]) => (
<div className="grp__row" key={k}>
<dt>{k.replace(/_/g, ' ')}</dt>
<dd>{v}</dd>
</div> </div>
) : (
<div className="prop" key={k}>
<dt>{label(k)}</dt>
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
</div>
)
))} ))}
</dl> </dl>
</div> </div>
) ) : null}
})} </div>
{/* The story runs alongside the file rather than under it, so what was
done and what was captured can be read against each other. */}
<aside className="lead__side">
<div className="panel panel--rail">
<div className="panel__head">
<div>
<h2 className="panel__title">What has happened</h2>
<p className="panel__sub">Every step, in order, and who took it.</p>
</div>
</div>
<div className="lead__feed">
<Timeline instanceId={instanceId} />
</div>
</div>
</aside>
</div>
<p className="lead__back"><Link className="grid__link" to="/">Back to the queue</Link></p>
</section> </section>
) )
} }

View File

@ -80,11 +80,18 @@ export default function Pipeline() {
</p> </p>
</div> </div>
{state.status === 'ready' ? ( {state.status === 'ready' ? (
<span className="page__count">{state.rows.length}</span> <div className="page__count">
<strong>{state.rows.length}</strong>
<span>{state.rows.length === 1 ? 'lead' : 'leads'}</span>
</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">
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
</div>
) : null}
{state.status === 'error' ? ( {state.status === 'error' ? (
<div className="notice"> <div className="notice">
@ -103,12 +110,13 @@ export default function Pipeline() {
) : null} ) : null}
{state.status === 'ready' && state.rows.length > 0 ? ( {state.status === 'ready' && state.rows.length > 0 ? (
<div className="gridwrap">
<table className="grid"> <table className="grid">
<thead> <thead>
<tr> <tr>
<th>Lead</th><th>Customer</th><th>Channel</th> <th>Customer</th><th>Channel</th>
<th>Product</th><th>Renewal due</th><th className="num">Premium</th> <th>Product</th><th>Renewal due</th><th className="num">Premium</th>
<th>Attribution</th><th>Added</th> <th>Attribution</th><th>Added</th><th aria-label="Actions" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -119,7 +127,6 @@ export default function Pipeline() {
const add = added(r.created_at) const add = added(r.created_at)
return ( return (
<tr key={id}> <tr key={id}>
<td><Link className="grid__link" to={`/lead/${id}`}>{r.lead_ref || id}</Link></td>
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td> <td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
<td><span className="chip">{ch.label}</span></td> <td><span className="chip">{ch.label}</span></td>
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td> <td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
@ -136,11 +143,15 @@ export default function Pipeline() {
: <span className="grid__sub">{r.attribution_status || '—'}</span>} : <span className="grid__sub">{r.attribution_status || '—'}</span>}
</td> </td>
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td> <td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
<td className="grid__act">
<Link className="grid__btn" to={`/lead/${id}`}>Open</Link>
</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
</table> </table>
</div>
) : null} ) : null}
</section> </section>
) )

View File

@ -1,109 +1,792 @@
/* ---- page header ---- */
.page__head { .page__head {
display: flex; align-items: flex-start; justify-content: space-between; display: flex;
gap: 24px; margin-bottom: 20px; align-items: flex-start;
} justify-content: space-between;
.page__title { margin: 0; font-size: 1.5rem; font-weight: 500; letter-spacing: -.01em; color: var(--zk-ink); } gap: 24px;
.page__sub { margin: 3px 0 0; font-size: .84rem; color: var(--zk-muted); } margin-bottom: 24px;
.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__title {
margin: 0;
font-size: 1.7rem;
font-weight: 500;
letter-spacing: -0.022em;
line-height: 1.2;
color: var(--zk-ink);
}
.page__sub {
margin: 6px 0 0;
max-width: 72ch;
font-size: 0.855rem;
line-height: 1.5;
color: var(--zk-muted);
}
/* The queue depth, as a stat tile rather than a loose number. */
.page__count {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
min-width: 84px;
padding: 10px 18px;
border-radius: var(--r-lg);
background: var(--zk-white);
border: 1px solid var(--zk-line);
box-shadow: var(--sh-sm);
}
.page__count strong {
font-size: 1.6rem;
font-weight: 500;
line-height: 1.1;
color: var(--zk-blue);
font-variant-numeric: tabular-nums;
}
.page__count span {
font-size: 0.6rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-grey);
}
.empty {
margin: 0;
color: var(--zk-muted);
font-size: 0.88rem;
text-align: center;
padding: 44px 24px;
border-radius: var(--r-lg);
border: 1px dashed var(--zk-line);
background: linear-gradient(180deg, var(--zk-white) 0%, var(--zk-tint) 100%);
}
.notice { .notice {
background: var(--zk-white); border: 1px solid var(--zk-line); background: var(--zk-white);
border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px; border: 1px solid var(--zk-line);
max-width: 60ch; border-left: 3px solid var(--zk-blue);
border-radius: var(--r-xs) var(--r-lg) var(--r-lg) var(--r-xs);
box-shadow: var(--sh-sm);
padding: 18px 22px;
max-width: 64ch;
} }
.notice strong { display: block; font-weight: 500; margin-bottom: 6px; }
.notice p { margin: 0 0 6px; font-size: .86rem; color: var(--zk-muted); line-height: 1.55; } .notice strong {
.notice__detail { font-size: .76rem !important; color: var(--zk-grey) !important; } display: block;
font-weight: 500;
margin-bottom: 7px;
}
.notice p {
margin: 0 0 6px;
font-size: 0.86rem;
color: var(--zk-muted);
line-height: 1.55;
}
.notice__detail {
font-size: 0.76rem !important;
color: var(--zk-grey) !important;
}
.notice code { .notice code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em; font-family: var(--font-mono);
background: var(--zk-tint); border: 1px solid var(--zk-line-soft); font-size: 0.8em;
border-radius: 3px; padding: 1px 4px; background: var(--zk-tint);
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-xs);
padding: 1px 5px;
}
/* ---- queue table ---- */
.gridwrap {
background: var(--zk-white);
border: 1px solid var(--zk-line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
overflow: hidden;
overflow-x: auto;
} }
.grid { .grid {
width: 100%; border-collapse: collapse; background: var(--zk-white); width: 100%;
border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem; /* Eight columns of operational data do not compress: below this the card
scrolls sideways rather than squeezing every cell into two words a line. */
min-width: 900px;
border-collapse: separate;
border-spacing: 0;
font-size: 0.86rem;
} }
.grid th {
text-align: left; font-size: .68rem; font-weight: 500; letter-spacing: .08em;
text-transform: uppercase; color: var(--zk-muted); padding: 10px 14px;
background: var(--zk-tint); border-bottom: 1px solid var(--zk-line); white-space: nowrap;
}
.grid td { padding: 11px 14px; border-bottom: 1px solid var(--zk-line-soft); vertical-align: top; }
.grid tbody tr:last-child td { border-bottom: none; }
.grid tbody tr:hover { background: var(--zk-tint); }
.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
.grid__sub { font-size: .76rem; color: var(--zk-muted); margin-top: 2px; }
.grid__link { color: var(--zk-blue); text-decoration: none; font-weight: 500; }
.grid__link:hover { text-decoration: underline; }
.chip { .grid th {
display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px; position: sticky;
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); top: 0;
z-index: 1;
text-align: left;
font-size: 0.65rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-muted);
padding: 12px 16px;
background: var(--zk-tint);
border-bottom: 1px solid var(--zk-line);
white-space: nowrap;
}
.grid td {
padding: 13px 16px;
border-bottom: 1px solid var(--zk-line-soft);
vertical-align: top;
}
.grid tbody tr {
transition: background var(--t-fast);
}
.grid tbody tr:last-child td {
border-bottom: none;
}
.grid tbody tr:hover {
background: var(--zk-tint);
}
/* An accent that arrives on hover, drawn inside the cell so it never disturbs
the table's own borders. */
.grid tbody tr:hover td:first-child {
box-shadow: inset 3px 0 0 var(--zk-blue);
}
.grid .num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.grid__sub {
font-size: 0.76rem;
color: var(--zk-muted);
margin-top: 3px;
}
.grid__link {
color: var(--zk-blue);
text-decoration: none;
font-weight: 500;
border-bottom: 1px solid transparent;
transition: border-color var(--t-fast);
}
.grid__link:hover {
border-bottom-color: currentColor;
}
/* Row action. Renders as a button; stays an anchor so an operator can open a
lead in a new tab from a queue.
NOT `.act` that class belongs to the Lead page's activity buttons and is
display:flex, which stretched this anchor to fill the cell. */
.grid__act {
text-align: right;
white-space: nowrap;
width: 1%;
}
.grid__btn {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
white-space: nowrap;
font-size: 0.78rem;
font-weight: 500;
padding: 6px 14px;
border-radius: var(--r-pill);
background: var(--zk-white);
color: var(--zk-blue);
border: 1px solid var(--zk-line);
transition: background var(--t-fast), border-color var(--t-fast), box-shadow var(--t-fast);
}
.grid__btn::after {
content: '→';
font-size: 0.85em;
transition: transform var(--t-fast);
}
.grid__btn:hover {
background: var(--zk-tint-blue);
border-color: var(--zk-blue-light);
}
.grid__btn:hover::after {
transform: translateX(2px);
}
.grid__btn:focus-visible {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
/* ---- loading skeleton ---- */
.skel {
display: flex;
flex-direction: column;
gap: 1px;
padding: 14px 0;
background: var(--zk-white);
border: 1px solid var(--zk-line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
overflow: hidden;
}
.skel__row {
height: 18px;
margin: 9px 16px;
border-radius: var(--r-sm);
background: linear-gradient(90deg, var(--zk-line-soft) 0%, var(--zk-tint) 40%, var(--zk-line-soft) 80%);
background-size: 420px 100%;
animation: zk-shimmer 1.25s linear infinite;
}
.skel__row:nth-child(odd) { width: calc(100% - 32px); }
.skel__row:nth-child(3n) { width: 72%; }
.skel__row:nth-child(3n + 2) { width: 88%; }
/* ---- chips ---- */
.chip {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
font-weight: 500;
padding: 3px 10px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
border: 1px solid var(--zk-blue-light);
}
.chip--warn {
background: var(--zk-amber-tint);
color: var(--zk-amber-ink);
border-color: var(--zk-amber-line);
} }
.chip--warn { background: #fdf1e7; color: #93500f; border-color: #f0c69a; }
/* ---- doors ---- */ /* ---- doors ---- */
.doors { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } .doors {
.door { display: grid;
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white); grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue); gap: 18px;
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px; }
.door {
position: relative;
font: inherit;
text-align: left;
cursor: pointer;
background: var(--zk-white);
border: 1px solid var(--zk-line);
border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
padding: 22px 22px 20px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
transition: box-shadow var(--t), transform var(--t-fast), border-color var(--t-fast);
}
/* The accent bar is the door's handle: 3px at rest, the full brand gradient
once the pointer is on it. */
.door::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--grad-blue);
transform-origin: left;
transition: height var(--t);
}
.door:hover {
border-color: var(--zk-blue-light);
box-shadow: var(--sh-md);
transform: translateY(-2px);
}
.door:hover::before {
height: 5px;
}
.door:focus-visible {
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.door strong {
font-size: 1.02rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--zk-ink);
}
.door p {
margin: 0;
font-size: 0.82rem;
color: var(--zk-muted);
line-height: 1.55;
}
.door .chip {
align-self: flex-start;
} }
.door:hover { border-color: var(--zk-blue-light); border-top-color: var(--zk-blue); background: var(--zk-tint); }
.door strong { font-size: 1rem; font-weight: 500; color: var(--zk-ink); }
.door p { margin: 0; font-size: .82rem; color: var(--zk-muted); line-height: 1.5; }
.door .chip { align-self: flex-start; }
/* ---- panel ---- */ /* ---- panel ---- */
.panel { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; } .panel {
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; } background: var(--zk-white);
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; } border: 1px solid var(--zk-line);
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; } border-radius: var(--r-lg);
box-shadow: var(--sh-sm);
padding: 24px 26px;
margin-bottom: 22px;
}
.panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
.panel__title {
margin: 0;
font-size: 1.06rem;
font-weight: 500;
letter-spacing: -0.012em;
}
.panel__sub {
margin: 5px 0 0;
font-size: 0.82rem;
color: var(--zk-muted);
max-width: 62ch;
line-height: 1.55;
}
/* ---- actions ---- */ /* ---- actions ---- */
.acts { display: flex; flex-wrap: wrap; gap: 10px; } .acts {
.act { display: flex;
font: inherit; cursor: pointer; text-align: left; background: var(--zk-white); flex-wrap: wrap;
border: 1px solid var(--zk-line); border-radius: 5px; padding: 10px 14px; gap: 10px;
display: flex; flex-direction: column; gap: 2px; min-width: 190px; }
.act {
font: inherit;
cursor: pointer;
text-align: left;
background: var(--zk-white);
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
padding: 11px 16px;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 196px;
transition: border-color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast), transform var(--t-fast);
}
.act:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint);
box-shadow: var(--sh-sm);
transform: translateY(-1px);
}
.act.is-open {
border-color: var(--zk-blue);
background: var(--zk-tint-blue);
box-shadow: var(--ring);
transform: none;
}
.act strong {
font-size: 0.885rem;
font-weight: 500;
}
.act__by {
font-size: 0.72rem;
color: var(--zk-muted);
}
.acts__form {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--zk-line-soft);
animation: zk-rise 0.24s var(--ease) both;
}
/* ---- back ----
An icon, not a sentence: the way out of a record is a known shape and does not
need naming twice on the page. It carries a title and an aria-label, so the
name is still there for a pointer that hovers and for a screen reader. */
.backbtn {
flex: none;
margin-top: 4px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--r-md);
border: 1px solid var(--zk-line);
background: var(--zk-white);
color: var(--zk-muted);
box-shadow: var(--sh-xs);
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast), box-shadow var(--t-fast);
}
.backbtn svg {
width: 16px;
height: 16px;
transition: transform var(--t-fast);
}
.backbtn:hover {
border-color: var(--zk-blue-light);
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
box-shadow: var(--sh-sm);
}
.backbtn:hover svg {
transform: translateX(-2px);
}
.backbtn:focus-visible {
outline: none;
border-color: var(--zk-blue);
box-shadow: var(--ring);
}
.page__lead {
display: flex;
align-items: flex-start;
gap: 14px;
min-width: 0;
}
/* ---- lead: the file beside the story ---- */
.lead {
display: grid;
grid-template-columns: minmax(0, 1fr) 384px;
gap: 20px;
align-items: start;
}
.lead__main {
min-width: 0;
}
/* The trail stays in view while the file is read against it. */
.lead__side {
position: sticky;
top: 90px;
min-width: 0;
}
.panel--rail {
padding: 0;
margin-bottom: 0;
overflow: hidden;
}
.panel--rail .panel__head {
padding: 20px 22px 0;
margin-bottom: 14px;
}
.lead__feed {
max-height: calc(100vh - 210px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 22px 20px;
}
/* One column below the width where a 384px rail stops paying for itself. */
@media (max-width: 1180px) {
.lead {
grid-template-columns: minmax(0, 1fr);
}
.lead__side {
position: static;
}
.lead__feed {
max-height: 560px;
}
}
/* ---- tabs ----
Underline rather than pills: these swap the whole panel below them, which is
what a tab is for; a pill segment reads as a filter on shared content. The
2px rule lives on the button, so a strip that wraps to two rows still marks
the active one correctly. */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 20px;
border-bottom: 1px solid var(--zk-line);
}
.tab {
font: inherit;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 13px;
background: none;
border: 0;
border-bottom: 2px solid transparent;
border-radius: var(--r-sm) var(--r-sm) 0 0;
color: var(--zk-muted);
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.tab:hover {
background: var(--zk-tint);
color: var(--zk-ink);
}
.tab.is-on {
color: var(--zk-blue-dark);
font-weight: 500;
border-bottom-color: var(--zk-blue);
}
.tab__n {
font-size: 0.66rem;
font-variant-numeric: tabular-nums;
min-width: 18px;
padding: 1px 6px;
border-radius: var(--r-pill);
background: var(--zk-line-soft);
color: var(--zk-muted);
text-align: center;
}
.tab.is-on .tab__n {
background: var(--zk-tint-blue);
color: var(--zk-blue);
}
/* ---- property rows ----
Facts read as label/value pairs on hairlines rather than as a wall of filled
boxes: the tint was carrying no information and made every field shout as
loudly as every other. */
.props {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
gap: 0 36px;
}
.prop {
display: grid;
grid-template-columns: minmax(96px, 33%) minmax(0, 1fr);
gap: 16px;
align-items: baseline;
padding: 11px 10px;
margin: 0 -10px;
border-radius: var(--r-md);
border-bottom: 1px solid var(--zk-line-soft);
transition: background var(--t-fast);
}
.prop:hover {
background: var(--zk-tint);
}
.prop dt {
font-size: 0.78rem;
color: var(--zk-muted);
line-height: 1.45;
}
.prop dd {
margin: 0;
font-size: 0.875rem;
color: var(--zk-ink);
line-height: 1.45;
overflow-wrap: anywhere;
}
.prop__num {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
/* Prose is not a property. It takes the full width, is labelled above rather
than beside, and is folded by ClampText. */
.prop--note {
grid-column: 1 / -1;
display: block;
margin: 10px -10px;
padding: 14px 16px;
border-bottom: 0;
border: 1px solid var(--zk-line-soft);
border-left: 3px solid var(--zk-blue-light);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: var(--zk-tint);
}
.prop--note:hover {
background: var(--zk-tint);
border-color: var(--zk-blue-light);
}
.prop--note dt {
margin-bottom: 7px;
font-size: 0.66rem;
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--zk-blue-dark);
}
.prop--note dd {
margin: 0;
} }
.act:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
.act.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
.act strong { font-size: .88rem; font-weight: 500; }
.act__by { font-size: .72rem; color: var(--zk-muted); }
.acts__form { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--zk-line-soft); }
/* ---- lead file ---- */ /* ---- lead file ---- */
.lead__stage { text-align: right; } .lead__stage {
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); } flex: none;
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); } display: flex;
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; } flex-direction: column;
.grp__title { margin: 0 0 10px; font-size: .72rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--zk-muted); } align-items: flex-end;
.grp__list { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 8px 20px; } gap: 5px;
.grp__row { display: flex; flex-direction: column; gap: 1px; padding: 4px 0; border-bottom: 1px solid var(--zk-line-soft); } text-align: right;
.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; } .lead__stagelabel {
display: block;
font-size: 0.6rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--zk-grey);
}
.lead__closed {
font-size: 0.7rem;
color: var(--zk-grey);
}
.lead__stage strong {
font-size: 0.9rem;
font-weight: 500;
color: var(--zk-blue-dark);
padding: 6px 14px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
border: 1px solid var(--zk-blue-light);
}
/* A closed lead is not a live stage; it should not wear the live stage's blue. */
.lead__stage strong.is-closed {
color: var(--zk-muted);
background: var(--zk-tint);
border-color: var(--zk-line);
}
/* ---- renewal countdown ---- */ /* ---- renewal countdown ---- */
.due { .due {
display: inline-block; font-size: .76rem; font-weight: 500; display: inline-flex;
padding: 2px 8px; border-radius: 3px; white-space: nowrap; align-items: center;
font-size: 0.75rem;
font-weight: 500;
padding: 3px 10px;
border-radius: var(--r-pill);
white-space: nowrap;
}
.due--lapsed {
background: var(--zk-danger-tint);
color: var(--zk-danger-ink);
border: 1px solid var(--zk-danger-line);
}
.due--urgent {
background: var(--zk-amber-tint);
color: var(--zk-amber-ink);
border: 1px solid var(--zk-amber-line);
}
.due--soon {
background: var(--zk-tint-blue);
color: var(--zk-blue-dark);
border: 1px solid var(--zk-blue-light);
}
.due--later {
background: var(--zk-tint);
color: var(--zk-muted);
border: 1px solid var(--zk-line);
} }
.due--lapsed { background: #fdeceb; color: #a3261f; border: 1px solid #f0c2bd; }
.due--urgent { background: #fdf1e7; color: #93500f; border: 1px solid #f0c69a; }
.due--soon { background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light); }
.due--later { background: var(--zk-tint); color: var(--zk-muted); border: 1px solid var(--zk-line); }
/* ---- lead header meta ---- */ /* ---- lead header meta ---- */
.lead__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; } .lead__meta {
.lead__meta div { display: flex; flex-direction: column; gap: 1px; } display: flex;
.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); } flex-wrap: wrap;
.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); } gap: 10px;
margin: 0 0 22px;
}
.lead__meta div {
display: flex;
flex-direction: column;
gap: 3px;
padding: 9px 16px;
border-radius: var(--r-md);
background: var(--zk-white);
border: 1px solid var(--zk-line);
box-shadow: var(--sh-xs);
}
.lead__meta dt {
font-size: 0.6rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--zk-grey);
}
.lead__meta dd {
margin: 0;
font-size: 0.85rem;
color: var(--zk-ink);
}
.lead__meta--money {
border-color: var(--zk-blue-light) !important;
background: var(--zk-tint-blue) !important;
}
.lead__meta--money dd {
font-size: 1rem;
font-weight: 500;
color: var(--zk-blue-dark);
font-variant-numeric: tabular-nums;
}