zurich_kotak/CLAUDE.md
Yashas e90e4204ec console: redesign the UI — one card language, icons, filters, live trail
Visual and information-architecture pass across every screen. No API call,
payload, poll, permission rule or workflow mirror changes; forms still render
from the live schema and the timeline's merge/grouping is unchanged (only the
display order is reversible).

Design language
- Ground + white cards with one hairline and one radius; sentence-case
  headings; shared primitives in index.css (.card .pill .btn .avatar .live).
- One inline icon set (components/Icon.jsx); no more filled navy/red tiles.
- Read-side labels in api/display.js: roles and enum tokens humanized,
  customer initials/tones, money and date formatters. Nothing sent changes.

Shell
- 56px header with brand, global lead search (portfolio rows, "/" to focus)
  and identity chip with a human role label.
- Sidebar rows carry icons and a clear active bar; AI roster at the foot,
  each opening its remit card.

Login
- Operator copy instead of retail copy; centred sign-in card with show/hide
  password; demo persona picker that fills the EMAIL only.

Overview
- "Who holds the work" as four quiet cards that warm only when owed.
- Pipeline flow bar with every stage named beneath it; KPI strip in a card.
- Action rows with chevron / view-only eye; expiry bands filter the table.

Queues
- Filter bar (search, product, channel, expiry band, stalled, hide closed)
  and sortable columns, all client-side on the fetched page.
- Avatars, product/channel chips, stage chips for closed leads, tinted
  stalled rows, count inline with the title.

Lead
- Avatar + chips in the header; metrics card with the stage rail inside,
  and a branch node for Referred / Parked / Lost / Declined.
- Timeline newest-first by default (toggle), the worker's face as the node,
  one footer row per entry, AI markdown rendered as bold.
- "AI team on this lead" panel with per-agent step counts; PDF tags on docs.

Forms & dialogs
- Required marked with an asterisk + legend; "Select…" placeholder; dd Mon
  yyyy hint under date inputs; open activity form in a titled panel;
  one-line dialog footnotes.

Fixes: horizontal overflow at 1024px, boxed "in N days" figure, raw tokens
(sme_underwriter, verbal_call, Rsa), findings cut at "1.".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 11:37:42 +05:30

12 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

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.jsxZinoProvider / useZino(): one client instance plus session state. Restores an identity, never a permission set.
  • src/api/errors.jsdescribeError() 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 <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 kindneeds / 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.