UI Enhancements in recordsview and detailsview
This commit is contained in:
parent
8b58b2243e
commit
ae548a3a10
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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.
|
||||
@ -1,82 +1,324 @@
|
||||
.af { display: flex; flex-direction: column; gap: 18px; }
|
||||
.af__loading { color: var(--zk-muted); font-size: .86rem; }
|
||||
.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; }
|
||||
.af__field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.af__field--wide { grid-column: 1 / -1; }
|
||||
.af {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: .74rem; font-weight: 500; color: var(--zk-muted);
|
||||
letter-spacing: .02em; display: flex; align-items: center; gap: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--zk-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.af__req {
|
||||
font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px;
|
||||
font-style: normal;
|
||||
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;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%;
|
||||
|
||||
.af input,
|
||||
.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 {
|
||||
font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
||||
font: inherit;
|
||||
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 {
|
||||
font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px;
|
||||
cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white);
|
||||
font: inherit;
|
||||
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 {
|
||||
font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted);
|
||||
font: inherit;
|
||||
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 {
|
||||
background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger);
|
||||
border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink);
|
||||
background: var(--zk-danger-tint);
|
||||
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 {
|
||||
font-size: .88rem; padding: 8px 10px; border-radius: 4px; min-height: 37px;
|
||||
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;
|
||||
font-size: 0.88rem;
|
||||
padding: 10px 12px;
|
||||
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 {
|
||||
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
||||
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
|
||||
font-size: 0.58rem;
|
||||
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 ---- */
|
||||
.ff { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ff__input { display: none; }
|
||||
.ff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ff__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ff__btn {
|
||||
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
|
||||
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
|
||||
color: var(--zk-blue-dark); text-align: center;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
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 {
|
||||
font-size: .78rem; color: var(--zk-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
color: var(--zk-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ff__read {
|
||||
border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
|
||||
border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
|
||||
border: 1px solid var(--zk-blue-light);
|
||||
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 {
|
||||
font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--zk-blue-dark); margin-bottom: 2px;
|
||||
font-size: 0.6rem;
|
||||
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 {
|
||||
font-size: .78rem; color: var(--zk-danger);
|
||||
background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
|
||||
font-size: 0.78rem;
|
||||
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;
|
||||
}
|
||||
|
||||
59
src/components/ClampText.css
Normal file
59
src/components/ClampText.css
Normal 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);
|
||||
}
|
||||
34
src/components/ClampText.jsx
Normal file
34
src/components/ClampText.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,39 +1,205 @@
|
||||
.tl { list-style: none; margin: 0; padding: 0 0 0 4px; }
|
||||
.tl__loading, .tl__err { color: var(--zk-muted); font-size: .86rem; padding: 14px 2px; }
|
||||
.tl__err { color: var(--zk-danger); }
|
||||
.tl {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 4px;
|
||||
}
|
||||
|
||||
.tl__item { position: relative; padding: 0 0 20px 26px; border-left: 2px solid var(--zk-line); }
|
||||
.tl__item:last-child { border-left-color: transparent; padding-bottom: 4px; }
|
||||
.tl__loading,
|
||||
.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 {
|
||||
position: absolute; left: -7px; top: 3px; width: 12px; height: 12px;
|
||||
border-radius: 50%; background: var(--zk-white); border: 2px solid var(--zk-grey);
|
||||
position: absolute;
|
||||
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
|
||||
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__item--ai .tl__dot {
|
||||
border-color: var(--zk-blue);
|
||||
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 {
|
||||
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 {
|
||||
display: block; font-size: .64rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
color: var(--zk-grey); margin-bottom: 2px;
|
||||
display: block;
|
||||
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__figs span { font-size: .8rem; color: var(--zk-ink); font-variant-numeric: tabular-nums; }
|
||||
.tl__figs em { font-style: normal; font-size: .68rem; color: var(--zk-grey); text-transform: capitalize; margin-right: 4px; }
|
||||
.tl__say p {
|
||||
margin: 0;
|
||||
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);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import ClampText from './ClampText.jsx'
|
||||
import { STAGES } from '../api/config.js'
|
||||
import './Timeline.css'
|
||||
|
||||
@ -47,6 +48,10 @@ export default function Timeline({ instanceId }) {
|
||||
const { client } = useZino()
|
||||
const [rows, setRows] = 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(() => {
|
||||
let dead = false
|
||||
@ -63,58 +68,73 @@ export default function Timeline({ instanceId }) {
|
||||
// Oldest first: a timeline reads forwards.
|
||||
const ordered = [...rows].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)))
|
||||
|
||||
const items = ordered.map((r, i) => {
|
||||
const roles = r.user_roles || []
|
||||
const aiRole = roles.find((x) => AI_ROLES[x])
|
||||
const isSystem = r.activity_id === 'DATA_UPDATE'
|
||||
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human'
|
||||
const d = r.data || {}
|
||||
return {
|
||||
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]])
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''),
|
||||
figures: Object.entries(d)
|
||||
.filter(([k, v]) => MONEY.has(k) && v)
|
||||
.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 (
|
||||
<ol className="tl">
|
||||
{ordered.map((r, i) => {
|
||||
const roles = r.user_roles || []
|
||||
const aiRole = roles.find((x) => AI_ROLES[x])
|
||||
const isSystem = r.activity_id === 'DATA_UPDATE'
|
||||
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 || {}
|
||||
<>
|
||||
{sysCount ? (
|
||||
<button type="button" className="tl__toggle" onClick={() => setShowSys((v) => !v)}>
|
||||
{showSys ? 'Hide' : 'Show'} {sysCount} system update{sysCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
const narrative = NARRATIVE
|
||||
.map(([k, label]) => [label, d[k]])
|
||||
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== '')
|
||||
|
||||
const figures = Object.entries(d)
|
||||
.filter(([k, v]) => MONEY.has(k) && v)
|
||||
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')])
|
||||
|
||||
return (
|
||||
<li key={r.id ?? i} className={'tl__item tl__item--' + kind}>
|
||||
<ol className="tl">
|
||||
{visible.map((it) => (
|
||||
<li key={it.key} className={'tl__item tl__item--' + it.kind}>
|
||||
<span className="tl__dot" aria-hidden="true" />
|
||||
<div className="tl__body">
|
||||
<div className="tl__head">
|
||||
<strong className="tl__what">
|
||||
{isSystem ? 'Data updated' : (r.activity_name || r.activity_id)}
|
||||
</strong>
|
||||
<span className={'tl__who tl__who--' + kind}>
|
||||
{kind === 'ai' ? 'AI employee' : kind === 'sys' ? 'system' : 'person'} · {actor}
|
||||
</span>
|
||||
{stage ? <span className="tl__stage">→ {stage.name}</span> : null}
|
||||
<strong className="tl__what">{it.what}</strong>
|
||||
{it.stage ? <span className="tl__stage">→ {it.stage.name}</span> : null}
|
||||
</div>
|
||||
<div className="tl__meta">
|
||||
<span className={'tl__who tl__who--' + it.kind}>
|
||||
{it.kind === 'ai' ? 'AI' : it.kind === 'sys' ? 'system' : 'person'} · {it.actor}
|
||||
</span>
|
||||
<span className="tl__when">{it.when}</span>
|
||||
</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}>
|
||||
<span className="tl__saylabel">{label}</span>
|
||||
<p>{String(v)}</p>
|
||||
<ClampText text={String(v)} lines={2} threshold={120} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{figures.length ? (
|
||||
{it.figures.length ? (
|
||||
<div className="tl__figs">
|
||||
{figures.map(([k, v]) => (
|
||||
{it.figures.map(([k, v]) => (
|
||||
<span key={k}><em>{k}</em> {v}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
104
src/index.css
104
src/index.css
@ -41,13 +41,48 @@
|
||||
--zk-white: #fff;
|
||||
--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-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
|
||||
font-family: var(--font-zurich);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--zk-ink);
|
||||
background: var(--zk-white);
|
||||
background: var(--zk-tint);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
@ -65,10 +100,75 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--zk-tint);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 24px; padding: 0 24px; height: 64px; background: var(--zk-white);
|
||||
border-bottom: 1px solid var(--zk-line); position: sticky; top: 0; z-index: 10;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
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__user { display: flex; flex-direction: column; text-align: right; line-height: 1.3; }
|
||||
.shell__user strong { font-size: .85rem; font-weight: 500; }
|
||||
.shell__user span { font-size: .72rem; color: var(--zk-muted); }
|
||||
.shell__signout {
|
||||
font: inherit; font-size: .8rem; padding: 7px 14px; border-radius: 4px; cursor: pointer;
|
||||
background: var(--zk-white); color: var(--zk-blue); border: 1px solid var(--zk-line);
|
||||
.shell__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.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 {
|
||||
width: 244px; flex: none; background: var(--zk-white);
|
||||
border-right: 1px solid var(--zk-line); padding: 18px 12px 28px;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
position: sticky;
|
||||
top: 66px;
|
||||
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 {
|
||||
margin: 0 0 8px; padding: 0 10px; font-size: .66rem; font-weight: 500;
|
||||
letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey);
|
||||
margin: 0 0 10px;
|
||||
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 {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 8px 10px; border-radius: 4px; text-decoration: none;
|
||||
color: var(--zk-ink); font-size: .84rem; border-left: 2px solid transparent;
|
||||
position: relative;
|
||||
display: flex;
|
||||
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. */
|
||||
.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 {
|
||||
font-size: .6rem; letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 3px; background: var(--zk-blue); color: var(--zk-white);
|
||||
flex: none;
|
||||
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 {
|
||||
display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
|
||||
padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
|
||||
background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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); }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { STAGES } from '../api/config.js'
|
||||
import UserMenu from './UserMenu.jsx'
|
||||
import logo from '../assets/brand/zurich_logo.webp'
|
||||
import './Shell.css'
|
||||
|
||||
@ -10,7 +10,6 @@ import './Shell.css'
|
||||
* as such, because it is the only place the machine stops.
|
||||
*/
|
||||
export default function Shell() {
|
||||
const { user, signOut } = useZino()
|
||||
const work = STAGES.filter((s) => s.kind !== 'end')
|
||||
const closed = STAGES.filter((s) => s.kind === 'end')
|
||||
|
||||
@ -19,51 +18,50 @@ export default function Shell() {
|
||||
<header className="shell__top">
|
||||
<div className="shell__brand">
|
||||
<img src={logo} alt="Zurich Kotak General Insurance" />
|
||||
<div>
|
||||
<div className="shell__brandtext">
|
||||
<strong>Lead Desk</strong>
|
||||
<span>Lead to Policy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shell__who">
|
||||
<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>
|
||||
<UserMenu />
|
||||
</header>
|
||||
|
||||
<div className="shell__body">
|
||||
<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>
|
||||
{work.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) =>
|
||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="shell__stages">
|
||||
{work.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) =>
|
||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
|
||||
{closed.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="shell__stages">
|
||||
{closed.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="shell__main">
|
||||
|
||||
161
src/layout/UserMenu.css
Normal file
161
src/layout/UserMenu.css
Normal 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
86
src/layout/UserMenu.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,19 @@ export function requireConfigValue(key) {
|
||||
*
|
||||
* `import.meta.env.BASE_URL` is NOT usable here — with Vite's relative base it
|
||||
* 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() {
|
||||
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,7 +1,8 @@
|
||||
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 ActivityForm from '../components/ActivityForm.jsx'
|
||||
import ClampText from '../components/ClampText.jsx'
|
||||
import Timeline from '../components/Timeline.jsx'
|
||||
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
|
||||
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']],
|
||||
]
|
||||
|
||||
/** 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) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
if (Array.isArray(v)) return v.join(', ')
|
||||
@ -59,9 +80,12 @@ function fmt(k, v) {
|
||||
export default function Lead() {
|
||||
const { instanceId } = useParams()
|
||||
const { client } = useZino()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [row, setRow] = useState(null)
|
||||
const [err, setErr] = useState(null)
|
||||
const [open, setOpen] = useState(null)
|
||||
const [tab, setTab] = useState(0)
|
||||
|
||||
const load = useCallback(() => {
|
||||
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 (!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 stage = STAGES.find((s) => s.name === stateName)
|
||||
const actions = ACTIONS[stage?.uid] || []
|
||||
@ -82,16 +112,34 @@ export default function Lead() {
|
||||
return (
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">{row.lead_ref || `Lead ${instanceId}`}</h1>
|
||||
<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>
|
||||
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lead__stage">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@ -102,80 +150,112 @@ export default function Lead() {
|
||||
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
|
||||
</div>
|
||||
) : 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.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.updated_at ? <div><dt>Last activity</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null}
|
||||
</dl>
|
||||
|
||||
{actions.length ? (
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">What happens next</h2>
|
||||
<p className="panel__sub">
|
||||
These are the only activities this state allows. Who normally performs
|
||||
each is shown — but the workflow decides, server-side, whether you may.
|
||||
</p>
|
||||
<div className="lead">
|
||||
<div className="lead__main">
|
||||
{actions.length ? (
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">What happens next</h2>
|
||||
<p className="panel__sub">
|
||||
These are the only activities this state allows. Who normally performs
|
||||
each is shown — but the workflow decides, server-side, whether you may.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="acts">
|
||||
{actions.map((a) => (
|
||||
<button key={a.uid} type="button"
|
||||
className={'act' + (open === a.uid ? ' is-open' : '')}
|
||||
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
||||
<strong>{a.label}</strong>
|
||||
<span className="act__by">{a.by}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="acts__form">
|
||||
<ActivityForm
|
||||
activityUid={open}
|
||||
instanceId={Number(instanceId)}
|
||||
onCancel={() => setOpen(null)}
|
||||
onDone={() => { setOpen(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="acts">
|
||||
{actions.map((a) => (
|
||||
<button key={a.uid} type="button"
|
||||
className={'act' + (open === a.uid ? ' is-open' : '')}
|
||||
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
||||
<strong>{a.label}</strong>
|
||||
<span className="act__by">{a.by}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="acts__form">
|
||||
<ActivityForm
|
||||
activityUid={open}
|
||||
instanceId={Number(instanceId)}
|
||||
onCancel={() => setOpen(null)}
|
||||
onDone={() => { setOpen(null); load() }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 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__head">
|
||||
<div>
|
||||
<h2 className="panel__title">Flow details</h2>
|
||||
<p className="panel__sub">Everything captured on this lead, grouped in the order it was worked.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<dl className="props">
|
||||
{groups[active][1].map(([k, v]) => (
|
||||
v.length > LONG ? (
|
||||
<div className="prop prop--note" key={k}>
|
||||
<dt>{label(k)}</dt>
|
||||
<dd><ClampText text={v} /></dd>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop" key={k}>
|
||||
<dt>{label(k)}</dt>
|
||||
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice"><strong>Terminal.</strong><p>No activity runs from {stateName}.</p></div>
|
||||
)}
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
<div>
|
||||
<h2 className="panel__title">What has happened</h2>
|
||||
<p className="panel__sub">
|
||||
Every step on this lead, in order, and who took it — an AI employee,
|
||||
a person, or the platform itself.
|
||||
</p>
|
||||
{/* 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>
|
||||
</div>
|
||||
<Timeline instanceId={instanceId} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{GROUPS.map(([title, keys]) => {
|
||||
const shown = keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)
|
||||
if (!shown.length) return null
|
||||
return (
|
||||
<div className="grp" key={title}>
|
||||
<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>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<p className="lead__back"><Link className="grid__link" to="/">Back to the queue</Link></p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -80,11 +80,18 @@ export default function Pipeline() {
|
||||
</p>
|
||||
</div>
|
||||
{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}
|
||||
</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' ? (
|
||||
<div className="notice">
|
||||
@ -103,44 +110,48 @@ export default function Pipeline() {
|
||||
) : null}
|
||||
|
||||
{state.status === 'ready' && state.rows.length > 0 ? (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead</th><th>Customer</th><th>Channel</th>
|
||||
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
|
||||
<th>Attribution</th><th>Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.rows.map((r) => {
|
||||
const id = r.instance_id ?? r.id
|
||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
||||
const exp = expiry(r.renewal_due_date)
|
||||
const add = added(r.created_at)
|
||||
return (
|
||||
<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><span className="chip">{ch.label}</span></td>
|
||||
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
||||
<td>
|
||||
{exp
|
||||
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
|
||||
<div className="grid__sub">{exp.on}</div></>
|
||||
: <span className="grid__sub">—</span>}
|
||||
</td>
|
||||
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
||||
<td>
|
||||
{r.attribution_status === 'contested'
|
||||
? <span className="chip chip--warn">Contested</span>
|
||||
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
|
||||
</td>
|
||||
<td>{add.abs}<div className="grid__sub">{add.rel}</div></td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="gridwrap">
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th><th>Channel</th>
|
||||
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
|
||||
<th>Attribution</th><th>Added</th><th aria-label="Actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{state.rows.map((r) => {
|
||||
const id = r.instance_id ?? r.id
|
||||
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
|
||||
const exp = expiry(r.renewal_due_date)
|
||||
const add = added(r.created_at)
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
|
||||
<td><span className="chip">{ch.label}</span></td>
|
||||
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
||||
<td>
|
||||
{exp
|
||||
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
|
||||
<div className="grid__sub">{exp.on}</div></>
|
||||
: <span className="grid__sub">—</span>}
|
||||
</td>
|
||||
<td className="num">{r.quoted_premium ? Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
|
||||
<td>
|
||||
{r.attribution_status === 'contested'
|
||||
? <span className="chip chip--warn">Contested</span>
|
||||
: <span className="grid__sub">{r.attribution_status || '—'}</span>}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
|
||||
@ -1,109 +1,792 @@
|
||||
/* ---- page header ---- */
|
||||
.page__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 24px; margin-bottom: 20px;
|
||||
}
|
||||
.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;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: var(--zk-white); border: 1px solid var(--zk-line);
|
||||
border-left: 3px solid var(--zk-blue); border-radius: 0 6px 6px 0; padding: 16px 20px;
|
||||
max-width: 60ch;
|
||||
background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line);
|
||||
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__detail { font-size: .76rem !important; color: var(--zk-grey) !important; }
|
||||
|
||||
.notice strong {
|
||||
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 {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8em;
|
||||
background: var(--zk-tint); border: 1px solid var(--zk-line-soft);
|
||||
border-radius: 3px; padding: 1px 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8em;
|
||||
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 {
|
||||
width: 100%; border-collapse: collapse; background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line); border-radius: 6px; overflow: hidden; font-size: .86rem;
|
||||
width: 100%;
|
||||
/* 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 {
|
||||
display: inline-block; font-size: .72rem; padding: 2px 8px; border-radius: 3px;
|
||||
background: var(--zk-tint-blue); color: var(--zk-blue-dark); border: 1px solid var(--zk-blue-light);
|
||||
.grid th {
|
||||
position: sticky;
|
||||
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 { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
.door {
|
||||
font: inherit; text-align: left; cursor: pointer; background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line); border-top: 3px solid var(--zk-blue);
|
||||
border-radius: 0 0 6px 6px; padding: 18px 20px; display: flex; flex-direction: column; gap: 8px;
|
||||
.doors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.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 { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 20px 22px; margin-bottom: 22px; }
|
||||
.panel__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||
.panel__title { margin: 0; font-size: 1.05rem; font-weight: 500; }
|
||||
.panel__sub { margin: 3px 0 0; font-size: .82rem; color: var(--zk-muted); max-width: 62ch; line-height: 1.5; }
|
||||
.panel {
|
||||
background: var(--zk-white);
|
||||
border: 1px solid var(--zk-line);
|
||||
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 ---- */
|
||||
.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;
|
||||
.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: 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__stage { text-align: right; }
|
||||
.lead__stagelabel { display: block; font-size: .66rem; letter-spacing: .1em; text-transform: uppercase; color: var(--zk-grey); }
|
||||
.lead__stage strong { font-size: 1rem; font-weight: 500; color: var(--zk-blue-dark); }
|
||||
.grp { background: var(--zk-white); border: 1px solid var(--zk-line); border-radius: 6px; padding: 16px 20px; margin-bottom: 14px; }
|
||||
.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; }
|
||||
.lead__stage {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.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 ---- */
|
||||
.due {
|
||||
display: inline-block; font-size: .76rem; font-weight: 500;
|
||||
padding: 2px 8px; border-radius: 3px; white-space: nowrap;
|
||||
display: inline-flex;
|
||||
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__meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 8px; }
|
||||
.lead__meta div { display: flex; flex-direction: column; gap: 1px; }
|
||||
.lead__meta dt { font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--zk-grey); }
|
||||
.lead__meta dd { margin: 0; font-size: .84rem; color: var(--zk-ink); }
|
||||
.lead__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user