From e90e4204ec21a14a9ded124d4905835d86d54dd1 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 9 Sep 2026 11:37:42 +0530 Subject: [PATCH] =?UTF-8?q?console:=20redesign=20the=20UI=20=E2=80=94=20on?= =?UTF-8?q?e=20card=20language,=20icons,=20filters,=20live=20trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 39 +- src/api/display.js | 214 +++ src/components/ActivityForm.css | 41 +- src/components/ActivityForm.jsx | 16 +- src/components/AgentChip.css | 41 +- src/components/CallTranscript.jsx | 5 +- src/components/ChainOfThought.css | 95 +- src/components/ChainOfThought.jsx | 55 +- src/components/ClampText.css | 9 +- src/components/Conversation.css | 8 +- src/components/Conversation.jsx | 6 +- src/components/Icon.jsx | 70 + src/components/StageRail.css | 99 +- src/components/StageRail.jsx | 68 +- src/components/Timeline.css | 456 +++--- src/components/Timeline.jsx | 389 ++--- src/index.css | 161 +- src/layout/GlobalSearch.jsx | 121 ++ src/layout/Shell.css | 532 +++---- src/layout/Shell.jsx | 87 +- src/layout/UserMenu.css | 132 +- src/layout/UserMenu.jsx | 32 +- src/pages/Login.css | 285 ++-- src/pages/Login.jsx | 222 +-- src/screens/Lead.jsx | 532 +++---- src/screens/Overview.jsx | 218 ++- src/screens/Pipeline.jsx | 330 ++-- src/screens/screens.css | 2414 ++++++++++------------------- 28 files changed, 3192 insertions(+), 3485 deletions(-) create mode 100644 src/api/display.js create mode 100644 src/components/Icon.jsx create mode 100644 src/layout/GlobalSearch.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 38ffbfb..31ab7f4 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 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. +`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. @@ -50,12 +50,33 @@ 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` — 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/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`. -- `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then - field groups (`GROUPS`, in the order the lead is worked). + `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 diff --git a/src/api/display.js b/src/api/display.js new file mode 100644 index 0000000..00fd05a --- /dev/null +++ b/src/api/display.js @@ -0,0 +1,214 @@ +/** + * 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 4d38e37..507826c 100644 --- a/src/components/ActivityForm.css +++ b/src/components/ActivityForm.css @@ -28,32 +28,37 @@ .af__field > span { font-size: var(--fs-2xs); font-weight: 500; - letter-spacing: 0.03em; - color: var(--zk-muted); + color: var(--zk-ink); display: flex; align-items: center; - gap: 8px; + gap: 6px; } .af__req { font-style: normal; - 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); + font-size: var(--fs-sm); + font-weight: 600; + line-height: 1; + color: var(--zk-danger); + margin-left: -4px; } +.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-md); + border-radius: var(--r-sm); border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); @@ -117,7 +122,7 @@ } .af__chip.is-on { - background: var(--grad-blue); + background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); box-shadow: var(--sh-xs); @@ -136,17 +141,17 @@ font-size: var(--fs-xs); font-weight: 500; padding: 10px 26px; - border-radius: var(--r-md); + border-radius: var(--r-sm); cursor: pointer; border: 0; - background: var(--grad-blue); + background: var(--zk-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(--grad-blue-hover); + background: var(--zk-blue-dark); box-shadow: var(--sh-md); transform: translateY(-1px); } @@ -166,7 +171,7 @@ font: inherit; font-size: var(--fs-xs); padding: 10px 20px; - border-radius: var(--r-md); + border-radius: var(--r-sm); cursor: pointer; border: 1px solid var(--zk-line); background: var(--zk-white); @@ -211,7 +216,7 @@ .af__auto { font-size: var(--fs-xs); padding: 10px 12px; - border-radius: var(--r-md); + border-radius: var(--r-sm); 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 8e564b3..5e465e5 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -3,6 +3,7 @@ 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' /** @@ -288,7 +289,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 ? required : null} + {f.name}{f.mandatory ? * : null} {f.data_type === 'file' || f.data_type === 'ocr' ? ( ) : f.data_type === 'select' ? ( ) : f.data_type === 'multiselect' ? ( @@ -318,6 +319,13 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on ) : f.data_type === 'longtext' ? (