commit 66d4c91f32b4cd0f0f58b86e08c81f21fb715a1b Author: Bhanu Prakash Sai Potteri Date: Tue Jun 30 15:28:12 2026 +0530 Initial import of lead-to-policy (dev env) Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4442163 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +node_modules +dist +dist-ssr +*.local +.DS_Store +.vite + +# Editor +.vscode/* +!.vscode/extensions.json +.idea + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local env (base URL etc.) +.env + +*.tsbuildinfo + +# Local MCP config (contains credentials) +.mcp.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c5b7ba --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Aria Console + +AI-employee-powered life-insurance **lead-to-policy** console for an Indian insurer. +Vite + React + TypeScript + Tailwind v4, built on the **Aria design system**. + +## Run + +```bash +npm install +npm run dev # http://localhost:5173 +npm run build # type-check + production build +npm run typecheck # type-check only +``` + +Create `.env` (gitignored) pointing at the gateway: + +``` +VITE_ZINO_API_URL=https://sandbox.getzino.in +``` + +Sign in with a platform user (e.g. `admin@getzino.com` / `Zino`); the JWT is +persisted in localStorage under `lead_to_policy_token`. + +## Backend wiring (live) + +This console is wired to the **sm2 Zino gateway** — sandbox **app 385 "Lead to +Policy"** (org 53), workflow `e29c3c33-…` (v1). No mock data on the four screens. + +`src/api/` is a thin SDK over the gateway: + +| File | Purpose | +| --- | --- | +| `config.ts` | App/workflow/RV/DV/activity UIDs, field ids, select options, state↔stage maps | +| `client.ts` | `ZinoClient` — login, app-scoped view/workflow calls, JWT persistence | +| `provider.tsx` | `ZinoProvider` + `useZino` + a dependency-free `useQuery` hook | +| `adapters.ts` | Map live records → the Aria domain types (`Lead`, `Decision`, timeline) | + +Endpoints used (all on the gateway): + +- `POST /usr/login` — auth +- `POST /app/385/view/recordview` — pipeline + lead lookup (`rv_template_uid`) +- `GET /app/385/view/audit?instance_id=` — activity log +- `GET /monitor/decisions?instance_id=` — AI-employee decision stream +- `POST /app/385/activity` — perform Recommend Product / Collect Documents + +Writes are real: submitting Recommend advances a lead Meeting Scheduled → +Product Recommended; submitting Documents advances Product Recommended → +Documents Collected. Document upload + OCR auto-extraction is intentionally +out of scope here (field-based KYC capture only). + +## Screens + +| Route | Screen | What it shows | +| --- | --- | --- | +| `/pipeline` | **Lead Pipeline** | KPI row, pipeline funnel, segment donut, leads table / kanban toggle. Click a lead → Lead 360. | +| `/leads/:id` | **Lead 360** | Profile, workflow stepper, current activity, AI decision stream + escalation callout. | +| `/recommend` | **Recommend Product** | Deep-linked (or picker-selected) lead; live indicative-premium helper, the lead's real AI suggestion, submits the Recommend Product activity. | +| `/documents` | **Documents** | Field-based KYC capture (PAN/Aadhaar/income/status), checklist + completion meter, submits the Collect Documents activity. | + +## Architecture + +``` +src/ +├── components/ +│ ├── core/ Button, Card, Badge, Avatar, Input, Select, MultiSelect +│ ├── insurance/ ConfidenceRing, RupeeAmount, SegmentBadge, ChannelBadge, +│ │ KpiCard, WorkflowStepper, TimelineEntry, AIDecisionCard, +│ │ LeadRow, DocumentUploadCard +│ └── index.ts single import surface — `import { Button } from '@/components'` +├── layout/Shell.tsx navy sidebar (nav + AI roster) + topbar, routed via +├── screens/ the four screens +├── data/mockData.ts typed mock data (Indian context) +├── types.ts shared domain types (Lead, Decision, Segment, Channel…) +├── lib/cn.ts className join helper +└── styles/ styles.css + tokens/ (colors, typography, spacing, fonts) +``` + +Every screen composes design-system components — they never re-implement primitives. +Components are typed, take a `className` for layout overrides, and are reusable across the app. + +## Design tokens → Tailwind + +`src/styles/styles.css` imports the Aria tokens (CSS custom properties) and wires them into +Tailwind's theme via `@theme inline`, so utilities resolve straight to the design system: + +- `bg-navy-900`, `text-sunrise-600`, `bg-emerald-100` … full brand + semantic palette +- `bg-card`, `text-strong`, `text-muted`, `border-border-subtle` … semantic aliases +- `rounded-lg` (16px card), `rounded-md` (12px control), `rounded-pill` +- `shadow-sm/md/lg`, `shadow-sunrise`; `font-sans` (Inter), `font-numeric` (Inter Tight) +- brand gradients via `.bg-sunrise` / `.bg-navy-grad`; tabular figures via `.nums` + +Brand: deep navy `#0B1B3B`, sunrise accent `#F26B3A → #FBA94C`, emerald success / +amber escalation; ₹ lakh/crore formatting; sentence case; no emoji. diff --git a/cascade_city_by_state.sql b/cascade_city_by_state.sql new file mode 100644 index 0000000..e741aa6 --- /dev/null +++ b/cascade_city_by_state.sql @@ -0,0 +1,44 @@ +-- Cascade: City filtered by State on Add Lead activity (app 385) +-- Activity 26398, workflow_version 29852 (config row id 405) +-- State field = state_region_input (-> States dataset, value_key=state) +-- City field = city_input (-> Cities dataset, display=city, value=city_id) +-- +-- Appends a filter_options rule to City: show only Cities rows where +-- Cities.state == current value of the State field. +-- Idempotent: skips if a rule with id 'rule_city_by_state' already exists. + +BEGIN; + +UPDATE workflow.tbl_wf_activity_ui_config +SET field_rules = COALESCE(field_rules, '[]'::jsonb) || '[ + { + "id": "rule_city_by_state", + "action": "filter_options", + "active": true, + "conditions": null, + "action_value": null, + "target_field": "city_input", + "filter_config": { + "logic": "AND", + "conditions": [ + { + "operator": "equals", + "form_field": "state_region_input", + "target_column": "state", + "source_value_key": "" + } + ] + } + } +]'::jsonb, + updated_at = now() +WHERE activity_id = 26398 + AND workflow_version_id = 29852 + AND NOT COALESCE(field_rules, '[]'::jsonb) @> '[{"id": "rule_city_by_state"}]'::jsonb; + +-- Verify (expect the new rule present) +SELECT id, activity_id, jsonb_pretty(field_rules) AS field_rules +FROM workflow.tbl_wf_activity_ui_config +WHERE activity_id = 26398 AND workflow_version_id = 29852; + +COMMIT; diff --git a/cleanup-phantom-agents.sql b/cleanup-phantom-agents.sql new file mode 100644 index 0000000..70177bf --- /dev/null +++ b/cleanup-phantom-agents.sql @@ -0,0 +1,42 @@ +-- Cleanup phantom Schedule-page agents (app 385, sandbox) +-- Junk test rows where owner_agent is a bare placeholder string instead of {id,name}. +-- Effect: nulls owner_agent -> these leads collapse into "Unassigned" on the Schedule page. +-- Affected instances: 4489 (Sandy), 4490 (Alex), 4502 (Owner Agent), 4511/4512 (Owner Agent*) +-- +-- NOTE: recordview reads from the denormalized companion table +-- tbl_wf_385_lead_to_policy, which is kept in sync only via the app write path. +-- A raw UPDATE to tbl_appdata_workflow_instances does NOT propagate, so we must +-- clean BOTH tables. + +BEGIN; + +-- Preview before committing +SELECT 'main' AS tbl, instance_id, data->'owner_agent' AS owner_agent +FROM public.tbl_appdata_workflow_instances +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) +UNION ALL +SELECT 'companion', instance_id, owner_agent +FROM public.tbl_wf_385_lead_to_policy +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512); + +-- 1. Main instance table: drop the bad owner_agent key (only bare-string placeholders) +UPDATE public.tbl_appdata_workflow_instances +SET data = data - 'owner_agent', + updated_at = now() +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) + AND jsonb_typeof(data->'owner_agent') = 'string'; + +-- 2. Companion recordview table: null out the bad owner_agent (only bare-string placeholders) +UPDATE public.tbl_wf_385_lead_to_policy +SET owner_agent = NULL +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) + AND jsonb_typeof(owner_agent) = 'string'; + +-- Verify (should return 0 rows) +SELECT 'main' AS tbl, instance_id FROM public.tbl_appdata_workflow_instances +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND data ? 'owner_agent' +UNION ALL +SELECT 'companion', instance_id FROM public.tbl_wf_385_lead_to_policy +WHERE instance_id IN (4489, 4490, 4502, 4511, 4512) AND owner_agent IS NOT NULL; + +COMMIT; diff --git a/flow-findings.md b/flow-findings.md new file mode 100644 index 0000000..f9dbf56 --- /dev/null +++ b/flow-findings.md @@ -0,0 +1,85 @@ +# Lead-to-Policy (Aria) — Live Flow Findings + +App **385**, workflow `e29c3c33` (db `workflow_id=29851`), org 53. Sandbox DB verified (`postgres-sandbox`, read-only) + AI decisions via `https://sandbox.getzino.in/monitor/decisions`. + +Loop: `/loop 4m` (cron `d62147e6`). Each tick re-checks the latest instance, observes what the AI did, recommends the next human payload. + +--- + +## State graph (state → activity → next state) + +| # | State | Human/forward activity | → next state | Disqualify? | +|---|-------|------------------------|--------------|-------------| +| 1 | New Lead | Capture Lead `cc1a73d5` (INIT) | Qualified (via Qualify Lead) | yes | +| 2 | Qualified | **Assign Lead `da61aaa1`** | Assigned | yes | +| 3 | Assigned | Log First Contact `fea6b3a8` | Contacted | yes | +| 4 | Contacted | Schedule Meeting `c444d32f` | Meeting Scheduled | yes | +| 5 | Meeting Scheduled | Recommend Product `c0e2004e` (→ stage-gate `…5a01`) | Product Recommended | yes | +| 6 | Product Recommended | Collect Documents `a8eb0faa` | Documents Collected | yes | +| 7 | Documents Collected | Assess Eligibility `ba3d3e7f` (→ gate `…0a01`) | Eligibility Assessed | yes | +| 8 | Eligibility Assessed | Submit Underwriting `ed8ec0dc` (→ gate `…0b02`) | Underwriting | yes | +| 9 | Underwriting | Issue Policy `7b8fb734` (→ gate `…0c03`) | Policy Issued | yes | +| 10 | Policy Issued | Onboard Customer `ae415f4e` | Customer 360 (END) | — | + +`Disqualify ea99fc60` is allowed in every non-terminal state → **Disqualified** (END). +`AI Product Recommendation 14d734da` is a self-loop on Meeting Scheduled (AI-only, no state change). + +--- + +## AI employees & triggers (verified in `activity_triggers`) + +Trigger = bound to a **source** activity; fires *after* a human performs it, then the bound AI employee autonomously performs the **next** workflow activity. + +| Trigger on activity | AI employee | user_id | Effect | +|---------------------|-------------|---------|--------| +| Capture Lead `cc1a73d5` | Insurance AI (Aria) #6 | **29180** | auto-performs **Qualify Lead** → Qualified | +| Assign Lead `da61aaa1` | Aria (Engagement) #7 | **29181** | auto-performs next (Log First Contact) | +| Log First Contact `fea6b3a8` | Aria (Engagement) #7 | **29181** | auto-advances toward Schedule Meeting | +| Schedule Meeting `c444d32f` | Aria Advisor #9 | **29188** | logs **AI Product Recommendation** decision (self-loop) before human Recommend Product | +| Collect Documents `a8eb0faa` | Aria (Underwriting) #8 | **29182** | auto-advances underwriting steps | +| Onboard Customer `ae415f4e` | *(no AI)* automation pipeline | — | customJS Compute Maturity → generateDoc Policy Schedule → sendEmail welcome | + +Note: Qualify Lead, Recommend Product, Assess Eligibility, Submit Underwriting, Issue Policy have **no** trigger bound → they are human/manual gates (no auto-advance). + +--- + +## Observation log + +### Tick 1 — 2026-06-26 ~07:25 (instance 5017, lead "Parikshith Takkar") + +Flow observed (verified DB + monitor): +- `07:20:46` — **Capture Lead** by human **29183** (Sales Agent). Lead: Bengaluru / Karnataka, Lawyer, ₹23L income, DOB 1976-05-25 (age 50), web, term interest, English. +- Capture trigger fired → **Insurance AI (Aria) 29180** auto-performed **Qualify Lead** at `07:21:31`: + - confidence **0.95**, status **submitted**, model `claude-sonnet-4-5`, 5 LLM calls, cost ~$0.124. + - set lead_score **95**, segment **hot**, annual_income 23,00,000, DOB confirmed. + - knowledge sources: Lead Qualification Guide, ABSLI Product Catalog, ABSLI Underwriting Rules. + - reasoning: age 50 within 18-65 entry; income supports ₹2.76–3.45 cr coverage; Lawyer = stable; no disqualifiers. +- **Current state: Qualified** (`41c7a19f`). No trigger on Qualify Lead → flow waits for human. + +**→ Next human activity = Assign Lead.** Recommended payload below. + +--- + +## ⭐ Recommended payload — next activity: **Assign Lead** (`da61aaa1`) + +Fields (from `activity_data_fields`; field_rules cascade city → region → agent): + +```json +{ + "assign_city": "Bengaluru", + "assigned_region_input": "South", + "owner_agent_input": { "id": 1, "name": "Asha Rao" } +} +``` + +- `assign_city` — select, mapped `city`. Lead already in Bengaluru. Drives region filter. +- `assigned_region_input` — **mandatory** lookup `tbl_branches.region`. Bengaluru branch (id 7, "Bengaluru MG Road") → region **South**. Filtered by city via rule `rule_region_by_city`. +- `owner_agent_input` — optional lookup `tbl_sales_agents` (stores id+name), filtered to region=South via `rule_agent_by_region`. South agents available: **Asha Rao (1)**, Karthik Iyer (7), Divya Menon (8). Pick any; Asha Rao recommended. + +**After you submit Assign Lead:** trigger fires → **Aria (Engagement) 29181** auto-performs the next step (Log First Contact → Contacted). Watch `/monitor/decisions?instance_id=5017` for user `29181`. + +--- + +### Tick 2 — 2026-06-26 ~07:29 — no change + +Instance 5017 still in **Qualified** (`updated_at` 07:21:31, unchanged). Assign Lead not yet submitted. Recommendation above unchanged. diff --git a/index.html b/index.html new file mode 100644 index 0000000..326dc01 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Zino | Lead Management + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4fe9e0f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2391 @@ +{ + "name": "aria-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aria-console", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8c7af4 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "aria-console", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Aria — AI-employee-powered life-insurance lead-to-policy console", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "lucide-react": "^1.21.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } +} diff --git a/public/zino.svg b/public/zino.svg new file mode 100644 index 0000000..473f960 --- /dev/null +++ b/public/zino.svg @@ -0,0 +1,8 @@ + + + diff --git a/rebuild_city_state_from_branches.sql b/rebuild_city_state_from_branches.sql new file mode 100644 index 0000000..9a34013 --- /dev/null +++ b/rebuild_city_state_from_branches.sql @@ -0,0 +1,65 @@ +-- Rebuild the State + City datasets from tbl_branches so every captured city is +-- always assignable (Create Lead → Assign cascade dependency). +-- +-- App 385. Cities dataset=264, States dataset=265. City field=46815, State field=46814. +-- +-- WHY: Assign Lead's region cascade matches assign_city == tbl_branches.city (city +-- NAMES), then owner agent by region. So the city captured at Create Lead must be a +-- branch city name. We therefore (a) source the City/State option lists straight from +-- tbl_branches, and (b) store the city NAME as the value (value_key=city), not city_id. +-- +-- Datasets are read live from tbl_datasets (no redeploy needed for the data). The field +-- property changes live in the deployed workflow config, so REDEPLOY the version after +-- running this. Re-runnable: deterministic full rebuild from the current branches. + +BEGIN; + +-- Cities dataset ← distinct (state_name, city) from branches. keys: state, city. +UPDATE tbl_datasets +SET keys = '["state","city"]'::jsonb, + items = ( + SELECT COALESCE( + jsonb_agg(jsonb_build_object('state', state_name, 'city', city) ORDER BY state_name, city), + '[]'::jsonb) + FROM (SELECT DISTINCT state_name, city FROM lead_to_policy.tbl_branches) t + ), + updated_at = now() +WHERE id = 264; + +-- States dataset ← distinct state_name from branches. key: state. +UPDATE tbl_datasets +SET keys = '["state"]'::jsonb, + items = ( + SELECT COALESCE( + jsonb_agg(jsonb_build_object('state', state_name) ORDER BY state_name), + '[]'::jsonb) + FROM (SELECT DISTINCT state_name FROM lead_to_policy.tbl_branches) t + ), + updated_at = now() +WHERE id = 265; + +-- City field (46815): store the city NAME so it matches tbl_branches.city in the +-- Assign region cascade (and displays correctly). +UPDATE workflow.tbl_wf_fields +SET properties = jsonb_set( + jsonb_set(properties, '{value_key}', '"city"'::jsonb), + '{dataset_keys}', '["state","city"]'::jsonb) +WHERE id = 46815; + +-- State field (46814): value + label + keys all on `state`. +UPDATE workflow.tbl_wf_fields +SET properties = jsonb_set( + jsonb_set( + jsonb_set(properties, '{value_key}', '"state"'::jsonb), + '{display_keys}', '["state"]'::jsonb), + '{dataset_keys}', '["state"]'::jsonb) +WHERE id = 46814; + +-- Verify +SELECT id, name, jsonb_array_length(items) AS rows, keys FROM tbl_datasets WHERE id IN (264,265); +SELECT id, field_s_id, + properties->>'value_key' AS value_key, + properties->'display_keys' AS display_keys +FROM workflow.tbl_wf_fields WHERE id IN (46814,46815); + +COMMIT; diff --git a/scripts/build_policy_docx.py b/scripts/build_policy_docx.py new file mode 100644 index 0000000..a87b628 --- /dev/null +++ b/scripts/build_policy_docx.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Build the ABSLI policy-schedule / welcome-kit .docx template for app 385. + +Dependency-free: writes a minimal-but-valid WordprocessingML package by hand. +Each {{placeholder}} is emitted as ONE contiguous run so docx-service's +placeholder substitution resolves it cleanly (it also handles fragmented runs, +but single runs are the safe path). + +Output: ai-employee-leadtopolicy/policy_schedule.docx +Upload it via Studio > Templates (app 385); docx-service substitutes the +{{vars}} mapped in the 44d generateDoc node. + +Placeholders (all sourced from companion tbl_wf_385_lead_to_policy at 44d): + policy_number lead_name plan_name sum_assured premium_amount + premium_frequency policy_term commencement_date maturity_date + advisor_name issue_date +""" +import os +import zipfile +from xml.sax.saxutils import escape + +OUT = os.path.join(os.path.dirname(__file__), "..", "..", + ".supacode", "repos", "sm2", "lead-to-policy", + "ai-employee-leadtopolicy", "policy_schedule.docx") +# When run from the aria-console scripts/ dir the relative hop above is brittle; +# allow an explicit override. +OUT = os.environ.get("POLICY_DOCX_OUT", OUT) + +NSW = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + +# ---- run / paragraph helpers ------------------------------------------------- + +def run(text, *, bold=False, size=None, color=None): + rpr = "" + props = [] + if bold: + props.append("") + if size: # half-points + props.append(f'') + if color: + props.append(f'') + if props: + rpr = "" + "".join(props) + "" + return (f'{rpr}{escape(text)}') + + +def para(runs, *, align=None, space_after=120, shading=None): + ppr_bits = [] + if align: + ppr_bits.append(f'') + if shading: + ppr_bits.append(f'') + ppr_bits.append(f'') + ppr = "" + "".join(ppr_bits) + "" + body = runs if isinstance(runs, str) else "".join(runs) + return f"{ppr}{body}" + + +def cell(text_runs, *, w=4500, fill=None): + shd = f'' if fill else "" + tcpr = f'{shd}' + return f"{tcpr}{para(text_runs, space_after=40)}" + + +def kv_row(label, placeholder): + left = cell([run(label, bold=True, size=20, color="475569")], w=3600, fill="F1F5F9") + right = cell([run("{{" + placeholder + "}}", size=20, color="0F172A")], w=5400) + return f"{left}{right}" + + +def table(rows): + borders = ( + "" + '' + '' + '' + '' + '' + '' + "" + ) + tblpr = f'{borders}' + grid = '' + return f"{tblpr}{grid}{''.join(rows)}" + + +# ---- document body ----------------------------------------------------------- + +body_parts = [ + para([run("Aditya Birla Sun Life Insurance", bold=True, size=32, color="9A1F40")], + align="center", space_after=40), + para([run("Policy Schedule & Welcome Kit", bold=True, size=24, color="475569")], + align="center", space_after=240), + + para([run("Dear ", size=22), + run("{{lead_name}}", bold=True, size=22), + run(",", size=22)]), + para([run( + "Welcome to the Aditya Birla Sun Life Insurance family. We are delighted to " + "confirm that your policy has been issued. Please find your policy schedule " + "below. Kindly retain this document for your records.", size=22)], + space_after=240), + + para([run("Policy Schedule", bold=True, size=24, color="9A1F40")], space_after=120), + table([ + kv_row("Policy Number", "policy_number"), + kv_row("Plan", "plan_name"), + kv_row("Life Assured", "lead_name"), + kv_row("Sum Assured (INR)", "sum_assured"), + kv_row("Premium (INR)", "premium_amount"), + kv_row("Premium Frequency", "premium_frequency"), + kv_row("Policy Term (years)", "policy_term"), + kv_row("Risk Commencement Date", "commencement_date"), + kv_row("Maturity Date", "maturity_date"), + kv_row("Date of Issue", "issue_date"), + kv_row("Your Advisor", "advisor_name"), + ]), + para([run("", size=22)], space_after=200), + + para([run( + "This is a computer-generated welcome document. The benefits, terms and " + "conditions are governed by the policy contract. For any assistance, please " + "contact your advisor named above or reach us at care.abslifeinsurance@adityabirlacapital.com.", + size=18, color="64748B")], space_after=240), + + para([run("Warm regards,", size=22)], space_after=40), + para([run("Team Aditya Birla Sun Life Insurance", bold=True, size=22)]), +] + +document_xml = ( + '\n' + f'' + "" + + "".join(body_parts) + + '' + '' + "" +) + +CONTENT_TYPES = ( + '\n' + '' + '' + '' + '' + "" +) + +RELS = ( + '\n' + '' + '' + "" +) + +DOC_RELS = ( + '\n' + '' + "" +) + +os.makedirs(os.path.dirname(OUT), exist_ok=True) +with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", CONTENT_TYPES) + z.writestr("_rels/.rels", RELS) + z.writestr("word/document.xml", document_xml) + z.writestr("word/_rels/document.xml.rels", DOC_RELS) + +print("wrote", os.path.abspath(OUT)) diff --git a/scripts/kyc-match.check.ts b/scripts/kyc-match.check.ts new file mode 100644 index 0000000..b7593dd --- /dev/null +++ b/scripts/kyc-match.check.ts @@ -0,0 +1,64 @@ +// Runnable assertions for src/lib/kyc-match.ts (no test runner is configured). +// node scripts/kyc-match.check.ts +// Lives outside src/ so it isn't part of the `tsc -b` app build. +import assert from 'node:assert/strict'; +import { crossCheck, dobVerdict, nameVerdict, normName } from '../src/lib/kyc-match.ts'; + +let n = 0; +const ok = (label: string, cond: boolean) => { + n++; + if (!cond) { + console.error('FAIL:', label); + process.exitCode = 1; + } +}; + +// normName: order-insensitive, honorific/punct strip +assert.equal(normName('Mr. Ravi Kumar'), 'kumar ravi'); +assert.equal(normName('KUMAR, RAVI'), 'kumar ravi'); + +// name: exact / case / order → match +ok('exact name', nameVerdict('Ravi Kumar', 'ravi kumar').status === 'match'); +ok('reordered name', nameVerdict('Ravi Kumar', 'Kumar Ravi').status === 'match'); +// initials → match +ok('initial expands', nameVerdict('R Kumar', 'Ravi Kumar').status === 'match'); +ok('middle initial', nameVerdict('Ravi K Sharma', 'Ravi Kumar Sharma').status === 'match'); +// typo / wrong person → mismatch +ok('typo name', nameVerdict('Rabi Kumar', 'Ravi Kumar').status === 'mismatch'); +ok('wrong person', nameVerdict('Ravi Kumar', 'Suresh Patel').status === 'mismatch'); +ok('extra surname missing', nameVerdict('Ravi', 'Ravi Kumar').status === 'mismatch'); +// empty side → unknown +ok('empty ocr', nameVerdict('Ravi Kumar', '').status === 'unknown'); +ok('empty captured', nameVerdict('', 'Ravi Kumar').status === 'unknown'); + +// DOB: captured IST-midnight instant ("…T18:30Z" → 1990-05-15 IST) vs OCR YYYY-MM-DD +// (literal compare, no tz shift). Normalization is pinned to Asia/Kolkata. +ok('dob match', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-15') === 'match'); +ok('dob mismatch day', dobVerdict('1990-05-14T18:30:00.000Z', '1990-05-16') === 'mismatch'); +ok('dob mismatch year', dobVerdict('1990-05-14T18:30:00.000Z', '1991-05-15') === 'mismatch'); +// DD/MM/YYYY as a card may print +ok('dob dd/mm/yyyy', dobVerdict('1990-05-14T18:30:00.000Z', '15/05/1990') === 'match'); +// null / unparseable → unknown +ok('dob null', dobVerdict('1990-05-14T18:30:00.000Z', null) === 'unknown'); +ok('dob garbage', dobVerdict('1990-05-14T18:30:00.000Z', 'unknown') === 'unknown'); + +// aggregate +const clean = crossCheck( + { name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' }, + { pan: { name: 'RAVI KUMAR', dob: '1990-05-15' }, aadhaar: { name: 'Ravi Kumar', dob: '1990-05-15' } }, +); +ok('clean: 4 checked', clean.fields.length === 4); +ok('clean: no mismatch', !clean.hasMismatch && clean.anyChecked); + +const bad = crossCheck( + { name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' }, + { pan: { name: 'Ravi Kumar', dob: '1990-05-15' }, aadhaar: { name: 'Suresh Patel', dob: '1985-01-01' } }, +); +ok('bad: hasMismatch', bad.hasMismatch); +ok('bad: 2 aadhaar mismatches', bad.fields.filter((f) => f.status === 'mismatch').length === 2); + +// nothing OCR'd yet +const empty = crossCheck({ name: 'Ravi Kumar', dob: '1990-05-14T18:30:00.000Z' }, {}); +ok('empty: nothing checked', !empty.anyChecked && !empty.hasMismatch); + +if (!process.exitCode) console.log(`kyc-match: all checks passed (${n} cases)`); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..641442e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,59 @@ +import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { Shell } from './layout/Shell'; +import { PipelineScreen } from './screens/PipelineScreen'; +import { ScheduleScreen } from './screens/ScheduleScreen'; +import { Lead360Screen } from './screens/Lead360Screen'; +import { + QualificationScreen, + InteractionScreen, + DocAssessmentScreen, + UnderwritingPolicyScreen, +} from './screens/worklists'; +import { Login } from './pages/Login'; +import { useZino } from './api/provider'; +import { LeadsProvider } from './api/leads'; +import { canSeeScreen } from './api/config'; + +function RequireAuth({ children }: { children: ReactNode }) { + const { user } = useZino(); + const location = useLocation(); + if (!user) return ; + return <>{children}; +} + +// Deep-link guard — a screen the user's roles can't see redirects to the +// dashboard (which is open to every operational role). +function RequireScreen({ path, children }: { path: string; children: ReactNode }) { + const { user } = useZino(); + if (!canSeeScreen(user?.roles, path)) return ; + return <>{children}; +} + +export function App() { + return ( + + } /> + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/src/api/adapters.ts b/src/api/adapters.ts new file mode 100644 index 0000000..ce8061e --- /dev/null +++ b/src/api/adapters.ts @@ -0,0 +1,286 @@ +// Map live API shapes onto the Aria design-system domain types. +import type { Channel, Decision, Lead, Segment, Tone } from '../types'; +import { + ACTIVITY_NAME_BY_UID, + AI_EMPLOYEES, + AI_RECOMMENDATION, + STATE_NAME_BY_UID, + SUPER_ROLES, + aiEmployeeForState, + stageOfState, +} from './config'; +import type { AiDecision, AuditEntry, LeadRecord } from './types'; + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +export function formatDate(iso?: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return `${d.getDate().toString().padStart(2, '0')} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; +} + +export function formatTime(iso?: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }); +} + +function ageFrom(iso?: string | null): number { + if (!iso) return 0; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return 0; + const now = new Date(); + let age = now.getFullYear() - d.getFullYear(); + const m = now.getMonth() - d.getMonth(); + if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--; + return age; +} + +function toSegment(raw?: string | null): Segment { + switch ((raw ?? '').toLowerCase()) { + case 'hot': + return 'Hot'; + case 'cold': + return 'Cold'; + default: + return 'Warm'; + } +} + +const CHANNELS: Record = { + whatsapp: 'WhatsApp', + web: 'Web', + referral: 'Referral', + agency: 'Agency', + bancassurance: 'Bancassurance', + direct: 'Direct', + event: 'Event', +}; + +function toChannel(raw?: string | null): Channel { + return CHANNELS[(raw ?? '').toLowerCase()] ?? 'Web'; +} + +// Several recordview fields arrive as structured objects, not scalars. These +// flatten each to a display string; screens must route reads through them +// rather than rendering the raw value (a raw object crashes React). + +// Phone from the phone-input widget: {dial_code, phone, phone_with_dial_code}. +function toPhone(raw: unknown): string { + if (typeof raw === 'string') return raw; + if (raw && typeof raw === 'object') { + const o = raw as { phone_with_dial_code?: string; phone?: string }; + return o.phone_with_dial_code ?? o.phone ?? '—'; + } + return '—'; +} + +// assigned_region is a tbl_branches lookup: {region}. Returns undefined when +// absent so it composes with `??` fallback chains. +export function regionOf(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw || undefined; + if (raw && typeof raw === 'object') { + return (raw as { region?: string }).region || undefined; + } + return undefined; +} + +// recommended_product is a product-catalog lookup; plan_name is its display +// column (older rows may carry a {name} or a plain string). +export function productOf(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw || undefined; + if (raw && typeof raw === 'object') { + const o = raw as { plan_name?: string; name?: string }; + return o.plan_name ?? o.name ?? undefined; + } + return undefined; +} + +// state -> a friendly "last action" line + tone for the pipeline list. +const STATE_ACTION: Record = { + 'New Lead': { action: 'Awaiting qualification', tone: 'neutral' }, + Qualified: { action: 'Qualified — ready to assign', tone: 'success' }, + Assigned: { action: 'Assigned to agent', tone: 'success' }, + Contacted: { action: 'First contact logged', tone: 'info' }, + 'Meeting Scheduled': { action: 'Meeting scheduled', tone: 'accent' }, + 'Product Recommended': { action: 'Product recommended', tone: 'accent' }, + 'Documents Collected': { action: 'KYC documents collected', tone: 'info' }, + 'Eligibility Assessed': { action: 'Eligibility assessed', tone: 'accent' }, + Underwriting: { action: 'Underwriting in progress', tone: 'accent' }, + 'Policy Issued': { action: 'Policy issued', tone: 'success' }, + 'Customer 360': { action: 'Onboarded — Customer 360', tone: 'success' }, + Disqualified: { action: 'Disqualified', tone: 'warning' }, +}; + +export function recordToLead(r: LeadRecord): Lead { + const state = r.current_state_name ?? 'New Lead'; + const ai = aiEmployeeForState(state); + const human = r.owner_agent?.name; + const owner = human ?? ai?.name ?? 'Unassigned'; + const meta = STATE_ACTION[state] ?? { action: state, tone: 'neutral' as Tone }; + + return { + id: String(r.instance_id), + name: r.lead_name ?? `Lead ${r.instance_id}`, + city: r.city ?? r.state_region ?? regionOf(r.assigned_region) ?? '—', + phone: toPhone(r.phone), + email: r.email ?? '—', + dob: formatDate(r.date_of_birth), + age: ageFrom(r.date_of_birth), + income: r.annual_income ?? 0, + occupation: r.occupation ?? '—', + language: r.preferred_language ?? '—', + interest: r.product_interest ?? '—', + score: r.lead_score ?? 0, + segment: toSegment(r.lead_segment), + channel: toChannel(r.channel_source), + owner, + ownerAi: !human && !!ai, + stage: Math.max(0, stageOfState(state)), + lastAction: meta.action, + lastTone: meta.tone, + }; +} + +// --- AI decision stream --- + +function asStringArray(v: unknown): string[] { + if (!v) return []; + if (Array.isArray(v)) { + return v + .map((x) => (typeof x === 'string' ? x : (x as { name?: string; title?: string })?.name ?? (x as { title?: string })?.title ?? '')) + .filter(Boolean); + } + return []; +} + +function firstSentence(text: string): string { + const t = text.trim(); + const m = t.match(/^.*?[.!?](\s|$)/); + return (m ? m[0] : t).trim(); +} + +export function decisionToCard(d: AiDecision): Decision { + const known = AI_EMPLOYEES[d.ai_user_id]; + const emp = known ?? { name: d.employee_name?.trim() || `AI ${d.ai_user_id}`, role: 'AI Employee' }; + const reasoning = (d.reasoning ?? '').trim(); + const activityName = + ACTIVITY_NAME_BY_UID[d.activity_id] ?? + (d.activity_name && !d.activity_name.includes('-') ? d.activity_name : null) ?? + d.instance_state ?? + 'Decision'; + return { + employee: emp.name, + role: emp.role, + activity: activityName, + time: formatTime(d.created_at), + confidence: typeof d.confidence === 'number' ? d.confidence : 0, + summary: reasoning ? firstSentence(reasoning) : 'Decision recorded.', + reasoning, + citations: asStringArray(d.knowledge_sources), + }; +} + +// --- Aria Advisor product recommendation --- + +export interface AiRecommendation { + /** Product name as the AI named it, e.g. "ABSLI DigiShield Term Plan". */ + product: string; + sumAssured: number | null; + rationale: string; + /** 0–1 (the decision confidence, or the ai_* field / 100). */ + confidence: number; + employee: string; + role: string; + time: string | null; +} + +function num(v: unknown): number | null { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** Pull Aria Advisor's pre-activity product recommendation out of the decision + * stream (it fires after Schedule Meeting). Returns null if she hasn't run. */ +export function recommendationFromDecisions(decisions: AiDecision[]): AiRecommendation | null { + const F = AI_RECOMMENDATION.fields; + // A recommendation decision = the right activity/employee, carrying a product + // name, and actually *submitted* (skip failed retries, which other employees + // log at low confidence). Prefer the real Aria Advisor over any stand-in. + const candidates = decisions.filter( + (x) => + x.status === 'submitted' && + typeof x.data?.[F.product] === 'string' && + (x.data[F.product] as string).trim() !== '' && + (x.activity_id === AI_RECOMMENDATION.activityUid || x.ai_user_id === AI_RECOMMENDATION.aiUserId), + ); + const d = + candidates.find((x) => x.ai_user_id === AI_RECOMMENDATION.aiUserId) ?? candidates[0]; + const product = d?.data?.[F.product]; + if (!d || typeof product !== 'string' || !product.trim()) return null; + const emp = AI_EMPLOYEES[d.ai_user_id] ?? { + name: d.employee_name?.trim() || 'Aria Advisor', + role: 'Product Recommendation', + }; + const pct = num(d.data?.[F.confidence]); + return { + product: product.trim(), + sumAssured: num(d.data?.[F.sumAssured]), + rationale: (typeof d.data?.[F.rationale] === 'string' ? (d.data[F.rationale] as string) : d.reasoning ?? '').trim(), + confidence: typeof d.confidence === 'number' && d.confidence > 0 ? d.confidence : pct != null ? pct / 100 : 0, + employee: emp.name, + role: emp.role, + time: d.created_at ? formatTime(d.created_at) : null, + }; +} + +// --- audit timeline --- + +export interface TimelineItem { + actor: string; + ai: boolean; + action: string; + time: string; + tone: Tone; +} + +// Audit entries carry no username — only user_id + human-readable roles. Show +// the person's role ("Sales Agent", "Underwriter") instead of a raw "User 5"; +// admins surface as "Admin". +function humanActor(userId: string, roles: string[] | undefined): string { + const r = roles ?? []; + const sup = r.find((role) => SUPER_ROLES.includes(role)); + if (sup) return 'Admin'; + if (r[0]) return r[0]; + return userId === '1' ? 'Admin' : 'Team member'; +} + +export function auditToTimeline(entries: AuditEntry[]): TimelineItem[] { + const items = [...entries] + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((e) => { + const ai = AI_EMPLOYEES[e.user_id]; + const activity = ACTIVITY_NAME_BY_UID[e.activity_id]; + const stateName = STATE_NAME_BY_UID[e.execution_state ?? '']; + const action = activity + ? `performed ${activity}` + : stateName + ? `advanced to ${stateName}` + : 'performed an activity'; + return { + actor: ai?.name ?? humanActor(e.user_id, e.user_roles), + ai: !!ai, + action, + time: formatTime(e.created_at), + tone: (ai ? 'accent' : 'neutral') as Tone, + }; + }); + + // Collapse consecutive identical (actor + action) entries from trigger fan-out. + return items.filter( + (it, i) => i === 0 || it.actor !== items[i - 1].actor || it.action !== items[i - 1].action, + ); +} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..a011025 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,290 @@ +import type { + ActivityResult, + AiDecision, + ApiError, + AuditEntry, + FormScreenResponse, + LoginResponse, + RecordViewParams, + RecordViewResponse, + User, +} from './types'; +import { APP_ID, DETAIL_VIEW_IDS, WORKFLOW_UUID } from './config'; + +const TOKEN_KEY = 'lead_to_policy_token'; + +/** + * HTTP client for the Zino gateway (sandbox). + * + * Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not. + * JWT persists in localStorage so the session survives refresh. + */ +export class ZinoClient { + readonly baseUrl: string; + private token: string | null = null; + private onAuthError?: () => void; + + constructor(baseUrl: string, onAuthError?: () => void) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.onAuthError = onAuthError; + if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY); + } + + setAuthErrorHandler(fn: () => void): void { + this.onAuthError = fn; + } + + setToken(token: string | null): void { + this.token = token; + if (typeof window === 'undefined') return; + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } + + getToken(): string | null { + return this.token; + } + + private async request(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + + return this.send(method, path, { + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + // Multipart variant — no Content-Type header (the browser sets the boundary). + private async requestForm(path: string, form: FormData): Promise { + const headers: Record = {}; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + return this.send('POST', path, { headers, body: form }); + } + + private async send(method: string, path: string, init: RequestInit): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { method, ...init }); + + if (res.status === 401) { + this.setToken(null); + this.onAuthError?.(); + throw { status: 401, message: 'Unauthorized' } as ApiError; + } + if (!res.ok) { + let message = res.statusText; + try { + const j = (await res.json()) as { error?: string }; + if (j.error) message = j.error; + } catch { + /* non-JSON body */ + } + throw { status: res.status, message } as ApiError; + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + // --- Auth --- + + async login(email: string, password: string, orgId?: string): Promise { + const res = await this.request('POST', '/usr/login', { + email, + password, + ...(orgId ? { org_id: Number(orgId) } : {}), + }); + this.setToken(res.token); + return res; + } + + logout(): void { + this.setToken(null); + } + + /** Decode the persisted JWT into a User (no network). */ + currentUser(): User | null { + if (!this.token) return null; + try { + const p = JSON.parse(atob(this.token.split('.')[1])); + return { + id: String(p.user_id ?? p.sub ?? ''), + org_id: String(p.org_id ?? ''), + name: p.name ?? '', + email: p.email ?? '', + roles: p.roles ?? [], + groups: p.groups ?? [], + }; + } catch { + return null; + } + } + + // --- Views --- + + recordView(rvUid: string, params: RecordViewParams = {}): Promise { + return this.request('POST', `/app/${APP_ID}/view/recordview`, { + rv_template_uid: rvUid, + search_query: { + page: params.page ?? 1, + limit: params.limit ?? 50, + sort_by: params.sortBy ?? '', + sort_dir: params.sortDir ?? 'desc', + search: params.search ?? '', + filters: (params.filters ?? []).map((f) => ({ + field_key: f.field_key, + value: f.value, + value2: '', + data_type: f.data_type ?? 'string', + })), + }, + }); + } + + detailView(dvUid: string, instanceId: number | string): Promise<{ + config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> }; + data: Record; + }> { + return this.request( + 'GET', + `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, + ); + } + + audit(instanceId: number | string): Promise { + return this.request( + 'GET', + `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`, + ); + } + + /** AI-employee decision stream for an instance (public monitor endpoint). */ + aiDecisions(instanceId: number | string): Promise { + return this.request<{ decisions: AiDecision[] }>( + 'GET', + `/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`, + ).then((r) => r.decisions ?? []); + } + + // --- Form schema (view-service) --- + + /** Live activity form config — fields, types, options, lookup/ocr config and + * the designer layout. The single source of truth for rendering a form. */ + formSchema(activityId: string, instanceId?: number | string): Promise { + return this.request('POST', `/app/${APP_ID}/view/form-screens`, { + activity_id: activityId, + device_type: 'desktop', + ...(instanceId != null ? { instance_id: instanceId } : {}), + }); + } + + // --- Workflow execution (core) --- + + startInstance(activityUid: string, data: Record): Promise { + return this.request('POST', `/app/${APP_ID}/start`, { + workflow_uuid: WORKFLOW_UUID, + activity_id: activityUid, + data, + }); + } + + performActivity( + instanceId: number | string, + activityUid: string, + data: Record, + ): Promise { + return this.request('POST', `/app/${APP_ID}/activity`, { + workflow_uuid: WORKFLOW_UUID, + instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId, + activity_id: activityUid, + data, + }); + } + + // Convenience — the single lead detail view. + leadDetail(instanceId: number | string) { + return this.detailView(DETAIL_VIEW_IDS.LEAD, instanceId); + } + + // --- RDBMS lookup records (owner-agent picker etc.) --- + + /** Search an RDBMS lookup template. Returns the matching rows. `formData` + * carries the current form values so the server can apply the activity's + * `field_rules` filter_options (dependent/cascading lookups, e.g. owner + * agents scoped to the chosen region). */ + lookupRecords( + templateUid: string, + opts: { + activityId: string; + fieldId: string; + search?: string; + instanceId?: number | string; + limit?: number; + formData?: Record; + }, + ): Promise<{ records: Array> }> { + return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, { + workflow_uuid: WORKFLOW_UUID, + activity_id: opts.activityId, + field_id: opts.fieldId, + instance_id: opts.instanceId || undefined, + search: opts.search ?? '', + limit: opts.limit ?? 50, + offset: 0, + form_data: opts.formData ?? {}, + }); + } + + // --- Dataset-backed select options (state/city cascade etc.) --- + + /** Fetch options for a dataset-backed select. The server resolves the field's + * dataset + filter_options from `activityId`/`fieldId`, applying the activity's + * rules against `formData` — so e.g. City options come back already scoped to + * the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */ + datasetOptions(opts: { + activityId: string; + fieldId: string; + search?: string; + instanceId?: number | string; + limit?: number; + formData?: Record; + }): Promise<{ options: Array<{ label: string; value: string; _raw?: Record }> }> { + return this.request('POST', `/app/${APP_ID}/dataset-options`, { + workflow_uuid: WORKFLOW_UUID, + activity_id: opts.activityId, + field_id: opts.fieldId, + instance_id: opts.instanceId || undefined, + search: opts.search ?? '', + limit: opts.limit ?? 200, + offset: 0, + form_data: opts.formData ?? {}, + }); + } + + // --- File upload + OCR (multipart, field-scoped) --- + + private fieldForm(file: File, ctx: FieldContext): FormData { + const form = new FormData(); + form.append('file', file); + form.append('workflow_uuid', WORKFLOW_UUID); + form.append('activity_id', ctx.activityId); + form.append('field_id', ctx.fieldId); + if (ctx.instanceId != null) form.append('instance_id', String(ctx.instanceId)); + return form; + } + + /** Upload a form-field file; returns the FileMeta to store as the field value. */ + uploadFile(file: File, ctx: FieldContext): Promise> { + return this.requestForm(`/app/${APP_ID}/upload`, this.fieldForm(file, ctx)); + } + + /** Run OCR on a document; `extracted` is keyed by the field's extraction keys. */ + extractOcr(file: File, ctx: FieldContext): Promise<{ extracted: Record; raw?: string; parse_error?: string }> { + return this.requestForm(`/app/${APP_ID}/ocr-extract`, this.fieldForm(file, ctx)); + } +} + +/** Identifiers the field-scoped CORE endpoints resolve config + RBAC from. */ +export interface FieldContext { + activityId: string; + fieldId: string; + instanceId?: number | string; +} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..2541411 --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,194 @@ +// Aria Lead-to-Policy — sandbox app 385, org 53. +// Workflow `e29c3c33` (v1). All ids verified against postgres-sandbox. + +export const ORG_ID = '53'; +export const APP_ID = '385'; +export const WORKFLOW_UUID = 'e29c3c33-e294-4051-8325-a20de9ed99fc'; + +// Record views (POST /app/385/view/recordview, body.rv_template_uid). +export const RECORD_VIEW_IDS = { + ALL_LEADS: '49a5d651-8055-451a-8348-b1ea65cc6357', // comprehensive — drives the pipeline +} as const; + +// Detail views (GET /app/385/view/detailview/{uid}). +export const DETAIL_VIEW_IDS = { + LEAD: 'aa96f572-b480-4623-ae9c-9ce0c44b8626', +} as const; + +// Onboard activity uid. The trigger writes welcome_email_status + policy_doc +// onto the lead record (read from the recordview). onboarding_notes stays a +// `local` field — it persists only in the activity submission, surfaced via the +// audit feed, where Customer 360 reads it. (welcome_pack_sent is dead.) +export const ONBOARD_ACTIVITY_UID = 'ae415f4e-a33e-432e-882f-733238de8bcc'; + +// Activity uids + their field ids (data{} keys). From introspect. +export const ACTIVITIES = { + RECOMMEND_PRODUCT: { + uid: 'c0e2004e-2d70-45f2-9cd5-734331010906', + fields: { + product: 'recommended_product_input', // lookup → tbl_products (see PRODUCT_LOOKUP) + sumAssured: 'sum_assured_input', // number + premiumAmount: 'premium_amount_input', // number + premiumFrequency: 'premium_frequency_input', // select + riders: 'riders_input', // multiselect + notes: 'recommendation_notes', // longtext + saValid: 'sa_valid_input', // boolean — computed (see lib/recommend.ts); gates Product Recommended + }, + }, + COLLECT_DOCUMENTS: { + uid: 'a8eb0faa-17f9-4491-a161-bd6b533adfd1', + fields: { + panOcr: 'pan_ocr_2', // ocr + aadhaarOcr: 'aadhar_ocr_2', // ocr + salarySlip: 'salary_slip_doc_input', // file + aadhaarNumber: 'aadhaar_number_input', // text + panNumber: 'pan_number_input', // text + verifiedIncome: 'verified_income_input', // number + docStatus: 'doc_status_input', // select (req) + }, + }, + SUBMIT_UNDERWRITING: { + uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730', + fields: { + status: 'underwriting_status_input', // select (req) — the verdict + ref: 'underwriting_ref_input', // id_gen (backend-generated UW-YYYY-####; do NOT send) + }, + }, + ISSUE_POLICY: { + uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489', + fields: { + issueDate: 'policy_issue_date_input', // date (req) + term: 'policy_term_input', // number (years) + maturity: 'maturity_date_input', // date (computed = issue + term) + premium: 'premium_amount_confirm', // number + number: 'policy_number_input', // id_gen (backend-generated POL-YYYY-####; do NOT send) + }, + }, +} as const; + +// Select options + lookup templates are no longer hardcoded here — screens +// read them live from the form schema (api/schema.ts → fieldFromSchema). + +// Canonical ordered lifecycle — matches live `current_state_name` 1:1 so a +// state name maps straight to a stepper/kanban index. Terminal "Disqualified" +// is handled out-of-band (STAGE_OF_STATE returns -1). +export const STAGES = [ + 'New Lead', + 'Qualified', + 'Assigned', + 'Contacted', + 'Meeting Scheduled', + 'Product Recommended', + 'Documents Collected', + 'Eligibility Assessed', + 'Underwriting', + 'Policy Issued', + 'Customer 360', +] as const; + +export function stageOfState(stateName?: string): number { + if (!stateName) return 0; + const i = STAGES.indexOf(stateName as (typeof STAGES)[number]); + return i; // -1 for Disqualified / unknown +} + +// UID -> human name maps, for rendering the audit trail (which stores raw +// activity + state UIDs). +export const STATE_NAME_BY_UID: Record = { + 'e469b82f-924e-47d9-9a9f-65b23d270047': 'New Lead', + '41c7a19f-f2fa-4b54-9dd5-310ae982d6c6': 'Qualified', + '8f038dc4-81b7-48c6-ac5b-b28c42b83214': 'Assigned', + 'f2aa6dbd-2149-455a-9466-58bd5beb6997': 'Contacted', + 'd5a2e33a-d2fe-4bfb-816f-61e064b77e97': 'Meeting Scheduled', + '2f33444a-1af0-4fa0-9461-eb8fc79ec175': 'Product Recommended', + 'a0d60916-c332-4d0a-8d02-501e22616b8f': 'Documents Collected', + '0a2a0a66-129a-4b2a-8e4e-1defc5ef85f6': 'Eligibility Assessed', + 'f8158695-5ac7-4e3a-9eda-bbab3fd8bbb3': 'Underwriting', + 'c964fe5e-c2a8-4d30-825c-fefa97a74368': 'Policy Issued', + '3d783ea2-b3d0-4eca-9424-9f8451c5d986': 'Customer 360', + '0ce7794e-30ba-4f09-b81e-26d5c9fec5b7': 'Disqualified', +}; + +export const ACTIVITY_NAME_BY_UID: Record = { + 'cc1a73d5-61e1-4416-af30-2b1eeb623a58': 'Capture Lead', + '6b741e44-37f2-4bb0-90ab-ed139f03b7b3': 'Qualify Lead', + 'da61aaa1-003b-4601-9f42-f93ff230497f': 'Assign Lead', + 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2': 'Log First Contact', + 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4': 'Schedule Meeting', + 'c0e2004e-2d70-45f2-9cd5-734331010906': 'Recommend Product', + 'a8eb0faa-17f9-4491-a161-bd6b533adfd1': 'Collect Documents', + 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a': 'Assess Eligibility', + 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730': 'Submit Underwriting', + '7b8fb734-a780-4c6a-837b-c5e7ca28e489': 'Issue Policy', + 'ae415f4e-a33e-432e-882f-733238de8bcc': 'Onboard Customer', + 'ea99fc60-caba-45c1-afc1-7386a2fa9220': 'Disqualify', +}; + +// AI employees (user_id -> identity). Used to label the decision stream and +// to attribute the "owner" of an in-flight lead. +export const AI_EMPLOYEES: Record = { + '29180': { name: 'Aria', role: 'Lead Qualifier' }, + '29181': { name: 'Aria Engage', role: 'Calls & Scheduling' }, + '29182': { name: 'Aria Underwrite', role: 'Underwriting' }, + '29188': { name: 'Aria Advisor', role: 'Product Recommendation' }, +}; + +// Aria Advisor's internal recommendation activity. It fires from the Schedule +// Meeting trigger (after the meeting is booked) and records the product +// recommendation as a decision — surfaced in the Recommend Product form. Its +// data{} carries the ai_* fields below. +export const AI_RECOMMENDATION = { + activityUid: '14d734da-f1e2-49af-9715-2146d26c0e1a', + aiUserId: '29188', + fields: { + product: 'ai_recommended_product_input', // product name (string) + sumAssured: 'ai_suggested_sum_assured_input', // number + rationale: 'ai_recommendation_rationale_input', // text + confidence: 'ai_recommendation_confidence_input', // 0–100 + }, +} as const; + +// Which AI employee is driving a given state (for owner attribution when no +// human sales agent is set). +export function aiEmployeeForState(stateName?: string): { name: string; role: string } | null { + switch (stateName) { + case 'New Lead': + return AI_EMPLOYEES['29180']; + case 'Assigned': + case 'Contacted': + case 'Meeting Scheduled': + case 'Product Recommended': + return AI_EMPLOYEES['29181']; + case 'Documents Collected': + case 'Eligibility Assessed': + return AI_EMPLOYEES['29182']; + default: + return null; + } +} + +// --- Screen RBAC (mirrors tbl_appconfig_screens.permissions for app 385) --- +// Roles that bypass screen gating entirely (platform admins see every screen). +export const SUPER_ROLES = ['Super Admin', 'Admin']; + +// Route path → roles allowed to see the screen. A path absent here is open to +// all authenticated users (e.g. the contextual Lead 360 detail). `/schedule` +// is an Aria-only screen — managers oversee agent meetings, agents see theirs. +export const SCREEN_ACCESS: Record = { + '/pipeline': ['Sales Agent', 'Underwriter', 'Branch Manager'], + '/qualify': ['Branch Manager'], + '/engage': ['Sales Agent'], + '/documents': ['Underwriter'], + '/underwriting': ['Underwriter', 'Branch Manager'], + '/schedule': ['Sales Agent', 'Branch Manager'], +}; + +/** Whether `roles` may see the screen at `path`. Super-admins always can; + * unlisted paths are open. */ +export function canSeeScreen(roles: string[] | undefined, path: string): boolean { + const allowed = SCREEN_ACCESS[path]; + if (!allowed) return true; + const r = roles ?? []; + if (r.some((role) => SUPER_ROLES.includes(role))) return true; + return r.some((role) => allowed.includes(role)); +} diff --git a/src/api/filters.ts b/src/api/filters.ts new file mode 100644 index 0000000..7a3484a --- /dev/null +++ b/src/api/filters.ts @@ -0,0 +1,118 @@ +// Client-side recordview filtering. The app loads the full lead recordview once +// (api/leads.tsx), so search + filters run over the in-memory rows rather than +// re-querying. Which fields are filterable/searchable comes from the live +// recordview config (`is_filter` / `is_search`); option lists are derived from +// the values actually present in the data (config carries no static options). +import type { LeadRecord, RecordViewField } from './types'; + +/** One field's active filter. `values` = set membership; min/max = number range; + * from/to = date range. Empty/absent on every key means "no filter". */ +export interface FieldFilter { + values?: string[]; + min?: number; + max?: number; + from?: string; + to?: string; +} + +export interface FilterState { + search: string; + byField: Record; +} + +export const EMPTY_FILTER: FilterState = { search: '', byField: {} }; + +export type FilterKind = 'set' | 'number' | 'date'; + +export function filterKind(dataType: string): FilterKind { + if (dataType === 'number') return 'number'; + if (dataType === 'date' || dataType === 'datetime') return 'date'; + return 'set'; +} + +export const filterableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_filter); +export const searchableFields = (fields: RecordViewField[]) => fields.filter((f) => f.is_search); + +// Flatten a record field to zero+ comparable strings. Several columns arrive as +// structured objects (owner_agent {name}, recommended_product {plan_name}, +// assigned_region {region}, phone {…}) or arrays (riders) — normalize them all. +export function fieldValues(record: LeadRecord, key: string): string[] { + return flatten(record[key]); +} + +function flatten(raw: unknown): string[] { + if (raw == null || raw === '') return []; + if (Array.isArray(raw)) return raw.flatMap(flatten); + if (typeof raw === 'object') { + const o = raw as Record; + const pick = + o.plan_name ?? o.name ?? o.region ?? o.label ?? o.value ?? o.phone_with_dial_code ?? o.phone; + return pick == null ? [] : [String(pick)]; + } + return [String(raw)]; +} + +/** Distinct present values for a field, sorted — the option list for a set filter. */ +export function distinctValues(records: LeadRecord[], key: string): string[] { + const set = new Set(); + for (const r of records) for (const v of fieldValues(r, key)) set.add(v); + return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); +} + +/** Title-case code-ish values (whatsapp / pending_docs); leave human text alone. */ +export function prettify(v: string): string { + if (/^[a-z0-9_-]+$/.test(v)) { + return v.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } + return v; +} + +export function isActive(f: FieldFilter | undefined): boolean { + if (!f) return false; + return !!f.values?.length || f.min != null || f.max != null || !!f.from || !!f.to; +} + +export function activeFilterCount(state: FilterState): number { + return Object.values(state.byField).filter(isActive).length; +} + +/** Apply search + per-field filters to the loaded rows. */ +export function applyFilters( + records: LeadRecord[], + state: FilterState, + fields: RecordViewField[], +): LeadRecord[] { + const q = state.search.trim().toLowerCase(); + const sFields = searchableFields(fields); + const dtByKey = new Map(fields.map((f) => [f.field_key, f.data_type])); + + return records.filter((r) => { + if (q) { + const hit = sFields.some((f) => fieldValues(r, f.field_key).some((v) => v.toLowerCase().includes(q))); + if (!hit) return false; + } + for (const [key, f] of Object.entries(state.byField)) { + if (!isActive(f)) continue; + const vals = fieldValues(r, key); + switch (filterKind(dtByKey.get(key) ?? 'text')) { + case 'set': + if (f.values?.length && !vals.some((v) => f.values!.includes(v))) return false; + break; + case 'number': { + const n = vals.length ? Number(vals[0]) : NaN; + if (f.min != null && (Number.isNaN(n) || n < f.min)) return false; + if (f.max != null && (Number.isNaN(n) || n > f.max)) return false; + break; + } + case 'date': { + const t = vals.length ? new Date(vals[0]).getTime() : NaN; + if (f.from && (Number.isNaN(t) || t < new Date(f.from).getTime())) return false; + // +1 day so an end date is inclusive of the whole day. + if (f.to && (Number.isNaN(t) || t > new Date(f.to).getTime() + 86_400_000)) return false; + break; + } + } + } + return true; + }); +} diff --git a/src/api/forms.ts b/src/api/forms.ts new file mode 100644 index 0000000..50dd905 --- /dev/null +++ b/src/api/forms.ts @@ -0,0 +1,189 @@ +// Activity metadata + the per-state "next action" map. Field schemas are no +// longer mirrored here — fetches them live from the form-screens +// endpoint (see api/schema.ts), so the form layout never drifts from config. + +export type FieldType = + | 'text' + | 'email' + | 'phone' + | 'number' + | 'date' + | 'datetime' + | 'longtext' + | 'select' + | 'multiselect' + | 'boolean' + | 'lookup' + | 'ocr' + | 'file'; + +export interface FieldOption { + value: string; + label: string; +} + +export interface OcrMapping { + extraction_key: string; + target_field: string; +} + +/** A renderable form field. Built at runtime by api/schema.ts from the live + * form-screens config; consumed by and the bespoke screens. */ +export interface FieldDef { + id: string; + label: string; + type: FieldType; + required?: boolean; + /** Designer `field_defaults[id].hidden` — render nothing, but still seed + + * submit its mapped value (used for filter-only inputs like assign_city). */ + hidden?: boolean; + /** Designer `field_defaults[id].disabled` — render read-only; the value is + * computed (e.g. premium_amount written by the custom_js). */ + disabled?: boolean; + /** Designer `field_defaults[id].value` — a server-supplied default to seed + * with (e.g. a hidden preferred_language defaulting to "english"). */ + prefill?: unknown; + options?: FieldOption[]; + /** Dataset-backed select: options are fetched live from /dataset-options + * (filter_options-aware) rather than `options`. See DatasetSelectField. */ + optionSource?: string; // 'dataset' | undefined (inline) + datasetUuid?: string; + displayKeys?: string[]; + valueKey?: string; + prefix?: string; + placeholder?: string; + /** Companion/record key to seed from (defaults to id minus the _input suffix). */ + seedKey?: string; + // lookup + lookupTemplate?: string; + lookupDisplay?: string[]; // columns shown in the label + lookupStorage?: string[]; // columns persisted on submit + // ocr + docLabel?: string; // "PAN" / "Aadhaar" + ocrMappings?: OcrMapping[]; +} + +/** Activity display copy + uid. Fields come from the schema, not from here. */ +export interface ActivityMeta { + uid: string; + name: string; + submitLabel: string; + /** Past-tense confirmation, e.g. "advanced to Qualified". */ + done: string; +} + +// Activity keys are stable, human-readable handles used by STATE_NEXT. +export const ACTIVITY_META = { + capture: { + uid: 'cc1a73d5-61e1-4416-af30-2b1eeb623a58', + name: 'Capture Lead', + submitLabel: 'Create lead', + done: 'created — added to the pipeline as a New Lead', + }, + qualify: { + uid: '6b741e44-37f2-4bb0-90ab-ed139f03b7b3', + name: 'Qualify Lead', + submitLabel: 'Qualify lead', + done: 'advanced to Qualified', + }, + assign: { + uid: 'da61aaa1-003b-4601-9f42-f93ff230497f', + name: 'Assign Lead', + submitLabel: 'Assign lead', + done: 'advanced to Assigned', + }, + contact: { + uid: 'fea6b3a8-846d-4f6f-8e92-033c4cf4b6e2', + name: 'Log First Contact', + submitLabel: 'Log contact', + done: 'advanced to Contacted', + }, + schedule: { + uid: 'c444d32f-ed6a-4e36-b8b7-b82e73a141d4', + name: 'Schedule Meeting', + submitLabel: 'Schedule meeting', + done: 'advanced to Meeting Scheduled', + }, + assess: { + uid: 'ba3d3e7f-0d3f-49f2-a156-6cace33c228a', + name: 'Assess Eligibility', + submitLabel: 'Submit assessment', + done: 'advanced to Eligibility Assessed', + }, + underwrite: { + uid: 'ed8ec0dc-4c2c-4d9e-b798-757f6a9d4730', + name: 'Submit Underwriting', + submitLabel: 'Submit underwriting', + done: 'advanced to Underwriting', + }, + issue: { + uid: '7b8fb734-a780-4c6a-837b-c5e7ca28e489', + name: 'Issue Policy', + submitLabel: 'Issue policy', + done: 'advanced to Policy Issued', + }, + onboard: { + uid: 'ae415f4e-a33e-432e-882f-733238de8bcc', + name: 'Onboard Customer', + submitLabel: 'Complete onboarding', + done: 'advanced to Customer 360', + }, + disqualify: { + uid: 'ea99fc60-caba-45c1-afc1-7386a2fa9220', + name: 'Disqualify', + submitLabel: 'Disqualify lead', + done: 'moved to Disqualified', + }, +} satisfies Record; + +export type ActivityKey = keyof typeof ACTIVITY_META; + +// OCR field configs for Collect Documents (extraction → mapped target inputs). +export const OCR_FIELDS: Record = { + pan: { + id: 'pan_ocr_2', + docLabel: 'PAN', + mappings: [{ extraction_key: 'pan_number', target_field: 'pan_number_input' }], + }, + aadhaar: { + id: 'aadhar_ocr_2', + docLabel: 'Aadhaar', + mappings: [{ extraction_key: 'aadhaar_number', target_field: 'aadhaar_number_input' }], + }, +}; + +// Per-state next action. `route` → dedicated rich screen; else inline form. +// Routed steps (recommend / documents / underwriting / issue) render their own +// screen, so `activity` is only set when the step renders an inline form. +export interface NextAction { + activity?: ActivityKey; + route?: string; + /** Button label for routed steps. */ + label?: string; +} + +export const STATE_NEXT: Record = { + 'New Lead': { activity: 'qualify' }, + Qualified: { activity: 'assign', route: '/assign', label: 'Assign lead' }, + Assigned: { activity: 'contact' }, + Contacted: { activity: 'schedule' }, + 'Meeting Scheduled': { route: '/recommend', label: 'Recommend product' }, + 'Product Recommended': { route: '/documents', label: 'Collect documents' }, + 'Documents Collected': { activity: 'assess' }, + 'Eligibility Assessed': { route: '/underwriting', label: 'Submit underwriting' }, + Underwriting: { route: '/issue', label: 'Issue policy' }, + 'Policy Issued': { activity: 'onboard' }, +}; + +// States from which Disqualify is a valid (secondary) action. +export const DISQUALIFY_STATES = new Set([ + 'New Lead', + 'Qualified', + 'Assigned', + 'Contacted', + 'Meeting Scheduled', + 'Product Recommended', + 'Documents Collected', + 'Eligibility Assessed', + 'Underwriting', +]); diff --git a/src/api/leads.tsx b/src/api/leads.tsx new file mode 100644 index 0000000..da3c865 --- /dev/null +++ b/src/api/leads.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useMemo } from 'react'; +import type { ReactNode } from 'react'; +import type { Lead } from '../types'; +import { useQuery, useZino } from './provider'; +import { recordToLead } from './adapters'; +import { RECORD_VIEW_IDS } from './config'; +import type { LeadRecord, RecordViewField } from './types'; + +interface LeadsContextValue { + /** Domain-mapped leads (pipeline list, roster, KPIs). */ + leads: Lead[]; + /** Raw recordview rows — action screens read region/income/etc. from these. */ + records: LeadRecord[]; + /** Recordview field config — drives the filter/search controls. */ + fields: RecordViewField[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +const LeadsContext = createContext(null); + +/** + * Fetch the full lead recordview once and share it. Both the sidebar roster + * (AI-employee counts + pipeline subtitle) and the pipeline screen read from + * here, so the list loads with a single request. + */ +export function LeadsProvider({ children }: { children: ReactNode }) { + const { client } = useZino(); + const { data, loading, error, refetch } = useQuery( + () => client.recordView(RECORD_VIEW_IDS.ALL_LEADS, { limit: 200, sortBy: 'updated_at', sortDir: 'desc' }), + [], + ); + + const records = useMemo(() => (data?.data ?? []) as LeadRecord[], [data]); + const fields = useMemo(() => data?.config?.fields ?? [], [data]); + const leads = useMemo(() => records.map(recordToLead), [records]); + const value = useMemo( + () => ({ leads, records, fields, loading, error, refetch }), + [leads, records, fields, loading, error, refetch], + ); + + return {children}; +} + +export function useLeads(): LeadsContextValue { + const ctx = useContext(LeadsContext); + if (!ctx) throw new Error('useLeads must be used within '); + return ctx; +} diff --git a/src/api/meetings.ts b/src/api/meetings.ts new file mode 100644 index 0000000..55d814a --- /dev/null +++ b/src/api/meetings.ts @@ -0,0 +1,79 @@ +// Derive a flat meeting/booking list from the shared lead recordview. +// One lead row carries at most one meeting (meeting_datetime + mode + address), +// owned by owner_agent. No separate agent/meeting table exists — the agent +// roster is whatever owner_agent values the leads carry. +import { useMemo } from 'react'; +import { useLeads } from './leads'; +import type { LeadRecord } from './types'; + +export interface Meeting { + leadId: string; + leadName: string; + /** Normalized agent display name, or 'Unassigned'. */ + agent: string; + agentId: number | null; + /** ISO datetime of the slot. */ + datetime: string; + /** Raw mode key (video | phone | branch | home_visit | …). */ + mode: string; + /** Zoom link / branch name / address — may be empty. */ + address?: string; + /** current_state_name — where the lead sits in the lifecycle. */ + stage: string; + interest?: string; +} + +const UNASSIGNED = 'Unassigned'; + +// owner_agent arrives as {id,name} (clean lookup rows), a bare string (legacy / +// test rows), or null. Flatten to a stable {name,id}. +function normAgent(raw: unknown): { name: string; id: number | null } { + if (raw && typeof raw === 'object') { + const o = raw as { id?: number; name?: string }; + const name = (o.name ?? '').trim(); + return { name: name || UNASSIGNED, id: typeof o.id === 'number' ? o.id : null }; + } + if (typeof raw === 'string') { + const name = raw.trim(); + return { name: name || UNASSIGNED, id: null }; + } + return { name: UNASSIGNED, id: null }; +} + +function recordToMeeting(r: LeadRecord): Meeting | null { + if (!r.meeting_datetime) return null; + const dt = new Date(r.meeting_datetime); + if (Number.isNaN(dt.getTime())) return null; + const agent = normAgent(r.owner_agent); + const address = typeof r.meeting_address === 'string' ? r.meeting_address.trim() : ''; + return { + leadId: String(r.instance_id), + leadName: r.lead_name ?? `Lead ${r.instance_id}`, + agent: agent.name, + agentId: agent.id, + datetime: r.meeting_datetime, + mode: (r.meeting_mode ?? '').toLowerCase(), + address: address || undefined, + stage: r.current_state_name ?? '—', + interest: r.product_interest ?? undefined, + }; +} + +export function useMeetings(): { meetings: Meeting[]; agents: string[]; loading: boolean; error: string | null } { + const { records, loading, error } = useLeads(); + return useMemo(() => { + const meetings = records + .map(recordToMeeting) + .filter((m): m is Meeting => m !== null) + .sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime()); + // Roster ordered by meeting load, Unassigned always last. + const counts = new Map(); + for (const m of meetings) counts.set(m.agent, (counts.get(m.agent) ?? 0) + 1); + const agents = [...counts.keys()].sort((a, b) => { + if (a === UNASSIGNED) return 1; + if (b === UNASSIGNED) return -1; + return (counts.get(b) ?? 0) - (counts.get(a) ?? 0); + }); + return { meetings, agents, loading, error }; + }, [records, loading, error]); +} diff --git a/src/api/provider.tsx b/src/api/provider.tsx new file mode 100644 index 0000000..9ae3150 --- /dev/null +++ b/src/api/provider.tsx @@ -0,0 +1,127 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { ReactNode } from 'react'; +import { ZinoClient } from './client'; +import type { User } from './types'; + +interface ZinoContextValue { + client: ZinoClient; + user: User | null; + login: (email: string, password: string, orgId?: string) => Promise; + logout: () => void; +} + +const ZinoContext = createContext(null); + +export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { + // One client per provider instance. onAuthError clears the user so routes + // bounce back to /login. + const clientRef = useRef(); + if (!clientRef.current) { + clientRef.current = new ZinoClient(baseUrl); + } + const client = clientRef.current; + + // Restore the session synchronously from the persisted JWT so a hard + // navigation to a protected route doesn't flash through /login. + const [user, setUser] = useState(() => client.currentUser()); + + useEffect(() => { + client.setAuthErrorHandler(() => setUser(null)); + }, [client]); + + const value = useMemo( + () => ({ + client, + user, + login: async (email, password, orgId) => { + const res = await client.login(email, password, orgId); + setUser(res.user ?? client.currentUser()); + }, + logout: () => { + client.logout(); + setUser(null); + }, + }), + [client, user], + ); + + return {children}; +} + +export function useZino(): ZinoContextValue { + const ctx = useContext(ZinoContext); + if (!ctx) throw new Error('useZino must be used within '); + return ctx; +} + +// --- minimal data-fetching hook (no extra deps) --- + +export interface QueryState { + data: T | null; + loading: boolean; + error: string | null; + refetch: () => void; +} + +/** + * Fire `fn` whenever a dependency in `deps` changes. Tracks loading/error and + * ignores results from a stale call. `enabled=false` short-circuits. + * `refetchMs` (opt-in) polls on that interval; the interval is cleared on + * unmount, dep change, or when disabled. + */ +export function useQuery( + fn: () => Promise, + deps: unknown[], + enabled = true, + refetchMs?: number, +): QueryState { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(enabled); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); + + const refetch = useCallback(() => setTick((t) => t + 1), []); + + useEffect(() => { + if (!enabled) { + setLoading(false); + return; + } + let live = true; + setLoading(true); + setError(null); + fn() + .then((d) => { + if (live) setData(d); + }) + .catch((e: unknown) => { + if (live) setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed')); + }) + .finally(() => { + if (live) setLoading(false); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, tick, enabled]); + + // Opt-in background polling. Kept separate from the fetch effect so the + // interval doesn't restart on every refetch; ticking triggers it instead. + useEffect(() => { + if (!enabled || !refetchMs || refetchMs <= 0) return; + const id = setInterval(() => setTick((t) => t + 1), refetchMs); + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...deps, enabled, refetchMs]); + + return { data, loading, error, refetch }; +} diff --git a/src/api/schema.ts b/src/api/schema.ts new file mode 100644 index 0000000..2d15b49 --- /dev/null +++ b/src/api/schema.ts @@ -0,0 +1,104 @@ +// Maps a live form-screens response onto aria's FieldDef shape, so forms render +// from config instead of hand-mirrored defs. The designer's `grid_config` +// decides which fields are user-facing; `fields[]` is the metadata pool. + +import type { FieldDef, FieldType } from './forms'; +import type { FormScreenField, FormScreenResponse } from './types'; + +const TYPE_MAP: Record = { + text: 'text', + email: 'email', + phone: 'phone', + number: 'number', + date: 'date', + datetime: 'datetime', + longtext: 'longtext', + select: 'select', + multiselect: 'multiselect', + boolean: 'boolean', + lookup: 'lookup', + ocr: 'ocr', + file: 'file', +}; + +/** One server field → FieldDef, or null if it's not a user input (id_gen, + * disabled/computed, or an unknown type). */ +function toFieldDef(f: FormScreenField): FieldDef | null { + if (f.properties?.disabled) return null; // server-generated (id_gen) + const type = TYPE_MAP[f.data_type]; + if (!type) return null; // id_gen + anything unmapped + const p = f.properties ?? {}; + + const def: FieldDef = { + id: f.id, + label: f.name, + type, + required: !!f.mandatory, + seedKey: f.mapped_workflow_field ?? f.id.replace(/_input$/, ''), + }; + + if (type === 'select' || type === 'multiselect') { + if (p.option_source === 'dataset' && p.dataset_uuid) { + // Dataset-backed: options fetched live (filter_options-aware) at render. + def.optionSource = 'dataset'; + def.datasetUuid = p.dataset_uuid; + def.displayKeys = p.display_keys ?? []; + def.valueKey = p.value_key ?? ''; + def.options = []; + } else { + def.options = p.options ?? []; + } + } + if (type === 'lookup' && p.lookup_config) { + def.lookupTemplate = p.lookup_config.rdbms_template_uuid; + def.lookupDisplay = (p.lookup_config.display_columns ?? []).map((c) => c.name); + def.lookupStorage = (p.lookup_config.storage_columns ?? []).map((c) => c.name); + } + if (type === 'ocr' && p.ocr_config) { + def.docLabel = p.ocr_config.template ?? f.name; + def.ocrMappings = p.ocr_config.field_mappings ?? []; + } + return def; +} + +/** User-facing fields for a form, in the designer's layout order. */ +export function schemaToFields(resp: FormScreenResponse): FieldDef[] { + const byUid = new Map(resp.fields.map((f) => [f.uid, f])); + const grid = (resp.grid_config ?? []).filter((g) => g.type === 'field'); + + const ordered: FormScreenField[] = grid.length + ? grid + .slice() + .sort((a, b) => a.y - b.y || a.x - b.x) + .map((g) => byUid.get(g.fieldUid)) + .filter((f): f is FormScreenField => !!f) + : resp.fields; + + return ordered + .map(toFieldDef) + .filter((d): d is FieldDef => d !== null) + .map((d) => ({ + ...d, + hidden: !!resp.field_defaults?.[d.id]?.hidden, + disabled: !!resp.field_defaults?.[d.id]?.disabled, + prefill: resp.field_defaults?.[d.id]?.value, + })); +} + +/** Single field's def by id — for bespoke screens that need one field's + * lookup template / options / mapping without rendering the whole form. */ +export function fieldFromSchema( + resp: FormScreenResponse | null | undefined, + id: string, +): FieldDef | undefined { + const f = resp?.fields.find((x) => x.id === id); + if (!f) return undefined; + const def = toFieldDef(f); + if (!def) return undefined; + return { + ...def, + hidden: !!resp?.field_defaults?.[id]?.hidden, + disabled: !!resp?.field_defaults?.[id]?.disabled, + prefill: resp?.field_defaults?.[id]?.value, + }; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..391005b --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,261 @@ +// Live API shapes + the SDK's domain types. + +export interface ApiError { + status: number; + message: string; +} + +export interface User { + id: string; + org_id: string; + name: string; + email: string; + roles: string[]; + groups: string[]; +} + +export interface LoginResponse { + token: string; + user: User; +} + +// --- Record view (POST /app/{appId}/view/recordview) --- + +export interface RecordViewField { + field_key: string; + output_label: string; + data_type: string; + is_filter: boolean; + is_search: boolean; +} + +export interface RecordViewResponse { + config: { fields: RecordViewField[] }; + data: Record[]; + pagination?: { page: number; limit: number; total_count: number; total_pages: number }; +} + +export interface RecordViewParams { + page?: number; + limit?: number; + sortBy?: string; + sortDir?: 'asc' | 'desc'; + search?: string; + filters?: Array<{ field_key: string; value: string; data_type?: string }>; +} + +// --- Form schema (POST /app/{appId}/view/form-screens) --- +// The live activity form config. `fields` is the metadata pool; `grid_config` +// is the designer's layout and decides which fields are user-facing. + +export interface FormScreenFieldProps { + options?: Array<{ label: string; value: string }>; + /** Dataset-backed select/multiselect: options come live from + * /dataset-options (filter_options-aware), not the static `options` above. + * `display_keys` are joined for the label; `value_key` is the stored value. */ + option_source?: string; // 'dataset' | (else inline) + dataset_uuid?: string; + display_keys?: string[]; + value_key?: string; + lookup_config?: { + rdbms_template_uuid: string; + display_columns?: Array<{ name: string }>; + storage_columns?: Array<{ name: string }>; + }; + ocr_config?: { + template?: string; + field_mappings?: Array<{ target_field: string; extraction_key: string }>; + }; + source?: string; + disabled?: boolean; +} + +export interface FormScreenField { + id: string; + uid: string; + name: string; + data_type: string; + mandatory?: boolean; + /** Companion/record column this field writes — the canonical seed key. */ + mapped_workflow_field?: string; + type?: string; // 'mapped' | 'local' + properties?: FormScreenFieldProps | null; +} + +export interface FormScreenGridItem { + type: string; // 'field' | ... + fieldUid: string; + x: number; + y: number; +} + +/** One condition of a field_rule filter_config. The lookup row's + * `target_column` is compared (currently only `equals`) against the current + * value of `form_field`. When the source field is itself a lookup/object, + * `source_value_key` names the sub-key to read off it (e.g. a region lookup's + * "region" column); empty means use the form value directly. */ +export interface FieldRuleCondition { + operator: string; + form_field: string; + target_column: string; + source_value_key?: string; +} + +/** A designer field_rule. We honour `action: "filter_options"`, which scopes a + * target field's options/lookup rows by another field's current value. */ +export interface FieldRule { + id: string; + action: string; + active?: boolean; + target_field: string; + filter_config?: { + logic?: string; // "AND" | "OR" + conditions?: FieldRuleCondition[]; + } | null; +} + +/** Per-field designer defaults, keyed by field id. `hidden` drops the field + * from the form UI while its mapped value is still seeded + submitted. */ +export interface FieldDefault { + hidden?: boolean; + /** Read-only in the form — value is computed (e.g. premium_amount by custom_js). */ + disabled?: boolean; + value?: unknown; +} + +export interface FormScreenResponse { + activity_uid: string; + activity_name: string; + fields: FormScreenField[]; + grid_config?: FormScreenGridItem[]; + /** Designer field_rules (cascading filter_options etc.). */ + field_rules?: FieldRule[]; + /** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */ + field_defaults?: Record; +} + +// --- Audit (GET /app/{appId}/view/audit) --- + +export interface AuditEntry { + id: number; + user_id: string; + user_roles: string[]; + activity_id: string; + data: Record; + execution_state?: string; + created_at: string; +} + +// --- AI decisions (GET /monitor/decisions) --- + +export interface AiDecision { + id: number; + ai_user_id: string; + instance_id: string; + activity_id: string; + activity_name?: string; + /** Server-resolved display name for the AI employee (preferred over the + * static AI_EMPLOYEES map). */ + employee_name?: string; + reasoning: string; + confidence: number; + status: string; + instance_state?: string; + knowledge_sources?: unknown; + tool_calls?: unknown; + /** The activity payload the AI employee submitted — e.g. the Aria Advisor + * product recommendation carries ai_recommended_product_input / + * ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */ + data?: Record; + created_at: string; +} + +// --- Workflow execution (core) --- + +export interface ActivityResult { + success?: boolean; + instance_id?: number | string; + data?: Record; + message?: string; + status_code?: number; + error?: string; +} + +// Generated policy schedule .docx, written by the Onboard trigger (policy_doc +// is a jsonb array on the lead record). +export interface PolicyDoc { + url: string; + blob_path?: string; + mime_type?: string; + size_bytes?: number; + original_name?: string; +} + +// Several recordview fields come back as structured objects, not scalars. +// Normalize via the helpers in adapters.ts before rendering/seeding. +export interface PhoneValue { + dial_code?: string; + phone?: string; + phone_with_dial_code?: string; +} + +export interface RegionValue { + region?: string; +} + +// `recommended_product` is a lookup into the product catalog (plan_name is the +// display column); `owner_agent` is a {id,name} lookup. +export interface ProductLookup { + id?: number; + plan_name?: string; + [key: string]: unknown; +} + +// A row from the all-leads record view, typed for what we read. +export interface LeadRecord { + instance_id: number; + current_state_name?: string | null; + lead_name?: string | null; + phone?: string | PhoneValue | null; + email?: string | null; + city?: string | null; + state_region?: string | null; + date_of_birth?: string | null; + annual_income?: number | null; + occupation?: string | null; + preferred_language?: string | null; + product_interest?: string | null; + lead_score?: number | null; + lead_segment?: string | null; + channel_source?: string | null; + owner_agent?: { id: number; name: string } | null; + assigned_region?: string | RegionValue | null; + sum_assured?: number | null; + premium_amount?: number | null; + premium_frequency?: string | null; + recommended_product?: string | ProductLookup | null; + riders?: string | string[] | null; + recommendation_notes?: string | null; + meeting_datetime?: string | null; + meeting_mode?: string | null; + doc_status?: string | null; + eligibility_status?: string | null; + eligibility_notes?: string | null; + verified_income?: number | null; + underwriting_ref?: string | null; + underwriting_status?: string | null; + pan_number?: string | null; + aadhaar_number?: string | null; + policy_number?: string | null; + policy_issue_date?: string | null; + policy_term?: number | null; + maturity_date?: string | null; + // Written by the Onboard trigger (5d), read off the lead record. + welcome_email_status?: string | null; + policy_doc?: PolicyDoc[] | null; + // Recordview system columns — last-activity / creation time, used to sort + // worklists latest-first. + created_at?: string | null; + updated_at?: string | null; + [key: string]: unknown; +} diff --git a/src/components/LeadFilters.tsx b/src/components/LeadFilters.tsx new file mode 100644 index 0000000..b19d74f --- /dev/null +++ b/src/components/LeadFilters.tsx @@ -0,0 +1,209 @@ +// Recordview filter toolbar: an inline search box + a "Filters" modal driven by +// the recordview's `is_filter` / `is_search` config. Drops in above any lead +// table (Pipeline, the worklists). Owns its modal; filtering itself is the pure +// applyFilters() in api/filters.ts, so the parent stays in control of the state. +import { useMemo, useState } from 'react'; +import { Search, SlidersHorizontal, X } from 'lucide-react'; +import { Button, Input, Modal, MultiSelect } from './core'; +import type { LeadRecord, RecordViewField } from '../api/types'; +import { + activeFilterCount, + distinctValues, + filterableFields, + filterKind, + isActive, + prettify, + searchableFields, + type FieldFilter, + type FilterState, +} from '../api/filters'; + +export interface LeadFiltersProps { + /** Unfiltered rows — option lists are derived from these. */ + records: LeadRecord[]; + fields: RecordViewField[]; + value: FilterState; + onChange: (next: FilterState) => void; + /** Field keys to omit (e.g. current_state_name on a state-scoped worklist). */ + excludeKeys?: string[]; +} + +export function LeadFilters({ records, fields, value, onChange, excludeKeys = [] }: LeadFiltersProps) { + const [open, setOpen] = useState(false); + + const searchFields = useMemo(() => searchableFields(fields), [fields]); + // Only surface filter fields that actually have values to filter on. + const fFields = useMemo( + () => + filterableFields(fields) + .filter((f) => !excludeKeys.includes(f.field_key)) + .map((f) => ({ field: f, options: filterKind(f.data_type) === 'set' ? distinctValues(records, f.field_key) : [] })) + .filter(({ field, options }) => filterKind(field.data_type) !== 'set' || options.length > 0), + [fields, records, excludeKeys], + ); + + const activeCount = activeFilterCount({ ...value, byField: pruneExcluded(value.byField, excludeKeys) }); + const searchLabel = searchFields.map((f) => f.output_label).join(', ') || 'leads'; + + if (!searchFields.length && !fFields.length) return null; + + const setField = (key: string, next: FieldFilter) => + onChange({ ...value, byField: { ...value.byField, [key]: next } }); + const clearField = (key: string) => { + const rest = { ...value.byField }; + delete rest[key]; + onChange({ ...value, byField: rest }); + }; + const clearAll = () => onChange({ search: '', byField: {} }); + + return ( +
+ {searchFields.length > 0 && ( + onChange({ ...value, search: e.target.value })} + placeholder={`Search ${searchLabel}…`} + prefix={} + className="w-full max-w-[280px]" + /> + )} + + {fFields.length > 0 && ( + + )} + + {(activeCount > 0 || value.search) && ( + + )} + + {/* Active-filter chips */} + {fFields.map(({ field }) => { + const f = value.byField[field.field_key]; + if (!isActive(f)) return null; + return ( + + {field.output_label}: + {summarize(f, field.data_type)} + + + ); + })} + + setOpen(false)} title="Filter leads" width="md"> +
+ {fFields.map(({ field, options }) => { + const kind = filterKind(field.data_type); + const f = value.byField[field.field_key] ?? {}; + if (kind === 'set') { + return ( + ({ value: v, label: prettify(v) }))} + value={f.values ?? []} + onChange={(vals) => setField(field.field_key, { values: vals })} + /> + ); + } + if (kind === 'number') { + return ( +
+ {field.output_label} +
+ setField(field.field_key, { ...f, min: numOrUndef(e.target.value) })} + /> + + setField(field.field_key, { ...f, max: numOrUndef(e.target.value) })} + /> +
+
+ ); + } + return ( +
+ {field.output_label} +
+ setField(field.field_key, { ...f, from: e.target.value || undefined })} + /> + + setField(field.field_key, { ...f, to: e.target.value || undefined })} + /> +
+
+ ); + })} +
+ +
+ + +
+
+
+ ); +} + +function numOrUndef(s: string): number | undefined { + if (s === '') return undefined; + const n = Number(s); + return Number.isNaN(n) ? undefined : n; +} + +function pruneExcluded(byField: Record, exclude: string[]): Record { + if (!exclude.length) return byField; + const out: Record = {}; + for (const [k, v] of Object.entries(byField)) if (!exclude.includes(k)) out[k] = v; + return out; +} + +function summarize(f: FieldFilter, dataType: string): string { + if (filterKind(dataType) === 'set') { + const vs = f.values ?? []; + return vs.length <= 2 ? vs.map(prettify).join(', ') : `${vs.length} selected`; + } + if (filterKind(dataType) === 'number') { + return `${f.min ?? '…'} – ${f.max ?? '…'}`; + } + return `${f.from ?? '…'} – ${f.to ?? '…'}`; +} diff --git a/src/components/ZinoLogo.tsx b/src/components/ZinoLogo.tsx new file mode 100644 index 0000000..17b6fe5 --- /dev/null +++ b/src/components/ZinoLogo.tsx @@ -0,0 +1,22 @@ +// Zino wordmark (copied from sm2 renderer assets/zino-logo.svg). Inlined as a +// component so it works under both the dev root and the /lead-to-policy/ build +// base without an asset-path dance. Brand orange (#ed563b) by default; pass a +// className with a `text-*` colour + `fill-current` to recolour. +export function ZinoLogo({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/src/components/activities/ActivityActions.tsx b/src/components/activities/ActivityActions.tsx new file mode 100644 index 0000000..3f8ee8e --- /dev/null +++ b/src/components/activities/ActivityActions.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { Button } from '../core/Button'; +import type { ButtonSize, ButtonVariant } from '../core/Button'; +import { Modal } from '../core/Modal'; +import { ActivityForm } from '../form/ActivityForm'; +import { DISQUALIFY_STATES } from '../../api/forms'; +import type { LeadRecord } from '../../api/types'; +import { STEP_BY_STATE, DISQUALIFY_STEP, CAPTURE_STEP, type ActivityStep } from './registry'; + +// A button that opens its step's form in a modal for one lead. Bespoke steps +// render their *Body; generic steps render from the live schema. +function StepButton({ + step, + record, + onDone, + variant, + size = 'sm', +}: { + step: ActivityStep; + record: LeadRecord; + onDone: () => void; + variant: ButtonVariant; + size?: ButtonSize; +}) { + const [open, setOpen] = useState(false); + const subtitle = `${record.lead_name ?? `Lead ${record.instance_id}`} · #${record.instance_id} · ${record.current_state_name ?? ''}`; + + return ( + <> + + {open && ( + setOpen(false)} title={step.title} subtitle={subtitle} width={step.width}> + {step.Body ? ( + { + setOpen(false); + onDone(); + }} + /> + ) : ( + { + setOpen(false); + onDone(); + }} + /> + )} + + )} + + ); +} + +/** The per-row action cell: the one primary activity for this lead's state, + * plus Disqualify wherever it's valid. */ +export function LeadActions({ record, onDone }: { record: LeadRecord; onDone: () => void }) { + const state = record.current_state_name ?? ''; + const primary = STEP_BY_STATE[state]; + const canDisqualify = DISQUALIFY_STATES.has(state); + + if (!primary && !canDisqualify) { + return ; + } + return ( +
+ {primary && } + {canDisqualify && } +
+ ); +} + +/** Toolbar "Add Lead" — Capture (INIT) submitted via /start, no instance yet. */ +export function AddLeadButton({ onDone }: { onDone: () => void }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} title={CAPTURE_STEP.title} width={CAPTURE_STEP.width}> + { + setOpen(false); + onDone(); + }} + /> + + )} + + ); +} diff --git a/src/components/activities/AssignBody.tsx b/src/components/activities/AssignBody.tsx new file mode 100644 index 0000000..0277c94 --- /dev/null +++ b/src/components/activities/AssignBody.tsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CheckCircle2, UserCheck } from 'lucide-react'; +import { AiSuggestionPanel, Button, Card } from '../'; +import { DatasetSelectField, LookupField, SchemaGuard } from '../form'; +import type { LookupValue } from '../form'; +import { SelectField } from '../core/SelectField'; +import { Input } from '../core/Input'; +import { useQuery, useZino } from '../../api/provider'; +import { decisionToCard } from '../../api/adapters'; +import { fieldFromSchema } from '../../api/schema'; +import { ACTIVITY_META } from '../../api/forms'; +import type { ActivityBodyProps } from './types'; + +const DEF = ACTIVITY_META.assign; + +export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps) { + const { client } = useZino(); + + const schemaQ = useQuery(() => client.formSchema(DEF.uid, instanceId), [instanceId]); + const CITY = useMemo(() => fieldFromSchema(schemaQ.data, 'assign_city'), [schemaQ.data]); + const REGION = useMemo(() => fieldFromSchema(schemaQ.data, 'assigned_region_input'), [schemaQ.data]); + const OWNER = useMemo(() => fieldFromSchema(schemaQ.data, 'owner_agent_input'), [schemaQ.data]); + + const [city, setCity] = useState(''); + const [region, setRegion] = useState(null); + const [agent, setAgent] = useState(null); + useEffect(() => { + if (!record) return; + setCity(String((record as Record).city ?? '')); + setRegion((record.assigned_region as LookupValue) ?? null); + setAgent((record.owner_agent as LookupValue) ?? null); + }, [record]); + + // Form values keyed exactly as the rules' `form_field`s reference them. + const formData: Record = { + assign_city: city, + assigned_region_input: region, + owner_agent_input: agent, + }; + + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function submit() { + if (!region) { + setResult({ ok: false, msg: 'Assigned region is required.' }); + return; + } + setSubmitting(true); + setResult(null); + try { + const res = await client.performActivity(instanceId, DEF.uid, { + assigned_region_input: region, + ...(agent ? { owner_agent_input: agent } : {}), + }); + setResult({ ok: true, msg: 'Lead assigned — advanced to Assigned.' }); + onSuccess?.(res); + } catch (e) { + setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); + } finally { + setSubmitting(false); + } + } + + return ( + +
+
+ {result && ( +
+ {result.ok && } + {result.msg} +
+ )} + + +
+ {/* assign_city is filter-only: when the designer marks it hidden + (field_defaults.assign_city.hidden), we don't render it but still + seed it from the lead's city and send it so region filters. */} + {CITY && + !CITY.hidden && + (() => { + // city drives the region list — drop stale region/agent picks on change. + const onCity = (v: string) => { + setCity(v); + setRegion(null); + setAgent(null); + }; + if (CITY.optionSource === 'dataset' && CITY.datasetUuid) { + return ( + + ); + } + return CITY.options?.length ? ( + + ) : ( + onCity(e.target.value)} + /> + ); + })()} + {REGION && ( + { + setRegion(v); + setAgent(null); // region drives the agent list — drop a stale pick + }} + formData={formData} + /> + )} + {OWNER && ( + + )} +
+
+ +
+
+
+ +
+
+
Assignment
+
+ + + +
+
+ {agent ? String(agent.name ?? '—') : 'No agent yet'} +
+
+ {agent?.region ? String(agent.region) : region?.region ? String(region.region) : 'Pick a region & agent'} +
+
+
+
+ + +
+
+
+ ); +} + +function AssignAiSuggestion({ instanceId }: { instanceId: number | string }) { + const { client } = useZino(); + const q = useQuery(() => client.aiDecisions(instanceId as number), [instanceId]); + const latest = (q.data ?? [])[0]; + if (!latest) return null; + return ; +} diff --git a/src/components/activities/DocumentsBody.tsx b/src/components/activities/DocumentsBody.tsx new file mode 100644 index 0000000..9796854 --- /dev/null +++ b/src/components/activities/DocumentsBody.tsx @@ -0,0 +1,414 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, CheckCircle2, FileText, Info, ShieldCheck, UploadCloud } from 'lucide-react'; +import { Avatar, Badge, Button, Card, DocumentUploadCard, formatINR, Input, SelectField } from '../'; +import type { BadgeTone, DocStatus, OcrField } from '../'; +import { SchemaGuard } from '../form'; +import { useQuery, useZino } from '../../api/provider'; +import { fieldFromSchema } from '../../api/schema'; +import { ACTIVITIES } from '../../api/config'; +import { OCR_FIELDS } from '../../api/forms'; +import { crossCheck, type DocExtract, type DocKey } from '../../lib/kyc-match'; +import type { ActivityBodyProps } from './types'; + +const ACT = ACTIVITIES.COLLECT_DOCUMENTS; +const F = ACT.fields; + +const STATUS_TONE: Record = { + pending: 'neutral', + verified: 'success', + mismatch: 'warning', + incomplete: 'info', +}; + +const DOC_LABEL: Record = { pan: 'PAN', aadhaar: 'Aadhaar' }; + +function mismatchLine(f: { doc: DocKey; field: 'name' | 'dob'; captured: string; ocr: string }): string { + const what = f.field === 'name' ? 'name' : 'DOB'; + const fmt = (v: string) => (f.field === 'name' ? `"${v || '—'}"` : v || '—'); + return `${DOC_LABEL[f.doc]} ${what} ${fmt(f.ocr)} ≠ captured ${fmt(f.captured)}`; +} + +function OcrCapture({ + ocr, + docKey, + activityId, + instanceId, + onAutofill, + onFile, + onExtract, +}: { + ocr: (typeof OCR_FIELDS)[keyof typeof OCR_FIELDS]; + docKey: DocKey; + activityId: string; + instanceId?: number | string; + onAutofill: (targetField: string, value: unknown) => void; + onFile: (file: File | null) => void; + onExtract: (doc: DocKey, ex: DocExtract) => void; +}) { + const { client } = useZino(); + const inputRef = useRef(null); + const [status, setStatus] = useState('empty'); + const [fileName, setFileName] = useState(null); + const [fields, setFields] = useState([]); + const [error, setError] = useState(null); + + function reset() { + setStatus('empty'); + setFileName(null); + setFields([]); + setError(null); + onFile(null); + onExtract(docKey, { name: null, dob: null }); // clear this doc's KYC cross-check + } + + async function handlePick(file: File) { + setFileName(file.name); + setStatus('processing'); + setError(null); + onFile(file); + try { + const res = await client.extractOcr(file, { activityId, fieldId: ocr.id, instanceId }); + const extracted = res.extracted ?? {}; + for (const m of ocr.mappings) { + if (Object.prototype.hasOwnProperty.call(extracted, m.extraction_key)) { + onAutofill(m.target_field, extracted[m.extraction_key]); + } + } + const asStr = (v: unknown) => (v == null ? null : String(v)); + onExtract(docKey, { name: asStr(extracted.full_name), dob: asStr(extracted.date_of_birth) }); + setFields( + Object.entries(extracted) + .filter(([, v]) => v !== null && v !== undefined && v !== '') + .map(([k, v]) => ({ label: k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), value: String(v), mono: /number|pan|aadhaar/i.test(k) })), + ); + setStatus('extracted'); + if (res.parse_error) setError(res.parse_error); + } catch (e) { + setStatus('empty'); + setError(e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'OCR failed')); + onFile(null); + } + } + + return ( +
+ { + const f = e.target.files?.[0]; + if (f) handlePick(f); + e.target.value = ''; + }} + /> + inputRef.current?.click()} + onConfirm={() => setStatus('verified')} + onReplace={() => inputRef.current?.click()} + onRemove={reset} + /> + {error &&
{error}
} +
+ ); +} + +export function DocumentsBody({ instanceId, record, onSuccess }: ActivityBodyProps) { + const { client } = useZino(); + + const schemaQ = useQuery(() => client.formSchema(ACT.uid, instanceId), [instanceId]); + const docStatusOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.docStatus)?.options ?? [], [schemaQ.data]); + + const [pan, setPan] = useState(''); + const [aadhaar, setAadhaar] = useState(''); + const [verifiedIncome, setVerifiedIncome] = useState(0); + const [docStatus, setDocStatus] = useState('verified'); + const [files, setFiles] = useState>({}); + const [ocrExtracts, setOcrExtracts] = useState>>({}); + const statusTouched = useRef(false); + + useEffect(() => { + if (!record) return; + setPan(record.pan_number || ''); + setAadhaar(record.aadhaar_number || ''); + setVerifiedIncome((record.verified_income as number) || record.annual_income || 0); + setDocStatus(record.doc_status || 'verified'); + setFiles({}); + setOcrExtracts({}); + statusTouched.current = false; + }, [record]); + + const setFile = (fieldId: string) => (f: File | null) => setFiles((p) => ({ ...p, [fieldId]: f })); + const applyExtract = (doc: DocKey, ex: DocExtract) => setOcrExtracts((p) => ({ ...p, [doc]: ex })); + + const kyc = useMemo( + () => crossCheck({ name: record?.lead_name, dob: record?.date_of_birth }, ocrExtracts), + [record, ocrExtracts], + ); + + useEffect(() => { + if (kyc.hasMismatch && !statusTouched.current) setDocStatus('mismatch'); + }, [kyc.hasMismatch]); + + const applyAutofill = (target: string, value: unknown) => { + const v = value == null ? '' : String(value); + if (target === F.panNumber) setPan(v.toUpperCase()); + else if (target === F.aadhaarNumber) setAadhaar(v.replace(/\s/g, '')); + }; + + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function submit() { + setSubmitting(true); + setResult(null); + const activityId = ACT.uid; + try { + const fileData: Record = {}; + for (const [fieldId, file] of Object.entries(files)) { + if (!file) continue; + const meta = await client.uploadFile(file, { activityId, fieldId, instanceId }); + fileData[fieldId] = [meta]; + } + const res = await client.performActivity(instanceId, activityId, { + [F.panNumber]: pan, + [F.aadhaarNumber]: aadhaar, + [F.verifiedIncome]: verifiedIncome, + [F.docStatus]: docStatus, + ...fileData, + }); + setResult({ ok: true, msg: 'Documents submitted — lead advanced to Documents Collected.' }); + onSuccess?.(res); + } catch (e) { + setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); + } finally { + setSubmitting(false); + } + } + + const checklist = [ + { doc: 'PAN number', done: !!pan }, + { doc: 'Aadhaar number', done: !!aadhaar }, + { doc: 'Verified income', done: verifiedIncome > 0 }, + { doc: 'Status confirmed', done: docStatus === 'verified' }, + ]; + const completion = Math.round((checklist.filter((c) => c.done).length / checklist.length) * 100); + + return ( + +
+
+
+ {record ? ( + <> + +
+
+ KYC for {record.lead_name ?? `Lead ${record.instance_id}`} · #{record.instance_id} +
+
Collected by Aria Underwrite · {record.current_state_name}
+
+ + ) : ( +
No lead record provided.
+ )} +
+ + {result && ( +
+ {result.ok && } + {result.msg} +
+ )} + + {kyc.hasMismatch ? ( +
+ +
+
Identity mismatch — review before advancing
+
    + {kyc.fields + .filter((f) => f.status === 'mismatch') + .map((f) => ( +
  • + {mismatchLine(f)} +
  • + ))} +
+
+ Document status set to "Mismatch". Override only once you've confirmed the documents belong to this lead. +
+
+
+ ) : kyc.anyChecked ? ( +
+ + OCR identity matches the captured lead (name + DOB). +
+ ) : null} + +
+ + +
+ + +
+ setPan(e.target.value.toUpperCase())} placeholder="ABCDE1234F" /> + setAadhaar(e.target.value)} placeholder="XXXX XXXX XXXX" /> + setVerifiedIncome(Number(e.target.value) || 0)} + /> + { + statusTouched.current = true; + setDocStatus(v as DocStatus); + }} + /> +
+ +
+ +
+ +
+ +
+ Upload PAN / Aadhaar to auto-extract the numbers via OCR, then confirm the values and the verification + status before advancing. Files upload on submit. +
+
+
+ +
+
+
+ +
+
+

Document checklist

+
+ {checklist.map((c) => ( +
+ {c.doc} + + {c.done ? 'Done' : 'Pending'} + +
+ ))} +
+ +
+ +
+ Identity check + + {kyc.hasMismatch ? 'Mismatch' : kyc.anyChecked ? 'Verified' : 'Awaiting OCR'} + +
+ +
+ Current status + + {docStatusOptions.find((o) => o.value === docStatus)?.label ?? docStatus} + +
+ +
+ KYC completion + {completion}% +
+
+
+
+
+ + {record?.annual_income ? ( +
+ Stated annual income on file: ₹{formatINR(record.annual_income)}. +
+ ) : null} +
+
+
+ ); +} + +function SalarySlipUpload({ value, onChange }: { value: File | null; onChange: (f: File | null) => void }) { + const ref = useRef(null); + return ( +
+ Salary slip + { + onChange(e.target.files?.[0] ?? null); + e.target.value = ''; + }} + /> + +
+ ); +} diff --git a/src/components/activities/IssuePolicyBody.tsx b/src/components/activities/IssuePolicyBody.tsx new file mode 100644 index 0000000..102e086 --- /dev/null +++ b/src/components/activities/IssuePolicyBody.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CheckCircle2 } from 'lucide-react'; +import { Button, Card, formatINR, Input } from '../'; +import { SchemaGuard } from '../form'; +import { useQuery, useZino } from '../../api/provider'; +import { ACTIVITIES } from '../../api/config'; +import type { ActivityBodyProps } from './types'; + +const F = ACTIVITIES.ISSUE_POLICY.fields; + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +function addYears(iso: string, years: number): string { + if (!iso || !years) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + d.setFullYear(d.getFullYear() + years); + return d.toISOString().slice(0, 10); +} + +export function IssuePolicyBody({ instanceId, record, onSuccess }: ActivityBodyProps) { + const { client } = useZino(); + + // Fields are hand-built (no schema-driven options), but we still fetch the + // form schema so an RBAC denial gates the form up front instead of only + // surfacing on submit. + const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.ISSUE_POLICY.uid, instanceId), [instanceId]); + + const [issueDate, setIssueDate] = useState(todayISO()); + const [term, setTerm] = useState(20); + const [premium, setPremium] = useState(0); + + useEffect(() => { + if (!record) return; + setIssueDate(record.policy_issue_date || todayISO()); + setTerm(record.policy_term || 20); + setPremium(record.premium_amount || 0); + }, [record]); + + const commencement = issueDate; + const maturity = useMemo(() => addYears(issueDate, term), [issueDate, term]); + + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + + async function submit() { + setSubmitting(true); + setResult(null); + try { + const res = await client.performActivity(instanceId, ACTIVITIES.ISSUE_POLICY.uid, { + [F.issueDate]: issueDate, + [F.term]: term, + [F.maturity]: maturity, + [F.premium]: premium, + }); + setResult({ ok: true, msg: 'Policy issued — number generated automatically.' }); + onSuccess?.(res); + } catch (e) { + setResult({ + ok: false, + msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed'), + }); + } finally { + setSubmitting(false); + } + } + + const lead = record; + + return ( + +
+
+ {result && ( +
+ {result.ok && } + {result.msg} +
+ )} + + +
+ setIssueDate(e.target.value)} + /> + setTerm(Number(e.target.value) || 0)} + /> + setPremium(Number(e.target.value) || 0)} + /> +
+
+ Policy number is auto-generated on issue (POL-YYYY-####). +
+
+ +
+
+
+ + {/* RIGHT — policy summary (dates computed in-app) */} +
+
+
+ Policy summary +
+
+ Policy number + + {lead?.policy_number ?? 'auto on issue'} + +
+
+ Risk commencement + {commencement || '—'} +
+
+ Term + {term ? `${term} yrs` : '—'} +
+
+ Maturity date + {maturity || '—'} +
+
+
+ Premium + ₹{formatINR(premium)} +
+
+
+ Risk commencement = issue date. Maturity = issue date + term, computed in-app. +
+
+
+ + ); +} diff --git a/src/components/activities/RecommendBody.tsx b/src/components/activities/RecommendBody.tsx new file mode 100644 index 0000000..bbaaad9 --- /dev/null +++ b/src/components/activities/RecommendBody.tsx @@ -0,0 +1,498 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { CheckCircle2, ChevronRight, Info, ShieldCheck, Sparkles, Wand2 } from 'lucide-react'; +import { + AiSuggestionPanel, + Avatar, + Button, + Card, + formatINR, + Input, + MultiSelect, + RupeeAmount, + SelectField, +} from '../'; +import { LookupField, SchemaGuard } from '../form'; +import type { LookupValue } from '../form'; +import { useQuery, useZino } from '../../api/provider'; +import { decisionToCard, recommendationFromDecisions } from '../../api/adapters'; +import type { AiRecommendation } from '../../api/adapters'; +import { fieldFromSchema } from '../../api/schema'; +import { ACTIVITIES } from '../../api/config'; +import { allowedRidersFor, normalizeRiderValues, recommendCalc, saValidIssues } from '../../lib/recommend'; +import type { ActivityBodyProps } from './types'; +import type { LeadRecord } from '../../api/types'; + +const F = ACTIVITIES.RECOMMEND_PRODUCT.fields; + +// Riders arrive as a JSON array, a CSV, or a Postgres array-literal `{a,b}` — +// normalizeRiderValues handles all three. +function toRiderValues(raw: LeadRecord['riders']): string[] { + return normalizeRiderValues(raw); +} + +export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyProps) { + const { client } = useZino(); + + const schemaQ = useQuery(() => client.formSchema(ACTIVITIES.RECOMMEND_PRODUCT.uid, instanceId), [instanceId]); + const productField = useMemo(() => fieldFromSchema(schemaQ.data, F.product), [schemaQ.data]); + const freqOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumFrequency)?.options ?? [], [schemaQ.data]); + const riderOptions = useMemo(() => fieldFromSchema(schemaQ.data, F.riders)?.options ?? [], [schemaQ.data]); + // Premium Amount is designer-disabled (field_defaults.premium_amount_input.disabled): + // the custom_js computes it from the rate-card, so it's read-only, never typed. + const premiumDisabled = useMemo(() => fieldFromSchema(schemaQ.data, F.premiumAmount)?.disabled ?? false, [schemaQ.data]); + + const [product, setProduct] = useState(null); + const [sum, setSum] = useState(2000000); + const [freq, setFreq] = useState('annual'); + const [premium, setPremium] = useState(0); + const [premiumTouched, setPremiumTouched] = useState(false); + // No seed: riders depend on the picked product. Before a product is chosen the + // field is disabled; the record-load effect fills in any saved riders. + const [riders, setRiders] = useState([]); + const [notes, setNotes] = useState(''); + // Labels of riders auto-removed on the last product switch (for the inline note). + const [prunedRiders, setPrunedRiders] = useState([]); + // Suppress the prune-note on the initial record-driven product set, so reopening + // a lead doesn't flash "removed riders". Re-armed on every record load. + const skipPruneNote = useRef(true); + + useEffect(() => { + if (!record) return; + skipPruneNote.current = true; + setPrunedRiders([]); + setProduct((record.recommended_product as LookupValue) ?? null); + setSum(record.sum_assured || 2000000); + setFreq(record.premium_frequency || 'annual'); + setRiders(toRiderValues(record.riders)); + setNotes(record.recommendation_notes || ''); + setPremiumTouched(false); + if (record.premium_amount) { + setPremium(record.premium_amount); + setPremiumTouched(true); + } + }, [record]); + + // Riders the picked product permits. null = legacy product value (no + // allowed_riders key) → show ALL options. Empty array = plan allows none. + const allowed = useMemo(() => allowedRidersFor(product as Record | null), [product]); + const visibleRiderOptions = useMemo( + () => (allowed == null ? riderOptions : riderOptions.filter((o) => allowed.includes(o.value))), + [riderOptions, allowed], + ); + const riderLabel = useMemo(() => { + const m = new Map(); + riderOptions.forEach((o) => m.set(o.value, o.label)); + return m; + }, [riderOptions]); + const ridersDisabled = !product || (allowed != null && allowed.length === 0); + const ridersHint = !product + ? 'Pick a product first' + : allowed != null && allowed.length === 0 + ? 'No riders available for this plan' + : undefined; + + // On a product switch, drop selected riders the new plan doesn't offer and note + // which were removed. Legacy values (allowed == null) keep every saved rider. + // Keyed on `product` only; setRiders fires solely when the set actually shrinks, + // so there's no render loop. + useEffect(() => { + if (allowed == null) { + setPrunedRiders([]); + return; + } + const removed = riders.filter((r) => !allowed.includes(r)); + if (removed.length) setRiders(riders.filter((r) => allowed.includes(r))); + if (skipPruneNote.current) { + skipPruneNote.current = false; + setPrunedRiders([]); + return; + } + setPrunedRiders(removed.map((r) => riderLabel.get(r) ?? r)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [product]); + + // Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate + // field). Without sa_valid the lead can't advance past Meeting Scheduled. + const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income); + // When the field is designer-disabled, the premium is always the computed + // rate-card value — a manual override (premiumTouched) can't apply. + const effectivePremium = !premiumDisabled && premiumTouched ? premium : calc.premium; + const issues = saValidIssues(calc); + + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); + + // One-click apply of Aria Advisor's pre-activity recommendation: sets the sum + // assured straight off, and resolves her product *name* against the live + // catalogue into the lookup value the field needs. The agent can still edit. + const [applying, setApplying] = useState(false); + const [applyNote, setApplyNote] = useState(null); + + async function applyRecommendation(rec: AiRecommendation) { + setApplyNote(null); + if (rec.sumAssured && rec.sumAssured > 0) { + setSum(rec.sumAssured); + setPremiumTouched(false); // let the rate-card premium recompute + } + if (!productField?.lookupTemplate) return; + setApplying(true); + try { + const { records } = await client.lookupRecords(productField.lookupTemplate, { + activityId: ACTIVITIES.RECOMMEND_PRODUCT.uid, + fieldId: F.product, + instanceId, + search: '', + limit: 50, + }); + const match = matchProduct(rec.product, records, productField.lookupDisplay ?? []); + if (match) { + const stored: LookupValue = {}; + (productField.lookupStorage ?? []).forEach((c) => (stored[c] = match[c] ?? null)); + (productField.lookupDisplay ?? []).forEach((c) => { + if (!(c in stored)) stored[c] = match[c] ?? null; + }); + setProduct(stored); + } else { + setApplyNote(`Couldn’t match “${rec.product}” to the catalogue — pick the product manually.`); + } + } catch { + setApplyNote('Couldn’t load the product catalogue — pick the product manually.'); + } finally { + setApplying(false); + } + } + + const incomeMultiple = record?.annual_income ? (sum / record.annual_income).toFixed(1) : '—'; + + async function submit() { + if (!product) { + setResult({ ok: false, msg: 'Pick a product from the catalogue.' }); + return; + } + setSubmitting(true); + setResult(null); + try { + const res = await client.performActivity(instanceId, ACTIVITIES.RECOMMEND_PRODUCT.uid, { + [F.product]: product, + [F.sumAssured]: sum, + [F.premiumAmount]: effectivePremium, + [F.premiumFrequency]: freq, + [F.riders]: riders, + [F.notes]: notes, + [F.saValid]: calc.saValid, + }); + setResult({ + ok: true, + msg: calc.saValid + ? 'Recommendation submitted — lead advanced to Product Recommended.' + : 'Recommendation saved, but the sum assured is outside the plan’s eligible range — the lead stays at Meeting Scheduled until corrected.', + }); + onSuccess?.(res); + } catch (e) { + setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); + } finally { + setSubmitting(false); + } + } + + // Product / frequency / riders render from the live schema. Without a guard a + // failed schema fetch (most often an RBAC denial — only some roles can + // recommend) silently drops those fields, leaving a broken half-form with no + // explanation. SchemaGuard surfaces loading + permission/error instead. + return ( + +
+
+ {result && ( +
+ {result.ok && } + {result.msg} +
+ )} + + +
+ {productField && ( + + )} + setSum(Number(e.target.value) || 0)} + /> + + { + if (premiumDisabled) return; + setPremium(Number(e.target.value) || 0); + setPremiumTouched(true); + }} + /> +
+ + {prunedRiders.length > 0 && ( +
+ + Removed {prunedRiders.join(', ')} — not offered on this plan. +
+ )} +
+
+