diff --git a/CLAUDE.md b/CLAUDE.md index 31ab7f4..38ffbfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,10 @@ 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. +`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. @@ -50,33 +50,12 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/ - `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/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`. 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. + `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 diff --git a/src/api/display.js b/src/api/display.js deleted file mode 100644 index 00fd05a..0000000 --- a/src/api/display.js +++ /dev/null @@ -1,214 +0,0 @@ -/** - * How raw workflow values read on screen. - * - * The platform stores roles and enums as snake_case tokens — `sme_underwriter`, - * `verbal_call`, `due_now` — and the console was printing them as-is in the - * header chip, the lead file and the trail. Nothing here changes a value that - * is SENT anywhere; these are read-side labels only. A token missing from the - * maps still reads as English through `humanize`, so a value added to the - * workflow tomorrow does not come back as a slug. - */ - -export const ROLE_LABELS = { - ops_admin: 'Operations', - partner_agent: 'Partner agent', - bank_rm: 'Bancassurance RM', - csr_direct: 'Direct sales', - sme_underwriter: 'SME underwriter', - ai_intake: 'Intake AI', - ai_engage: 'Engage AI', - ai_advisor: 'Advisor AI', - ai_kyc: 'KYC AI', - ai_uw_referral: 'Referral AI', -} - -export function roleLabel(role) { - if (!role) return '' - return ROLE_LABELS[role] ?? humanize(role) -} - -/** Enum values the workflow writes, in the words a person would use. */ -const VALUE_LABELS = { - // channels & lines - direct: 'Direct', - bancassurance: 'Bancassurance', - agency: 'Agency', - motor: 'Motor', - sme_package: 'SME Package', - // consent / contact - verbal_call: 'Verbal, on the call', - written: 'Written', - whatsapp: 'WhatsApp', - email: 'Email', - connected: 'Connected', - no_answer: 'No answer', - callback: 'Callback requested', - not_interested: 'Not interested', - wrong_number: 'Wrong number', - due_now: 'Due now', - too_early: 'Too early', - lapsed: 'Lapsed', - // documents / outcomes - complete: 'Complete', - partial: 'Partial', - pending: 'Pending', - received: 'Received', - proceed: 'Proceed', - clear: 'Clear', - cleared: 'Cleared', - refer: 'Refer', - referred: 'Referred', - decline: 'Decline', - declined: 'Declined', - accepted: 'Accepted', - rejected: 'Rejected', - paid: 'Paid', - unpaid: 'Unpaid', - issued: 'Issued', - yes: 'Yes', - no: 'No', - // vehicle - petrol: 'Petrol', - diesel: 'Diesel', - cng: 'CNG', - electric: 'Electric', - hybrid: 'Hybrid', - // kyc - ckyc: 'CKYC', - okyc: 'Aadhaar OTP', - vkyc: 'Video KYC', - match: 'Match', - mismatch: 'Mismatch', -} - -/** Words that keep their case when a slug is spelt out. */ -const KEEP = { - pan: 'PAN', gstin: 'GSTIN', gst: 'GST', kyc: 'KYC', ai: 'AI', sme: 'SME', - uw: 'UW', od: 'OD', tp: 'TP', idv: 'IDV', ncb: 'NCB', rm: 'RM', posp: 'POSP', - rc: 'RC', otp: 'OTP', id: 'ID', bi: 'BI', ee: 'EE', wip: 'WIP', amc: 'AMC', - irdai: 'IRDAI', msme: 'MSME', rsa: 'RSA', pa: 'PA', cpa: 'CPA', ckyc: 'CKYC', sms: 'SMS', -} - -/** Looks like a stored token rather than free text: lowercase words joined by - * underscores, nothing else. Names, references, emails and numbers all fail - * this test and pass through untouched. */ -const TOKEN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/ - -export function isToken(v) { - return typeof v === 'string' && v.length <= 40 && TOKEN.test(v) -} - -/** - * A snake_case token as English. Anything that is not a token — a name, an - * email, a registration number, a sentence — comes back exactly as given. - */ -export function humanize(v) { - if (!isToken(v)) return v - if (VALUE_LABELS[v]) return VALUE_LABELS[v] - const words = v.split('_').map((w) => KEEP[w] ?? w) - const s = words.join(' ') - return s.charAt(0).toUpperCase() + s.slice(1) -} - -/** Initials for a customer or a person, two letters at most. */ -export function initials(name) { - const parts = String(name || '').trim().split(/\s+/).filter(Boolean) - if (!parts.length) return '·' - const a = parts[0][0] || '' - const b = parts.length > 1 ? parts[parts.length - 1][0] : (parts[0][1] || '') - return (a + b).toUpperCase() -} - -/** A stable tone for a name, so the same customer wears the same colour on - * every screen. Six hues, all readable with white text. */ -const TONES = ['blue', 'teal', 'violet', 'amber', 'plum', 'slate'] -export function toneFor(name) { - const s = String(name || '') - let h = 0 - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0 - return TONES[h % TONES.length] -} - -/** Indian money, short. */ -export function inrShort(v) { - const n = Number(v) - if (!Number.isFinite(n) || !n) return null - if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr' - if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L' - return '₹' + Math.round(n).toLocaleString('en-IN') -} - -/** Indian money, in full. */ -export function inrFull(v) { - const n = Number(v) - if (!Number.isFinite(n)) return null - return '₹' + n.toLocaleString('en-IN') -} - -/** dd Mon yyyy, the way an Indian insurer writes a date. */ -export function dateLong(v) { - if (!v) return '' - const d = new Date(String(v).length === 10 ? String(v) + 'T00:00:00' : v) - if (isNaN(d)) return String(v) - return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) -} - -/** How long ago, short. */ -export function ago(ts) { - if (!ts) return '' - const d = new Date(ts) - if (isNaN(d)) return '' - const mins = Math.round((Date.now() - d.getTime()) / 60000) - if (mins < 1) return 'just now' - if (mins < 60) return mins + 'm ago' - if (mins < 1440) return Math.round(mins / 60) + 'h ago' - return Math.round(mins / 1440) + 'd ago' -} - -/** - * The first sentence of an AI paragraph — the employees write the conclusion - * first, so this IS the finding. Null when the split would be useless. - */ -export function findingOf(text) { - const m = String(text).match(/^(.{24,200}?[^\d\s][.!?])(\s|$)/) - return m ? m[1].trim() : null -} - -/** - * The first ~180 characters as a finding when no clean first sentence exists, - * cut at a word so it never ends mid-token. - */ -export function leadIn(text, max = 180) { - const t = String(text || '').replace(/\s+/g, ' ').trim() - if (t.length <= max) return t - const cut = t.slice(0, max) - return cut.slice(0, Math.max(cut.lastIndexOf(' '), 100)) + '…' -} - -/** - * The employees write light markdown — **bold** and list markers. Rendered as - * plain runs with the bold kept, so a paragraph never shows its asterisks. - */ -export function mdRuns(text) { - const t = String(text || '') - const out = [] - const re = /\*\*(.+?)\*\*/g - let last = 0, m - while ((m = re.exec(t))) { - if (m.index > last) out.push({ b: false, t: t.slice(last, m.index) }) - out.push({ b: true, t: m[1] }) - last = m.index + m[0].length - } - if (last < t.length) out.push({ b: false, t: t.slice(last) }) - return out -} - -/** The icon each queue kind wears in the sidebar and on the overview. */ -export const KIND_ICON = { - needs: 'inbox', - customer: 'chat', - auto: 'bot', - waiting: 'clock', - end: 'archive', - all: 'list', -} diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css index 507826c..4d38e37 100644 --- a/src/components/ActivityForm.css +++ b/src/components/ActivityForm.css @@ -28,37 +28,32 @@ .af__field > span { font-size: var(--fs-2xs); font-weight: 500; - color: var(--zk-ink); + letter-spacing: 0.03em; + color: var(--zk-muted); display: flex; align-items: center; - gap: 6px; + gap: 8px; } .af__req { font-style: normal; - font-size: var(--fs-sm); - font-weight: 600; - line-height: 1; - color: var(--zk-danger); - margin-left: -4px; + font-size: var(--fs-3xs); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--zk-blue); + background: var(--zk-tint-blue); + padding: 2px 7px; + border-radius: var(--r-pill); } -.af__legend { - margin: -8px 0 0; - font-size: var(--fs-2xs); - color: var(--zk-grey); -} -.af__legend .af__req { margin: 0 3px 0 0; font-size: var(--fs-xs); } - -.af__hint { font-size: 11.5px; color: var(--zk-muted); margin-top: -2px; } - .af input, .af select, .af textarea { font: inherit; font-size: var(--fs-xs); padding: 10px 12px; - border-radius: var(--r-sm); + border-radius: var(--r-md); border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); @@ -122,7 +117,7 @@ } .af__chip.is-on { - background: var(--zk-blue); + background: var(--grad-blue); border-color: var(--zk-blue); color: var(--zk-white); box-shadow: var(--sh-xs); @@ -141,17 +136,17 @@ font-size: var(--fs-xs); font-weight: 500; padding: 10px 26px; - border-radius: var(--r-sm); + border-radius: var(--r-md); cursor: pointer; border: 0; - background: var(--zk-blue); + background: var(--grad-blue); color: var(--zk-white); box-shadow: var(--sh-sm); transition: background var(--t), box-shadow var(--t), transform var(--t-fast), opacity var(--t-fast); } .af__submit:hover:not(:disabled) { - background: var(--zk-blue-dark); + background: var(--grad-blue-hover); box-shadow: var(--sh-md); transform: translateY(-1px); } @@ -171,7 +166,7 @@ font: inherit; font-size: var(--fs-xs); padding: 10px 20px; - border-radius: var(--r-sm); + border-radius: var(--r-md); cursor: pointer; border: 1px solid var(--zk-line); background: var(--zk-white); @@ -216,7 +211,7 @@ .af__auto { font-size: var(--fs-xs); padding: 10px 12px; - border-radius: var(--r-sm); + border-radius: var(--r-md); min-height: 41px; border: 1px dashed var(--zk-line); background: var(--zk-tint); diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index 5e465e5..8e564b3 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -3,7 +3,6 @@ import { useZino } from '../api/provider.jsx' import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js' import { describeError, describeValidation } from '../api/errors.js' import FileField from './FileField.jsx' -import { dateLong } from '../api/display.js' import './ActivityForm.css' /** @@ -289,7 +288,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on + (f.data_type === 'longtext' ? ' af__field--wide' : '') + (missing.includes(f.id) ? ' is-missing' : '')} > - {f.name}{f.mandatory ? * : null} + {f.name}{f.mandatory ? required : null} {f.data_type === 'file' || f.data_type === 'ocr' ? ( ) : f.data_type === 'select' ? ( ) : f.data_type === 'multiselect' ? ( @@ -319,13 +318,6 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on ) : f.data_type === 'longtext' ? (