# 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 `` 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.