zurich_kotak/CLAUDE.md
Yashas 607693a04a console: make the phone layout work
Opened at 390px the masthead wrapped inside its own 56px bar and clipped —
"ZURICH kotak", "Lead Desk / Lead to policy" and the user's name all on
three lines each — and the nine-item rail stacked above the content, so
every page began with a screenful of menu.

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:46:27 +05:30

188 lines
11 KiB
Markdown

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