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>
11 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 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 inlocalStorage, 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 akindthe caller branches on (stalere-fetches,gonereturns to the list).src/layout/Shell.jsx— header + the sidebar, grouped byNAV_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 oncurrent_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 fromENTRY; skips the chooser when only one is surfaced.src/components/ActivityForm.jsx— renders the live activity schema and submits it back.src/components/FileField.jsxhandlesfile/ocrfields;src/components/Timeline.jsxrenders 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.turnsis every message deduped (one submission is recorded up to three times);episodesare its contiguous bursts, each carrying the workflow steps that ran before it;byRowsays 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_FIELDSis why no step quotescustomer_reply/customer_answeritself: 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_idonPOST /usr/loginmust be a string; a number returnscannot unmarshal number into Go struct field LoginRequest.org_id.POST /view/recordviewnames the viewrv_template_uid(the GET spelling isrv_id), and paging/sort/filters live insidesearch_query. Top-level gives400 Missing param: rv_template_uid (body).- Row key varies by source type: workflow views return
data, rdbms views have returnedrecords— hence thedata ?? records ?? rowsfallbacks. - Audit must use the app-scoped path
/app/536/view/audit. Bare/view/auditis not an API route; it falls through to the SPA and returns HTML with a 200. POST /uploadrequiresworkflow_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 andocr_config— it ignores anything the client claims about them./ocr-extracttakes a reference to an already-uploaded file, not bytes, and answers{ extracted, raw }. Files therefore upload on pick, not on submit.POST /view/recordviewtakes 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_labelnames every one of the 117 fields, so labels come from the server rather than from prettified ids. - The
messageon a/startor/activityresponse is written per activity ("Documents received — capturing the risk") and is surfaced verbatim, not replaced with a toast. - On submit,
ActivityFormsends only fields the activity defines, drops empties (an unknown field is fatal to schema validation) and skipsid_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.