# 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 14 pre-existing errors — 8 `react-hooks/refs`, 3 `react-hooks/set-state-in-effect`, 3 `react-refresh/only-export-components` (the provider and portfolio files export both a component and hooks). 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` — 56px header (brand, `GlobalSearch`, `UserMenu`) + the sidebar, grouped by `NAV_GROUPS` (who is holding the lead) rather than by pipeline position, with queue counts from one tallied list call and the AI roster at its foot. - `src/layout/GlobalSearch.jsx` — finds a lead from anywhere. Filters the rows `PortfolioProvider` already holds (name, business, ref, mobile, registration) — no request of its own. `/` focuses it. - `src/api/display.js` — read-side labels only: `roleLabel`, `humanize` (snake_case tokens → English; names, refs, emails pass through untouched), `initials`/`toneFor` for customer avatars, money and date formatters, `findingOf`/`leadIn`/`mdRuns` for AI prose. Nothing in here changes a value that is SENT anywhere. - `src/components/Icon.jsx` — the one monoline icon set. Use the NAME, never a path. - `src/index.css` — the tokens plus the shared primitives every screen builds from: `.card`, `.pill`, `.btn`, `.avatar`, `.live`, `.h-sec`/`.h-sub`. Screen stylesheets style content; these style containers. Page ground is `--zk-ground`, bounded regions are white cards. - `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on `current_state_name`. The filter bar (search, product, channel, expiry band, stalled, hide closed) and the column sort narrow the PAGE that came back, client-side; they never change the request. - `src/screens/Lead.jsx` — one lead: header (avatar, chips, Your move / whose move), a metrics card with the `StageRail` inside it, the status strip, Next step, the timeline (newest first by default, toggle to read as a story), and a sticky reference rail: the AI team with per-agent step counts, then At a glance. The full record is `LeadFileDialog` (`GROUPS`, in the order the lead is worked). - `src/pages/Login.jsx` — sign-in plus a demo persona picker (the README accounts for org 84). Picking one fills the EMAIL only and focuses the password box — no credential lives in the bundle. What the persona may do is still the workflow's decision. - `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 `` the server writes into the `` 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.