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