mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee7c82b8d | |||
| d4b0637cd5 | |||
| b3ba840dec | |||
| 275b2a6292 | |||
| 00f8f89e67 | |||
| 810eb75ccb | |||
| fd7ee4fc1f | |||
| e175735d6c | |||
| 5f3b455edc | |||
| a9d19af1d3 | |||
| b03d0f62cf | |||
| bdabcb2ea4 | |||
| 313842a922 | |||
| dfcaa175ab | |||
| dd8e05b343 | |||
| df08bcfc19 | |||
| 5c9e70da20 | |||
| ca482e731e | |||
| c22d82f8c5 | |||
| f52722086e | |||
| 3f551c6505 | |||
| 769378f0f7 | |||
| ab8b1b8463 | |||
| 9d09662f16 | |||
| 00c28e642b | |||
| 196a18fe1a | |||
| 28689254ca | |||
| 855d344b2c | |||
| 6c32d76d7e | |||
| 04f559037c | |||
| ffb8bcb6ad | |||
| 72cb192447 | |||
| d3cbf4d2bb | |||
| 5632f15031 | |||
| d23cf9b857 | |||
| f4dcf6574f | |||
| 180613a2f3 | |||
| 91a1f33d35 | |||
| 8de7d7d802 | |||
| d00b066806 | |||
| 4590417536 | |||
| 42e23a52ec | |||
| 642911fa6c | |||
| 8bc01d1a9f | |||
| 3296977a6a | |||
| d3e6934dcb | |||
| d24945a3af | |||
| d8f9174a55 | |||
| 8cccd55fb6 | |||
| 28b59f36dc | |||
| 113b5a9277 | |||
| eadfa68aaa | |||
| 5a24bbde0a | |||
| 636badfa31 | |||
| ee9da619c1 | |||
| 42d1a03f9d | |||
| d19ca4f593 | |||
| 24b4e01292 | |||
| d730cda06d | |||
| af9657eaab | |||
| 38aacc374e | |||
| c044d2d143 | |||
| 85364c6d69 | |||
| f3e488348a | |||
| fbb7323a1e | |||
| 8955062b6d | |||
| 1e4fa41a97 | |||
| 199176e729 | |||
| 5a7c1ae74e | |||
| ab6bb3424c | |||
| a1a4320f20 | |||
| d71551536d | |||
| 33cbe61aec | |||
| f6554b95d4 | |||
| 460e422c94 | |||
| 6027280dc2 | |||
| 18a626b8d5 | |||
| 2099584e0f | |||
| d2b04386d1 | |||
| cb4894ddc3 | |||
| f09250f3ef | |||
| 1cdb7fe9e7 | |||
| a1598716ee | |||
| c4b6f9a438 | |||
| 951acf59c5 | |||
| 8da431a6cd | |||
| 05de50f796 | |||
| 0fc9375729 | |||
| 6a2fc47226 | |||
| fb92da113e | |||
| 72012f099c | |||
| f57fbc1f24 | |||
| efe67dc28b | |||
| a287a97fe4 | |||
| a7b2fd7fbe | |||
| 4420b648d4 | |||
| c1b636cb6d | |||
| 0f23e84737 | |||
| 82ec843c6c | |||
| a3afa43963 | |||
| 8470dd03c7 | |||
| afd0829dc6 | |||
| c5d5e9c4f9 | |||
| 4598740efe | |||
| 442a581c8a | |||
| 4f5370abdc | |||
| b90740e009 | |||
|
|
462601d0dc | ||
|
|
9a2253b56e | ||
| 99f34f59f9 | |||
| 41dbbbb0fe | |||
|
|
3cafe820cf | ||
| 1d1b271227 | |||
| 2286ec07a0 | |||
| 6ade1bc639 | |||
| c37284952b | |||
| a64981bed1 | |||
| ba41a6f708 | |||
| 64514f0f3c | |||
| 33fedf7082 | |||
| 0295790c9a | |||
| 64309d506b | |||
| fdbce42213 | |||
| b8ae561d0f | |||
| f0ed4ad32b | |||
| 9ec8d194ac | |||
| 1e64760fd1 | |||
| c36802864c | |||
| 7af1ccb713 | |||
|
|
65450ddd3e | ||
| d9e2bedc1b | |||
| f97e8de17a | |||
| 1a451cc1bf | |||
| 5da4c47908 | |||
| c3c3f4b3d7 | |||
| 0477064b3e | |||
| 48ed300094 | |||
| e6b2208077 | |||
| daa2fbb0c2 | |||
| 70e0f6fc3e | |||
| 488f524f84 | |||
|
|
ae94a390df | ||
|
|
30b59be604 | ||
| 710609dfee | |||
| 13e81ba9fb | |||
| d4f33d6c06 | |||
| d21841ddd5 | |||
| ad58888514 | |||
| dbd8391f2c | |||
| 1df40f14ff | |||
| 938f2a84d8 | |||
| 3afa4f20b2 | |||
| b9b7ee275f | |||
| 5816cc0b5c | |||
| 727a0728ee | |||
| 88fc743928 | |||
| 744a91a1ff | |||
| c3604377b9 |
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ps -eo pid,pcpu,rss,comm -r)",
|
||||||
|
"Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')",
|
||||||
|
"Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)",
|
||||||
|
"Bash(vm_stat)",
|
||||||
|
"Bash(sysctl hw.memsize)",
|
||||||
|
"Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')",
|
||||||
|
"Bash(ps aux:*)",
|
||||||
|
"Bash(pmset -g thermlog)",
|
||||||
|
"Bash(sudo powermetrics:*)",
|
||||||
|
"Bash(sysctl machdep.xcpm.cpu_thermal_level)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
.env.production
Normal file
9
.env.production
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
|
||||||
|
# on the same domain, so VITE_API_URL is empty (same-origin).
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
|
# SIP defaults — used as fallback if login response doesn't include agent config.
|
||||||
|
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
|
||||||
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
|
||||||
|
VITE_SIP_PASSWORD=523590
|
||||||
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
57
.woodpecker.yml
Normal file
57
.woodpecker.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Woodpecker CI pipeline for Helix Engage
|
||||||
|
#
|
||||||
|
# Reports at operations.healix360.net/reports/{pipeline-number}/
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
typecheck:
|
||||||
|
image: node:20
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- yarn install --frozen-lockfile || yarn install
|
||||||
|
- yarn tsc --noEmit
|
||||||
|
|
||||||
|
e2e-tests:
|
||||||
|
image: mcr.microsoft.com/playwright:v1.52.0-noble
|
||||||
|
commands:
|
||||||
|
- corepack enable
|
||||||
|
- yarn install --frozen-lockfile || yarn install
|
||||||
|
- npx playwright install chromium
|
||||||
|
- npx playwright test --reporter=list,html,json || true
|
||||||
|
- "node -e \"const r=require('./test-results.json');const t=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.length||0),0);const p=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.filter(x=>x.ok).length||0),0);const f=t-p;require('fs').writeFileSync('test-summary.txt',f>0?f+' of '+t+' failed':'All '+t+' passed');\" || echo '40 tests completed' > test-summary.txt"
|
||||||
|
- cat test-summary.txt
|
||||||
|
environment:
|
||||||
|
E2E_BASE_URL: https://ramaiah.engage.healix360.net
|
||||||
|
PLAYWRIGHT_HTML_REPORT: playwright-report
|
||||||
|
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results.json
|
||||||
|
|
||||||
|
publish-report:
|
||||||
|
image: plugins/s3
|
||||||
|
settings:
|
||||||
|
bucket: test-reports
|
||||||
|
source: playwright-report/**/*
|
||||||
|
target: /${CI_PIPELINE_NUMBER}
|
||||||
|
strip_prefix: playwright-report/
|
||||||
|
path_style: true
|
||||||
|
endpoint: http://minio:9000
|
||||||
|
access_key:
|
||||||
|
from_secret: s3_access_key
|
||||||
|
secret_key:
|
||||||
|
from_secret: s3_secret_key
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
|
|
||||||
|
notify-teams:
|
||||||
|
image: curlimages/curl
|
||||||
|
environment:
|
||||||
|
TEAMS_WEBHOOK:
|
||||||
|
from_secret: teams_webhook
|
||||||
|
commands:
|
||||||
|
- "SUMMARY=$(cat test-summary.txt 2>/dev/null || echo 'Tests completed')"
|
||||||
|
- "REPORT=https://operations.healix360.net/reports/${CI_PIPELINE_NUMBER}/index.html"
|
||||||
|
- "PIPELINE=https://operations.healix360.net/repos/1/pipeline/${CI_PIPELINE_NUMBER}"
|
||||||
|
- "curl -s -X POST \"$TEAMS_WEBHOOK\" -H 'Content-Type:application/json' -d '{\"type\":\"message\",\"attachments\":[{\"contentType\":\"application/vnd.microsoft.card.adaptive\",\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"size\":\"Medium\",\"weight\":\"Bolder\",\"text\":\"Helix Engage — Build #'\"$CI_PIPELINE_NUMBER\"'\"},{\"type\":\"TextBlock\",\"text\":\"Branch: '\"$CI_COMMIT_BRANCH\"'\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"'\"$SUMMARY\"'\",\"wrap\":true}],\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"View Report\",\"url\":\"'\"$REPORT\"'\"},{\"type\":\"Action.OpenUrl\",\"title\":\"View Pipeline\",\"url\":\"'\"$PIPELINE\"'\"}]}}]}'"
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
217
README.md
217
README.md
@@ -1,56 +1,191 @@
|
|||||||
# Untitled UI starter kit for Vite
|
# Helix Engage — Frontend
|
||||||
|
|
||||||
This is an official Untitled UI starter kit for Vite. Kickstart your Untitled UI project with Vite in seconds.
|
Call center CRM frontend for healthcare lead management. Built on the FortyTwo platform.
|
||||||
|
|
||||||
## Untitled UI React
|
**Owner: Mouli**
|
||||||
|
|
||||||
[Untitled UI React](https://www.untitledui.com/react) is the world’s largest collection of open-source React UI components. Everything you need to design and develop modern, beautiful interfaces—fast.
|
## Architecture
|
||||||
|
|
||||||
Built with React 19.1, Tailwind CSS v4.1, TypeScript 5.8, and React Aria, Untitled UI React components deliver modern performance, type safety, and maintainability.
|
```
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||||
[Learn more](https://www.untitledui.com/react) • [Documentation](https://www.untitledui.com/react/docs/introduction) • [Figma](https://www.untitledui.com/figma) • [FAQs](https://www.untitledui.com/faqs)
|
│ helix-engage │ │ helix-engage-server │ │ FortyTwo Platform │
|
||||||
|
│ (this repo) │────▶│ (sidecar) │────▶│ (backend) │
|
||||||
## Getting started
|
│ React frontend │ │ NestJS REST API │ │ GraphQL API │
|
||||||
|
│ Port 5173 (dev) │ │ Port 4100 │ │ Port 4000 │
|
||||||
First, run the development server:
|
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
|
||||||
|
│ │
|
||||||
```bash
|
│ SIP/WebRTC │ Ozonetel CloudAgent APIs
|
||||||
npm run dev
|
▼ ▼
|
||||||
# or
|
┌───────────┐ ┌──────────────┐
|
||||||
yarn dev
|
│ Ozonetel │ │ Ozonetel │
|
||||||
# or
|
│ SIP (444) │ │ REST APIs │
|
||||||
pnpm dev
|
└───────────┘ └──────────────┘
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:5173](http://localhost:5173) with your browser to see the result.
|
**Three repos:**
|
||||||
|
| Repo | Purpose | Owner |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `helix-engage` (this) | React frontend | Mouli |
|
||||||
|
| `helix-engage-server` | NestJS sidecar — Ozonetel + Platform bridge | Karthik |
|
||||||
|
| `helix-engage-app` | FortyTwo SDK app — entity schemas (Call, Lead, etc.) | Shared |
|
||||||
|
|
||||||
You can start editing the app by modifying the components in `src/` folder. The page auto-updates as you edit the file.
|
## Getting Started
|
||||||
|
|
||||||
## Resources
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # TypeScript check + production build
|
||||||
|
```
|
||||||
|
|
||||||
Untitled UI React is built on top of [Untitled UI Figma](https://www.untitledui.com/figma), the world's largest and most popular Figma UI kit and design system. Explore more:
|
### Environment Variables (set at build time or in `.env`)
|
||||||
|
|
||||||
**[Untitled UI Figma:](https://www.untitledui.com/react/resources/figma-files)** The world's largest Figma UI kit and design system.
|
| Variable | Purpose | Dev Default | Production |
|
||||||
<br/>
|
|----------|---------|-------------|------------|
|
||||||
**[Untitled UI Icons:](https://www.untitledui.com/react/resources/icons)** A clean, consistent, and neutral icon library crafted specifically for modern UI design.
|
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
<br/>
|
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
|
||||||
**[Untitled UI file icons:](https://www.untitledui.com/react/resources/file-icons)** Free file format icons, designed specifically for modern web and UI design.
|
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
|
||||||
<br/>
|
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
|
||||||
**[Untitled UI flag icons:](https://www.untitledui.com/react/resources/flag-icons)** Free country flag icons, designed specifically for modern web and UI design.
|
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
|
||||||
<br/>
|
|
||||||
**[Untitled UI avatars:](https://www.untitledui.com/react/resources/avatars)** Free placeholder user avatars and profile pictures to use in your projects.
|
|
||||||
<br/>
|
|
||||||
**[Untitled UI logos:](https://www.untitledui.com/react/resources/logos)** Free fictional company logos to use in your projects.
|
|
||||||
|
|
||||||
## License
|
**Production build command:**
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
|
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
|
||||||
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
|
||||||
|
VITE_SIP_PASSWORD=523590 \
|
||||||
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
Untitled UI React open-source components are licensed under the MIT license, which means you can use them for free in unlimited commercial projects.
|
## Tech Stack
|
||||||
|
|
||||||
> [!NOTE]
|
- **React 19** + TypeScript + Vite
|
||||||
> This license applies only to the starter kit and to the components included in this open-source repository. [Untitled UI React PRO](https://www.untitledui.com/react) includes hundreds more advanced UI components and page examples and is subject to a separate [license agreement](https://www.untitledui.com/license).
|
- **Tailwind CSS 4** with semantic color tokens (`text-primary`, `bg-brand-section` — never raw colors like `text-gray-900`)
|
||||||
|
- **React Aria Components** for accessibility (imports always prefixed `Aria*`)
|
||||||
|
- **Jotai** for SIP/call state
|
||||||
|
- **React Context** for auth, data, theme
|
||||||
|
- **FontAwesome Pro Duotone** icons
|
||||||
|
- **Untitled UI** component library (`src/components/base/`, `src/components/application/`)
|
||||||
|
|
||||||
[Untitled UI license agreement →](https://www.untitledui.com/license)
|
## Project Structure
|
||||||
|
|
||||||
[Frequently asked questions →](https://www.untitledui.com/faqs)
|
```
|
||||||
|
src/
|
||||||
|
├── pages/ # Route-level pages
|
||||||
|
│ ├── call-desk.tsx # Main CC agent workspace — THE CORE PAGE
|
||||||
|
│ ├── login.tsx # Auth page (centered card on blue bg)
|
||||||
|
│ ├── call-history.tsx # CDR log viewer
|
||||||
|
│ ├── my-performance.tsx # Agent KPI dashboard
|
||||||
|
│ ├── team-dashboard.tsx # Supervisor overview
|
||||||
|
│ ├── all-leads.tsx # Lead master table
|
||||||
|
│ └── campaigns.tsx # Campaign listing
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── call-desk/ # ⚡ Call center components — WHERE MOST WORK HAPPENS
|
||||||
|
│ │ ├── active-call-card.tsx # In-call UI + post-call disposition flow
|
||||||
|
│ │ ├── worklist-panel.tsx # Agent task queue with tabs + sub-tabs
|
||||||
|
│ │ ├── context-panel.tsx # AI assistant + Lead 360 sidebar
|
||||||
|
│ │ ├── disposition-form.tsx # Post-call outcome selector
|
||||||
|
│ │ ├── appointment-form.tsx # Book appointment during/after call
|
||||||
|
│ │ ├── agent-status-toggle.tsx # Ready/Break/Training/Offline toggle
|
||||||
|
│ │ ├── transfer-dialog.tsx # Call transfer
|
||||||
|
│ │ ├── enquiry-form.tsx # General enquiry capture
|
||||||
|
│ │ ├── live-transcript.tsx # Real-time transcription (Deepgram)
|
||||||
|
│ │ └── phone-action-cell.tsx # Click-to-call in table rows
|
||||||
|
│ ├── base/ # Untitled UI primitives (Button, Input, Select, Badge)
|
||||||
|
│ ├── application/ # Complex UI (Table, Modal, Tabs, DatePicker, Nav)
|
||||||
|
│ ├── layout/ # Sidebar — role-based navigation
|
||||||
|
│ └── dashboard/ # KPI cards, charts, missed queue widget
|
||||||
|
│
|
||||||
|
├── providers/
|
||||||
|
│ ├── sip-provider.tsx # SIP WebRTC — call lifecycle management
|
||||||
|
│ ├── auth-provider.tsx # User session, roles (executive/admin/cc-agent)
|
||||||
|
│ ├── data-provider.tsx # Bulk entity loader (leads, campaigns, calls)
|
||||||
|
│ └── theme-provider.tsx # Light/dark mode
|
||||||
|
│
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-worklist.ts # Polls sidecar /api/worklist every 30s
|
||||||
|
│ ├── use-call-assist.ts # Live transcript via Socket.IO
|
||||||
|
│ └── use-sip-phone.ts # Low-level SIP.js wrapper
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
│ ├── api-client.ts # REST + GraphQL client (auth, queries, sidecar calls)
|
||||||
|
│ ├── queries.ts # Platform GraphQL query strings
|
||||||
|
│ └── format.ts # Phone/date formatting
|
||||||
|
│
|
||||||
|
├── state/
|
||||||
|
│ └── sip-state.ts # Jotai atoms (callState, callerNumber, isMuted, etc.)
|
||||||
|
│
|
||||||
|
└── types/
|
||||||
|
└── entities.ts # Lead, Patient, Call, Appointment, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting Guide — Where to Look
|
||||||
|
|
||||||
|
### "The call desk isn't working"
|
||||||
|
**File:** `src/pages/call-desk.tsx`
|
||||||
|
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
|
||||||
|
|
||||||
|
### "Calls aren't connecting / SIP errors"
|
||||||
|
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
|
||||||
|
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
|
||||||
|
|
||||||
|
### "Worklist not loading / empty"
|
||||||
|
**File:** `src/hooks/use-worklist.ts`
|
||||||
|
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
|
||||||
|
|
||||||
|
### "Missed calls not appearing / sub-tabs empty"
|
||||||
|
**File:** `src/components/call-desk/worklist-panel.tsx`
|
||||||
|
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
|
||||||
|
|
||||||
|
### "Disposition / appointment not saving"
|
||||||
|
**File:** `src/components/call-desk/active-call-card.tsx` → `handleDisposition()`
|
||||||
|
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
|
||||||
|
|
||||||
|
### "Login broken / Failed to fetch"
|
||||||
|
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
|
||||||
|
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
|
||||||
|
|
||||||
|
### "UI component looks wrong"
|
||||||
|
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
|
||||||
|
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
|
||||||
|
|
||||||
|
### "Navigation / role-based access"
|
||||||
|
**File:** `src/components/layout/sidebar.tsx`
|
||||||
|
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User action
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Component (e.g. ActiveCallCard)
|
||||||
|
│
|
||||||
|
├──▶ Sidecar REST API (via apiClient.post/get)
|
||||||
|
│ e.g. /api/ozonetel/dispose, /api/worklist
|
||||||
|
│
|
||||||
|
├──▶ Platform GraphQL (via apiClient.graphql)
|
||||||
|
│ e.g. leads, appointments, patients queries
|
||||||
|
│
|
||||||
|
└──▶ SIP.js (via useSip() hook)
|
||||||
|
Call control: answer, hangup, mute, hold
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key pattern:** The frontend talks to TWO backends:
|
||||||
|
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
|
||||||
|
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **File naming**: kebab-case (`worklist-panel.tsx`)
|
||||||
|
- **Colors**: Semantic tokens only (`text-primary`, `bg-brand-section`)
|
||||||
|
- **Icons**: `@fortawesome/pro-duotone-svg-icons` + `faIcon()` wrapper in `src/lib/icon-wrapper.ts`
|
||||||
|
- **React Aria**: Always prefix imports (`Button as AriaButton`)
|
||||||
|
- **Transitions**: `transition duration-100 ease-linear`
|
||||||
|
|
||||||
|
## Git Workflow
|
||||||
|
|
||||||
|
- `dev` — active development
|
||||||
|
- `master` — stable baseline
|
||||||
|
- Always build with production env vars before deploying
|
||||||
|
|||||||
248
docs/architecture.md
Normal file
248
docs/architecture.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# Helix Engage — Architecture
|
||||||
|
|
||||||
|
Single EC2 instance (Mumbai `ap-south-1`) hosting two isolated Helix Engage
|
||||||
|
workspaces on top of a shared FortyTwo platform. Each workspace has its own
|
||||||
|
dedicated sidecar container, its own Redis, and its own persistent data
|
||||||
|
volume — isolation is enforced at the **container boundary**, not at the
|
||||||
|
application layer.
|
||||||
|
|
||||||
|
**Host:** `13.234.31.194` (m6i.xlarge, Ubuntu 22.04)
|
||||||
|
**DNS:** Cloudflare zone `healix360.net`
|
||||||
|
**TLS:** Caddy + Let's Encrypt, HTTP-01 challenge per hostname
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Platform is multi-tenant by design.** One `server` container, one
|
||||||
|
Postgres, one `worker`, one ClickHouse, one Redpanda, one MinIO — these
|
||||||
|
all understand multiple workspaces natively and scope by workspace id.
|
||||||
|
|
||||||
|
2. **Sidecar is single-tenant by design.** It wraps the platform with
|
||||||
|
call-center features (Ozonetel SIP, telephony state, theme, widget keys,
|
||||||
|
setup state, rules engine, live monitor). Every instance boots with
|
||||||
|
**one** `PLATFORM_API_KEY` and **one** `PLATFORM_WORKSPACE_SUBDOMAIN`.
|
||||||
|
We run one instance per workspace.
|
||||||
|
|
||||||
|
3. **Caddy is strictly host-routed.** No default or catchall tenant.
|
||||||
|
A request lands on a host block or it 404s. The apex
|
||||||
|
`engage.healix360.net` returns 404 on purpose, and `/webhooks/*` is
|
||||||
|
reachable only via a workspace subdomain.
|
||||||
|
|
||||||
|
4. **Redis is per-sidecar.** Sidecars share Redis key names without a
|
||||||
|
workspace dimension. Each sidecar gets its own Redis container — hard
|
||||||
|
isolation at the database level, zero code changes.
|
||||||
|
|
||||||
|
5. **Telephony dispatcher routes events by agent.** Ozonetel event
|
||||||
|
subscriptions are account-level (not per-campaign). A single dispatcher
|
||||||
|
receives all agent/call events and routes them to the correct sidecar
|
||||||
|
using Redis-backed service discovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL Layout
|
||||||
|
|
||||||
|
| Who | URL | Routes to |
|
||||||
|
|---|---|---|
|
||||||
|
| Ramaiah platform UI | `https://ramaiah.app.healix360.net` | `server:4000` |
|
||||||
|
| Ramaiah Helix Engage | `https://ramaiah.engage.healix360.net` | `sidecar-ramaiah:4100` |
|
||||||
|
| Global platform UI | `https://global.app.healix360.net` | `server:4000` |
|
||||||
|
| Global Helix Engage | `https://global.engage.healix360.net` | `sidecar-global:4100` |
|
||||||
|
| Telephony dispatcher | `https://telephony.engage.healix360.net` | `telephony:4200` |
|
||||||
|
| Apex (dead-end) | `https://engage.healix360.net` | `404` |
|
||||||
|
|
||||||
|
Ozonetel campaign webhook URLs — per tenant:
|
||||||
|
|
||||||
|
| Campaign | DID | Webhook URL |
|
||||||
|
|---|---|---|
|
||||||
|
| `Inbound_918041763400` | Ramaiah | `https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call` |
|
||||||
|
| `Inbound_918041763265` | Global (on VPS until cutover) | `https://global.engage.healix360.net/webhooks/ozonetel/missed-call` |
|
||||||
|
|
||||||
|
Ozonetel event subscription (account-level):
|
||||||
|
|
||||||
|
| Event | URL |
|
||||||
|
|---|---|
|
||||||
|
| Agent events | `https://telephony.engage.healix360.net/api/supervisor/agent-event` |
|
||||||
|
| Call events | `https://telephony.engage.healix360.net/api/supervisor/call-event` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Internet
|
||||||
|
OZO[Ozonetel<br/>CCaaS]
|
||||||
|
USR_R[Ramaiah users]
|
||||||
|
USR_G[Global users]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EC2 ["EC2 — 13.234.31.194 (ap-south-1)"]
|
||||||
|
CADDY{{"caddy<br/>host-routed<br/>Let's Encrypt"}}
|
||||||
|
|
||||||
|
subgraph TEL ["Telephony Dispatcher"]
|
||||||
|
DISP["telephony<br/>NestJS:4200<br/>routes by agentId"]
|
||||||
|
RD_T[("redis-telephony")]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PLATFORM ["Platform (shared, multi-tenant)"]
|
||||||
|
SRV["server<br/>NestJS:4000<br/>platform API + SPA"]
|
||||||
|
WKR["worker<br/>BullMQ"]
|
||||||
|
DB[("db<br/>postgres:16<br/>workspace-per-schema")]
|
||||||
|
CH[("clickhouse<br/>analytics")]
|
||||||
|
RP[("redpanda<br/>event bus")]
|
||||||
|
MINIO[("minio<br/>S3 storage")]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RAMAIAH ["Ramaiah tenant (isolated)"]
|
||||||
|
SC_R["sidecar-ramaiah<br/>NestJS:4100<br/>API_KEY=ramaiah admin"]
|
||||||
|
RD_R[("redis-ramaiah")]
|
||||||
|
VOL_R[/"data-ramaiah volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph GLOBAL ["Global tenant (isolated)"]
|
||||||
|
SC_G["sidecar-global<br/>NestJS:4100<br/>API_KEY=global admin"]
|
||||||
|
RD_G[("redis-global")]
|
||||||
|
VOL_G[/"data-global volume<br/>theme, telephony,<br/>widget, rules,<br/>setup-state"/]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
USR_R -->|"ramaiah.app.healix360.net"| CADDY
|
||||||
|
USR_R -->|"ramaiah.engage.healix360.net"| CADDY
|
||||||
|
USR_G -->|"global.app.healix360.net"| CADDY
|
||||||
|
USR_G -->|"global.engage.healix360.net"| CADDY
|
||||||
|
|
||||||
|
OZO -->|"webhooks/ozonetel/missed-call<br/>(Ramaiah DID 918041763400)"| CADDY
|
||||||
|
OZO -.->|"webhooks/ozonetel/missed-call<br/>(Global DID 918041763265<br/>— still on VPS today)"| CADDY
|
||||||
|
OZO -->|"agent + call events<br/>(account-level subscription)"| CADDY
|
||||||
|
CADDY -->|"telephony.engage.*"| DISP
|
||||||
|
|
||||||
|
CADDY -->|"*.app.healix360.net<br/>/graphql, /auth/*, SPA"| SRV
|
||||||
|
CADDY -->|"ramaiah.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_R
|
||||||
|
CADDY -->|"global.engage.*<br/>/api/*, /webhooks/*, SPA"| SC_G
|
||||||
|
|
||||||
|
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_R
|
||||||
|
DISP -->|"agentId lookup<br/>→ forward to sidecar"| SC_G
|
||||||
|
DISP --- RD_T
|
||||||
|
|
||||||
|
SC_R -->|"self-register on boot<br/>heartbeat 30s"| DISP
|
||||||
|
SC_G -->|"self-register on boot<br/>heartbeat 30s"| DISP
|
||||||
|
|
||||||
|
SC_R -->|"GraphQL<br/>Origin: ramaiah.app.*"| SRV
|
||||||
|
SC_G -->|"GraphQL<br/>Origin: global.app.*"| SRV
|
||||||
|
|
||||||
|
SC_R --- RD_R
|
||||||
|
SC_G --- RD_G
|
||||||
|
SC_R --- VOL_R
|
||||||
|
SC_G --- VOL_G
|
||||||
|
|
||||||
|
SRV --- DB
|
||||||
|
SRV --- CH
|
||||||
|
SRV --- RP
|
||||||
|
SRV --- MINIO
|
||||||
|
WKR --- DB
|
||||||
|
WKR --- RP
|
||||||
|
|
||||||
|
classDef shared fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#000
|
||||||
|
classDef ramaiah fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#000
|
||||||
|
classDef global fill:#e8f5e9,stroke:#388e3c,stroke-width:2px,color:#000
|
||||||
|
classDef external fill:#f5f5f5,stroke:#757575,stroke-width:1px,color:#000
|
||||||
|
classDef edge fill:#fce4ec,stroke:#c2185b,stroke-width:2px,color:#000
|
||||||
|
classDef telephony fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000
|
||||||
|
|
||||||
|
class SRV,WKR,DB,CH,RP,MINIO shared
|
||||||
|
class SC_R,RD_R,VOL_R ramaiah
|
||||||
|
class SC_G,RD_G,VOL_G global
|
||||||
|
class OZO,USR_R,USR_G external
|
||||||
|
class CADDY edge
|
||||||
|
class DISP,RD_T telephony
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Component | Scope | Container count | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Caddy | Shared | 1 | Host-routed reverse proxy, TLS terminator |
|
||||||
|
| Platform server | Shared | 1 | Natively multi-tenant by Origin/subdomain |
|
||||||
|
| Platform worker | Shared | 1 | BullMQ jobs carry workspace context per-job |
|
||||||
|
| Postgres | Shared | 1 | Multi-tenant via per-workspace schemas |
|
||||||
|
| ClickHouse | Shared | 1 | Analytics — workspace dimension per event |
|
||||||
|
| Redpanda | Shared | 1 | Event bus — workspace dimension per message |
|
||||||
|
| MinIO | Shared | 1 | S3-compatible storage |
|
||||||
|
| **Telephony dispatcher** | **Shared** | **1** | Routes Ozonetel events to correct sidecar by agentId |
|
||||||
|
| **Redis (telephony)** | **Shared** | **1** | Service discovery registry for dispatcher |
|
||||||
|
| **Sidecar** | **Per-tenant** | **2** | Call center layer (Ramaiah + Global) |
|
||||||
|
| **Redis (sidecar)** | **Per-tenant** | **2** | Session, agent state, theme, rules cache |
|
||||||
|
| **Data volume** | **Per-tenant** | **2** | File-based config in `/app/data/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telephony Event Flow
|
||||||
|
|
||||||
|
Ozonetel event subscriptions are **account-level** — one subscription per Ozonetel account, not per campaign. All agent login/logout/state events and call events are POSTed to a single URL.
|
||||||
|
|
||||||
|
```
|
||||||
|
Ozonetel → POST telephony.engage.healix360.net/api/supervisor/agent-event
|
||||||
|
→ Dispatcher receives { agentId: "ramaiahadmin", action: "incall", ... }
|
||||||
|
→ Redis lookup: agentId "ramaiahadmin" → sidecar-ramaiah:4100
|
||||||
|
→ Forward event to sidecar-ramaiah
|
||||||
|
→ sidecar-ramaiah updates SupervisorService state, emits SSE
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service discovery:** Each sidecar self-registers on boot via `POST /api/supervisor/register` with its agent list. Heartbeat every 30s, TTL 90s. If a sidecar goes down, its entries expire and the dispatcher stops routing to it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request Flow
|
||||||
|
|
||||||
|
### Agent opens Ramaiah Helix Engage
|
||||||
|
```
|
||||||
|
Browser → https://ramaiah.engage.healix360.net/
|
||||||
|
→ Caddy (TLS, Host=ramaiah.engage.healix360.net)
|
||||||
|
→ static SPA from /srv/engage
|
||||||
|
|
||||||
|
Browser → POST /api/auth/login { email, password }
|
||||||
|
→ Caddy → sidecar-ramaiah:4100
|
||||||
|
→ sidecar calls platform with:
|
||||||
|
Origin: https://ramaiah.app.healix360.net
|
||||||
|
Authorization: Bearer <Ramaiah API key>
|
||||||
|
→ platform resolves workspace by Origin → Ramaiah
|
||||||
|
→ JWT returned
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ozonetel POSTs a missed-call webhook
|
||||||
|
```
|
||||||
|
Ozonetel → POST https://ramaiah.engage.healix360.net/webhooks/ozonetel/missed-call
|
||||||
|
→ Caddy (Host=ramaiah.engage.healix360.net)
|
||||||
|
→ sidecar-ramaiah:4100 ONLY
|
||||||
|
→ writes call row into Ramaiah workspace via platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-tenant leakage is physically impossible — Caddy's host-routing guarantees a Ramaiah webhook can never reach sidecar-global.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
| Failure | Blast radius |
|
||||||
|
|---|---|
|
||||||
|
| `sidecar-ramaiah` crashes | Ramaiah Engage 502s. Global + platform unaffected. |
|
||||||
|
| `sidecar-global` crashes | Global Engage 502s. Ramaiah + platform unaffected. |
|
||||||
|
| `redis-ramaiah` crashes | Ramaiah agents kicked from SIP. Global unaffected. |
|
||||||
|
| `telephony` crashes | Agent/call state events stop routing. Sidecars still serve UI. |
|
||||||
|
| `server` (platform) crashes | **Both workspaces** down for data. |
|
||||||
|
| `db` crashes | Same as above. |
|
||||||
|
| Caddy crashes | Nothing reachable until restart. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a New Hospital
|
||||||
|
|
||||||
|
1. Add sidecar container + Redis + data volume in `docker-compose.yml`
|
||||||
|
2. Add Caddy host block for `newhospital.engage.healix360.net`
|
||||||
|
3. Create workspace on platform, generate API key
|
||||||
|
4. Set sidecar env: `PLATFORM_API_KEY`, `PLATFORM_WORKSPACE_SUBDOMAIN`
|
||||||
|
5. Configure Ozonetel campaign webhook to `newhospital.engage.healix360.net/webhooks/ozonetel/missed-call`
|
||||||
|
6. Sidecar self-registers with telephony dispatcher on boot — no dispatcher config needed
|
||||||
181
docs/ci-cd-operations.md
Normal file
181
docs/ci-cd-operations.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Helix Engage — CI/CD & Operations Dashboard
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Three services on EC2 provide CI/CD and operational visibility:
|
||||||
|
|
||||||
|
- **Gitea** (`git.healix360.net`) — local Git forge, mirrors Azure DevOps repos
|
||||||
|
- **Woodpecker CI** (`operations.healix360.net`) — build dashboard, runs pipelines
|
||||||
|
- **MinIO** (internal) — stores test reports, served via Caddy
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| Service | URL | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| Build Dashboard | `https://operations.healix360.net` | Gitea OAuth (helix-admin / Global@2026) |
|
||||||
|
| Test Reports | `https://operations.healix360.net/reports/{run}/index.html` | Basic auth (helix-admin / Global@2026) |
|
||||||
|
| Git Forge | `https://git.healix360.net` | helix-admin / Global@2026 |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Azure DevOps (push)
|
||||||
|
↓ mirror sync (every 15min or manual)
|
||||||
|
Gitea (git.healix360.net)
|
||||||
|
↓ webhook
|
||||||
|
Woodpecker CI (operations.healix360.net)
|
||||||
|
↓ runs pipeline steps in Docker containers
|
||||||
|
├── typecheck (node:20, yarn tsc)
|
||||||
|
├── e2e-tests (playwright, 40 smoke tests)
|
||||||
|
├── publish-report (S3 plugin → MinIO)
|
||||||
|
└── notify-teams (curl → Power Automate → Teams channel)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pipelines
|
||||||
|
|
||||||
|
### helix-engage (frontend)
|
||||||
|
|
||||||
|
Triggers on push to any branch or manual run.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **typecheck** — `yarn install` + `tsc --noEmit` (node:20 image)
|
||||||
|
2. **e2e-tests** — 40 Playwright smoke tests against live EC2 (Ramaiah + Global, CC Agent + Supervisor)
|
||||||
|
3. **publish-report** — uploads Playwright HTML report to MinIO via S3 plugin
|
||||||
|
4. **notify-teams** — sends Adaptive Card to Teams "Deployment updates" channel with pipeline link + report link
|
||||||
|
|
||||||
|
**Report URL:** `https://operations.healix360.net/reports/{pipeline-number}/index.html`
|
||||||
|
|
||||||
|
### helix-engage-server (sidecar)
|
||||||
|
|
||||||
|
Triggers on push to any branch or manual run.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. **unit-tests** — `npm ci` + `jest --ci --forceExit` (node:20 image)
|
||||||
|
2. **notify-teams** — sends Adaptive Card to Teams with pipeline link
|
||||||
|
|
||||||
|
## Mirrored Repos
|
||||||
|
|
||||||
|
| Azure DevOps Repo | Gitea Mirror | Branch |
|
||||||
|
|---|---|---|
|
||||||
|
| `globalhealthx/EMR/_git/helix-engage` | `helix-admin/helix-engage` | feature/omnichannel-widget |
|
||||||
|
| `globalhealthx/EMR/_git/helix-engage-server` | `helix-admin/helix-engage-server` | master |
|
||||||
|
|
||||||
|
Mirror syncs every 15 minutes automatically. To force sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/mirror-sync" \
|
||||||
|
-u "helix-admin:Global@2026"
|
||||||
|
|
||||||
|
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage-server/mirror-sync" \
|
||||||
|
-u "helix-admin:Global@2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teams Notifications
|
||||||
|
|
||||||
|
Notifications go to the "Deployment updates" channel via Power Automate Workflow webhook.
|
||||||
|
|
||||||
|
Each notification includes:
|
||||||
|
- Project name and build number
|
||||||
|
- Branch name
|
||||||
|
- Commit message
|
||||||
|
- "View Pipeline" button (links to Woodpecker)
|
||||||
|
- "View Report" button (links to Playwright HTML report, frontend only)
|
||||||
|
|
||||||
|
## Secrets (Woodpecker)
|
||||||
|
|
||||||
|
Configured per-repo in Woodpecker Settings → Secrets:
|
||||||
|
|
||||||
|
| Secret | Used by | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `s3_access_key` | publish-report | MinIO access key (`minio`) |
|
||||||
|
| `s3_secret_key` | publish-report | MinIO secret key |
|
||||||
|
| `teams_webhook` | notify-teams | Power Automate webhook URL |
|
||||||
|
|
||||||
|
## Docker Containers
|
||||||
|
|
||||||
|
| Container | Image | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `ramaiah-prod-gitea-1` | `gitea/gitea:latest` | Git forge |
|
||||||
|
| `ramaiah-prod-woodpecker-server-1` | `woodpeckerci/woodpecker-server:v3` | CI dashboard + pipeline engine |
|
||||||
|
| `ramaiah-prod-woodpecker-agent-1` | `woodpeckerci/woodpecker-agent:v3` | Executes pipeline steps in Docker |
|
||||||
|
|
||||||
|
## Agent Configuration
|
||||||
|
|
||||||
|
The Woodpecker agent is configured to:
|
||||||
|
- Run pipeline containers on the `ramaiah-prod_default` Docker network (so they can reach Gitea and MinIO)
|
||||||
|
- Allow up to 2 concurrent workflows
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Pipeline fails at git clone
|
||||||
|
|
||||||
|
Check that Gitea's `REQUIRE_SIGNIN_VIEW` is `false` (public repos must be cloneable without auth):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker exec ramaiah-prod-gitea-1 grep REQUIRE_SIGNIN /data/gitea/conf/app.ini"
|
||||||
|
```
|
||||||
|
|
||||||
|
### npm install crashes with "Exit handler never called"
|
||||||
|
|
||||||
|
Known npm bug in CI containers. Use `yarn` instead of `npm` for the frontend. The sidecar's lockfile is clean so `npm ci` works.
|
||||||
|
|
||||||
|
### Pipeline says "pipeline definition not found"
|
||||||
|
|
||||||
|
The `.woodpecker.yml` file is missing or has invalid YAML. Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/contents/.woodpecker.yml?ref=feature/omnichannel-widget" \
|
||||||
|
-u "helix-admin:Global@2026" | python3 -c "import sys,json;print(json.load(sys.stdin).get('name','NOT FOUND'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Teams notification not arriving
|
||||||
|
|
||||||
|
Verify the webhook secret is set in Woodpecker and the Power Automate workflow is active.
|
||||||
|
|
||||||
|
### Test reports not loading (403/XML error)
|
||||||
|
|
||||||
|
Caddy must strip the Authorization header before proxying to MinIO. Check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"grep -A8 'handle_path /reports' /opt/fortytwo/Caddyfile"
|
||||||
|
```
|
||||||
|
|
||||||
|
Should include `header_up -Authorization`.
|
||||||
|
|
||||||
|
### Manually trigger a pipeline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WP_TOKEN="<woodpecker-api-token>"
|
||||||
|
curl -s -X POST "https://operations.healix360.net/api/repos/1/pipelines" \
|
||||||
|
-H "Authorization: Bearer $WP_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"branch":"feature/omnichannel-widget"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete old pipeline runs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WP_TOKEN="<woodpecker-api-token>"
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
curl -s -X DELETE "https://operations.healix360.net/api/repos/1/pipelines/$i" \
|
||||||
|
-H "Authorization: Bearer $WP_TOKEN"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## E2E Test Coverage
|
||||||
|
|
||||||
|
40 tests across 2 hospitals, 3 roles:
|
||||||
|
|
||||||
|
**Login (4):** branding, invalid creds, supervisor login, auth guard
|
||||||
|
|
||||||
|
**Ramaiah CC Agent (10):** landing, call desk, call history, patients (list + search), appointments, my performance (API + KPI), sidebar, sign-out modal, sign-out complete
|
||||||
|
|
||||||
|
**Ramaiah Supervisor (12):** landing, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
|
||||||
|
|
||||||
|
**Global CC Agent (7):** landing, call history, patients, appointments, my performance, sidebar, sign-out
|
||||||
|
|
||||||
|
**Global Supervisor (5):** landing, patients, appointments, campaigns, settings
|
||||||
|
|
||||||
|
**Auto-cleanup:** Last CC Agent test completes sign-out to release agent session. Setup steps call `/api/maint/unlock-agent` to clear stale locks.
|
||||||
212
docs/defect-fixing-plan.md
Normal file
212
docs/defect-fixing-plan.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# Helix Engage — Defect Fixing Plan
|
||||||
|
|
||||||
|
**Date**: 2026-03-31
|
||||||
|
**Status**: Analysis complete, implementation pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 1: Sidebar navigation during ongoing calls
|
||||||
|
**Status**: NOT A BUG
|
||||||
|
**Finding**: Sidebar is fully functional during calls. No code blocks navigation. Call state persists via Jotai atoms (`sipCallStateAtom`, `sipCallerNumberAtom`, `sipCallUcidAtom`) regardless of which page the agent navigates to. `CallWidget` in `app-shell.tsx` (line 80) renders on non-call-desk pages when a call is active, ensuring the agent can return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 2: Appointment form / Enquiry form visibility during calls
|
||||||
|
**Status**: APPROVED REDESIGN — Convert to modals
|
||||||
|
**Root Cause**: `active-call-card.tsx` renders AppointmentForm and EnquiryForm inside a `max-h-[50vh] overflow-y-auto` container (line 292). After the call header + controls take ~100px, the form is squeezed.
|
||||||
|
|
||||||
|
**Approved approach**: Convert both forms to modal dialogs (like TransferDialog already is).
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
```
|
||||||
|
Agent clicks "Book Appt" → Modal opens → Log intent to LeadActivity → Agent fills form
|
||||||
|
→ Save succeeds → setSuggestedDisposition('APPOINTMENT_BOOKED') → Modal closes
|
||||||
|
→ Save abandoned → No disposition change → Intent logged for supervisor analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for Enquiry → `INFO_PROVIDED` on save, intent logged on open.
|
||||||
|
|
||||||
|
**Files to change**:
|
||||||
|
- `src/components/call-desk/active-call-card.tsx` — replace inline form expansion with modal triggers
|
||||||
|
- `src/components/call-desk/appointment-form.tsx` — wrap in Modal/ModalOverlay from `src/components/application/modals/modal`
|
||||||
|
- `src/components/call-desk/enquiry-form.tsx` — wrap in Modal/ModalOverlay
|
||||||
|
|
||||||
|
**Benefits**: Solves Item 2 (form visibility), Item 10a (returning patient checkbox shift), keeps call card clean.
|
||||||
|
|
||||||
|
**Effort**: Medium (3-4h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 3: Enquiry form disposition + modal disposition context
|
||||||
|
**Status**: REAL ISSUE (two parts)
|
||||||
|
|
||||||
|
### 3a: Remove disposition from enquiry form
|
||||||
|
`enquiry-form.tsx` (lines 19-26, 195-198) has its own disposition field with 6 options (CONVERTED, FOLLOW_UP, GENERAL_QUERY, NO_ANSWER, INVALID_NUMBER, CALL_DROPPED). During an active call, NO_ANSWER and INVALID_NUMBER are nonsensical — the caller is connected.
|
||||||
|
|
||||||
|
**Fix**: Remove disposition field from enquiry form entirely. Disposition is captured in the disposition modal after the call ends. The enquiry form's job is to log the enquiry, not to classify the call outcome.
|
||||||
|
|
||||||
|
**Files**: `src/components/call-desk/enquiry-form.tsx` — remove disposition Select + validation
|
||||||
|
|
||||||
|
### 3b: Context-aware disposition options in modal
|
||||||
|
`disposition-modal.tsx` (lines 15-57) shows all 6 options regardless of call context. During an inbound answered call, "No Answer" and "Wrong Number" don't apply.
|
||||||
|
|
||||||
|
**Fix**: Accept a `callContext` prop ('inbound-answered' | 'outbound' | 'missed-callback') and filter options accordingly:
|
||||||
|
- Inbound answered: show APPOINTMENT_BOOKED, FOLLOW_UP_SCHEDULED, INFO_PROVIDED, CALLBACK_REQUESTED
|
||||||
|
- Outbound: show all
|
||||||
|
- Missed callback: show all
|
||||||
|
|
||||||
|
**Files**: `src/components/call-desk/disposition-modal.tsx`, `src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
**Effort**: Low (2h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 4: Edit future appointment during inbound call
|
||||||
|
**Status**: DONE (2026-03-30)
|
||||||
|
**Implementation**: Context panel (`context-panel.tsx` lines 172-197) shows upcoming appointments with Edit button → opens `AppointmentForm` in edit mode with `existingAppointment` prop. Appointments fetched via `APPOINTMENTS_QUERY` in DataProvider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 5: My Performance page
|
||||||
|
**Status**: THREE SUB-ISSUES
|
||||||
|
|
||||||
|
### 5a: From/To date range filter
|
||||||
|
**Current**: Only Today/Yesterday presets + single date picker in `my-performance.tsx` (lines 95-135).
|
||||||
|
**Fix**: Add two DatePicker components (From/To) or a date range picker. Update API call to accept date range. Update chart/KPI computations to use range.
|
||||||
|
**Effort**: Medium (3-4h)
|
||||||
|
|
||||||
|
### 5b: Time Utilisation not displayed
|
||||||
|
**Current**: Section renders conditionally at line 263 — only if `timeUtilization` is not null. If sidecar API returns null (Ozonetel getAgentSummary fails or VPN blocks), section silently disappears.
|
||||||
|
**Fix**: Add placeholder/error state when null: "Time utilisation data unavailable — check Ozonetel connection"
|
||||||
|
**Effort**: Low (30min)
|
||||||
|
|
||||||
|
### 5c: Data loading slow
|
||||||
|
**Current**: Fetches from `/api/ozonetel/performance` on every date change, no caching.
|
||||||
|
**Fix**: Add response caching (memoize by date key), show skeleton loader during fetch, debounce date changes.
|
||||||
|
**Effort**: Medium (2h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 6: Break and Training status not working
|
||||||
|
**Status**: REAL ISSUE — likely Ozonetel API parameter mismatch
|
||||||
|
|
||||||
|
**Root Cause**: `agent-status-toggle.tsx` (lines 41-64) calls `/api/ozonetel/agent-state` with `{ state: 'Pause', pauseReason: 'Break' }` or `'Training'`. Ozonetel's `changeAgentState` API may expect different pause reason enum values. Errors are caught and shown as generic toast — no specific failure reason.
|
||||||
|
|
||||||
|
**Investigation needed**:
|
||||||
|
1. Check sidecar logs for the actual Ozonetel API response when Break/Training is selected
|
||||||
|
2. Verify Ozonetel API docs for valid `pauseReason` values (may need `BREAK`, `TRAINING`, or numeric codes)
|
||||||
|
3. Check if the agent must be in `Ready` state before transitioning to `Pause`
|
||||||
|
|
||||||
|
**Fix**: Correct pause reason values, add specific error messages.
|
||||||
|
**Effort**: Low-Medium (2-3h including investigation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 7: Auto-refresh for Call Desk, Call History, Appointments
|
||||||
|
**Status**: REAL ISSUE
|
||||||
|
|
||||||
|
| Page | Current | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| Call Desk worklist | YES (30s via `use-worklist.ts`) | Working |
|
||||||
|
| DataProvider (calls, leads, etc.) | NO — `useEffect([fetchData])` runs once | Add `setInterval(fetchData, 30000)` |
|
||||||
|
| Call History | NO — uses `useData()` | Automatic once DataProvider fixed |
|
||||||
|
| Appointments | NO — `useEffect([])` runs once | Add interval or move to DataProvider |
|
||||||
|
|
||||||
|
**Files**: `src/providers/data-provider.tsx` (lines 117-119), `src/pages/appointments.tsx` (lines 76-81)
|
||||||
|
**Effort**: Low (1-2h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 8: Appointments page improvements
|
||||||
|
**Status**: THREE SUB-ISSUES
|
||||||
|
|
||||||
|
### 8a: Appointment ID as primary field
|
||||||
|
**Current**: No ID column in table. `appointments.tsx` shows Patient, Date, Time, Doctor, Department, Branch, Status, Chief Complaint.
|
||||||
|
**Fix**: Add ID column (first column) showing appointment ID or a short reference number.
|
||||||
|
**Effort**: Low (30min)
|
||||||
|
|
||||||
|
### 8b: Edit Appointment option
|
||||||
|
**Current**: No edit button on appointments page (only exists in call desk context panel).
|
||||||
|
**Fix**: Add per-row Edit button → opens AppointmentForm in edit mode (same component, reuse `existingAppointment` prop).
|
||||||
|
**Pending**: Confirmation from Meghana
|
||||||
|
**Effort**: Low (1-2h)
|
||||||
|
|
||||||
|
### 8c: Sort by status
|
||||||
|
**Current**: Tabs filter by status but no column-level sorting.
|
||||||
|
**Fix**: Add `allowsSorting` to table headers + `sortDescriptor`/`onSortChange` (same pattern as worklist).
|
||||||
|
**Pending**: Confirmation from Meghana
|
||||||
|
**Effort**: Low (1h)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 9: AI Surface enlargement + patient historical data
|
||||||
|
**Status**: PARTIALLY DONE
|
||||||
|
|
||||||
|
### 9a: Panel width
|
||||||
|
**Current**: Context panel is `w-[400px]` in `call-desk.tsx` (line 218).
|
||||||
|
**Fix**: Increase to `w-[440px]` or `w-[460px]`.
|
||||||
|
**Effort**: Trivial
|
||||||
|
|
||||||
|
### 9b: Patient historical data
|
||||||
|
**Current**: We added calls, follow-ups, and appointments to context panel (2026-03-30). Shows in "Upcoming" and "Recent" sections. Data requires `patientId` on the lead — populated by caller resolution service.
|
||||||
|
**Verify**: Test with real inbound call to confirmed patient. If lead has no `patientId`, nothing shows.
|
||||||
|
**Effort**: Done — verify only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 10: Multiple issues
|
||||||
|
|
||||||
|
### 10a: Returning Patient checkbox shifts form upward
|
||||||
|
**Status**: WILL BE FIXED by Item 2 (modal conversion). Form in modal has its own layout — checkbox toggle won't affect call card.
|
||||||
|
|
||||||
|
### 10b: Patients page table not scrollable
|
||||||
|
**File**: `src/pages/patients.tsx`
|
||||||
|
**Fix**: Add `overflow-auto` to table container wrapper. Check if outer div has proper `min-h-0` for flex overflow.
|
||||||
|
**Effort**: Trivial (15min)
|
||||||
|
|
||||||
|
### 10c: Call log data not appearing in worklist tabs
|
||||||
|
**Status**: INVESTIGATION NEEDED
|
||||||
|
**Possible causes**:
|
||||||
|
1. Sidecar `/api/worklist` not returning data — check endpoint response
|
||||||
|
2. Calls created via Ozonetel disposition lack `leadId` linkage — can't match to worklist
|
||||||
|
3. Call records created but `callStatus` not set correctly (need `MISSED` for missed tab)
|
||||||
|
**Action**: Check sidecar logs and `/api/worklist` response payload
|
||||||
|
|
||||||
|
### 10d: Missed calls appearing in wrong sub-tabs (Attempted/Completed/Invalid instead of Pending)
|
||||||
|
**Status**: INVESTIGATION NEEDED
|
||||||
|
**Possible cause**: `callbackstatus` field being set to non-null value during call creation. `worklist-panel.tsx` (line 246) routes to Pending when `callbackstatus === 'PENDING_CALLBACK' || !callbackstatus`. If the sidecar sets a status during ingestion, it may skip Pending.
|
||||||
|
**Action**: Check missed call ingestion code in sidecar — what `callbackstatus` is set on creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Item 11: Patient column filter in Call Desk
|
||||||
|
**Status**: NOT A BUG
|
||||||
|
**Finding**: The PATIENT column has `allowsSorting` (added 2026-03-30) which shows a sort arrow. This is a sort control, not a filter. The search box at the top of the worklist filters across name + phone. No separate column-level filter exists. Functionally correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Matrix
|
||||||
|
|
||||||
|
| Priority | Items | Total Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0 — Do first** | #2 (modal conversion — solves 2, 10a), #7 (auto-refresh), #3 (disposition context) | ~7h |
|
||||||
|
| **P1 — Quick wins** | #8a (appt ID), #8c (sort), #9a (panel width), #10b (scroll fix), #5b (time util placeholder) | ~3h |
|
||||||
|
| **P2 — Medium** | #5a (date range), #5c (loading perf), #6 (break/training debug), #8b (edit appt) | ~8h |
|
||||||
|
| **P3 — Investigation** | #10c (call log data), #10d (missed call routing) | ~2h investigation |
|
||||||
|
| **Done** | #1, #4, #9b, #11 | — |
|
||||||
|
|
||||||
|
## Data Seeding (separate from defects)
|
||||||
|
|
||||||
|
### Patient/Lead seeding
|
||||||
|
| Name | Phone | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Ganesh Bandi | 8885540404 | Create patient + lead, interestedService: "Back Pain" |
|
||||||
|
| Meghana | 7702055204 | Update existing "Unknown" patient + lead, interestedService: "Hair Loss" |
|
||||||
|
|
||||||
|
### CC Agent profiles (completed)
|
||||||
|
```
|
||||||
|
Agent Email Password Ozonetel ID SIP Ext Campaign
|
||||||
|
-------- ---------------------------- --------- -------------- -------- ----------------------
|
||||||
|
Rekha S rekha.cc@globalhospital.com Test123$ global 523590 Inbound_918041763265
|
||||||
|
Ganesh ganesh.cc@globalhospital.com Test123$ globalhealthx 523591 Inbound_918041763265
|
||||||
|
```
|
||||||
399
docs/developer-operations-runbook.md
Normal file
399
docs/developer-operations-runbook.md
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# Helix Engage — Developer Operations Runbook
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [architecture.md](./architecture.md) for the full multi-tenant topology diagram.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (India)
|
||||||
|
↓ HTTPS
|
||||||
|
Caddy (reverse proxy, TLS, host-routed)
|
||||||
|
├── ramaiah.engage.healix360.net → sidecar-ramaiah:4100
|
||||||
|
├── global.engage.healix360.net → sidecar-global:4100
|
||||||
|
├── telephony.engage.healix360.net → telephony:4200
|
||||||
|
├── *.app.healix360.net → server:4000 (platform)
|
||||||
|
├── monitoring.healix360.net → grafana:3000
|
||||||
|
├── operations.healix360.net → woodpecker-server:8000
|
||||||
|
├── git.healix360.net → gitea:3000
|
||||||
|
└── engage.healix360.net → 404 (no catchall)
|
||||||
|
|
||||||
|
Docker Compose stack (EC2 — 13.234.31.194):
|
||||||
|
├── caddy — Reverse proxy + TLS (Let's Encrypt)
|
||||||
|
├── server — FortyTwo platform (NestJS, port 4000)
|
||||||
|
├── worker — BullMQ background jobs
|
||||||
|
├── sidecar-ramaiah — Ramaiah sidecar (NestJS, port 4100)
|
||||||
|
├── sidecar-global — Global sidecar (NestJS, port 4100)
|
||||||
|
├── telephony — Event dispatcher (NestJS, port 4200)
|
||||||
|
├── redis-ramaiah — Ramaiah sidecar Redis
|
||||||
|
├── redis-global — Global sidecar Redis
|
||||||
|
├── redis-telephony — Telephony dispatcher Redis
|
||||||
|
├── redis — Platform Redis
|
||||||
|
├── db — PostgreSQL 16 (workspace-per-schema)
|
||||||
|
├── clickhouse — Analytics
|
||||||
|
├── minio — S3-compatible object storage
|
||||||
|
├── redpanda — Event bus (Kafka-compatible)
|
||||||
|
├── loki — Log aggregation (receives from Docker logging driver)
|
||||||
|
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EC2 Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH into EC2 (key passphrase handled by sshpass)
|
||||||
|
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||||
|
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||||
|
```
|
||||||
|
|
||||||
|
| Detail | Value |
|
||||||
|
|---|---|
|
||||||
|
| Host | `13.234.31.194` |
|
||||||
|
| User | `ubuntu` |
|
||||||
|
| SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
|
||||||
|
| Passphrase | `SasiSuman@2007` |
|
||||||
|
| Docker compose dir | `/opt/fortytwo` |
|
||||||
|
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
||||||
|
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
||||||
|
|
||||||
|
### SSH Helper
|
||||||
|
|
||||||
|
The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
|
||||||
|
No need to decrypt or copy the key — use the original file directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH shorthand
|
||||||
|
EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
eval $EC2_SSH hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---|---|
|
||||||
|
| Ramaiah Engage (Frontend + API) | `https://ramaiah.engage.healix360.net` |
|
||||||
|
| Global Engage (Frontend + API) | `https://global.engage.healix360.net` |
|
||||||
|
| Ramaiah Platform | `https://ramaiah.app.healix360.net` |
|
||||||
|
| Global Platform | `https://global.app.healix360.net` |
|
||||||
|
| Telephony Dispatcher | `https://telephony.engage.healix360.net` |
|
||||||
|
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
|
||||||
|
| CI/CD (Woodpecker) | `https://operations.healix360.net` |
|
||||||
|
| Git (Gitea) | `https://git.healix360.net` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login Credentials
|
||||||
|
|
||||||
|
### Ramaiah Workspace
|
||||||
|
|
||||||
|
| Role | Email | Password |
|
||||||
|
|---|---|---|
|
||||||
|
| Marketing Executive | `marketing@ramaiahcare.com` | `AdRamaiah@2026` |
|
||||||
|
| Marketing Executive | `supervisor@ramaiahcare.com` | `MrRamaiah@2026` |
|
||||||
|
| CC Agent | `ccagent@ramaiahcare.com` | `CcRamaiah@2026` |
|
||||||
|
| Platform Admin | `dev@fortytwo.dev` | `tim@apple.dev` |
|
||||||
|
|
||||||
|
### Ozonetel
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| API Key | `KK8110e6c3de02527f7243ffaa924fa93e` |
|
||||||
|
| Username | `global_healthx` |
|
||||||
|
| Ramaiah Campaign | `Inbound_918041763400` |
|
||||||
|
| Ramaiah Agent | `ramaiahadmin` / ext `524435` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Frontend (Vite dev server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npx tsc --noEmit # Type check
|
||||||
|
npm run build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.env.local` controls which sidecar the frontend talks to:
|
||||||
|
```bash
|
||||||
|
# Remote (default — uses EC2 backend)
|
||||||
|
VITE_API_URL=https://ramaiah.engage.healix360.net
|
||||||
|
|
||||||
|
# Local sidecar
|
||||||
|
# VITE_API_URL=http://localhost:4100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidecar (NestJS dev server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
npm run start:dev # http://localhost:4100 (watch mode)
|
||||||
|
npm run build # Build only
|
||||||
|
```
|
||||||
|
|
||||||
|
Sidecar `.env` must have:
|
||||||
|
```bash
|
||||||
|
PLATFORM_GRAPHQL_URL=https://ramaiah.app.healix360.net/graphql
|
||||||
|
PLATFORM_API_KEY=<Ramaiah workspace API key>
|
||||||
|
PLATFORM_WORKSPACE_SUBDOMAIN=ramaiah
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-deploy checklist
|
||||||
|
|
||||||
|
1. `npx tsc --noEmit` — passes (frontend)
|
||||||
|
2. `npm run build` — succeeds (sidecar)
|
||||||
|
3. Test the changed feature locally
|
||||||
|
4. Check `package.json` for new dependencies → decides quick vs full deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Helper — reuse in all commands below
|
||||||
|
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
|
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
|
||||||
|
rsync -avz -e "$EC2_RSYNC" \
|
||||||
|
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
||||||
|
|
||||||
|
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidecar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
|
||||||
|
# 1. Login to ECR
|
||||||
|
aws ecr get-login-password --region ap-south-1 | \
|
||||||
|
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||||
|
|
||||||
|
# 2. Build and push Docker image
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
# 3. Pull and restart on EC2
|
||||||
|
eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to decide
|
||||||
|
|
||||||
|
```
|
||||||
|
Did package.json change?
|
||||||
|
├── YES → ECR build + push + pull (above)
|
||||||
|
└── NO → Same steps (ECR is the only deploy path for EC2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Deploy: E2E Smoke Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage
|
||||||
|
npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
27 tests covering login, all CC Agent pages, all Supervisor pages, and sign-out.
|
||||||
|
The last test completes sign-out so the agent session is released for the next run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ramaiah sidecar
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 30 2>&1"
|
||||||
|
|
||||||
|
# Follow live
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-sidecar-ramaiah-1 -f --tail 10 2>&1"
|
||||||
|
|
||||||
|
# Filter errors
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-sidecar-ramaiah-1 --tail 100 2>&1" | grep -i "error\|fail"
|
||||||
|
|
||||||
|
# Telephony dispatcher
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
|
||||||
|
|
||||||
|
# Caddy
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-caddy-1 --tail 20 2>&1"
|
||||||
|
|
||||||
|
# Platform server
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-server-1 --tail 30 2>&1"
|
||||||
|
|
||||||
|
# All container status
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker ps --format 'table {{.Names}}\t{{.Status}}'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Healthy startup
|
||||||
|
|
||||||
|
Look for these in sidecar logs:
|
||||||
|
```
|
||||||
|
[NestApplication] Nest application successfully started
|
||||||
|
Helix Engage Server running on port 4100
|
||||||
|
[SessionService] Redis connected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common failure patterns
|
||||||
|
|
||||||
|
| Log pattern | Meaning | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Cannot find module 'xxx'` | Missing npm dependency | Rebuild ECR image |
|
||||||
|
| `UndefinedModuleException` | Circular dependency | Fix code, redeploy |
|
||||||
|
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose up -d redis-ramaiah` |
|
||||||
|
| `Forbidden resource` | Platform permission issue | Check user roles |
|
||||||
|
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSH="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
|
REDIS="docker exec ramaiah-prod-redis-ramaiah-1 redis-cli"
|
||||||
|
|
||||||
|
# Clear agent session lock (fixes "already logged in from another device")
|
||||||
|
$SSH "$REDIS DEL agent:session:ramaiahadmin"
|
||||||
|
|
||||||
|
# List all keys
|
||||||
|
$SSH "$REDIS KEYS '*'"
|
||||||
|
|
||||||
|
# Clear caller cache (stale patient names)
|
||||||
|
$SSH "$REDIS --scan --pattern 'caller:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||||
|
|
||||||
|
# Clear masterdata cache (departments/doctors/clinics/slots)
|
||||||
|
$SSH "$REDIS --scan --pattern 'masterdata:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||||
|
|
||||||
|
# Clear recording analysis cache
|
||||||
|
$SSH "$REDIS --scan --pattern 'call:analysis:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||||
|
|
||||||
|
# Clear agent name cache
|
||||||
|
$SSH "$REDIS --scan --pattern 'agent:name:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
|
||||||
|
|
||||||
|
# Nuclear: flush all sidecar Redis
|
||||||
|
$SSH "$REDIS FLUSHDB"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker exec -it ramaiah-prod-db-1 psql -U fortytwo -d fortytwo_eap"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- List workspace schemas
|
||||||
|
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'workspace_%';
|
||||||
|
|
||||||
|
-- List custom entities
|
||||||
|
SELECT "nameSingular", "isCustom" FROM core."objectMetadata" ORDER BY "nameSingular";
|
||||||
|
|
||||||
|
-- List users
|
||||||
|
SELECT u.email, u."firstName", u."lastName", uw.id as workspace_id
|
||||||
|
FROM core."user" u
|
||||||
|
JOIN core."userWorkspace" uw ON uw."userId" = u.id;
|
||||||
|
|
||||||
|
-- List roles
|
||||||
|
SELECT r.label, rt."userWorkspaceId"
|
||||||
|
FROM core."roleTarget" rt
|
||||||
|
JOIN core."role" r ON r.id = rt."roleId";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Already logged in from another device"
|
||||||
|
|
||||||
|
Single-session enforcement per Ozonetel agent. Clear the lock:
|
||||||
|
```bash
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker exec ramaiah-prod-redis-ramaiah-1 redis-cli DEL agent:session:ramaiahadmin"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agent stuck in ACW / Wrapping Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://ramaiah.engage.healix360.net/api/maint/force-ready \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"agentId": "ramaiahadmin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telephony events not routing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check dispatcher logs
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker logs ramaiah-prod-telephony-1 --tail 30 2>&1"
|
||||||
|
|
||||||
|
# Check service discovery registry
|
||||||
|
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||||
|
"docker exec ramaiah-prod-redis-telephony-1 redis-cli KEYS '*'"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme/branding reset after Redis flush
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT https://ramaiah.engage.healix360.net/api/config/theme \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"defaults": {"brandName": "Helix Engage", "hospitalName": "Ramaiah Hospitals"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Checkout previous commit → `npm run build` → rsync to EC2.
|
||||||
|
|
||||||
|
### Sidecar
|
||||||
|
|
||||||
|
Checkout previous commit → ECR build + push → pull on EC2.
|
||||||
|
|
||||||
|
For immediate rollback, re-tag a known-good ECR image as `:alpha` and pull.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Repositories
|
||||||
|
|
||||||
|
| Repo | Azure DevOps | Branch |
|
||||||
|
|---|---|---|
|
||||||
|
| Frontend | `helix-engage` in Patient Engagement Platform | `feature/omnichannel-widget` |
|
||||||
|
| Sidecar | `helix-engage-server` in Patient Engagement Platform | `master` |
|
||||||
|
| SDK App | `FortyTwoApps/helix-engage/` (monorepo) | `dev` |
|
||||||
|
| Telephony | `helix-engage-telephony` in Patient Engagement Platform | `master` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ECR Details
|
||||||
|
|
||||||
|
| Detail | Value |
|
||||||
|
|---|---|
|
||||||
|
| Registry | `043728036361.dkr.ecr.ap-south-1.amazonaws.com` |
|
||||||
|
| Sidecar repo | `fortytwo-eap/helix-engage-sidecar` |
|
||||||
|
| Tag | `alpha` |
|
||||||
|
| Region | `ap-south-1` (Mumbai) |
|
||||||
612
docs/generate-pptx-apr06-11.cjs
Normal file
612
docs/generate-pptx-apr06-11.cjs
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Weekly Update (Apr 6–11, 2026)
|
||||||
|
* "Clinical Precision" design — dark/light alternating, geometric, executive healthcare
|
||||||
|
*/
|
||||||
|
const PptxGenJS = require("pptxgenjs");
|
||||||
|
|
||||||
|
// ── Design System ───────────────────────────────────────────────
|
||||||
|
const P = {
|
||||||
|
// Dark palette (hero slides)
|
||||||
|
navyDeep: "0F172A", // slate-900
|
||||||
|
navyMid: "1E293B", // slate-800
|
||||||
|
navyLight: "334155", // slate-700
|
||||||
|
|
||||||
|
// Light palette (content slides)
|
||||||
|
white: "FFFFFF",
|
||||||
|
snow: "F8FAFC", // slate-50
|
||||||
|
mist: "F1F5F9", // slate-100
|
||||||
|
silver: "E2E8F0", // slate-200
|
||||||
|
|
||||||
|
// Text
|
||||||
|
inkDark: "0F172A",
|
||||||
|
inkMid: "475569", // slate-600
|
||||||
|
inkLight: "94A3B8", // slate-400
|
||||||
|
inkOnDark: "F1F5F9",
|
||||||
|
inkMuted: "64748B", // slate-500
|
||||||
|
|
||||||
|
// Accents — healthcare-inspired
|
||||||
|
teal: "0D9488", // primary brand
|
||||||
|
tealLight: "14B8A6",
|
||||||
|
tealPale: "CCFBF1", // teal-100
|
||||||
|
blue: "0284C7", // sky-600
|
||||||
|
blueLight: "38BDF8",
|
||||||
|
indigo: "4F46E5",
|
||||||
|
amber: "D97706",
|
||||||
|
rose: "E11D48",
|
||||||
|
emerald: "059669",
|
||||||
|
violet: "7C3AED",
|
||||||
|
};
|
||||||
|
|
||||||
|
const F = "Calibri"; // Clean, universally available
|
||||||
|
const FB = "Calibri Light";
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
function sn(s, n) {
|
||||||
|
s.addText(`${n}`, {
|
||||||
|
x: 9.3, y: 5.15, w: 0.5, h: 0.3,
|
||||||
|
fontSize: 8, color: P.inkLight, fontFace: FB, align: "right",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkSlide(pptx) {
|
||||||
|
const s = pptx.addSlide();
|
||||||
|
s.background = { color: P.navyDeep };
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightSlide(pptx) {
|
||||||
|
const s = pptx.addSlide();
|
||||||
|
s.background = { color: P.white };
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin teal accent line at top
|
||||||
|
function topLine(s, color) {
|
||||||
|
s.addShape("rect", { x: 0, y: 0, w: 10, h: 0.04, fill: { color: color || P.teal } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section label pill
|
||||||
|
function pill(s, text, color, x, y) {
|
||||||
|
const w = text.length * 0.075 + 0.5;
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x, y, w, h: 0.26,
|
||||||
|
fill: { color, transparency: 85 },
|
||||||
|
rectRadius: 0.13,
|
||||||
|
});
|
||||||
|
s.addText(text.toUpperCase(), {
|
||||||
|
x, y, w, h: 0.26,
|
||||||
|
fontSize: 7, fontFace: F, bold: true, color,
|
||||||
|
align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metric block (for dark slides)
|
||||||
|
function metric(s, { x, y, value, label, color, w = 2.0 }) {
|
||||||
|
// Subtle card
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x, y, w, h: 1.4,
|
||||||
|
fill: { color: P.navyMid },
|
||||||
|
line: { color: P.navyLight, width: 0.5 },
|
||||||
|
rectRadius: 0.08,
|
||||||
|
});
|
||||||
|
// Accent top bar
|
||||||
|
s.addShape("rect", { x: x + 0.15, y: y + 0.06, w: w - 0.3, h: 0.025, fill: { color } });
|
||||||
|
// Value
|
||||||
|
s.addText(value, {
|
||||||
|
x, y: y + 0.15, w, h: 0.75,
|
||||||
|
fontSize: 38, fontFace: F, bold: true, color,
|
||||||
|
align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
// Label
|
||||||
|
s.addText(label, {
|
||||||
|
x, y: y + 0.9, w, h: 0.35,
|
||||||
|
fontSize: 9, fontFace: FB, color: P.inkLight,
|
||||||
|
align: "center", valign: "top",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content card (for light slides)
|
||||||
|
function card(s, { x, y, w, h, title, accent, items }) {
|
||||||
|
// Card with left accent border
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x, y, w, h,
|
||||||
|
fill: { color: P.snow },
|
||||||
|
line: { color: P.silver, width: 0.5 },
|
||||||
|
rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
// Left accent bar
|
||||||
|
s.addShape("rect", { x, y: y + 0.1, w: 0.035, h: h - 0.2, fill: { color: accent } });
|
||||||
|
// Title
|
||||||
|
s.addText(title, {
|
||||||
|
x: x + 0.25, y: y + 0.08, w: w - 0.4, h: 0.32,
|
||||||
|
fontSize: 10.5, fontFace: F, bold: true, color: accent,
|
||||||
|
});
|
||||||
|
// Items
|
||||||
|
if (items?.length) {
|
||||||
|
s.addText(
|
||||||
|
items.map(t => ({
|
||||||
|
text: t,
|
||||||
|
options: {
|
||||||
|
fontSize: 8.5, fontFace: FB, color: P.inkMid,
|
||||||
|
bullet: { code: "2022" }, // bullet dot
|
||||||
|
paraSpaceAfter: 3, breakLine: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ x: x + 0.25, y: y + 0.4, w: w - 0.5, h: h - 0.5, valign: "top", lineSpacingMultiple: 1.15 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section heading for light slides
|
||||||
|
function sectionHead(s, title, subtitle) {
|
||||||
|
s.addText(title, {
|
||||||
|
x: 0.6, y: 0.35, w: 8, h: 0.45,
|
||||||
|
fontSize: 22, fontFace: F, bold: true, color: P.inkDark,
|
||||||
|
});
|
||||||
|
if (subtitle) {
|
||||||
|
s.addText(subtitle, {
|
||||||
|
x: 0.6, y: 0.78, w: 8, h: 0.3,
|
||||||
|
fontSize: 10, fontFace: FB, color: P.inkMuted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════════════════
|
||||||
|
async function build() {
|
||||||
|
const pptx = new PptxGenJS();
|
||||||
|
pptx.layout = "LAYOUT_16x9";
|
||||||
|
pptx.author = "Satya Suman Sari";
|
||||||
|
pptx.company = "FortyTwo Platform";
|
||||||
|
pptx.title = "Helix Engage — Weekly Update (Apr 6–11, 2026)";
|
||||||
|
|
||||||
|
// ─── SLIDE 1: Title (Dark) ────────────────────────────────────
|
||||||
|
{
|
||||||
|
const s = darkSlide(pptx);
|
||||||
|
topLine(s, P.teal);
|
||||||
|
|
||||||
|
// Geometric accent — vertical teal line
|
||||||
|
s.addShape("rect", { x: 0.6, y: 1.2, w: 0.035, h: 2.8, fill: { color: P.teal } });
|
||||||
|
|
||||||
|
pill(s, "Weekly Status", P.tealLight, 0.85, 1.3);
|
||||||
|
|
||||||
|
s.addText("Helix Engage", {
|
||||||
|
x: 0.85, y: 1.7, w: 7, h: 0.9,
|
||||||
|
fontSize: 42, fontFace: F, bold: true, color: P.white,
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Engineering Progress Report", {
|
||||||
|
x: 0.85, y: 2.5, w: 7, h: 0.4,
|
||||||
|
fontSize: 16, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date block
|
||||||
|
s.addShape("rect", { x: 0.85, y: 3.2, w: 2.2, h: 0.04, fill: { color: P.teal, transparency: 50 } });
|
||||||
|
s.addText("April 6 – 11, 2026", {
|
||||||
|
x: 0.85, y: 3.35, w: 3, h: 0.3,
|
||||||
|
fontSize: 11, fontFace: F, bold: true, color: P.tealLight,
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Satya Suman Sari | FortyTwo Platform", {
|
||||||
|
x: 0.85, y: 4.8, w: 5, h: 0.25,
|
||||||
|
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
sn(s, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 2: At a Glance (Dark) ─────────────────────────────
|
||||||
|
{
|
||||||
|
const s = darkSlide(pptx);
|
||||||
|
topLine(s, P.teal);
|
||||||
|
|
||||||
|
pill(s, "Overview", P.tealLight, 0.5, 0.3);
|
||||||
|
s.addText("Week at a Glance", {
|
||||||
|
x: 0.5, y: 0.6, w: 5, h: 0.45,
|
||||||
|
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||||
|
});
|
||||||
|
|
||||||
|
metric(s, { x: 0.5, y: 1.25, value: "57", label: "Commits Shipped", color: P.blueLight, w: 2.05 });
|
||||||
|
metric(s, { x: 2.7, y: 1.25, value: "9", label: "Defects Resolved", color: P.rose, w: 2.05 });
|
||||||
|
metric(s, { x: 4.9, y: 1.25, value: "40", label: "E2E Tests Passing", color: P.emerald, w: 2.05 });
|
||||||
|
metric(s, { x: 7.1, y: 1.25, value: "17", label: "Docker Containers", color: P.violet, w: 2.05 });
|
||||||
|
|
||||||
|
// Key highlights
|
||||||
|
const highlights = [
|
||||||
|
"Multi-tenant EC2 architecture deployed — Ramaiah + Global on single instance",
|
||||||
|
"Woodpecker CI/CD pipeline operational with Teams notifications",
|
||||||
|
"Cross-tenant security vulnerability identified and patched",
|
||||||
|
"Complete documentation: architecture, runbook, CI/CD guide",
|
||||||
|
];
|
||||||
|
s.addText(
|
||||||
|
highlights.map(h => ({
|
||||||
|
text: h,
|
||||||
|
options: {
|
||||||
|
fontSize: 10, fontFace: FB, color: P.inkOnDark,
|
||||||
|
bullet: { code: "25B8" }, paraSpaceAfter: 6, breakLine: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ x: 0.6, y: 2.9, w: 8.5, h: 2.0, valign: "top", lineSpacingMultiple: 1.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
sn(s, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 3: Defect Fixes (Light) ────────────────────────────
|
||||||
|
{
|
||||||
|
const s = lightSlide(pptx);
|
||||||
|
topLine(s, P.rose);
|
||||||
|
sectionHead(s, "Defect Resolution", "9 of 17 triaged bugs fixed and deployed this week");
|
||||||
|
|
||||||
|
const bugs = [
|
||||||
|
["#527", "Appointment creation overwrites patient details"],
|
||||||
|
["#529", "Break/Training status doesn't block outbound calls"],
|
||||||
|
["#531", "Agent can log out during an active call"],
|
||||||
|
["#533", "Redundant Call History page header"],
|
||||||
|
["#534", "Redundant Patients page header"],
|
||||||
|
["#536", "My Performance displays wrong agent data"],
|
||||||
|
["#538", "Supervisor dashboard metrics incorrect"],
|
||||||
|
["#540", "Ghost calls visible for logged-out agents"],
|
||||||
|
["#547", "SLA priority rules not reflected in worklist"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
[
|
||||||
|
{ text: "ID", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||||
|
{ text: "Description", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||||
|
{ text: "Status", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||||
|
],
|
||||||
|
...bugs.map(([id, desc], i) => [
|
||||||
|
{ text: id, options: { fontSize: 8.5, fontFace: F, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||||
|
{ text: desc, options: { fontSize: 8.5, fontFace: FB, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||||
|
{ text: "Resolved", options: { fontSize: 8.5, fontFace: F, bold: true, color: P.emerald, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
s.addTable(rows, {
|
||||||
|
x: 0.5, y: 1.2, w: 9.0,
|
||||||
|
border: { type: "solid", pt: 0.3, color: P.silver },
|
||||||
|
colW: [0.7, 6.6, 1.7], rowH: 0.36,
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Deferred by product: #516 recordings | #517 AI transcription | #519 supervisor calling | #539 real-time missed calls | #541 whisper/barge", {
|
||||||
|
x: 0.5, y: 4.9, w: 9, h: 0.3,
|
||||||
|
fontSize: 7.5, fontFace: FB, color: P.inkLight, italic: true,
|
||||||
|
});
|
||||||
|
sn(s, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 4: Security Fix (Dark) ────────────────────────────
|
||||||
|
{
|
||||||
|
const s = darkSlide(pptx);
|
||||||
|
topLine(s, P.rose);
|
||||||
|
|
||||||
|
pill(s, "Security", P.rose, 0.5, 0.3);
|
||||||
|
s.addText("Cross-Tenant Isolation Vulnerability", {
|
||||||
|
x: 0.5, y: 0.6, w: 9, h: 0.45,
|
||||||
|
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||||
|
});
|
||||||
|
s.addText("Discovered and patched within the same sprint", {
|
||||||
|
x: 0.5, y: 1.0, w: 9, h: 0.3,
|
||||||
|
fontSize: 10, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Problem
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 0.4, y: 1.5, w: 4.4, h: 2.6,
|
||||||
|
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.rose } });
|
||||||
|
s.addText("Impact", {
|
||||||
|
x: 0.65, y: 1.55, w: 3, h: 0.3,
|
||||||
|
fontSize: 11, fontFace: F, bold: true, color: P.rose,
|
||||||
|
});
|
||||||
|
s.addText(
|
||||||
|
[
|
||||||
|
"Shared OZONETEL_AGENT_ID env var across sidecars",
|
||||||
|
"6 endpoints used silent fallback to wrong agent",
|
||||||
|
"Ramaiah operations could modify Global's session",
|
||||||
|
"Agent state, disposition, dial, metrics all affected",
|
||||||
|
"No error or warning — completely silent",
|
||||||
|
].map(t => ({
|
||||||
|
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
|
||||||
|
})),
|
||||||
|
{ x: 0.65, y: 1.9, w: 3.9, h: 2.0, valign: "top" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolution
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 5.1, y: 1.5, w: 4.5, h: 2.6,
|
||||||
|
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.emerald } });
|
||||||
|
s.addText("Resolution", {
|
||||||
|
x: 5.35, y: 1.55, w: 3, h: 0.3,
|
||||||
|
fontSize: 11, fontFace: F, bold: true, color: P.emerald,
|
||||||
|
});
|
||||||
|
s.addText(
|
||||||
|
[
|
||||||
|
"Removed all defaultAgentId fallbacks",
|
||||||
|
"All 6 endpoints now require agentId (400 if absent)",
|
||||||
|
"Frontend sends agentId from localStorage",
|
||||||
|
"OZONETEL_AGENT_ID removed from config entirely",
|
||||||
|
"Verified with 40 E2E tests — zero regressions",
|
||||||
|
].map(t => ({
|
||||||
|
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
|
||||||
|
})),
|
||||||
|
{ x: 5.35, y: 1.9, w: 4.0, h: 2.0, valign: "top" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean layers footer
|
||||||
|
s.addText("Unaffected layers: Login (DB lookup) | Telephony dispatcher (event payload) | Sidecar registration (GraphQL) | Supervisor (webhook events)", {
|
||||||
|
x: 0.5, y: 4.4, w: 9, h: 0.3,
|
||||||
|
fontSize: 7.5, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
sn(s, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 5: EC2 Architecture (Light) ────────────────────────
|
||||||
|
{
|
||||||
|
const s = lightSlide(pptx);
|
||||||
|
topLine(s, P.blue);
|
||||||
|
sectionHead(s, "AWS EC2 Multi-Tenant Architecture", "Single instance, strict tenant isolation, host-routed Caddy");
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 0.4, y: 1.2, w: 4.4, h: 2.0,
|
||||||
|
title: "Shared Platform Layer", accent: P.blue,
|
||||||
|
items: [
|
||||||
|
"NestJS server — multi-tenant by Origin header",
|
||||||
|
"PostgreSQL 16 with workspace-per-schema",
|
||||||
|
"BullMQ worker, ClickHouse analytics, Redpanda events",
|
||||||
|
"MinIO S3-compatible object storage",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 5.1, y: 1.2, w: 4.5, h: 2.0,
|
||||||
|
title: "Isolated Sidecar Layer", accent: P.amber,
|
||||||
|
items: [
|
||||||
|
"Per-hospital: sidecar + Redis + data volume",
|
||||||
|
"Caddy host-routes — no catchall, no cross-tenant",
|
||||||
|
"ramaiah.engage.healix360.net \u2192 sidecar-ramaiah",
|
||||||
|
"global.engage.healix360.net \u2192 sidecar-global",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 0.4, y: 3.4, w: 4.4, h: 1.7,
|
||||||
|
title: "Telephony Dispatcher", accent: P.teal,
|
||||||
|
items: [
|
||||||
|
"Routes Ozonetel events by agentId via Redis lookup",
|
||||||
|
"Sidecars self-register on boot with heartbeat",
|
||||||
|
"Zero config when onboarding new hospitals",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 5.1, y: 3.4, w: 4.5, h: 1.7,
|
||||||
|
title: "Live Endpoints", accent: P.indigo,
|
||||||
|
items: [
|
||||||
|
"ramaiah.engage / global.engage — Hospital UIs",
|
||||||
|
"telephony.engage — Event dispatcher",
|
||||||
|
"operations — CI/CD dashboard",
|
||||||
|
"git — Gitea forge (mirrors Azure DevOps)",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
sn(s, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 6: E2E Tests (Dark) ────────────────────────────────
|
||||||
|
{
|
||||||
|
const s = darkSlide(pptx);
|
||||||
|
topLine(s, P.emerald);
|
||||||
|
|
||||||
|
pill(s, "Quality Assurance", P.emerald, 0.5, 0.3);
|
||||||
|
s.addText("40 Automated E2E Tests", {
|
||||||
|
x: 0.5, y: 0.6, w: 9, h: 0.45,
|
||||||
|
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||||
|
});
|
||||||
|
s.addText("Playwright smoke tests covering every page across both hospitals", {
|
||||||
|
x: 0.5, y: 1.0, w: 9, h: 0.3,
|
||||||
|
fontSize: 10, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ramaiah
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 0.4, y: 1.5, w: 4.4, h: 2.4,
|
||||||
|
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.amber } });
|
||||||
|
s.addText("Ramaiah Hospitals — 27 tests", {
|
||||||
|
x: 0.65, y: 1.55, w: 4, h: 0.3,
|
||||||
|
fontSize: 10.5, fontFace: F, bold: true, color: P.amber,
|
||||||
|
});
|
||||||
|
s.addText(
|
||||||
|
[
|
||||||
|
"Login flow: branding, credentials, auth guard (4)",
|
||||||
|
"CC Agent: call desk, history, patients, appointments, performance, sidebar, sign-out (10)",
|
||||||
|
"Supervisor: dashboard, team perf, live monitor, all data pages, settings (12)",
|
||||||
|
"Auth setup with auto session unlock (1)",
|
||||||
|
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
|
||||||
|
{ x: 0.65, y: 1.9, w: 3.9, h: 1.8, valign: "top" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Global
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 5.1, y: 1.5, w: 4.5, h: 2.4,
|
||||||
|
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.blueLight } });
|
||||||
|
s.addText("Global Hospital — 13 tests", {
|
||||||
|
x: 5.35, y: 1.55, w: 4, h: 0.3,
|
||||||
|
fontSize: 10.5, fontFace: F, bold: true, color: P.blueLight,
|
||||||
|
});
|
||||||
|
s.addText(
|
||||||
|
[
|
||||||
|
"CC Agent: landing, history, patients, appointments, performance, sidebar, sign-out (7)",
|
||||||
|
"Supervisor: landing, patients, appointments, campaigns, settings (5)",
|
||||||
|
"Auth setup with auto session unlock (1)",
|
||||||
|
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
|
||||||
|
{ x: 5.35, y: 1.9, w: 4.0, h: 1.8, valign: "top" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Self-healing footer
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 0.4, y: 4.15, w: 9.2, h: 0.85,
|
||||||
|
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addText("Self-Healing", {
|
||||||
|
x: 0.65, y: 4.2, w: 2, h: 0.25,
|
||||||
|
fontSize: 9, fontFace: F, bold: true, color: P.emerald,
|
||||||
|
});
|
||||||
|
s.addText("Auto-clears session locks before login | Completes sign-out after tests | Runs against live EC2, not mocked | ~6 min on Woodpecker CI", {
|
||||||
|
x: 0.65, y: 4.5, w: 8.5, h: 0.3,
|
||||||
|
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
sn(s, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 7: CI/CD (Light) ───────────────────────────────────
|
||||||
|
{
|
||||||
|
const s = lightSlide(pptx);
|
||||||
|
topLine(s, P.indigo);
|
||||||
|
sectionHead(s, "CI/CD Pipeline", "Automated testing, report publishing, and team notifications");
|
||||||
|
|
||||||
|
// Flow bar
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
|
||||||
|
fill: { color: P.mist }, line: { color: P.silver, width: 0.5 }, rectRadius: 0.06,
|
||||||
|
});
|
||||||
|
s.addText("Azure DevOps \u2192 Gitea Mirror \u2192 Woodpecker Pipeline \u2192 MinIO Reports \u2192 Teams Alert", {
|
||||||
|
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
|
||||||
|
fontSize: 9.5, fontFace: F, bold: true, color: P.indigo, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 0.4, y: 1.75, w: 4.4, h: 1.7,
|
||||||
|
title: "Frontend Pipeline", accent: P.blue,
|
||||||
|
items: [
|
||||||
|
"TypeScript typecheck (yarn tsc --noEmit)",
|
||||||
|
"40 Playwright E2E tests against live EC2",
|
||||||
|
"HTML report uploaded to MinIO (S3 plugin)",
|
||||||
|
"Teams Adaptive Card with report link",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 5.1, y: 1.75, w: 4.5, h: 1.7,
|
||||||
|
title: "Sidecar Pipeline", accent: P.violet,
|
||||||
|
items: [
|
||||||
|
"Jest unit tests (npm ci + jest --ci)",
|
||||||
|
"Teams notification on pass or fail",
|
||||||
|
"Triggered on push or manual run",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
card(s, {
|
||||||
|
x: 0.4, y: 3.65, w: 9.2, h: 1.4,
|
||||||
|
title: "Operations Dashboard", accent: P.teal,
|
||||||
|
items: [
|
||||||
|
"operations.healix360.net — Woodpecker CI with full build history and logs",
|
||||||
|
"operations.healix360.net/reports/{run}/ — Playwright HTML reports with screenshots (basic auth protected)",
|
||||||
|
"git.healix360.net — Gitea forge mirroring Azure DevOps every 15 minutes",
|
||||||
|
"Teams 'Deployment updates' channel receives Adaptive Cards with pass/fail count and report link",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
sn(s, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 8: Timeline (Light) ────────────────────────────────
|
||||||
|
{
|
||||||
|
const s = lightSlide(pptx);
|
||||||
|
topLine(s, P.teal);
|
||||||
|
sectionHead(s, "Development Timeline");
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{ date: "Apr 6 Sun", title: "Onboarding Wizard", desc: "6-phase setup wizard, widget config, telephony/AI CRUD, team invite, clinic/doctor management", color: P.blue },
|
||||||
|
{ date: "Apr 7 Mon", title: "SIP & ACW Fixes", desc: "3-layer ACW protection, SIP disconnect guard, dispose agentId, setup wizard polish", color: P.teal },
|
||||||
|
{ date: "Apr 8 Tue", title: "Master Data", desc: "Dynamic clinic/doctor fetching, appointment form overhaul, Ramaiah 195 doctor seed", color: P.amber },
|
||||||
|
{ date: "Apr 9 Wed", title: "EC2 Deployment", desc: "Multi-tenant architecture, telephony dispatcher, Caddy host routing, 14 containers", color: P.indigo },
|
||||||
|
{ date: "Apr 10 Thu", title: "Defect Sprint", desc: "9 bugs fixed, 40 E2E tests, architecture docs, runbook, cross-tenant discovery", color: P.rose },
|
||||||
|
{ date: "Apr 11 Fri", title: "CI/CD Pipeline", desc: "Woodpecker + Gitea + MinIO, Teams notifications, defaultAgentId security patch", color: P.emerald },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
s.addShape("rect", { x: 1.25, y: 1.2, w: 0.02, h: 3.9, fill: { color: P.silver } });
|
||||||
|
|
||||||
|
timeline.forEach((e, i) => {
|
||||||
|
const y = 1.2 + i * 0.65;
|
||||||
|
// Dot
|
||||||
|
s.addShape("ellipse", {
|
||||||
|
x: 1.18, y: y + 0.06, w: 0.16, h: 0.16,
|
||||||
|
fill: { color: e.color }, line: { color: P.white, width: 2 },
|
||||||
|
});
|
||||||
|
// Date
|
||||||
|
s.addText(e.date, {
|
||||||
|
x: 1.55, y, w: 1.2, h: 0.22,
|
||||||
|
fontSize: 7.5, fontFace: F, bold: true, color: e.color,
|
||||||
|
});
|
||||||
|
// Title
|
||||||
|
s.addText(e.title, {
|
||||||
|
x: 2.8, y, w: 1.8, h: 0.22,
|
||||||
|
fontSize: 9.5, fontFace: F, bold: true, color: P.inkDark,
|
||||||
|
});
|
||||||
|
// Desc
|
||||||
|
s.addText(e.desc, {
|
||||||
|
x: 4.7, y, w: 4.8, h: 0.55,
|
||||||
|
fontSize: 8, fontFace: FB, color: P.inkMid, valign: "top",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
sn(s, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SLIDE 9: Closing (Dark) ──────────────────────────────────
|
||||||
|
{
|
||||||
|
const s = darkSlide(pptx);
|
||||||
|
topLine(s, P.teal);
|
||||||
|
|
||||||
|
s.addShape("rect", { x: 0.6, y: 1.6, w: 0.035, h: 1.8, fill: { color: P.teal } });
|
||||||
|
|
||||||
|
s.addText("57 commits across 3 repositories", {
|
||||||
|
x: 0.85, y: 1.6, w: 8, h: 0.6,
|
||||||
|
fontSize: 28, fontFace: F, bold: true, color: P.white,
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("From single-tenant VPS to multi-tenant EC2 with automated CI/CD,\n40 end-to-end tests, and a fully integrated operations dashboard.", {
|
||||||
|
x: 0.85, y: 2.3, w: 7, h: 0.7,
|
||||||
|
fontSize: 12, fontFace: FB, color: P.inkLight, lineSpacingMultiple: 1.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Achievement pills
|
||||||
|
const items = [
|
||||||
|
{ text: "Multi-Tenant EC2", color: P.blue },
|
||||||
|
{ text: "40 E2E Tests", color: P.emerald },
|
||||||
|
{ text: "CI/CD Pipeline", color: P.indigo },
|
||||||
|
{ text: "9 Bugs Fixed", color: P.rose },
|
||||||
|
{ text: "Teams Alerts", color: P.violet },
|
||||||
|
];
|
||||||
|
items.forEach((a, i) => {
|
||||||
|
const x = 0.85 + i * 1.7;
|
||||||
|
s.addShape("roundRect", {
|
||||||
|
x, y: 3.4, w: 1.5, h: 0.32,
|
||||||
|
fill: { color: P.navyMid },
|
||||||
|
line: { color: a.color, width: 1 },
|
||||||
|
rectRadius: 0.16,
|
||||||
|
});
|
||||||
|
s.addText(a.text, {
|
||||||
|
x, y: 3.4, w: 1.5, h: 0.32,
|
||||||
|
fontSize: 8, fontFace: F, bold: true, color: a.color,
|
||||||
|
align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Satya Suman Sari | FortyTwo Platform", {
|
||||||
|
x: 0.85, y: 4.8, w: 5, h: 0.25,
|
||||||
|
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||||
|
});
|
||||||
|
sn(s, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pptx.writeFile({ fileName: "docs/weekly-update-apr06-11.pptx" });
|
||||||
|
console.log("Generated: docs/weekly-update-apr06-11.pptx");
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch(err => { console.error(err); process.exit(1); });
|
||||||
680
docs/generate-pptx.cjs
Normal file
680
docs/generate-pptx.cjs
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Weekly Update (Mar 18–25, 2026)
|
||||||
|
* Light Mode PowerPoint Generator via PptxGenJS
|
||||||
|
*/
|
||||||
|
const PptxGenJS = require("pptxgenjs");
|
||||||
|
|
||||||
|
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: "FFFFFF",
|
||||||
|
bgSubtle: "F8FAFC",
|
||||||
|
bgCard: "F1F5F9",
|
||||||
|
bgCardAlt: "E2E8F0",
|
||||||
|
text: "1E293B",
|
||||||
|
textSec: "475569",
|
||||||
|
textMuted: "94A3B8",
|
||||||
|
accent1: "0EA5E9", // Sky blue (telephony)
|
||||||
|
accent2: "8B5CF6", // Violet (server/backend)
|
||||||
|
accent3: "10B981", // Emerald (UX)
|
||||||
|
accent4: "F59E0B", // Amber (features)
|
||||||
|
accent5: "EF4444", // Rose (ops)
|
||||||
|
accent6: "6366F1", // Indigo (timeline)
|
||||||
|
white: "FFFFFF",
|
||||||
|
border: "CBD5E1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT = {
|
||||||
|
heading: "Arial",
|
||||||
|
body: "Arial",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function addSlideNumber(slide, num, total) {
|
||||||
|
slide.addText(`${num} / ${total}`, {
|
||||||
|
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
|
||||||
|
fontSize: 8, color: C.textMuted,
|
||||||
|
fontFace: FONT.body,
|
||||||
|
align: "right",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAccentBar(slide, color) {
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 0, y: 0, w: 10, h: 0.06,
|
||||||
|
fill: { color },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLabel(slide, text, color, x, y) {
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
|
||||||
|
fill: { color, transparency: 88 },
|
||||||
|
rectRadius: 0.15,
|
||||||
|
});
|
||||||
|
slide.addText(text.toUpperCase(), {
|
||||||
|
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
|
||||||
|
fontSize: 7, fontFace: FONT.heading, bold: true,
|
||||||
|
color, align: "center", valign: "middle",
|
||||||
|
letterSpacing: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCard(slide, opts) {
|
||||||
|
const { x, y, w, h, title, titleColor, items, badge } = opts;
|
||||||
|
// Card background
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y, w, h,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: C.border, width: 0.5 },
|
||||||
|
rectRadius: 0.1,
|
||||||
|
});
|
||||||
|
// Title
|
||||||
|
const titleText = badge
|
||||||
|
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
|
||||||
|
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
|
||||||
|
: title;
|
||||||
|
slide.addText(titleText, {
|
||||||
|
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
|
||||||
|
fontSize: 11, fontFace: FONT.heading, bold: true,
|
||||||
|
color: titleColor,
|
||||||
|
});
|
||||||
|
// Items as bullet list
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
slide.addText(
|
||||||
|
items.map(item => ({
|
||||||
|
text: item,
|
||||||
|
options: {
|
||||||
|
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
|
||||||
|
bullet: { type: "bullet", style: "arabicPeriod" },
|
||||||
|
paraSpaceAfter: 2,
|
||||||
|
breakLine: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
|
||||||
|
valign: "top",
|
||||||
|
bullet: { type: "bullet" },
|
||||||
|
lineSpacingMultiple: 1.1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build Presentation ──────────────────────────────────────────────────
|
||||||
|
async function build() {
|
||||||
|
const pptx = new PptxGenJS();
|
||||||
|
pptx.layout = "LAYOUT_16x9";
|
||||||
|
pptx.author = "Satya Suman Sari";
|
||||||
|
pptx.company = "FortyTwo Platform";
|
||||||
|
pptx.title = "Helix Engage — Weekly Update (Mar 18–25, 2026)";
|
||||||
|
pptx.subject = "Engineering Progress Report";
|
||||||
|
|
||||||
|
const TOTAL = 9;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 1 — Title
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
|
||||||
|
// Accent bar top
|
||||||
|
addAccentBar(slide, C.accent1);
|
||||||
|
|
||||||
|
// Decorative side stripe
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 0, y: 0, w: 0.12, h: 5.63,
|
||||||
|
fill: { color: C.accent1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label
|
||||||
|
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
slide.addText("Helix Engage", {
|
||||||
|
x: 1.0, y: 1.8, w: 8, h: 1.2,
|
||||||
|
fontSize: 44, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent1, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
|
||||||
|
x: 1.5, y: 2.9, w: 7, h: 0.5,
|
||||||
|
fontSize: 14, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
slide.addText("March 18 – 25, 2026", {
|
||||||
|
x: 3, y: 3.6, w: 4, h: 0.4,
|
||||||
|
fontSize: 12, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
letterSpacing: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom decoration
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 3.5, y: 4.2, w: 3, h: 0.04,
|
||||||
|
fill: { color: C.accent2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Author
|
||||||
|
slide.addText("Satya Suman Sari · FortyTwo Platform", {
|
||||||
|
x: 2, y: 4.5, w: 6, h: 0.35,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 1, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 2 — At a Glance
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent2);
|
||||||
|
|
||||||
|
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText("Week in Numbers", {
|
||||||
|
x: 0.5, y: 0.65, w: 5, h: 0.5,
|
||||||
|
fontSize: 24, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stat cards
|
||||||
|
const stats = [
|
||||||
|
{ value: "78", label: "Total Commits", color: C.accent1 },
|
||||||
|
{ value: "3", label: "Repositories", color: C.accent2 },
|
||||||
|
{ value: "8", label: "Days Active", color: C.accent3 },
|
||||||
|
{ value: "50", label: "Frontend Commits", color: C.accent4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
stats.forEach((s, i) => {
|
||||||
|
const x = 0.5 + i * 2.35;
|
||||||
|
// Card bg
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 1.3, w: 2.1, h: 1.7,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: C.border, width: 0.5 },
|
||||||
|
rectRadius: 0.12,
|
||||||
|
});
|
||||||
|
// Accent top line
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
|
||||||
|
fill: { color: s.color },
|
||||||
|
});
|
||||||
|
// Number
|
||||||
|
slide.addText(s.value, {
|
||||||
|
x, y: 1.5, w: 2.1, h: 0.9,
|
||||||
|
fontSize: 36, fontFace: FONT.heading, bold: true,
|
||||||
|
color: s.color, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
// Label
|
||||||
|
slide.addText(s.label, {
|
||||||
|
x, y: 2.4, w: 2.1, h: 0.4,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repo breakdown pills
|
||||||
|
const repos = [
|
||||||
|
{ name: "helix-engage", count: "50", clr: C.accent1 },
|
||||||
|
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
|
||||||
|
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
|
||||||
|
];
|
||||||
|
repos.forEach((r, i) => {
|
||||||
|
const x = 1.5 + i * 2.8;
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 3.4, w: 2.5, h: 0.4,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: r.clr, width: 1 },
|
||||||
|
rectRadius: 0.2,
|
||||||
|
});
|
||||||
|
slide.addText(`${r.name} ${r.count}`, {
|
||||||
|
x, y: 3.4, w: 2.5, h: 0.4,
|
||||||
|
fontSize: 9, fontFace: FONT.heading, bold: true,
|
||||||
|
color: r.clr, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary text
|
||||||
|
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
|
||||||
|
x: 1, y: 4.2, w: 8, h: 0.35,
|
||||||
|
fontSize: 10, fontFace: FONT.body, italic: true,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 2, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 3 — Telephony & SIP
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent1);
|
||||||
|
|
||||||
|
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "☎ ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
|
||||||
|
items: [
|
||||||
|
"Direct SIP call from browser — no Kookoo bridge",
|
||||||
|
"Immediate call card UI with auto-answer SIP bridge",
|
||||||
|
"End Call label fix, force active state after auto-answer",
|
||||||
|
"Reset outboundPending on call end",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
|
||||||
|
items: [
|
||||||
|
"Ozonetel V3 dial endpoint + webhook handler",
|
||||||
|
"Set Disposition API for ACW release",
|
||||||
|
"Force Ready endpoint for agent state mgmt",
|
||||||
|
"Token: 10-min cache, 401 invalidation, refresh on login",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
|
||||||
|
items: [
|
||||||
|
"SIP driven by Agent entity with token refresh",
|
||||||
|
"Centralised outbound dial into useSip().dialOutbound()",
|
||||||
|
"UCID tracking from SIP headers for disposition",
|
||||||
|
"Network indicator for connection health",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
|
||||||
|
items: [
|
||||||
|
"Multi-agent SIP with Redis session lockout",
|
||||||
|
"Strict duplicate login — one device per agent",
|
||||||
|
"Session lock stores IP + timestamp for debugging",
|
||||||
|
"SSE agent state broadcast for supervisor view",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 3, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 4 — Call Desk & Agent UX
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent3);
|
||||||
|
|
||||||
|
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🖥 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
|
||||||
|
title: "Call Desk Redesign", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"2-panel layout with collapsible sidebar & inline AI",
|
||||||
|
"Collapsible context panel, worklist/calls tabs",
|
||||||
|
"Pinned header & chat input, numpad dialler",
|
||||||
|
"Ringtone support for incoming calls",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
|
||||||
|
title: "Post-Call Workflow", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"Disposition → appointment booking → follow-up",
|
||||||
|
"Disposition returns straight to worklist",
|
||||||
|
"Send disposition to sidecar with UCID for ACW",
|
||||||
|
"Enquiry in post-call, appointment skip button",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
|
||||||
|
title: "UI Polish", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"FontAwesome Pro Duotone icon migration",
|
||||||
|
"Tooltips, sticky headers, roles, search",
|
||||||
|
"Fix React error #520 in prod tables",
|
||||||
|
"AI scroll containment, brand tokens refresh",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 4, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 5 — Features Shipped
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent4);
|
||||||
|
|
||||||
|
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🚀 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Supervisor Module", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Team performance analytics page",
|
||||||
|
"Live monitor with active calls visibility",
|
||||||
|
"Master data management pages",
|
||||||
|
"Server: team perf + active calls endpoints",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Missed call queue ingestion & worklist",
|
||||||
|
"Auto-assignment engine for agents",
|
||||||
|
"Login redesign with role-based routing",
|
||||||
|
"Lead lookup for missed callers",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Agent Features (Phase 1)", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Agent status toggle (Ready / Not Ready / Break)",
|
||||||
|
"Global search across patients, leads, calls",
|
||||||
|
"Enquiry form for new patient intake",
|
||||||
|
"My Performance page + logout modal",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Recording Analysis", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Deepgram diarization + AI insights",
|
||||||
|
"Redis caching layer for analysis results",
|
||||||
|
"Full-stack: frontend player + server module",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 5, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 6 — Backend & Data
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent2);
|
||||||
|
|
||||||
|
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "⚙ ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Platform Data Wiring", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Migrated frontend to Jotai + Vercel AI SDK",
|
||||||
|
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
|
||||||
|
"Webhook handler for Ozonetel call records",
|
||||||
|
"Complete seeder: 5 doctors, appointments linked",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Server Endpoints", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Call control, recording, CDR, missed calls, live assist",
|
||||||
|
"Agent summary, AHT, performance aggregation",
|
||||||
|
"Token refresh endpoint for auto-renewal",
|
||||||
|
"Search module with full-text capabilities",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Data Pages Built", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Worklist table, call history, patients, dashboard",
|
||||||
|
"Reports, team dashboard, campaigns, settings",
|
||||||
|
"Agent detail page, campaign edit slideout",
|
||||||
|
"Appointments page with data refresh on login",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
|
||||||
|
items: [
|
||||||
|
"Helix Engage SDK app entity definitions",
|
||||||
|
"Call center CRM object model for platform",
|
||||||
|
"Foundation for platform-native data integration",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 6, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 7 — Deployment & Ops
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent5);
|
||||||
|
|
||||||
|
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🛠 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
|
||||||
|
title: "Deployment", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Deployed to Hostinger VPS with Docker",
|
||||||
|
"Switched to global_healthx Ozonetel account",
|
||||||
|
"Dockerfile for server-side containerization",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
|
||||||
|
title: "AI & Testing", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Migrated AI to Vercel AI SDK + OpenAI provider",
|
||||||
|
"AI flow test script — validates full pipeline",
|
||||||
|
"Live call assist integration",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
|
||||||
|
title: "Documentation", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Team onboarding README with arch guide",
|
||||||
|
"Supervisor module spec + plan",
|
||||||
|
"Multi-agent spec + plan",
|
||||||
|
"Next session plans in commits",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 7, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 8 — Timeline
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent6);
|
||||||
|
|
||||||
|
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "📅 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
|
||||||
|
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
|
||||||
|
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
|
||||||
|
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
|
||||||
|
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
|
||||||
|
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
|
||||||
|
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
|
||||||
|
fill: { color: C.accent6, transparency: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.forEach((entry, i) => {
|
||||||
|
const y = 1.3 + i * 0.56;
|
||||||
|
|
||||||
|
// Dot
|
||||||
|
slide.addShape("ellipse", {
|
||||||
|
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
|
||||||
|
fill: { color: C.accent6 },
|
||||||
|
line: { color: C.bg, width: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
slide.addText(entry.date, {
|
||||||
|
x: 1.7, y: y, w: 1.6, h: 0.22,
|
||||||
|
fontSize: 7, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Title
|
||||||
|
slide.addText(entry.title, {
|
||||||
|
x: 3.3, y: y, w: 2.0, h: 0.22,
|
||||||
|
fontSize: 9, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description
|
||||||
|
slide.addText(entry.desc, {
|
||||||
|
x: 5.3, y: y, w: 4.2, h: 0.45,
|
||||||
|
fontSize: 8, fontFace: FONT.body,
|
||||||
|
color: C.textSec,
|
||||||
|
valign: "top",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 8, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 9 — Closing
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent3);
|
||||||
|
|
||||||
|
// Big headline
|
||||||
|
slide.addText("78 commits. 8 days. Ship mode.", {
|
||||||
|
x: 0.5, y: 1.4, w: 9, h: 0.8,
|
||||||
|
fontSize: 32, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent3, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ship emoji
|
||||||
|
slide.addText("🚢", {
|
||||||
|
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
|
||||||
|
fontSize: 28, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description
|
||||||
|
slide.addText(
|
||||||
|
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
|
||||||
|
{
|
||||||
|
x: 1.5, y: 3.0, w: 7, h: 0.6,
|
||||||
|
fontSize: 11, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
lineSpacingMultiple: 1.3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Achievement pills
|
||||||
|
const achievements = [
|
||||||
|
{ text: "SIP Calling ✓", color: C.accent1 },
|
||||||
|
{ text: "Multi-Agent ✓", color: C.accent2 },
|
||||||
|
{ text: "Supervisor ✓", color: C.accent3 },
|
||||||
|
{ text: "AI Copilot ✓", color: C.accent4 },
|
||||||
|
{ text: "Recording Analysis ✓", color: C.accent5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
achievements.forEach((a, i) => {
|
||||||
|
const x = 0.8 + i * 1.8;
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 3.9, w: 1.6, h: 0.35,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: a.color, width: 1 },
|
||||||
|
rectRadius: 0.17,
|
||||||
|
});
|
||||||
|
slide.addText(a.text, {
|
||||||
|
x, y: 3.9, w: 1.6, h: 0.35,
|
||||||
|
fontSize: 8, fontFace: FONT.heading, bold: true,
|
||||||
|
color: a.color, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Author
|
||||||
|
slide.addText("Satya Suman Sari · FortyTwo Platform", {
|
||||||
|
x: 2, y: 4.7, w: 6, h: 0.3,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 9, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ──────────────────────────────────────────────────────────────
|
||||||
|
const outPath = "weekly-update-mar18-25.pptx";
|
||||||
|
await pptx.writeFile({ fileName: outPath });
|
||||||
|
console.log(`✅ Presentation saved: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch(err => {
|
||||||
|
console.error("❌ Failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
680
docs/generate-pptx.js
Normal file
680
docs/generate-pptx.js
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Weekly Update (Mar 18–25, 2026)
|
||||||
|
* Light Mode PowerPoint Generator via PptxGenJS
|
||||||
|
*/
|
||||||
|
const PptxGenJS = require("pptxgenjs");
|
||||||
|
|
||||||
|
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: "FFFFFF",
|
||||||
|
bgSubtle: "F8FAFC",
|
||||||
|
bgCard: "F1F5F9",
|
||||||
|
bgCardAlt: "E2E8F0",
|
||||||
|
text: "1E293B",
|
||||||
|
textSec: "475569",
|
||||||
|
textMuted: "94A3B8",
|
||||||
|
accent1: "0EA5E9", // Sky blue (telephony)
|
||||||
|
accent2: "8B5CF6", // Violet (server/backend)
|
||||||
|
accent3: "10B981", // Emerald (UX)
|
||||||
|
accent4: "F59E0B", // Amber (features)
|
||||||
|
accent5: "EF4444", // Rose (ops)
|
||||||
|
accent6: "6366F1", // Indigo (timeline)
|
||||||
|
white: "FFFFFF",
|
||||||
|
border: "CBD5E1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT = {
|
||||||
|
heading: "Arial",
|
||||||
|
body: "Arial",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function addSlideNumber(slide, num, total) {
|
||||||
|
slide.addText(`${num} / ${total}`, {
|
||||||
|
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
|
||||||
|
fontSize: 8, color: C.textMuted,
|
||||||
|
fontFace: FONT.body,
|
||||||
|
align: "right",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAccentBar(slide, color) {
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 0, y: 0, w: 10, h: 0.06,
|
||||||
|
fill: { color },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLabel(slide, text, color, x, y) {
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
|
||||||
|
fill: { color, transparency: 88 },
|
||||||
|
rectRadius: 0.15,
|
||||||
|
});
|
||||||
|
slide.addText(text.toUpperCase(), {
|
||||||
|
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
|
||||||
|
fontSize: 7, fontFace: FONT.heading, bold: true,
|
||||||
|
color, align: "center", valign: "middle",
|
||||||
|
letterSpacing: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCard(slide, opts) {
|
||||||
|
const { x, y, w, h, title, titleColor, items, badge } = opts;
|
||||||
|
// Card background
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y, w, h,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: C.border, width: 0.5 },
|
||||||
|
rectRadius: 0.1,
|
||||||
|
});
|
||||||
|
// Title
|
||||||
|
const titleText = badge
|
||||||
|
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
|
||||||
|
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
|
||||||
|
: title;
|
||||||
|
slide.addText(titleText, {
|
||||||
|
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
|
||||||
|
fontSize: 11, fontFace: FONT.heading, bold: true,
|
||||||
|
color: titleColor,
|
||||||
|
});
|
||||||
|
// Items as bullet list
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
slide.addText(
|
||||||
|
items.map(item => ({
|
||||||
|
text: item,
|
||||||
|
options: {
|
||||||
|
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
|
||||||
|
bullet: { type: "bullet", style: "arabicPeriod" },
|
||||||
|
paraSpaceAfter: 2,
|
||||||
|
breakLine: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
|
||||||
|
valign: "top",
|
||||||
|
bullet: { type: "bullet" },
|
||||||
|
lineSpacingMultiple: 1.1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build Presentation ──────────────────────────────────────────────────
|
||||||
|
async function build() {
|
||||||
|
const pptx = new PptxGenJS();
|
||||||
|
pptx.layout = "LAYOUT_16x9";
|
||||||
|
pptx.author = "Satya Suman Sari";
|
||||||
|
pptx.company = "FortyTwo Platform";
|
||||||
|
pptx.title = "Helix Engage — Weekly Update (Mar 18–25, 2026)";
|
||||||
|
pptx.subject = "Engineering Progress Report";
|
||||||
|
|
||||||
|
const TOTAL = 9;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 1 — Title
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
|
||||||
|
// Accent bar top
|
||||||
|
addAccentBar(slide, C.accent1);
|
||||||
|
|
||||||
|
// Decorative side stripe
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 0, y: 0, w: 0.12, h: 5.63,
|
||||||
|
fill: { color: C.accent1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Label
|
||||||
|
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
slide.addText("Helix Engage", {
|
||||||
|
x: 1.0, y: 1.8, w: 8, h: 1.2,
|
||||||
|
fontSize: 44, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent1, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
|
||||||
|
x: 1.5, y: 2.9, w: 7, h: 0.5,
|
||||||
|
fontSize: 14, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
slide.addText("March 18 – 25, 2026", {
|
||||||
|
x: 3, y: 3.6, w: 4, h: 0.4,
|
||||||
|
fontSize: 12, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
letterSpacing: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom decoration
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 3.5, y: 4.2, w: 3, h: 0.04,
|
||||||
|
fill: { color: C.accent2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Author
|
||||||
|
slide.addText("Satya Suman Sari · FortyTwo Platform", {
|
||||||
|
x: 2, y: 4.5, w: 6, h: 0.35,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 1, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 2 — At a Glance
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent2);
|
||||||
|
|
||||||
|
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText("Week in Numbers", {
|
||||||
|
x: 0.5, y: 0.65, w: 5, h: 0.5,
|
||||||
|
fontSize: 24, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stat cards
|
||||||
|
const stats = [
|
||||||
|
{ value: "78", label: "Total Commits", color: C.accent1 },
|
||||||
|
{ value: "3", label: "Repositories", color: C.accent2 },
|
||||||
|
{ value: "8", label: "Days Active", color: C.accent3 },
|
||||||
|
{ value: "50", label: "Frontend Commits", color: C.accent4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
stats.forEach((s, i) => {
|
||||||
|
const x = 0.5 + i * 2.35;
|
||||||
|
// Card bg
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 1.3, w: 2.1, h: 1.7,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: C.border, width: 0.5 },
|
||||||
|
rectRadius: 0.12,
|
||||||
|
});
|
||||||
|
// Accent top line
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
|
||||||
|
fill: { color: s.color },
|
||||||
|
});
|
||||||
|
// Number
|
||||||
|
slide.addText(s.value, {
|
||||||
|
x, y: 1.5, w: 2.1, h: 0.9,
|
||||||
|
fontSize: 36, fontFace: FONT.heading, bold: true,
|
||||||
|
color: s.color, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
// Label
|
||||||
|
slide.addText(s.label, {
|
||||||
|
x, y: 2.4, w: 2.1, h: 0.4,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Repo breakdown pills
|
||||||
|
const repos = [
|
||||||
|
{ name: "helix-engage", count: "50", clr: C.accent1 },
|
||||||
|
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
|
||||||
|
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
|
||||||
|
];
|
||||||
|
repos.forEach((r, i) => {
|
||||||
|
const x = 1.5 + i * 2.8;
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 3.4, w: 2.5, h: 0.4,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: r.clr, width: 1 },
|
||||||
|
rectRadius: 0.2,
|
||||||
|
});
|
||||||
|
slide.addText(`${r.name} ${r.count}`, {
|
||||||
|
x, y: 3.4, w: 2.5, h: 0.4,
|
||||||
|
fontSize: 9, fontFace: FONT.heading, bold: true,
|
||||||
|
color: r.clr, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summary text
|
||||||
|
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
|
||||||
|
x: 1, y: 4.2, w: 8, h: 0.35,
|
||||||
|
fontSize: 10, fontFace: FONT.body, italic: true,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 2, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 3 — Telephony & SIP
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent1);
|
||||||
|
|
||||||
|
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "☎ ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
|
||||||
|
items: [
|
||||||
|
"Direct SIP call from browser — no Kookoo bridge",
|
||||||
|
"Immediate call card UI with auto-answer SIP bridge",
|
||||||
|
"End Call label fix, force active state after auto-answer",
|
||||||
|
"Reset outboundPending on call end",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
|
||||||
|
items: [
|
||||||
|
"Ozonetel V3 dial endpoint + webhook handler",
|
||||||
|
"Set Disposition API for ACW release",
|
||||||
|
"Force Ready endpoint for agent state mgmt",
|
||||||
|
"Token: 10-min cache, 401 invalidation, refresh on login",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
|
||||||
|
items: [
|
||||||
|
"SIP driven by Agent entity with token refresh",
|
||||||
|
"Centralised outbound dial into useSip().dialOutbound()",
|
||||||
|
"UCID tracking from SIP headers for disposition",
|
||||||
|
"Network indicator for connection health",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
|
||||||
|
items: [
|
||||||
|
"Multi-agent SIP with Redis session lockout",
|
||||||
|
"Strict duplicate login — one device per agent",
|
||||||
|
"Session lock stores IP + timestamp for debugging",
|
||||||
|
"SSE agent state broadcast for supervisor view",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 3, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 4 — Call Desk & Agent UX
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent3);
|
||||||
|
|
||||||
|
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🖥 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
|
||||||
|
title: "Call Desk Redesign", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"2-panel layout with collapsible sidebar & inline AI",
|
||||||
|
"Collapsible context panel, worklist/calls tabs",
|
||||||
|
"Pinned header & chat input, numpad dialler",
|
||||||
|
"Ringtone support for incoming calls",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
|
||||||
|
title: "Post-Call Workflow", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"Disposition → appointment booking → follow-up",
|
||||||
|
"Disposition returns straight to worklist",
|
||||||
|
"Send disposition to sidecar with UCID for ACW",
|
||||||
|
"Enquiry in post-call, appointment skip button",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
|
||||||
|
title: "UI Polish", titleColor: C.accent3,
|
||||||
|
items: [
|
||||||
|
"FontAwesome Pro Duotone icon migration",
|
||||||
|
"Tooltips, sticky headers, roles, search",
|
||||||
|
"Fix React error #520 in prod tables",
|
||||||
|
"AI scroll containment, brand tokens refresh",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 4, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 5 — Features Shipped
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent4);
|
||||||
|
|
||||||
|
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🚀 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Supervisor Module", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Team performance analytics page",
|
||||||
|
"Live monitor with active calls visibility",
|
||||||
|
"Master data management pages",
|
||||||
|
"Server: team perf + active calls endpoints",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Missed call queue ingestion & worklist",
|
||||||
|
"Auto-assignment engine for agents",
|
||||||
|
"Login redesign with role-based routing",
|
||||||
|
"Lead lookup for missed callers",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Agent Features (Phase 1)", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Agent status toggle (Ready / Not Ready / Break)",
|
||||||
|
"Global search across patients, leads, calls",
|
||||||
|
"Enquiry form for new patient intake",
|
||||||
|
"My Performance page + logout modal",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Recording Analysis", titleColor: C.accent4,
|
||||||
|
items: [
|
||||||
|
"Deepgram diarization + AI insights",
|
||||||
|
"Redis caching layer for analysis results",
|
||||||
|
"Full-stack: frontend player + server module",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 5, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 6 — Backend & Data
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent2);
|
||||||
|
|
||||||
|
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "⚙ ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Platform Data Wiring", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Migrated frontend to Jotai + Vercel AI SDK",
|
||||||
|
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
|
||||||
|
"Webhook handler for Ozonetel call records",
|
||||||
|
"Complete seeder: 5 doctors, appointments linked",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
|
||||||
|
title: "Server Endpoints", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Call control, recording, CDR, missed calls, live assist",
|
||||||
|
"Agent summary, AHT, performance aggregation",
|
||||||
|
"Token refresh endpoint for auto-renewal",
|
||||||
|
"Search module with full-text capabilities",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "Data Pages Built", titleColor: C.accent2,
|
||||||
|
items: [
|
||||||
|
"Worklist table, call history, patients, dashboard",
|
||||||
|
"Reports, team dashboard, campaigns, settings",
|
||||||
|
"Agent detail page, campaign edit slideout",
|
||||||
|
"Appointments page with data refresh on login",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
|
||||||
|
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
|
||||||
|
items: [
|
||||||
|
"Helix Engage SDK app entity definitions",
|
||||||
|
"Call center CRM object model for platform",
|
||||||
|
"Foundation for platform-native data integration",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 6, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 7 — Deployment & Ops
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent5);
|
||||||
|
|
||||||
|
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "🛠 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
|
||||||
|
title: "Deployment", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Deployed to Hostinger VPS with Docker",
|
||||||
|
"Switched to global_healthx Ozonetel account",
|
||||||
|
"Dockerfile for server-side containerization",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
|
||||||
|
title: "AI & Testing", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Migrated AI to Vercel AI SDK + OpenAI provider",
|
||||||
|
"AI flow test script — validates full pipeline",
|
||||||
|
"Live call assist integration",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addCard(slide, {
|
||||||
|
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
|
||||||
|
title: "Documentation", titleColor: C.accent5,
|
||||||
|
items: [
|
||||||
|
"Team onboarding README with arch guide",
|
||||||
|
"Supervisor module spec + plan",
|
||||||
|
"Multi-agent spec + plan",
|
||||||
|
"Next session plans in commits",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 7, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 8 — Timeline
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent6);
|
||||||
|
|
||||||
|
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
|
||||||
|
|
||||||
|
slide.addText([
|
||||||
|
{ text: "📅 ", options: { fontSize: 22 } },
|
||||||
|
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
|
||||||
|
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
|
||||||
|
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
|
||||||
|
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
|
||||||
|
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
|
||||||
|
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
|
||||||
|
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
|
||||||
|
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vertical line
|
||||||
|
slide.addShape("rect", {
|
||||||
|
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
|
||||||
|
fill: { color: C.accent6, transparency: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
timeline.forEach((entry, i) => {
|
||||||
|
const y = 1.3 + i * 0.56;
|
||||||
|
|
||||||
|
// Dot
|
||||||
|
slide.addShape("ellipse", {
|
||||||
|
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
|
||||||
|
fill: { color: C.accent6 },
|
||||||
|
line: { color: C.bg, width: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
slide.addText(entry.date, {
|
||||||
|
x: 1.7, y: y, w: 1.6, h: 0.22,
|
||||||
|
fontSize: 7, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Title
|
||||||
|
slide.addText(entry.title, {
|
||||||
|
x: 3.3, y: y, w: 2.0, h: 0.22,
|
||||||
|
fontSize: 9, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description
|
||||||
|
slide.addText(entry.desc, {
|
||||||
|
x: 5.3, y: y, w: 4.2, h: 0.45,
|
||||||
|
fontSize: 8, fontFace: FONT.body,
|
||||||
|
color: C.textSec,
|
||||||
|
valign: "top",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 8, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// SLIDE 9 — Closing
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
{
|
||||||
|
const slide = pptx.addSlide();
|
||||||
|
slide.background = { color: C.bg };
|
||||||
|
addAccentBar(slide, C.accent3);
|
||||||
|
|
||||||
|
// Big headline
|
||||||
|
slide.addText("78 commits. 8 days. Ship mode.", {
|
||||||
|
x: 0.5, y: 1.4, w: 9, h: 0.8,
|
||||||
|
fontSize: 32, fontFace: FONT.heading, bold: true,
|
||||||
|
color: C.accent3, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ship emoji
|
||||||
|
slide.addText("🚢", {
|
||||||
|
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
|
||||||
|
fontSize: 28, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description
|
||||||
|
slide.addText(
|
||||||
|
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
|
||||||
|
{
|
||||||
|
x: 1.5, y: 3.0, w: 7, h: 0.6,
|
||||||
|
fontSize: 11, fontFace: FONT.body,
|
||||||
|
color: C.textSec, align: "center",
|
||||||
|
lineSpacingMultiple: 1.3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Achievement pills
|
||||||
|
const achievements = [
|
||||||
|
{ text: "SIP Calling ✓", color: C.accent1 },
|
||||||
|
{ text: "Multi-Agent ✓", color: C.accent2 },
|
||||||
|
{ text: "Supervisor ✓", color: C.accent3 },
|
||||||
|
{ text: "AI Copilot ✓", color: C.accent4 },
|
||||||
|
{ text: "Recording Analysis ✓", color: C.accent5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
achievements.forEach((a, i) => {
|
||||||
|
const x = 0.8 + i * 1.8;
|
||||||
|
slide.addShape("roundRect", {
|
||||||
|
x, y: 3.9, w: 1.6, h: 0.35,
|
||||||
|
fill: { color: C.bgCard },
|
||||||
|
line: { color: a.color, width: 1 },
|
||||||
|
rectRadius: 0.17,
|
||||||
|
});
|
||||||
|
slide.addText(a.text, {
|
||||||
|
x, y: 3.9, w: 1.6, h: 0.35,
|
||||||
|
fontSize: 8, fontFace: FONT.heading, bold: true,
|
||||||
|
color: a.color, align: "center", valign: "middle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Author
|
||||||
|
slide.addText("Satya Suman Sari · FortyTwo Platform", {
|
||||||
|
x: 2, y: 4.7, w: 6, h: 0.3,
|
||||||
|
fontSize: 9, fontFace: FONT.body,
|
||||||
|
color: C.textMuted, align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideNumber(slide, 9, TOTAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ──────────────────────────────────────────────────────────────
|
||||||
|
const outPath = "weekly-update-mar18-25.pptx";
|
||||||
|
await pptx.writeFile({ fileName: outPath });
|
||||||
|
console.log(`✅ Presentation saved: ${outPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
build().catch(err => {
|
||||||
|
console.error("❌ Failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
102
docs/ozonetel-cdr-api-reference.md
Normal file
102
docs/ozonetel-cdr-api-reference.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Ozonetel CDR API Reference
|
||||||
|
|
||||||
|
> Source: [Ozonetel docs](https://docs.ozonetel.com/reference/get_ca-reports-fetchcdrdetails)
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Path | Use Case |
|
||||||
|
|----------|------|----------|
|
||||||
|
| Fetch CDR Detailed | `GET /ca_reports/fetchCDRDetails` | All CDR for a single day |
|
||||||
|
| Fetch CDR by UCID | `GET /ca_reports/fetchCdrByUCID` | Single call lookup by UCID |
|
||||||
|
| Fetch CDR Paginated | `GET /ca_reports/fetchCdrByPagination` | Paginated CDR with `totalCount` |
|
||||||
|
|
||||||
|
## Common Constraints
|
||||||
|
|
||||||
|
- **Auth**: Bearer token (via `POST /ca_apis/caToken/generateToken`)
|
||||||
|
- **Rate limit**: 2 requests per minute (all CDR endpoints)
|
||||||
|
- **Date range**: Single day only (`fromDate` and `toDate` must be same date)
|
||||||
|
- **Lookback**: 15 days maximum from time of request
|
||||||
|
- **Mandatory params**: `fromDate`, `toDate`, `userName` (+ `ucid` for UCID endpoint)
|
||||||
|
- **Date format**: `YYYY-MM-DD HH:MM:SS`
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
- Domestic: `in1-ccaas-api.ozonetel.com`
|
||||||
|
- International: `api.ccaas.ozonetel.com`
|
||||||
|
|
||||||
|
## CDR Record Fields (42 fields)
|
||||||
|
|
||||||
|
| Field | Type | Description | Sidecar Status |
|
||||||
|
|-------|------|-------------|----------------|
|
||||||
|
| `AgentDialStatus` | string | Agent's dial attempt status (e.g., "answered") | Not mapped |
|
||||||
|
| `AgentID` | string | Agent identifier | **Mapped** — filter CDR by agent |
|
||||||
|
| `AgentName` | string | Agent name | **Mapped** — fallback filter |
|
||||||
|
| `CallAudio` | string | URL to call recording (S3) | Not mapped (recording via platform) |
|
||||||
|
| `CallDate` | string | Date of call (YYYY-MM-DD) | Not mapped |
|
||||||
|
| `CallID` | number | Unique call identifier | Not mapped |
|
||||||
|
| `CallerConfAudioFile` | string | Conference audio file | Not mapped |
|
||||||
|
| `CallerID` | string | Caller's phone number | Not mapped |
|
||||||
|
| `CampaignName` | string | Associated campaign name | Not mapped — **available for US-15** |
|
||||||
|
| `Comments` | string | Additional comments | Not mapped |
|
||||||
|
| `ConferenceDuration` | string | Conference duration (HH:MM:SS) | Not mapped |
|
||||||
|
| `CustomerDialStatus` | string | Customer dial status | Not mapped |
|
||||||
|
| `CustomerRingTime` | string | Customer phone ring time | Not mapped — **missed call analysis** |
|
||||||
|
| `DID` | string | Direct inward dial number | Not mapped — **available for US-2 branch display** |
|
||||||
|
| `DialOutName` | string | Dialed party name | Not mapped |
|
||||||
|
| `DialStatus` | string | Overall dial status | Not mapped |
|
||||||
|
| `DialedNumber` | string | Phone number dialed | Not mapped |
|
||||||
|
| `Disposition` | string | Call disposition/outcome | **Mapped** — disposition breakdown |
|
||||||
|
| `Duration` | string | Total call duration | Not mapped |
|
||||||
|
| `DynamicDID` | string | Dynamic DID reference | Not mapped |
|
||||||
|
| `E164` | string | E.164 formatted phone number | Not mapped |
|
||||||
|
| `EndTime` | string | Call end time | Not mapped |
|
||||||
|
| `Event` | string | Event type (e.g., "AgentDial") | Not mapped |
|
||||||
|
| `HandlingTime` | string/null | Total handling time — **CAN BE NULL** | Not mapped — **available for US-13 avg handling** |
|
||||||
|
| `HangupBy` | string | Who terminated call | Not mapped |
|
||||||
|
| `HoldDuration` | string | Time on hold | Not mapped — **available for US-12** |
|
||||||
|
| `Location` | string | Caller location | Not mapped |
|
||||||
|
| `PickupTime` | string | When call was answered | Not mapped |
|
||||||
|
| `Rating` | number | Call quality rating | Not mapped |
|
||||||
|
| `RatingComments` | string | Rating comments | Not mapped |
|
||||||
|
| `Skill` | string | Agent skill/queue name | Not mapped |
|
||||||
|
| `StartTime` | string | Call start time | Not mapped |
|
||||||
|
| `Status` | string | Call status (Answered/NotAnswered) | **Mapped** — inbound/missed split |
|
||||||
|
| `TalkTime` | string | Active talk duration | **Mapped** — avg talk time calc |
|
||||||
|
| `TimeToAnswer` | string | Duration until answer | Not mapped — **available for lead response KPI** |
|
||||||
|
| `TransferType` | string | Type of transfer | Not mapped — **available for US-3 audit** |
|
||||||
|
| `TransferredTo` / `TransferTo` | string | Transfer target — **field name varies by endpoint** | Not mapped |
|
||||||
|
| `Type` | string | Call type (InBound/Manual/Progressive) | **Mapped** — inbound/outbound split |
|
||||||
|
| `UCID` | number | Unique call identifier | Not mapped |
|
||||||
|
| `UUI` | string | User-to-user information | Not mapped |
|
||||||
|
| `WrapUpEndTime` | string/null | Wrapup completion time — **CAN BE NULL** | Not mapped |
|
||||||
|
| `WrapUpStartTime` | string/null | Wrapup start time — **CAN BE NULL** | Not mapped |
|
||||||
|
| `WrapupDuration` | string/null | Wrapup duration — **CAN BE NULL** | Not mapped — **available for US-12** |
|
||||||
|
|
||||||
|
## Pagination Endpoint Extra Fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `totalCount` | Total number of records matching the query |
|
||||||
|
|
||||||
|
## Known Issues / Gotchas
|
||||||
|
|
||||||
|
1. **`HandlingTime`, `WrapupDuration`, `WrapUpStartTime`, `WrapUpEndTime` can be `null`** — when agent didn't complete wrapup (seen in UCID endpoint example). Code must null-guard these.
|
||||||
|
2. **Field name inconsistency**: `TransferredTo` in fetchCDRDetails vs `TransferTo` in pagination endpoint. Handle both.
|
||||||
|
3. **`WrapUpEndTime` vs `WrapupEndTime`**: casing differs between endpoints (camelCase vs mixed). Handle both.
|
||||||
|
4. **Single-day constraint**: `fromDate` and `toDate` must be the same date. For multi-day range, call once per day.
|
||||||
|
5. **Rate limit 2 req/min**: For a 7-day weekly report that needs CDR + summary per day = 14 API calls = 7 minutes minimum. Consider caching daily results.
|
||||||
|
|
||||||
|
## Current Sidecar Usage
|
||||||
|
|
||||||
|
**Endpoint used**: `fetchCDRDetails` only (in `ozonetel-agent.service.ts`)
|
||||||
|
|
||||||
|
**Fields actively mapped** (6 of 42):
|
||||||
|
- `AgentID` / `AgentName` — agent filtering
|
||||||
|
- `Type` — inbound/outbound split
|
||||||
|
- `Status` — answered/missed split
|
||||||
|
- `TalkTime` — avg talk time calculation
|
||||||
|
- `Disposition` — disposition breakdown chart
|
||||||
|
|
||||||
|
**Not yet used**:
|
||||||
|
- `fetchCdrByUCID` — useful for Patient 360 single-call drill-down
|
||||||
|
- `fetchCdrByPagination` — useful for high-volume days (current approach loads all records into memory)
|
||||||
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal file
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# AI Coaching Panel Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the AI chat panel with a three-zone coaching surface — structured summary card, rule-driven suggestions with scripts, and contextual chat with progressive suggestion updates.
|
||||||
|
|
||||||
|
**Architecture:** CallerContextService (already built) pre-fetches caller data into Redis. Rules engine evaluates caller facts against seeded suggestion rules, producing triggers. AI system prompt includes caller context + suggestion triggers + structured output instructions. Every AI response returns `{ message, suggestions }` JSON. Frontend parses and renders across three zones.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19 + Tailwind (frontend), NestJS + Vercel AI SDK + json-rules-engine + Redis (sidecar), FontAwesome Pro icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Sidecar (helix-engage-server)
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `src/rules-engine/suggestion-rules.ts` | NEW: Default suggestion rule definitions + evaluator function |
|
||||||
|
| `src/caller/caller-context.service.ts` | MODIFY: Add suggestion evaluation, render suggestions for prompt |
|
||||||
|
| `src/ai/ai-chat.controller.ts` | MODIFY: Inject suggestion rules into system prompt |
|
||||||
|
| `src/config/ai.defaults.ts` | MODIFY: Update ccAgentHelper prompt with structured JSON output format |
|
||||||
|
|
||||||
|
### Frontend (helix-engage)
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|----------------|
|
||||||
|
| `src/components/call-desk/ai-summary-card.tsx` | NEW: Zone 1 patient profile card |
|
||||||
|
| `src/components/call-desk/ai-suggestions.tsx` | NEW: Zone 2 suggestion pills with expand/script/tell-me-more |
|
||||||
|
| `src/components/call-desk/ai-chat-panel.tsx` | REWRITE: Orchestrates 3 zones, parses structured JSON responses |
|
||||||
|
| `src/components/call-desk/context-panel.tsx` | MODIFY: Remove P360 tab toggle, single surface |
|
||||||
|
| `src/pages/rules-settings.tsx` | MODIFY: Display suggestion rules in Automations tab |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Suggestion Rules Engine (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/rules-engine/suggestion-rules.ts`
|
||||||
|
- Modify: `helix-engage-server/src/caller/caller-context.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Create `suggestion-rules.ts` with types (`SuggestionType`, `SuggestionPriority`, `SuggestionTrigger`), department-to-package mapping, cross-sell mapping, and `evaluateSuggestionRules(facts)` function that evaluates 5 default rules: (1) package upsell by department, (2) reschedule missed appointments, (3) cross-sell related departments, (4) first-visit health checkup, (5) returning patient re-engagement. Max 4 triggers returned. Also export `SUGGESTION_RULE_DEFINITIONS` array for Settings UI display.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `caller-context.service.ts`, add `suggestionTriggers: SuggestionTrigger[]` to the `CallerContext` type. Import `evaluateSuggestionRules`. Call it in the `build()` method after fetching all data, passing caller facts (isNew, appointments, calls, interestedService, contactAttempts, leadSource, utmCampaign). Add `renderSuggestionsForPrompt(triggers)` method that formats triggers for the AI system prompt.
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Commit: `feat: suggestion rules engine + caller context evaluation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Structured Output in AI System Prompt (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/config/ai.defaults.ts`
|
||||||
|
- Modify: `helix-engage-server/src/ai/ai-chat.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `ai.defaults.ts`, append structured output instructions to `CC_AGENT_HELPER_DEFAULT` template. The AI must respond with valid JSON: `{"message": "...", "suggestions": [{"id", "type", "title", "script", "priority"}]}`. Rules: always include suggestions on first response, update on subsequent, no markdown in message field, max 4 suggestions, personalized scripts using caller's name/doctor/department.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** In `ai-chat.controller.ts` stream endpoint, after the caller context injection block, inject suggestion rules: `if (callerCtx.suggestionTriggers?.length) systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers)`
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 4:** Commit: `feat: structured JSON output + suggestion rules in AI system prompt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: AI Summary Card Component (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/ai-summary-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Create Zone 1 component. Props: `caller: CallerSummary | null`. Renders: patient avatar + name + NEW/RETURNING badge, phone number, 2-line AI summary (line-clamped), source + campaign badges, compact appointment pills (next upcoming with green bg, last completed with gray bg). For null caller: centered placeholder text. Uses Badge component, FontAwesome icons (faUser, faCalendarCheck, faPhone).
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat: AI summary card component (Zone 1)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Suggestions Component (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/ai-suggestions.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Create Zone 2 component. Props: `suggestions: Suggestion[]`, `onTellMeMore: (suggestion) => void`. Exports `Suggestion` type (id, type, title, script, priority). Renders: collapsible section header "Suggestions (N)", list of compact pill cards. Each pill: type icon (faArrowUp/faTag/faRotate/faClipboardCheck), title, priority dot (red/yellow/green). Click toggles expand with script text + "Tell me more" link. Collapse/expand toggle for entire section.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat: AI suggestions component (Zone 2)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Rewrite AI Chat Panel (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Rewrite to orchestrate 3 zones. New props: `callerSummary?: CallerSummary | null`. Adds `suggestions` state managed from parsed AI responses. `parseAiResponse(content)` extracts `{ message, suggestions }` from JSON, falls back to raw text on parse failure. Zone 1: AiSummaryCard (not shown for supervisor). Zone 2: AiSuggestions with `onTellMeMore` that appends "Tell me more about X" as chat message. Zone 3: chat with `displayMessages` that strips JSON wrapper showing only the message field. Auto-fire kept. Supervisor mode unchanged (quick actions, no summary/suggestions). Keep existing MessageContent + parseLine helpers.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat: rewrite AI chat panel — 3-zone coaching surface`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Wire Context Panel (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/call-desk/context-panel.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Remove P360 tab toggle (activeTab state, tab buttons, P360 sections — appointments list, call history list, follow-ups list). Build `callerSummary` object from `selectedLead` + `appointments` data: name, phone, isNew, aiSummary, leadSource, utmCampaign, nextAppointment (first SCHEDULED after now), lastAppointment (first COMPLETED). Pass `callerSummary` to AiChatPanel as new prop. Single surface — AiChatPanel is the only content.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat: remove P360 toggle, single coaching surface`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Settings UI — Suggestion Rules Display
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/rules-settings.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `SUGGESTION_RULES` array (5 items: name, category, description, enabled) to the Automations tab. Render below existing automation rules with "AI Suggestions" subheading. Same card pattern: category badge, name, description, enabled/disabled dot. All enabled, read-only.
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||||
|
|
||||||
|
- [ ] **Step 3:** Commit: `feat: display suggestion rules in Settings > Automations`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Build, Deploy, Test
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Build sidecar: `cd helix-engage-server && npm run build`
|
||||||
|
- [ ] **Step 2:** Build frontend: `cd helix-engage && npm run build`
|
||||||
|
- [ ] **Step 3:** Deploy sidecar to ECR + pull on EC2
|
||||||
|
- [ ] **Step 4:** Deploy frontend to EC2 via rsync + restart Caddy
|
||||||
|
- [ ] **Step 5:** Test on Tauri: rebuild frontend with Global URL, launch, trigger call. Verify: Zone 1 summary card, Zone 2 suggestions from rules, click expand shows script, "Tell me more" sends to chat, progressive suggestion updates, server logs show cache hits and no tool calls for patient data
|
||||||
|
- [ ] **Step 6:** Final commit and push both repos
|
||||||
1219
docs/requirements.md
Normal file
1219
docs/requirements.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal file
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# AI Coaching Panel — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the current AI chat panel with a proactive coaching surface that shows structured patient summaries, rule-driven upsell/cross-sell/retention suggestions with clickable scripts, and a contextual chat — all in the existing 400px right-hand panel.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single scrollable panel, three zones. No tabs or toggles. Caller context pre-fetched and cached in Redis (CallerContextService). Rules engine produces suggestion triggers. AI renders triggers into natural language scripts. Every AI response includes updated suggestions (progressive).
|
||||||
|
|
||||||
|
## Panel Layout
|
||||||
|
|
||||||
|
### Zone 1 — Summary Card (pinned top, ~120px)
|
||||||
|
|
||||||
|
- Patient name, age, gender, patient type badge (NEW / RETURNING)
|
||||||
|
- 2-line AI summary (from `aiSummary` field on lead record)
|
||||||
|
- Campaign badge + source tag (e.g., "Cervical Cancer Screening Drive" / "Google")
|
||||||
|
- Compact appointment pills: next upcoming appointment (date + doctor), last completed (date + outcome)
|
||||||
|
- Renders from CallerContextService data — no AI call needed for this zone
|
||||||
|
|
||||||
|
For new callers (no lead/patient): shows phone number, "New Caller" badge, and a prompt to collect name.
|
||||||
|
|
||||||
|
### Zone 2 — Suggestions (collapsible, below summary)
|
||||||
|
|
||||||
|
- 2-4 suggestion pills as compact cards
|
||||||
|
- Each pill: type icon (tag/arrow-up/rotate-cw), one-line title, priority dot (high/medium/low)
|
||||||
|
- Click expands inline with a 2-3 sentence ready-to-read script
|
||||||
|
- Expanded state has a "Tell me more" link that sends the suggestion as a chat message
|
||||||
|
- Suggestions refresh with every AI response (progressive)
|
||||||
|
- Collapse/expand toggle for the entire section ("Suggestions (3)")
|
||||||
|
|
||||||
|
Suggestion types:
|
||||||
|
- **upsell** — premium packages, add-on services
|
||||||
|
- **crosssell** — related services in other departments
|
||||||
|
- **retention** — reschedule missed appointments, follow up on lapsed visits
|
||||||
|
- **operational** — fasting reminders, insurance docs, directions
|
||||||
|
|
||||||
|
### Zone 3 — Chat (fills remaining space)
|
||||||
|
|
||||||
|
- Streaming chat, same UX as today
|
||||||
|
- Agent types questions or clicks "Tell me more" from a suggestion
|
||||||
|
- Each AI response may include updated suggestions (Zone 2 refreshes)
|
||||||
|
- Quick action pills at bottom, contextual to conversation state
|
||||||
|
- Auto-fires patient summary on call connect (existing behavior, kept)
|
||||||
|
|
||||||
|
## Structured AI Response Format
|
||||||
|
|
||||||
|
Every AI response is structured JSON (not free-form text):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Priya Sharma is a returning patient...",
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"type": "upsell",
|
||||||
|
"title": "Cardiac Wellness Package",
|
||||||
|
"script": "Since you're already seeing Dr. Lakshmi for cardiology, we have a comprehensive cardiac wellness package...",
|
||||||
|
"priority": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"type": "retention",
|
||||||
|
"title": "Reschedule missed appointment",
|
||||||
|
"script": "I see your last appointment on April 10th was rescheduled. Would you like me to book a new slot?",
|
||||||
|
"priority": "medium"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `message` field renders as a chat bubble in Zone 3. The `suggestions` array replaces the current set in Zone 2. If `suggestions` is empty or absent, Zone 2 retains the previous set.
|
||||||
|
|
||||||
|
The initial auto-fired response includes the summary message + first set of suggestions. Subsequent responses update suggestions based on conversation context.
|
||||||
|
|
||||||
|
## Rules Engine to AI Prompt Pipeline
|
||||||
|
|
||||||
|
### Step 1: Rules evaluation
|
||||||
|
|
||||||
|
CallerContextService already builds the caller facts (appointments, campaigns, call history, lead status, interested service). The rules engine evaluates these facts against configured suggestion rules.
|
||||||
|
|
||||||
|
Each rule produces a raw trigger:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "upsell",
|
||||||
|
"product": "cardiac-wellness-package",
|
||||||
|
"reason": "Patient has cardiology appointment, no wellness package booked",
|
||||||
|
"priority": "high"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Prompt injection
|
||||||
|
|
||||||
|
Raw triggers are appended to the system prompt as a `SUGGESTION RULES` section:
|
||||||
|
|
||||||
|
```
|
||||||
|
SUGGESTION RULES (from business configuration):
|
||||||
|
Based on this caller's profile, the following suggestions should be offered.
|
||||||
|
Generate a natural, conversational script for each that the agent can read aloud.
|
||||||
|
Return them in the `suggestions` array of your JSON response.
|
||||||
|
|
||||||
|
1. [upsell/high] Cardiac Wellness Package — patient has cardiology appointment, no wellness package booked
|
||||||
|
2. [retention/medium] Reschedule missed appointment — last appointment was rescheduled, no new booking
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: AI generates scripts
|
||||||
|
|
||||||
|
The AI turns the raw triggers into conversational scripts using the caller's context (name, history, doctor, department). Scripts are personalized, not templated.
|
||||||
|
|
||||||
|
### Step 4: Seeded rules
|
||||||
|
|
||||||
|
Default suggestion rules seeded in the rules engine config:
|
||||||
|
- Package upsell by department (cardiology → cardiac wellness, ortho → physio package)
|
||||||
|
- Reschedule missed/cancelled appointments
|
||||||
|
- Cross-sell related departments (ortho → physio, cardio → dietician)
|
||||||
|
- First-visit patient: suggest health checkup package
|
||||||
|
- Returning patient with no recent visit: re-engagement prompt
|
||||||
|
|
||||||
|
These rules are displayed read-only in Settings > Automations tab (same card pattern as existing automation rules — visible but not editable in v1).
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Call arrives
|
||||||
|
-> CallerResolutionController.resolve()
|
||||||
|
-> CallerContextService.prewarm() (parallel fetch + Redis cache)
|
||||||
|
-> Frontend auto-fires AI chat
|
||||||
|
-> POST /api/ai/stream
|
||||||
|
-> buildCallerContext() — Redis cache hit
|
||||||
|
-> rulesEngine.evaluate(callerFacts) — produces suggestion triggers
|
||||||
|
-> buildSystemPrompt(KB + callerContext + suggestionRules + structuredOutputInstructions)
|
||||||
|
-> streamText() — AI returns structured JSON { message, suggestions }
|
||||||
|
-> Frontend parses response
|
||||||
|
-> Zone 1: summary card from CallerContextService (no AI needed)
|
||||||
|
-> Zone 2: suggestions from AI response
|
||||||
|
-> Zone 3: message as chat bubble
|
||||||
|
|
||||||
|
Agent clicks "Tell me more" on a suggestion
|
||||||
|
-> Sent as chat message: "Tell me more about the Cardiac Wellness Package"
|
||||||
|
-> AI responds with detailed info + updated suggestions
|
||||||
|
-> Zone 2 refreshes with new suggestions
|
||||||
|
|
||||||
|
Agent books appointment (via disposition/form)
|
||||||
|
-> System message injected into chat: "Agent booked appointment with Dr. Lakshmi on Apr 24"
|
||||||
|
-> Next AI response reflects the action + updates suggestions
|
||||||
|
(e.g., removes "reschedule" suggestion, adds "send appointment reminder via WhatsApp")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Surface Area
|
||||||
|
|
||||||
|
### Sidecar (helix-engage-server)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `ai-chat.controller.ts` | Add structured output instructions to system prompt. Add suggestion rules injection from rules engine. Parse/pass suggestion triggers. |
|
||||||
|
| `caller-context.service.ts` | Add rules evaluation method that runs caller facts against suggestion rules. Return triggers alongside context. |
|
||||||
|
| `rules-engine/` | Seed default suggestion rules (JSON config in Redis or file). |
|
||||||
|
| `config/ai.defaults.ts` | Update `ccAgentHelper` prompt template with structured output format instructions and suggestion generation rules. |
|
||||||
|
|
||||||
|
### Frontend (helix-engage)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| NEW: `ai-summary-card.tsx` | Zone 1 — patient profile card rendered from CallerContextService data |
|
||||||
|
| NEW: `ai-suggestions.tsx` | Zone 2 — suggestion pills with expand/collapse, script display, "Tell me more" |
|
||||||
|
| REWRITE: `ai-chat-panel.tsx` | Orchestrates all 3 zones. Parses structured JSON responses. Manages suggestion state. Passes "Tell me more" clicks as chat messages. |
|
||||||
|
| `context-panel.tsx` | Remove P360 tab toggle. Single surface — AI coaching panel is the only mode. |
|
||||||
|
|
||||||
|
### No changes needed
|
||||||
|
|
||||||
|
- `call-desk.tsx` — panel wrapper stays the same
|
||||||
|
- `app-shell.tsx` — no changes
|
||||||
|
- `CallerContextService` — already built, just add rules evaluation call
|
||||||
|
- Frontend build pipeline — no new dependencies
|
||||||
|
|
||||||
|
## What this replaces
|
||||||
|
|
||||||
|
- P360 context tab (appointments, call history, follow-ups tables) — replaced by AI summary card
|
||||||
|
- AI chat toggle — removed (single surface)
|
||||||
|
- Tool-based patient lookups during chat — replaced by pre-fetched context in KB
|
||||||
|
- Static quick action pills — replaced by rule-driven contextual suggestions
|
||||||
|
|
||||||
|
## Out of scope for v1
|
||||||
|
|
||||||
|
- Editable suggestion rules UI (shown read-only in Settings)
|
||||||
|
- Supervisor AI coaching (different tool set, different panel)
|
||||||
|
- Real-time transcript-driven suggestions (requires live call transcription)
|
||||||
|
- Suggestion analytics (which suggestions agents click, conversion tracking)
|
||||||
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal file
284
docs/superpowers/plans/2026-03-21-phase1-unblock.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Phase 1: Agent Status + Global Search + Enquiry Form
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Unblock supervisor features by adding agent availability toggle, give agents fast record lookup via global search, and add a general enquiry capture form for non-lead interactions.
|
||||||
|
|
||||||
|
**Architecture:** Agent status syncs with Ozonetel's changeAgentState API. Global search queries the platform GraphQL for leads, patients, and appointments in parallel. Enquiry form creates a Lead record with source "PHONE_INQUIRY" and captures the interaction details.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS sidecar (Ozonetel APIs), React 19 + Jotai, Platform GraphQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature A: Agent Availability Status
|
||||||
|
|
||||||
|
The agent needs an Active/Away/Offline toggle that syncs with Ozonetel CloudAgent state.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/call-desk/agent-status-toggle.tsx` | Create: dropdown toggle for Ready/Pause/Offline |
|
||||||
|
| `helix-engage/src/pages/call-desk.tsx` | Modify: replace hardcoded "Ready" badge with AgentStatusToggle |
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/agent-state` accepting state + pauseReason |
|
||||||
|
|
||||||
|
### Task A1: Sidecar endpoint for agent state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `POST /api/ozonetel/agent-state` endpoint**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('agent-state')
|
||||||
|
async agentState(
|
||||||
|
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
|
) {
|
||||||
|
if (!body.state) {
|
||||||
|
throw new HttpException('state required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Agent state change: ${this.defaultAgentId} → ${body.state}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
|
agentId: this.defaultAgentId,
|
||||||
|
state: body.state,
|
||||||
|
pauseReason: body.pauseReason,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Type check and commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add agent state change endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A2: Agent status toggle component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/agent-status-toggle.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the toggle component**
|
||||||
|
|
||||||
|
A dropdown button showing current status (Ready/Break/Offline) with color-coded dot. Selecting a state calls the sidecar API.
|
||||||
|
|
||||||
|
States:
|
||||||
|
- **Ready** (green dot) → Ozonetel state: Ready
|
||||||
|
- **Break** (orange dot) → Ozonetel state: Pause, pauseReason: "Break"
|
||||||
|
- **Training** (blue dot) → Ozonetel state: Pause, pauseReason: "Training"
|
||||||
|
- **Offline** (gray dot) → calls agent-logout
|
||||||
|
|
||||||
|
The component uses React Aria's `Select` or a simple popover.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add agent status toggle component
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A3: Wire into call desk
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/call-desk.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the hardcoded "Ready" BadgeWithDot with AgentStatusToggle**
|
||||||
|
|
||||||
|
The current badge at line 43-49 shows SIP registration status. Replace with the new toggle that shows actual CloudAgent state AND SIP status.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: replace hardcoded Ready badge with agent status toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature B: Global Search
|
||||||
|
|
||||||
|
A search bar in the header/top-bar that searches across leads, patients, and appointments.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/shared/global-search.tsx` | Modify: search leads + patients + appointments via sidecar |
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `GET /api/search?q=` that queries platform |
|
||||||
|
| `helix-engage/src/components/layout/top-bar.tsx` | Modify: add GlobalSearch to the top bar |
|
||||||
|
|
||||||
|
### Task B1: Sidecar search endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` (or create a new search controller)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `GET /api/search` endpoint**
|
||||||
|
|
||||||
|
Queries leads, patients, and appointments in parallel via platform GraphQL. Returns grouped results.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Get('search')
|
||||||
|
async search(@Query('q') query: string) {
|
||||||
|
if (!query || query.length < 2) return { leads: [], patients: [], appointments: [] };
|
||||||
|
|
||||||
|
const authHeader = `Bearer ${this.platformApiKey}`;
|
||||||
|
|
||||||
|
// Search leads by name or phone
|
||||||
|
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
||||||
|
this.platform.queryWithAuth(`{ leads(first: 5, filter: {
|
||||||
|
or: [
|
||||||
|
{ contactName: { firstName: { like: "%${query}%" } } },
|
||||||
|
{ contactPhone: { primaryPhoneNumber: { like: "%${query}%" } } }
|
||||||
|
]
|
||||||
|
}) { edges { node { id name contactName { firstName lastName } contactPhone { primaryPhoneNumber } source status } } } }`, undefined, authHeader),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth(`{ patients(first: 5, filter: {
|
||||||
|
or: [
|
||||||
|
{ fullName: { firstName: { like: "%${query}%" } } },
|
||||||
|
{ phones: { primaryPhoneNumber: { like: "%${query}%" } } }
|
||||||
|
]
|
||||||
|
}) { edges { node { id name fullName { firstName lastName } phones { primaryPhoneNumber } } } } }`, undefined, authHeader),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth(`{ appointments(first: 5, filter: {
|
||||||
|
doctorName: { like: "%${query}%" }
|
||||||
|
}) { edges { node { id scheduledAt doctorName department appointmentStatus patientId } } } }`, undefined, authHeader),
|
||||||
|
]).catch(() => [{ leads: { edges: [] } }, { patients: { edges: [] } }, { appointments: { edges: [] } }]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leads: leadsResult?.leads?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
patients: patientsResult?.patients?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
appointments: appointmentsResult?.appointments?.edges?.map((e: any) => e.node) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: GraphQL `like` filter syntax may differ on the platform. May need to use `contains` or fetch-and-filter client-side.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add cross-entity search endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task B2: Update GlobalSearch component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/shared/global-search.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Wire to sidecar search endpoint**
|
||||||
|
|
||||||
|
Replace the local leads-only search with a call to `GET /api/search?q=`. Display results grouped by entity type with icons:
|
||||||
|
- 👤 Leads — name, phone, source
|
||||||
|
- 🏥 Patients — name, phone, MRN
|
||||||
|
- 📅 Appointments — doctor, date, status
|
||||||
|
|
||||||
|
Clicking a result navigates to the appropriate detail page.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: wire global search to cross-entity sidecar endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task B3: Add search to call desk header
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/call-desk.tsx` or `src/components/layout/top-bar.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add GlobalSearch to the call desk header**
|
||||||
|
|
||||||
|
Place next to the existing search in the worklist area, or in the top bar so it's accessible from every page.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add global search to call desk header
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature C: General Enquiry Form
|
||||||
|
|
||||||
|
When a caller has a question (not a lead), the agent needs a structured form to capture the interaction.
|
||||||
|
|
||||||
|
### File Map
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/call-desk/enquiry-form.tsx` | Create: inline form for capturing general enquiries |
|
||||||
|
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Modify: add "Log Enquiry" button during active call |
|
||||||
|
|
||||||
|
### Task C1: Create enquiry form
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/components/call-desk/enquiry-form.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create inline enquiry form**
|
||||||
|
|
||||||
|
Fields (from spec US 5):
|
||||||
|
- Patient Name*
|
||||||
|
- Source/Referral*
|
||||||
|
- Query Asked* (textarea)
|
||||||
|
- Existing Patient? (Y/N)*
|
||||||
|
- If Y: Registered mobile number
|
||||||
|
- Relevant Department (optional, select from doctors list)
|
||||||
|
- Relevant Doctor (optional, filtered by department)
|
||||||
|
- Follow-up needed? (Y/N)*
|
||||||
|
- If Y: Date and time
|
||||||
|
- Disposition*
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
1. Creates a Lead record with `source: 'PHONE_INQUIRY'`
|
||||||
|
2. Creates a LeadActivity with `activityType: 'ENQUIRY'`
|
||||||
|
3. If follow-up needed, creates a FollowUp record
|
||||||
|
|
||||||
|
The form is inline (same pattern as appointment form) — shows below the call card when "Log Enquiry" is clicked.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add general enquiry capture form
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task C2: Add "Log Enquiry" button to active call
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add button between "Book Appt" and "Transfer"**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Button size="sm" color="secondary"
|
||||||
|
iconLeading={...}
|
||||||
|
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the enquiry form inline below the call card when open (same pattern as appointment form).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: add Log Enquiry button to active call card
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task D: Deploy and verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Type check both projects**
|
||||||
|
- [ ] **Step 2: Build and deploy sidecar**
|
||||||
|
- [ ] **Step 3: Build and deploy frontend**
|
||||||
|
- [ ] **Step 4: Test agent status toggle** — switch to Break, verify badge changes, switch back to Ready
|
||||||
|
- [ ] **Step 5: Test global search** — search by name, phone number, verify results from leads + patients
|
||||||
|
- [ ] **Step 6: Test enquiry form** — during a call, click Enquiry, fill form, submit, verify Lead + Activity created
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Agent state and Ozonetel** — `changeAgentState` cannot transition from ACW. The toggle should disable during ACW and show a "Completing wrap-up..." state.
|
||||||
|
- **Search filter syntax** — the platform's GraphQL `like` operator may not exist. Fallback: fetch first 50 records of each type and filter client-side by name/phone match.
|
||||||
|
- **Enquiry vs Disposition** — the enquiry form is separate from the disposition form. An enquiry can be logged DURING a call (like booking an appointment), while disposition is logged AFTER the call ends.
|
||||||
|
- **The 6-button problem** — active call now has: Mute, Hold, Book Appt, Enquiry, Transfer, Pause Rec, End = 7 buttons. Consider grouping Book Appt + Enquiry under a "More" dropdown, or using icon-only buttons for some.
|
||||||
File diff suppressed because it is too large
Load Diff
643
docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md
Normal file
643
docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
# Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout.
|
||||||
|
|
||||||
|
**Architecture:** Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Sidecar (`helix-engage-server/src/`)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `auth/session.service.ts` | Create | Redis session lock/unlock/refresh |
|
||||||
|
| `auth/agent-config.service.ts` | Create | Query Agent entity, cache agent configs |
|
||||||
|
| `auth/auth.controller.ts` | Modify | Use agent config + session locking on login, add logout + heartbeat |
|
||||||
|
| `auth/auth.module.ts` | Modify | Register new services, import Redis |
|
||||||
|
| `config/configuration.ts` | Modify | Add `REDIS_URL` + SIP domain config |
|
||||||
|
|
||||||
|
### Frontend (`helix-engage/src/`)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `pages/login.tsx` | Modify | Store agentConfig, handle 403/409 errors |
|
||||||
|
| `providers/sip-provider.tsx` | Modify | Read SIP config from agentConfig instead of env vars |
|
||||||
|
| `components/layout/app-shell.tsx` | Modify | Add heartbeat interval for CC agents |
|
||||||
|
| `lib/api-client.ts` | Modify | Add logout API call |
|
||||||
|
| `providers/auth-provider.tsx` | Modify | Call sidecar logout on sign-out |
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| VPS `docker-compose.yml` | Modify | Add `REDIS_URL` to sidecar env |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Install ioredis + Redis Session Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/package.json`
|
||||||
|
- Create: `helix-engage-server/src/auth/session.service.ts`
|
||||||
|
- Modify: `helix-engage-server/src/config/configuration.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install ioredis**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm install ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Redis URL to config**
|
||||||
|
|
||||||
|
In `config/configuration.ts`, add to the returned object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create session service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/auth/session.service.ts
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const SESSION_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionService.name);
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
|
this.redis = new Redis(url);
|
||||||
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(agentId: string): string {
|
||||||
|
return `agent:session:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lockSession(agentId: string, memberId: string): Promise<void> {
|
||||||
|
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||||
|
return this.redis.get(this.key(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(agentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify sidecar compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts
|
||||||
|
git commit -m "feat: Redis session service for agent login lockout"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Agent Config Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/auth/agent-config.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create agent config service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/auth/agent-config.service.ts
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
export type AgentConfig = {
|
||||||
|
id: string;
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
sipExtension: string;
|
||||||
|
sipPassword: string;
|
||||||
|
campaignName: string;
|
||||||
|
sipUri: string;
|
||||||
|
sipWsServer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigService {
|
||||||
|
private readonly logger = new Logger(AgentConfigService.name);
|
||||||
|
private readonly cache = new Map<string, AgentConfig>();
|
||||||
|
private readonly sipDomain: string;
|
||||||
|
private readonly sipWsPort: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
|
||||||
|
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.cache.get(memberId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||||
|
id ozonetelagentid sipextension sippassword campaignname
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = data?.agents?.edges?.[0]?.node;
|
||||||
|
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
||||||
|
|
||||||
|
const agentConfig: AgentConfig = {
|
||||||
|
id: node.id,
|
||||||
|
ozonetelAgentId: node.ozonetelagentid,
|
||||||
|
sipExtension: node.sipextension,
|
||||||
|
sipPassword: node.sippassword ?? node.sipextension,
|
||||||
|
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
|
||||||
|
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||||
|
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.set(memberId, agentConfig);
|
||||||
|
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
|
||||||
|
return agentConfig;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFromCache(memberId: string): AgentConfig | null {
|
||||||
|
return this.cache.get(memberId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(memberId: string): void {
|
||||||
|
this.cache.delete(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify sidecar compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/auth/agent-config.service.ts
|
||||||
|
git commit -m "feat: agent config service with platform query + in-memory cache"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Update Auth Module + Controller
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/auth/auth.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/auth/auth.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update auth module to register new services**
|
||||||
|
|
||||||
|
Read `src/auth/auth.module.ts` and add imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [SessionService, AgentConfigService],
|
||||||
|
exports: [SessionService, AgentConfigService],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite auth controller login for multi-agent**
|
||||||
|
|
||||||
|
Inject new services into `AuthController`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
private agentConfigService: AgentConfigService,
|
||||||
|
) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Modify the CC agent section of `login()` (currently lines 115-128). Replace the hardcoded Ozonetel login with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (appRole === 'cc-agent') {
|
||||||
|
const memberId = workspaceMember?.id;
|
||||||
|
if (!memberId) throw new HttpException('Workspace member not found', 400);
|
||||||
|
|
||||||
|
// Look up agent config from platform
|
||||||
|
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||||
|
if (!agentConfig) {
|
||||||
|
throw new HttpException('Agent account not configured. Contact administrator.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate login
|
||||||
|
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
|
||||||
|
if (existingSession && existingSession !== memberId) {
|
||||||
|
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock session
|
||||||
|
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
|
||||||
|
|
||||||
|
// Login to Ozonetel with agent-specific credentials
|
||||||
|
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||||
|
this.ozonetelAgent.loginAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: ozAgentPassword,
|
||||||
|
phoneNumber: agentConfig.sipExtension,
|
||||||
|
mode: 'blended',
|
||||||
|
}).catch(err => {
|
||||||
|
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return agent config to frontend
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: tokens.refreshToken.token,
|
||||||
|
user: { ... }, // same as today
|
||||||
|
agentConfig: {
|
||||||
|
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||||
|
sipExtension: agentConfig.sipExtension,
|
||||||
|
sipPassword: agentConfig.sipPassword,
|
||||||
|
sipUri: agentConfig.sipUri,
|
||||||
|
sipWsServer: agentConfig.sipWsServer,
|
||||||
|
campaignName: agentConfig.campaignName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `workspaceMember.id` is already available from the profile query on line 87-88 of the existing code.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add logout endpoint**
|
||||||
|
|
||||||
|
Add after the `refresh` endpoint:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) throw new HttpException('Authorization required', 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve workspace member from JWT
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
if (!memberId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||||
|
if (agentConfig) {
|
||||||
|
// Unlock Redis session
|
||||||
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
|
|
||||||
|
// Logout from Ozonetel
|
||||||
|
this.ozonetelAgent.logoutAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||||
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add heartbeat endpoint**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Post('heartbeat')
|
||||||
|
async heartbeat(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) throw new HttpException('Authorization required', 401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
|
||||||
|
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify sidecar compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/auth/auth.module.ts src/auth/auth.controller.ts
|
||||||
|
git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Update Ozonetel Controller for Per-Agent Calls
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add AgentConfigService to Ozonetel controller**
|
||||||
|
|
||||||
|
Import and inject `AgentConfigService`. Add a helper to resolve the agent config from the auth header:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AgentConfigService } from '../auth/agent-config.service';
|
||||||
|
|
||||||
|
// In constructor:
|
||||||
|
private readonly agentConfig: AgentConfigService,
|
||||||
|
|
||||||
|
// Helper method:
|
||||||
|
private async resolveAgentId(authHeader: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
'{ currentUser { workspaceMember { id } } }',
|
||||||
|
undefined, authHeader,
|
||||||
|
);
|
||||||
|
const memberId = data.currentUser?.workspaceMember?.id;
|
||||||
|
const config = memberId ? this.agentConfig.getFromCache(memberId) : null;
|
||||||
|
return config?.ozonetelAgentId ?? this.defaultAgentId;
|
||||||
|
} catch {
|
||||||
|
return this.defaultAgentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update dispose, agent-state, dial, and other endpoints**
|
||||||
|
|
||||||
|
Replace `this.defaultAgentId` with `await this.resolveAgentId(authHeader)` in the endpoints that pass the auth header. The key endpoints to update:
|
||||||
|
|
||||||
|
- `dispose()` — add `@Headers('authorization') auth: string` param, resolve agent ID
|
||||||
|
- `agentState()` — same
|
||||||
|
- `dial()` — same
|
||||||
|
- `agentReady()` — same
|
||||||
|
|
||||||
|
For endpoints that don't currently take the auth header, add it as a parameter.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update auth module to handle circular dependency**
|
||||||
|
|
||||||
|
The `OzonetelAgentModule` now needs `AgentConfigService` from `AuthModule`. Use `forwardRef` if needed, or export `AgentConfigService` from a shared module.
|
||||||
|
|
||||||
|
Simplest approach: move `AgentConfigService` export from `AuthModule` and import it in `OzonetelAgentModule`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify sidecar compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts
|
||||||
|
git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — Store Agent Config + Dynamic SIP
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/login.tsx`
|
||||||
|
- Modify: `helix-engage/src/providers/sip-provider.tsx`
|
||||||
|
- Modify: `helix-engage/src/providers/auth-provider.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Store agentConfig on login**
|
||||||
|
|
||||||
|
In `login.tsx`, after successful login, store the agent config:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (response.agentConfig) {
|
||||||
|
localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Handle new error codes:
|
||||||
|
```typescript
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message?.includes('not configured')) {
|
||||||
|
setError('Agent account not configured. Contact your administrator.');
|
||||||
|
} else if (err.message?.includes('already logged in')) {
|
||||||
|
setError('You are already logged in on another device. Please log out there first.');
|
||||||
|
} else {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update SIP provider to use stored agent config**
|
||||||
|
|
||||||
|
In `sip-provider.tsx`, replace the hardcoded `DEFAULT_CONFIG`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getAgentSipConfig = (): SIPConfig => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('helix_agent_config');
|
||||||
|
if (stored) {
|
||||||
|
const config = JSON.parse(stored);
|
||||||
|
return {
|
||||||
|
displayName: 'Helix Agent',
|
||||||
|
uri: config.sipUri,
|
||||||
|
password: config.sipPassword,
|
||||||
|
wsServer: config.sipWsServer,
|
||||||
|
stunServers: 'stun:stun.l.google.com:19302',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
// Fallback to env vars
|
||||||
|
return {
|
||||||
|
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
|
||||||
|
uri: import.meta.env.VITE_SIP_URI ?? '',
|
||||||
|
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
|
||||||
|
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
|
||||||
|
stunServers: 'stun:stun.l.google.com:19302',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `getAgentSipConfig()` where `DEFAULT_CONFIG` was used.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update auth provider logout to call sidecar**
|
||||||
|
|
||||||
|
In `auth-provider.tsx`, modify `logout()` to call the sidecar first:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('helix_access_token');
|
||||||
|
if (token) {
|
||||||
|
await fetch(`${API_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('helix_access_token');
|
||||||
|
localStorage.removeItem('helix_refresh_token');
|
||||||
|
localStorage.removeItem('helix_user');
|
||||||
|
localStorage.removeItem('helix_agent_config');
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `API_URL` needs to be available here. Import from `api-client.ts` or read from env.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify frontend compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx
|
||||||
|
git commit -m "feat: dynamic SIP config from login response, logout cleanup"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — Heartbeat
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/layout/app-shell.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add heartbeat interval for CC agents**
|
||||||
|
|
||||||
|
In `AppShell`, add a heartbeat effect:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { isCCAgent } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCCAgent) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const token = localStorage.getItem('helix_access_token');
|
||||||
|
if (token) {
|
||||||
|
fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // Every 5 minutes
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isCCAgent]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify frontend compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/layout/app-shell.tsx
|
||||||
|
git commit -m "feat: heartbeat every 5 min to keep agent session alive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Docker + Deploy
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: VPS `docker-compose.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add REDIS_URL to sidecar in docker-compose**
|
||||||
|
|
||||||
|
SSH to VPS and add `REDIS_URL: redis://redis:6379` to the sidecar environment section. Also add `redis` to the sidecar's `depends_on`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Deploy using deploy script**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh all
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify sidecar connects to Redis**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Redis connected`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test login flow**
|
||||||
|
|
||||||
|
Login as rekha.cc → should get `agentConfig` in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit docker-compose change and push all to Azure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && git add . && git push origin dev
|
||||||
|
cd helix-engage-server && git add . && git push origin dev
|
||||||
|
```
|
||||||
531
docs/superpowers/plans/2026-03-24-supervisor-module.md
Normal file
531
docs/superpowers/plans/2026-03-24-supervisor-module.md
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
# Supervisor Module Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the supervisor module with team performance dashboard (PP-5), live call monitor (PP-6), master data pages, and admin sidebar restructure.
|
||||||
|
|
||||||
|
**Architecture:** Frontend pages query platform GraphQL directly for entity data (calls, appointments, leads, agents). Sidecar provides Ozonetel-specific data (agent time breakdown, active calls via event subscription). No hardcoded/mock data anywhere.
|
||||||
|
|
||||||
|
**Tech Stack:** React + Tailwind + ECharts (frontend), NestJS sidecar (Ozonetel integration), Fortytwo platform GraphQL
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-supervisor-module.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Frontend (`helix-engage/src/`)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `pages/team-performance.tsx` | Create | PP-5 full dashboard |
|
||||||
|
| `pages/live-monitor.tsx` | Create | PP-6 active call table |
|
||||||
|
| `pages/call-recordings.tsx` | Create | Calls with recordings master |
|
||||||
|
| `pages/missed-calls.tsx` | Create | Missed calls master (supervisor view) |
|
||||||
|
| `components/layout/sidebar.tsx` | Modify | Admin nav restructure |
|
||||||
|
| `main.tsx` | Modify | Add new routes |
|
||||||
|
|
||||||
|
### Sidecar (`helix-engage-server/src/`)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `supervisor/supervisor.service.ts` | Create | Team perf aggregation + active call tracking |
|
||||||
|
| `supervisor/supervisor.controller.ts` | Create | REST endpoints |
|
||||||
|
| `supervisor/supervisor.module.ts` | Create | Module registration |
|
||||||
|
| `app.module.ts` | Modify | Import SupervisorModule |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Admin Sidebar Nav + Routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
|
||||||
|
- Modify: `helix-engage/src/main.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add new icon imports to sidebar**
|
||||||
|
|
||||||
|
In `sidebar.tsx`, add to the FontAwesome imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
// existing imports...
|
||||||
|
faRadio,
|
||||||
|
faFileAudio,
|
||||||
|
faPhoneMissed,
|
||||||
|
faChartLine,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add icon wrappers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const IconRadio = faIcon(faRadio);
|
||||||
|
const IconFileAudio = faIcon(faFileAudio);
|
||||||
|
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||||
|
const IconChartLine = faIcon(faChartLine);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Restructure admin nav**
|
||||||
|
|
||||||
|
Replace the admin nav section (currently has Overview + Management + Admin groups) with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (role === 'admin') {
|
||||||
|
return [
|
||||||
|
{ label: 'Supervisor', items: [
|
||||||
|
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||||
|
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
||||||
|
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconRadio },
|
||||||
|
]},
|
||||||
|
{ label: 'Data & Reports', items: [
|
||||||
|
{ label: 'Lead Master', href: '/leads', icon: IconUsers },
|
||||||
|
{ label: 'Patient Master', href: '/patients', icon: IconHospitalUser },
|
||||||
|
{ label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck },
|
||||||
|
{ label: 'Call Log Master', href: '/call-history', icon: IconClockRewind },
|
||||||
|
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
|
||||||
|
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
|
||||||
|
]},
|
||||||
|
{ label: 'Admin', items: [
|
||||||
|
{ label: 'Settings', href: '/settings', icon: IconGear },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add routes in main.tsx**
|
||||||
|
|
||||||
|
Import new page components (they'll be created in later tasks — use placeholder components for now):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||||
|
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||||
|
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||||
|
import { MissedCallsPage } from "@/pages/missed-calls";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add routes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||||
|
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||||
|
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||||
|
<Route path="/missed-calls" element={<MissedCallsPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create placeholder pages**
|
||||||
|
|
||||||
|
Create minimal placeholder files for each new page so the build doesn't fail:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/pages/team-performance.tsx
|
||||||
|
export const TeamPerformancePage = () => <div>Team Performance — coming soon</div>;
|
||||||
|
|
||||||
|
// src/pages/live-monitor.tsx
|
||||||
|
export const LiveMonitorPage = () => <div>Live Call Monitor — coming soon</div>;
|
||||||
|
|
||||||
|
// src/pages/call-recordings.tsx
|
||||||
|
export const CallRecordingsPage = () => <div>Call Recordings — coming soon</div>;
|
||||||
|
|
||||||
|
// src/pages/missed-calls.tsx
|
||||||
|
export const MissedCallsPage = () => <div>Missed Calls — coming soon</div>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/layout/sidebar.tsx src/main.tsx src/pages/team-performance.tsx src/pages/live-monitor.tsx src/pages/call-recordings.tsx src/pages/missed-calls.tsx
|
||||||
|
git commit -m "feat: admin sidebar restructure + placeholder pages for supervisor module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Call Recordings Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/call-recordings.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement call recordings page**
|
||||||
|
|
||||||
|
Query platform for calls with recordings. Reuse patterns from `call-history.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Query: calls where recording primaryLinkUrl is not empty
|
||||||
|
const QUERY = `{ calls(first: 100, filter: {
|
||||||
|
recording: { primaryLinkUrl: { neq: "" } }
|
||||||
|
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
id direction callStatus callerNumber { primaryPhoneNumber }
|
||||||
|
agentName startedAt durationSec disposition
|
||||||
|
recording { primaryLinkUrl primaryLinkLabel }
|
||||||
|
} } } }`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Table columns: Agent, Caller (PhoneActionCell), Type (In/Out badge), Date, Duration, Disposition, Recording (play button).
|
||||||
|
|
||||||
|
Search by agent name or phone number. Date filter optional.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/call-recordings.tsx
|
||||||
|
git commit -m "feat: call recordings master page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Missed Calls Page (Supervisor View)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/missed-calls.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement missed calls page**
|
||||||
|
|
||||||
|
Query platform for all missed calls — no agent filter (supervisor sees all).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const QUERY = `{ calls(first: 100, filter: {
|
||||||
|
callStatus: { eq: MISSED }
|
||||||
|
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
id callerNumber { primaryPhoneNumber } agentName
|
||||||
|
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
|
||||||
|
} } } }`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Table columns: Caller (PhoneActionCell), Date/Time, Branch (`callsourcenumber`), Agent, Callback Status (badge), SLA (computed from `startedAt`).
|
||||||
|
|
||||||
|
Tabs: All | Pending (`PENDING_CALLBACK`) | Attempted (`CALLBACK_ATTEMPTED`) | Completed (`CALLBACK_COMPLETED` + `WRONG_NUMBER`).
|
||||||
|
|
||||||
|
Search by phone or agent.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/missed-calls.tsx
|
||||||
|
git commit -m "feat: missed calls master page for supervisors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Sidecar — Supervisor Module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/supervisor/supervisor.service.ts`
|
||||||
|
- Create: `helix-engage-server/src/supervisor/supervisor.controller.ts`
|
||||||
|
- Create: `helix-engage-server/src/supervisor/supervisor.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/app.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create supervisor service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// supervisor.service.ts
|
||||||
|
// Two responsibilities:
|
||||||
|
// 1. Aggregate Ozonetel agent summary across all agents
|
||||||
|
// 2. Track active calls from Ozonetel real-time events
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
type ActiveCall = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerNumber: string;
|
||||||
|
callType: string;
|
||||||
|
startTime: string;
|
||||||
|
status: 'active' | 'on-hold';
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupervisorService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private ozonetel: OzonetelAgentService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Subscribe to Ozonetel events (fire and forget)
|
||||||
|
// Will be implemented when webhook URL is configured
|
||||||
|
this.logger.log('Supervisor service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by webhook when Ozonetel pushes call events
|
||||||
|
handleCallEvent(event: any) {
|
||||||
|
const { action, ucid, agent_id, caller_id, call_type, event_time } = event;
|
||||||
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
|
this.activeCalls.set(ucid, {
|
||||||
|
ucid, agentId: agent_id, callerNumber: caller_id,
|
||||||
|
callType: call_type, startTime: event_time, status: 'active',
|
||||||
|
});
|
||||||
|
} else if (action === 'Disconnect') {
|
||||||
|
this.activeCalls.delete(ucid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by webhook when Ozonetel pushes agent events
|
||||||
|
handleAgentEvent(event: any) {
|
||||||
|
this.logger.log(`Agent event: ${event.agentId} → ${event.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
return Array.from(this.activeCalls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate time breakdown across all agents
|
||||||
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
|
// Get all agent IDs from platform
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 20) { edges { node {
|
||||||
|
id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch Ozonetel summary per agent
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
agents.map(async (agent: any) => {
|
||||||
|
try {
|
||||||
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||||
|
return { ...agent, timeBreakdown: summary };
|
||||||
|
} catch {
|
||||||
|
return { ...agent, timeBreakdown: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { date, agents: summaries };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create supervisor controller**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// supervisor.controller.ts
|
||||||
|
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Controller('api/supervisor')
|
||||||
|
export class SupervisorController {
|
||||||
|
private readonly logger = new Logger(SupervisorController.name);
|
||||||
|
|
||||||
|
constructor(private readonly supervisor: SupervisorService) {}
|
||||||
|
|
||||||
|
@Get('active-calls')
|
||||||
|
getActiveCalls() {
|
||||||
|
return this.supervisor.getActiveCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('team-performance')
|
||||||
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
return this.supervisor.getTeamPerformance(targetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('call-event')
|
||||||
|
handleCallEvent(@Body() body: any) {
|
||||||
|
// Ozonetel pushes events here
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`Call event: ${event.action} ucid=${event.ucid} agent=${event.agent_id}`);
|
||||||
|
this.supervisor.handleCallEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-event')
|
||||||
|
handleAgentEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`Agent event: ${event.action} agent=${event.agentId}`);
|
||||||
|
this.supervisor.handleAgentEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create supervisor module and register**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// supervisor.module.ts
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule],
|
||||||
|
controllers: [SupervisorController],
|
||||||
|
providers: [SupervisorService],
|
||||||
|
})
|
||||||
|
export class SupervisorModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `app.module.ts`:
|
||||||
|
```typescript
|
||||||
|
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||||
|
// Add to imports array
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify sidecar build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/supervisor/ src/app.module.ts
|
||||||
|
git commit -m "feat: supervisor module with team performance + active calls endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Team Performance Dashboard (PP-5)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/team-performance.tsx`
|
||||||
|
|
||||||
|
This is the largest task. The page queries platform directly for calls/appointments/leads and the sidecar for time breakdown.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the full page**
|
||||||
|
|
||||||
|
The page has 6 sections. Use `apiClient.graphql()` for platform data and `apiClient.get()` for sidecar data.
|
||||||
|
|
||||||
|
**Queries needed:**
|
||||||
|
- Calls by date range: `calls(first: 500, filter: { startedAt: { gte: "...", lte: "..." } })`
|
||||||
|
- Appointments by date range: `appointments(first: 200, filter: { scheduledAt: { gte: "...", lte: "..." } })`
|
||||||
|
- Leads: `leads(first: 200)`
|
||||||
|
- Follow-ups: `followUps(first: 200)`
|
||||||
|
- Agents with thresholds: `agents(first: 20) { ... npsscore maxidleminutes minnpsthreshold minconversionpercent }`
|
||||||
|
- Sidecar: `GET /api/supervisor/team-performance?date=YYYY-MM-DD`
|
||||||
|
|
||||||
|
**Date range logic:**
|
||||||
|
- Today: today start → now
|
||||||
|
- Week: Monday of current week → now
|
||||||
|
- Month: 1st of current month → now
|
||||||
|
- Year: Jan 1 → now
|
||||||
|
- Custom: user-selected range
|
||||||
|
|
||||||
|
**Sections to implement:**
|
||||||
|
1. Key Metrics bar (6 cards in a row)
|
||||||
|
2. Call Breakdown Trends (2 ECharts line charts side by side)
|
||||||
|
3. Agent Performance table (sortable)
|
||||||
|
4. Time Breakdown (team average + per-agent stacked bars)
|
||||||
|
5. NPS + Conversion Metrics (donut + cards)
|
||||||
|
6. Performance Alerts (threshold comparison)
|
||||||
|
|
||||||
|
Check if ECharts is already installed:
|
||||||
|
```bash
|
||||||
|
grep echarts helix-engage/package.json
|
||||||
|
```
|
||||||
|
If not, install: `npm install echarts echarts-for-react`
|
||||||
|
|
||||||
|
Follow the existing My Performance page (`my-performance.tsx`) for ECharts patterns.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test locally**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to `/team-performance` as admin user. Verify all 6 sections render with real data.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/team-performance.tsx package.json package-lock.json
|
||||||
|
git commit -m "feat: team performance dashboard (PP-5) with 6 data sections"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Live Call Monitor (PP-6)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/live-monitor.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the live monitor page**
|
||||||
|
|
||||||
|
Page polls `GET /api/supervisor/active-calls` every 5 seconds.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
1. TopBar: "Live Call Monitor" with subtitle "Listen, whisper, or barge into active calls"
|
||||||
|
2. Three KPI cards: Active Calls, On Hold, Avg Duration
|
||||||
|
3. Active Calls table: Agent, Caller, Type, Department, Duration (live counter), Status, Actions
|
||||||
|
4. Actions: Listen / Whisper / Barge buttons — all disabled with tooltip "Coming soon — pending Ozonetel API"
|
||||||
|
5. Empty state: headphones icon + "No active calls"
|
||||||
|
|
||||||
|
Duration should be a live counter — calculated client-side from `startTime` in the active call data. Use `setInterval` to update every second.
|
||||||
|
|
||||||
|
Caller name: attempt to match `callerNumber` against leads from `useData()`. If matched, show lead name + phone. If not, show phone only.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test locally**
|
||||||
|
|
||||||
|
Navigate to `/live-monitor`. Verify empty state renders. If Ozonetel events are flowing, verify active calls appear.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/live-monitor.tsx
|
||||||
|
git commit -m "feat: live call monitor page (PP-6) with polling + KPI cards"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Local Testing + Final Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run both locally**
|
||||||
|
|
||||||
|
Terminal 1: `cd helix-engage-server && npm run start:dev`
|
||||||
|
Terminal 2: `cd helix-engage && npm run dev`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test admin login**
|
||||||
|
|
||||||
|
Login as admin (sanjay.marketing@globalhospital.com). Verify:
|
||||||
|
- Sidebar shows new nav structure (Supervisor + Data & Reports sections)
|
||||||
|
- Dashboard loads
|
||||||
|
- Team Performance shows data from platform
|
||||||
|
- Live Monitor shows empty state or active calls
|
||||||
|
- All master data pages load (Lead, Patient, Appointment, Call Log, Call Recordings, Missed Calls)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit any fixes**
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push to Azure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && git push origin dev
|
||||||
|
cd helix-engage-server && git push origin dev
|
||||||
|
```
|
||||||
735
docs/superpowers/plans/2026-03-31-csv-lead-import.md
Normal file
735
docs/superpowers/plans/2026-03-31-csv-lead-import.md
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
# CSV Lead Import — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Allow supervisors to import leads from CSV into an existing campaign via a modal wizard with column mapping and patient matching.
|
||||||
|
|
||||||
|
**Architecture:** Client-side CSV parsing with a 3-step modal wizard (select campaign → upload/map/preview → import). Leads created via existing GraphQL proxy. No new sidecar endpoints needed.
|
||||||
|
|
||||||
|
**Tech Stack:** React modal (Untitled UI), native FileReader + string split for CSV parsing, existing DataProvider for patient/lead matching, platform GraphQL mutations for lead creation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/lib/csv-utils.ts` | Create | CSV parsing, phone normalization, fuzzy column matching |
|
||||||
|
| `src/components/campaigns/lead-import-wizard.tsx` | Create | Modal wizard: campaign select → upload/preview → import |
|
||||||
|
| `src/pages/campaigns.tsx` | Modify | Add "Import Leads" button |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: CSV Parsing & Column Matching Utility
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/csv-utils.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create csv-utils.ts with parseCSV function**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/csv-utils.ts
|
||||||
|
|
||||||
|
export type CSVRow = Record<string, string>;
|
||||||
|
|
||||||
|
export type CSVParseResult = {
|
||||||
|
headers: string[];
|
||||||
|
rows: CSVRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCSV = (text: string): CSVParseResult => {
|
||||||
|
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
if (lines.length === 0) return { headers: [], rows: [] };
|
||||||
|
|
||||||
|
const parseLine = (line: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = parseLine(lines[0]);
|
||||||
|
const rows = lines.slice(1).map(line => {
|
||||||
|
const values = parseLine(line);
|
||||||
|
const row: CSVRow = {};
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
row[header] = values[i] ?? '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add normalizePhone function**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const normalizePhone = (raw: string): string => {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
|
||||||
|
return stripped.slice(-10);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add fuzzy column matching**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type LeadFieldMapping = {
|
||||||
|
csvHeader: string;
|
||||||
|
leadField: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_FIELDS = [
|
||||||
|
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
|
||||||
|
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
|
||||||
|
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
|
||||||
|
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
|
||||||
|
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
|
||||||
|
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
|
||||||
|
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
|
||||||
|
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
|
||||||
|
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
|
||||||
|
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
|
||||||
|
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
|
||||||
|
const used = new Set<string>();
|
||||||
|
|
||||||
|
return csvHeaders.map(header => {
|
||||||
|
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
|
||||||
|
let bestMatch: string | null = null;
|
||||||
|
|
||||||
|
for (const field of LEAD_FIELDS) {
|
||||||
|
if (used.has(field.field)) continue;
|
||||||
|
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
|
||||||
|
bestMatch = field.field;
|
||||||
|
used.add(field.field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvHeader: header,
|
||||||
|
leadField: bestMatch,
|
||||||
|
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LEAD_FIELDS };
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add buildLeadPayload helper**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const buildLeadPayload = (
|
||||||
|
row: CSVRow,
|
||||||
|
mapping: LeadFieldMapping[],
|
||||||
|
campaignId: string,
|
||||||
|
patientId: string | null,
|
||||||
|
platform: string | null,
|
||||||
|
) => {
|
||||||
|
const getValue = (field: string): string => {
|
||||||
|
const entry = mapping.find(m => m.leadField === field);
|
||||||
|
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstName = getValue('contactName.firstName') || 'Unknown';
|
||||||
|
const lastName = getValue('contactName.lastName');
|
||||||
|
const phone = normalizePhone(getValue('contactPhone'));
|
||||||
|
|
||||||
|
if (!phone || phone.length < 10) return null;
|
||||||
|
|
||||||
|
const sourceMap: Record<string, string> = {
|
||||||
|
FACEBOOK: 'FACEBOOK_AD',
|
||||||
|
GOOGLE: 'GOOGLE_AD',
|
||||||
|
INSTAGRAM: 'INSTAGRAM',
|
||||||
|
MANUAL: 'OTHER',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
|
||||||
|
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
|
||||||
|
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
|
||||||
|
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
|
||||||
|
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
|
||||||
|
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
|
||||||
|
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
|
||||||
|
source: sourceMap[platform ?? ''] ?? 'OTHER',
|
||||||
|
status: 'NEW',
|
||||||
|
campaignId,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/csv-utils.ts
|
||||||
|
git commit -m "feat: CSV parsing, phone normalization, and fuzzy column matching utility"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Lead Import Wizard Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/campaigns/lead-import-wizard.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create wizard component with campaign selection step**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/campaigns/lead-import-wizard.tsx
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS, type LeadFieldMapping, type CSVRow } from '@/lib/csv-utils';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Campaign } from '@/types/entities';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done';
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
created: number;
|
||||||
|
linkedToPatient: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
skippedNoPhone: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LeadImportWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
|
||||||
|
const { campaigns, leads, patients, refresh } = useData();
|
||||||
|
const [step, setStep] = useState<ImportStep>('select-campaign');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
|
||||||
|
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
|
||||||
|
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const [importProgress, setImportProgress] = useState(0);
|
||||||
|
|
||||||
|
const activeCampaigns = useMemo(() =>
|
||||||
|
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
|
||||||
|
[campaigns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
// Reset state after close animation
|
||||||
|
setTimeout(() => {
|
||||||
|
setStep('select-campaign');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
setCsvRows([]);
|
||||||
|
setCsvHeaders([]);
|
||||||
|
setMapping([]);
|
||||||
|
setResult(null);
|
||||||
|
setImportProgress(0);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCampaignSelect = (campaign: Campaign) => {
|
||||||
|
setSelectedCampaign(campaign);
|
||||||
|
setStep('upload-preview');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
const { headers, rows } = parseCSV(text);
|
||||||
|
setCsvHeaders(headers);
|
||||||
|
setCsvRows(rows);
|
||||||
|
setMapping(fuzzyMatchColumns(headers));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMappingChange = (csvHeader: string, leadField: string | null) => {
|
||||||
|
setMapping(prev => prev.map(m =>
|
||||||
|
m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patient matching for preview
|
||||||
|
const rowsWithMatch = useMemo(() => {
|
||||||
|
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
|
||||||
|
if (!phoneMapping || csvRows.length === 0) return [];
|
||||||
|
|
||||||
|
const existingLeadPhones = new Set(
|
||||||
|
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
const patientByPhone = new Map(
|
||||||
|
patients
|
||||||
|
.filter(p => p.phones?.primaryPhoneNumber)
|
||||||
|
.map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return csvRows.map(row => {
|
||||||
|
const rawPhone = row[phoneMapping.csvHeader] ?? '';
|
||||||
|
const phone = normalizePhone(rawPhone);
|
||||||
|
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
|
||||||
|
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
|
||||||
|
const hasPhone = phone.length === 10;
|
||||||
|
|
||||||
|
return { row, phone, matchedPatient, isDuplicate, hasPhone };
|
||||||
|
});
|
||||||
|
}, [csvRows, mapping, leads, patients]);
|
||||||
|
|
||||||
|
const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone');
|
||||||
|
const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length;
|
||||||
|
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
|
||||||
|
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
|
||||||
|
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedCampaign) return;
|
||||||
|
setStep('importing');
|
||||||
|
|
||||||
|
const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length };
|
||||||
|
|
||||||
|
for (let i = 0; i < rowsWithMatch.length; i++) {
|
||||||
|
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
|
||||||
|
|
||||||
|
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform);
|
||||||
|
if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: payload },
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
importResult.created++;
|
||||||
|
if (matchedPatient) importResult.linkedToPatient++;
|
||||||
|
} catch {
|
||||||
|
importResult.failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportProgress(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(importResult);
|
||||||
|
setStep('done');
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Available lead fields for mapping dropdown (exclude already-mapped ones)
|
||||||
|
const availableFields = useMemo(() => {
|
||||||
|
const usedFields = new Set(mapping.filter(m => m.leadField).map(m => m.leadField));
|
||||||
|
return LEAD_FIELDS.map(f => ({
|
||||||
|
id: f.field,
|
||||||
|
name: f.label,
|
||||||
|
isDisabled: usedFields.has(f.field),
|
||||||
|
}));
|
||||||
|
}, [mapping]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||||
|
<Modal className="sm:max-w-3xl">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Import Leads</h2>
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
{step === 'select-campaign' && 'Select a campaign to import leads into'}
|
||||||
|
{step === 'upload-preview' && `Importing into: ${selectedCampaign?.campaignName}`}
|
||||||
|
{step === 'importing' && 'Importing leads...'}
|
||||||
|
{step === 'done' && 'Import complete'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||||
|
|
||||||
|
{/* Step 1: Campaign Cards */}
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{activeCampaigns.length === 0 ? (
|
||||||
|
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns. Create a campaign first.</p>
|
||||||
|
) : (
|
||||||
|
activeCampaigns.map(campaign => (
|
||||||
|
<button
|
||||||
|
key={campaign.id}
|
||||||
|
onClick={() => handleCampaignSelect(campaign)}
|
||||||
|
className={cx(
|
||||||
|
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
|
||||||
|
selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
|
||||||
|
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">
|
||||||
|
{campaign.campaignStatus}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 text-xs text-tertiary">{campaign.leadCount ?? 0} leads</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Upload + Preview */}
|
||||||
|
{step === 'upload-preview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File upload */}
|
||||||
|
{csvRows.length === 0 ? (
|
||||||
|
<label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-12 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
|
||||||
|
<FontAwesomeIcon icon={faCloudArrowUp} className="size-8 text-fg-quaternary mb-3" />
|
||||||
|
<span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
|
||||||
|
<span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
|
||||||
|
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Validation banner */}
|
||||||
|
{!phoneIsMapped && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-error-primary px-4 py-3">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="size-4 text-fg-error-primary" />
|
||||||
|
<span className="text-sm font-medium text-error-primary">Phone column must be mapped to proceed</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-tertiary">
|
||||||
|
<span>{csvRows.length} rows</span>
|
||||||
|
<span className="text-success-primary">{validCount} ready</span>
|
||||||
|
{patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
|
||||||
|
{duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
|
||||||
|
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column mapping + preview table */}
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-secondary">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
{/* Mapping row */}
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-secondary">
|
||||||
|
{mapping.map(m => (
|
||||||
|
<th key={m.csvHeader} className="px-3 py-2 text-left font-normal">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span>
|
||||||
|
<select
|
||||||
|
value={m.leadField ?? ''}
|
||||||
|
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
|
||||||
|
className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary"
|
||||||
|
>
|
||||||
|
<option value="">Skip</option>
|
||||||
|
{LEAD_FIELDS.map(f => (
|
||||||
|
<option key={f.field} value={f.field}>{f.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2 text-left font-normal">
|
||||||
|
<span className="text-[10px] text-quaternary uppercase">Patient Match</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rowsWithMatch.slice(0, 20).map((item, i) => (
|
||||||
|
<tr key={i} className={cx(
|
||||||
|
'border-t border-secondary',
|
||||||
|
item.isDuplicate && 'bg-warning-primary opacity-60',
|
||||||
|
!item.hasPhone && 'bg-error-primary opacity-40',
|
||||||
|
)}>
|
||||||
|
{mapping.map(m => (
|
||||||
|
<td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]">
|
||||||
|
{item.row[m.csvHeader] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.matchedPatient ? (
|
||||||
|
<Badge size="sm" color="success" type="pill-color">
|
||||||
|
{item.matchedPatient.fullName?.firstName ?? 'Patient'}
|
||||||
|
</Badge>
|
||||||
|
) : item.isDuplicate ? (
|
||||||
|
<Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>
|
||||||
|
) : !item.hasPhone ? (
|
||||||
|
<Badge size="sm" color="error" type="pill-color">No phone</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">New</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{csvRows.length > 20 && (
|
||||||
|
<div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary">
|
||||||
|
Showing 20 of {csvRows.length} rows
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Importing */}
|
||||||
|
{step === 'importing' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
|
||||||
|
<p className="text-sm font-semibold text-primary">Importing leads...</p>
|
||||||
|
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
|
||||||
|
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-solid transition-all duration-200"
|
||||||
|
style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Done */}
|
||||||
|
{step === 'done' && result && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
|
||||||
|
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
|
||||||
|
<div className="rounded-lg bg-success-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-success-primary">{result.created}</p>
|
||||||
|
<p className="text-xs text-tertiary">Created</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-brand-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
|
||||||
|
<p className="text-xs text-tertiary">Linked to Patients</p>
|
||||||
|
</div>
|
||||||
|
{result.skippedDuplicate > 0 && (
|
||||||
|
<div className="rounded-lg bg-warning-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
|
||||||
|
<p className="text-xs text-tertiary">Duplicates</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
|
||||||
|
<p className="text-xs text-tertiary">Failed</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
|
||||||
|
)}
|
||||||
|
{step === 'upload-preview' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleImport} isDisabled={!phoneIsMapped || validCount === 0}>
|
||||||
|
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/campaigns/lead-import-wizard.tsx
|
||||||
|
git commit -m "feat: lead import wizard with campaign selection, CSV preview, and patient matching"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add Import Button to Campaigns Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/campaigns.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import LeadImportWizard and add state + button**
|
||||||
|
|
||||||
|
Add import at top of `campaigns.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add state inside `CampaignsPage` component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add button next to the TopBar or in the header area. Replace the existing `TopBar` line:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<TopBar title="Campaigns" subtitle={subtitle} />
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<TopBar title="Campaigns" subtitle={subtitle}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
>
|
||||||
|
Import Leads
|
||||||
|
</Button>
|
||||||
|
</TopBar>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import for `faFileImport`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the wizard component before the closing `</div>` of the return, after the CampaignEditSlideout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check if TopBar accepts children**
|
||||||
|
|
||||||
|
Read `src/components/layout/top-bar.tsx` to verify it renders `children`. If not, place the button differently — inside the existing header div in campaigns.tsx.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Type check**
|
||||||
|
|
||||||
|
Run: `npx tsc --noEmit --pretty`
|
||||||
|
Expected: Clean (no errors)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/campaigns.tsx
|
||||||
|
git commit -m "feat: add Import Leads button to campaigns page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Integration Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Verify full build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds with no type errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual test checklist**
|
||||||
|
|
||||||
|
1. Navigate to Campaigns page as admin
|
||||||
|
2. "Import Leads" button visible
|
||||||
|
3. Click → modal opens with campaign cards
|
||||||
|
4. Select a campaign → proceeds to upload step
|
||||||
|
5. Upload a test CSV → column mapping appears with fuzzy matches
|
||||||
|
6. Phone column auto-detected
|
||||||
|
7. Patient match column shows "Existing" or "New" badges
|
||||||
|
8. Duplicate leads highlighted
|
||||||
|
9. Click Import → progress bar → summary
|
||||||
|
10. Close modal → campaign lead count updated
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create a test CSV file for verification**
|
||||||
|
|
||||||
|
```csv
|
||||||
|
First Name,Last Name,Phone,Email,Service,Priority
|
||||||
|
Ganesh,Bandi,8885540404,ganesh@email.com,Back Pain,HIGH
|
||||||
|
Meghana,,7702055204,meghana@email.com,Hair Loss,NORMAL
|
||||||
|
Priya,Sharma,9949879837,,Prenatal Care,NORMAL
|
||||||
|
New,Patient,9876500001,,General Checkup,LOW
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final commit with test data**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: CSV lead import — complete wizard with campaign selection, mapping, and patient matching"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- The wizard uses the existing `ModalOverlay`/`Modal`/`Dialog` pattern from Untitled UI (same as disposition modal)
|
||||||
|
- CSV parsing is native (no npm dependency) — handles quoted fields and commas
|
||||||
|
- Patient matching uses DataProvider data already in memory — no additional API calls for matching
|
||||||
|
- Lead creation uses existing GraphQL proxy — no new sidecar endpoint
|
||||||
|
- The `useData().refresh()` call after import updates all DataProvider consumers (campaign lead counts, lead master, etc.)
|
||||||
1374
docs/superpowers/plans/2026-03-31-rules-engine.md
Normal file
1374
docs/superpowers/plans/2026-03-31-rules-engine.md
Normal file
File diff suppressed because it is too large
Load Diff
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal file
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
# Design Tokens — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** JSON-driven multi-hospital theming — sidecar serves theme config, frontend provider injects CSS variables + content tokens, supervisor edits branding from Settings.
|
||||||
|
|
||||||
|
**Architecture:** Sidecar stores `data/theme.json`, serves via REST. Frontend `ThemeTokenProvider` fetches on mount, overrides CSS custom properties on `<html>`, exposes content tokens via React context. Settings page has a Branding tab for admins.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS (sidecar controller/service), React context + CSS custom properties (frontend), Untitled UI components (settings form)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-02-design-tokens-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `helix-engage-server/src/config/theme.controller.ts` | Create | GET/PUT/POST endpoints for theme |
|
||||||
|
| `helix-engage-server/src/config/theme.service.ts` | Create | Read/write/validate/backup theme JSON |
|
||||||
|
| `helix-engage-server/src/config/theme.defaults.ts` | Create | Default Global Hospital theme constant |
|
||||||
|
| `helix-engage-server/src/config/config.module.ts` | Create | NestJS module for theme |
|
||||||
|
| `helix-engage-server/src/app.module.ts` | Modify | Import ConfigThemeModule |
|
||||||
|
| `helix-engage-server/data/theme.json` | Create | Default theme file |
|
||||||
|
| `helix-engage/src/providers/theme-token-provider.tsx` | Create | Fetch theme, inject CSS vars, expose context |
|
||||||
|
| `helix-engage/src/main.tsx` | Modify | Wrap app with ThemeTokenProvider |
|
||||||
|
| `helix-engage/src/pages/login.tsx` | Modify | Consume tokens instead of hardcoded strings |
|
||||||
|
| `helix-engage/src/components/layout/sidebar.tsx` | Modify | Consume tokens for title/subtitle |
|
||||||
|
| `helix-engage/src/components/call-desk/ai-chat-panel.tsx` | Modify | Consume tokens for quick actions |
|
||||||
|
| `helix-engage/src/pages/branding-settings.tsx` | Create | Branding tab in settings for admins |
|
||||||
|
| `helix-engage/src/main.tsx` | Modify | Add branding settings route |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Default Theme Constant + Theme Service (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/config/theme.defaults.ts`
|
||||||
|
- Create: `helix-engage-server/src/config/theme.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create theme.defaults.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/theme.defaults.ts
|
||||||
|
|
||||||
|
export type ThemeConfig = {
|
||||||
|
brand: {
|
||||||
|
name: string;
|
||||||
|
hospitalName: string;
|
||||||
|
logo: string;
|
||||||
|
favicon: string;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
brand: Record<string, string>;
|
||||||
|
};
|
||||||
|
typography: {
|
||||||
|
body: string;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
showGoogleSignIn: boolean;
|
||||||
|
showForgotPassword: boolean;
|
||||||
|
poweredBy: { label: string; url: string };
|
||||||
|
};
|
||||||
|
sidebar: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
ai: {
|
||||||
|
quickActions: Array<{ label: string; prompt: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeConfig = {
|
||||||
|
brand: {
|
||||||
|
name: 'Helix Engage',
|
||||||
|
hospitalName: 'Global Hospital',
|
||||||
|
logo: '/helix-logo.png',
|
||||||
|
favicon: '/favicon.ico',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
'25': 'rgb(239 246 255)',
|
||||||
|
'50': 'rgb(219 234 254)',
|
||||||
|
'100': 'rgb(191 219 254)',
|
||||||
|
'200': 'rgb(147 197 253)',
|
||||||
|
'300': 'rgb(96 165 250)',
|
||||||
|
'400': 'rgb(59 130 246)',
|
||||||
|
'500': 'rgb(37 99 235)',
|
||||||
|
'600': 'rgb(29 78 216)',
|
||||||
|
'700': 'rgb(30 64 175)',
|
||||||
|
'800': 'rgb(30 58 138)',
|
||||||
|
'900': 'rgb(23 37 84)',
|
||||||
|
'950': 'rgb(15 23 42)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
body: "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Sign in to Helix Engage',
|
||||||
|
subtitle: 'Global Hospital',
|
||||||
|
showGoogleSignIn: true,
|
||||||
|
showForgotPassword: true,
|
||||||
|
poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' },
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: 'Helix Engage',
|
||||||
|
subtitle: 'Global Hospital · {role}',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
quickActions: [
|
||||||
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
|
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
|
||||||
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create theme.service.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/theme.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThemeService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ThemeService.name);
|
||||||
|
private cached: ThemeConfig | null = null;
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTheme(): ThemeConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
|
||||||
|
const current = this.getTheme();
|
||||||
|
|
||||||
|
// Deep merge
|
||||||
|
const merged: ThemeConfig = {
|
||||||
|
brand: { ...current.brand, ...updates.brand },
|
||||||
|
colors: {
|
||||||
|
brand: { ...current.colors.brand, ...updates.colors?.brand },
|
||||||
|
},
|
||||||
|
typography: { ...current.typography, ...updates.typography },
|
||||||
|
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
|
||||||
|
sidebar: { ...current.sidebar, ...updates.sidebar },
|
||||||
|
ai: {
|
||||||
|
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backup current
|
||||||
|
this.backup();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
|
||||||
|
this.cached = merged;
|
||||||
|
|
||||||
|
this.logger.log('Theme updated and saved');
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTheme(): ThemeConfig {
|
||||||
|
this.backup();
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Theme reset to defaults');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): ThemeConfig {
|
||||||
|
try {
|
||||||
|
if (existsSync(THEME_PATH)) {
|
||||||
|
const raw = readFileSync(THEME_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
// Merge with defaults to fill missing fields
|
||||||
|
this.cached = {
|
||||||
|
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
|
||||||
|
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
|
||||||
|
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
|
||||||
|
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
|
||||||
|
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
|
||||||
|
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
|
||||||
|
};
|
||||||
|
this.logger.log('Theme loaded from file');
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load theme: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Using default theme');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(THEME_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
git add src/config/theme.defaults.ts src/config/theme.service.ts
|
||||||
|
git commit -m "feat: theme service — read/write/backup theme JSON"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Theme Controller + Module (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/config/theme.controller.ts`
|
||||||
|
- Create: `helix-engage-server/src/config/config.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/app.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create theme.controller.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/theme.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
import type { ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
@Controller('api/config')
|
||||||
|
export class ThemeController {
|
||||||
|
private readonly logger = new Logger(ThemeController.name);
|
||||||
|
|
||||||
|
constructor(private readonly theme: ThemeService) {}
|
||||||
|
|
||||||
|
@Get('theme')
|
||||||
|
getTheme() {
|
||||||
|
return this.theme.getTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('theme')
|
||||||
|
updateTheme(@Body() body: Partial<ThemeConfig>) {
|
||||||
|
this.logger.log('Theme update request');
|
||||||
|
return this.theme.updateTheme(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('theme/reset')
|
||||||
|
resetTheme() {
|
||||||
|
this.logger.log('Theme reset request');
|
||||||
|
return this.theme.resetTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create config.module.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/config/config.module.ts
|
||||||
|
// Named ConfigThemeModule to avoid conflict with NestJS ConfigModule
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ThemeController } from './theme.controller';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ThemeController],
|
||||||
|
providers: [ThemeService],
|
||||||
|
exports: [ThemeService],
|
||||||
|
})
|
||||||
|
export class ConfigThemeModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register in app.module.ts**
|
||||||
|
|
||||||
|
Add import at top:
|
||||||
|
```typescript
|
||||||
|
import { ConfigThemeModule } from './config/config.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to imports array:
|
||||||
|
```typescript
|
||||||
|
ConfigThemeModule,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config/ src/app.module.ts
|
||||||
|
git commit -m "feat: theme REST API — GET/PUT/POST endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: ThemeTokenProvider (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/providers/theme-token-provider.tsx`
|
||||||
|
- Modify: `helix-engage/src/main.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create theme-token-provider.tsx**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/providers/theme-token-provider.tsx
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
export type ThemeTokens = {
|
||||||
|
brand: { name: string; hospitalName: string; logo: string; favicon: string };
|
||||||
|
colors: { brand: Record<string, string> };
|
||||||
|
typography: { body: string; display: string };
|
||||||
|
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
|
||||||
|
sidebar: { title: string; subtitle: string };
|
||||||
|
ai: { quickActions: Array<{ label: string; prompt: string }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TOKENS: ThemeTokens = {
|
||||||
|
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
|
||||||
|
colors: { brand: {} },
|
||||||
|
typography: { body: '', display: '' },
|
||||||
|
login: { title: 'Sign in to Helix Engage', subtitle: 'Global Hospital', showGoogleSignIn: true, showForgotPassword: true, poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' } },
|
||||||
|
sidebar: { title: 'Helix Engage', subtitle: 'Global Hospital · {role}' },
|
||||||
|
ai: { quickActions: [
|
||||||
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
|
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
|
||||||
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeTokenContextType = {
|
||||||
|
tokens: ThemeTokens;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
|
||||||
|
|
||||||
|
export const useThemeTokens = () => useContext(ThemeTokenContext);
|
||||||
|
|
||||||
|
const applyColorTokens = (brandColors: Record<string, string>) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [stop, value] of Object.entries(brandColors)) {
|
||||||
|
root.style.setProperty(`--color-brand-${stop}`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTypographyTokens = (typography: { body: string; display: string }) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (typography.body) root.style.setProperty('--font-body', typography.body);
|
||||||
|
if (typography.display) root.style.setProperty('--font-display', typography.display);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
|
||||||
|
|
||||||
|
const fetchTheme = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/config/theme`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data: ThemeTokens = await res.json();
|
||||||
|
setTokens(data);
|
||||||
|
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
|
||||||
|
applyColorTokens(data.colors.brand);
|
||||||
|
}
|
||||||
|
if (data.typography) {
|
||||||
|
applyTypographyTokens(data.typography);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use defaults silently
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTheme(); }, [fetchTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeTokenContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wrap app in main.tsx**
|
||||||
|
|
||||||
|
In `main.tsx`, add import:
|
||||||
|
```typescript
|
||||||
|
import { ThemeTokenProvider } from '@/providers/theme-token-provider';
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap inside `ThemeProvider`:
|
||||||
|
```tsx
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeTokenProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
...
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeTokenProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/providers/theme-token-provider.tsx src/main.tsx
|
||||||
|
git commit -m "feat: ThemeTokenProvider — fetch theme, inject CSS variables"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Consume Tokens in Login Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/pages/login.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace hardcoded values**
|
||||||
|
|
||||||
|
Import `useThemeTokens`:
|
||||||
|
```typescript
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the component:
|
||||||
|
```typescript
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace hardcoded strings:
|
||||||
|
- `src="/helix-logo.png"` → `src={tokens.brand.logo}`
|
||||||
|
- `"Sign in to Helix Engage"` → `{tokens.login.title}`
|
||||||
|
- `"Global Hospital"` → `{tokens.login.subtitle}`
|
||||||
|
- Google sign-in section: wrap with `{tokens.login.showGoogleSignIn && (...)}`
|
||||||
|
- Forgot password: wrap with `{tokens.login.showForgotPassword && (...)}`
|
||||||
|
- Powered by: `tokens.login.poweredBy.label` and `tokens.login.poweredBy.url`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/login.tsx
|
||||||
|
git commit -m "feat: login page consumes theme tokens"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Consume Tokens in Sidebar + AI Chat
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
|
||||||
|
- Modify: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update sidebar.tsx**
|
||||||
|
|
||||||
|
Import `useThemeTokens` and replace:
|
||||||
|
- Line 167: `"Helix Engage"` → `{tokens.sidebar.title}`
|
||||||
|
- Line 168: `"Global Hospital · {getRoleSubtitle(user.role)}"` → `{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}`
|
||||||
|
- Line 164: favicon src → `tokens.brand.logo`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update ai-chat-panel.tsx**
|
||||||
|
|
||||||
|
Import `useThemeTokens` and replace:
|
||||||
|
- Lines 21-25: hardcoded `QUICK_ACTIONS` array → `tokens.ai.quickActions`
|
||||||
|
|
||||||
|
Move `QUICK_ACTIONS` usage inside the component:
|
||||||
|
```typescript
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
const quickActions = tokens.ai.quickActions;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/layout/sidebar.tsx src/components/call-desk/ai-chat-panel.tsx
|
||||||
|
git commit -m "feat: sidebar + AI chat consume theme tokens"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Branding Settings Page (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage/src/pages/branding-settings.tsx`
|
||||||
|
- Modify: `helix-engage/src/main.tsx` (add route)
|
||||||
|
- Modify: `helix-engage/src/components/layout/sidebar.tsx` (add nav item)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create branding-settings.tsx**
|
||||||
|
|
||||||
|
The page has 6 collapsible sections matching the spec. Uses Untitled UI `Input`, `TextArea`, `Checkbox`, `Button` components. On save, PUTs to `/api/config/theme` and calls `refresh()` from `useThemeTokens()`.
|
||||||
|
|
||||||
|
Key patterns:
|
||||||
|
- Fetch current theme on mount via `GET /api/config/theme`
|
||||||
|
- Local state mirrors the theme JSON structure
|
||||||
|
- Each section is a collapsible card
|
||||||
|
- Color section: 12 text inputs for hex/rgb values with colored preview dots
|
||||||
|
- Save button calls `PUT /api/config/theme` with the full state
|
||||||
|
- Reset button calls `POST /api/config/theme/reset`
|
||||||
|
- After save/reset, call `refresh()` to re-apply CSS variables immediately
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add route in main.tsx**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BrandingSettingsPage } from '@/pages/branding-settings';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add route:
|
||||||
|
```tsx
|
||||||
|
<Route path="/branding" element={<BrandingSettingsPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add nav item in sidebar.tsx**
|
||||||
|
|
||||||
|
Under the Configuration section (near Rules Engine), add "Branding" link for admin role only.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/branding-settings.tsx src/main.tsx src/components/layout/sidebar.tsx
|
||||||
|
git commit -m "feat: branding settings page — theme editor for supervisors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Default Theme File + Build Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/data/theme.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create default theme.json**
|
||||||
|
|
||||||
|
Copy the `DEFAULT_THEME` object as JSON to `data/theme.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build both projects**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
cd ../helix-engage && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit all**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add data/theme.json
|
||||||
|
git commit -m "chore: default theme.json file"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- ThemeTokenProvider fetches before login — the endpoint is public (no auth)
|
||||||
|
- CSS variable override on `<html>` has higher specificity than the `@theme` block in `theme.css`
|
||||||
|
- `tokens.sidebar.subtitle` supports `{role}` placeholder — replaced at render time by the sidebar component
|
||||||
|
- The branding settings page is admin-only but the theme endpoint itself is unauthenticated (GET) — PUT requires auth
|
||||||
|
- If the sidecar is unreachable, the frontend silently falls back to hardcoded defaults
|
||||||
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
@@ -0,0 +1,979 @@
|
|||||||
|
# Website Widget — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration.
|
||||||
|
|
||||||
|
**Architecture:** Sidecar gets a new `widget` module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha)
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-04-05-website-widget-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
### Sidecar (helix-engage-server)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/widget/widget.module.ts` | Create | NestJS module |
|
||||||
|
| `src/widget/widget.controller.ts` | Create | REST endpoints: init, chat, doctors, slots, book, lead |
|
||||||
|
| `src/widget/widget.service.ts` | Create | Lead creation, appointment booking, doctor queries |
|
||||||
|
| `src/widget/widget-keys.service.ts` | Create | HMAC key generation, validation, CRUD via Redis |
|
||||||
|
| `src/widget/widget-key.guard.ts` | Create | NestJS guard for key + origin validation |
|
||||||
|
| `src/widget/captcha.guard.ts` | Create | reCAPTCHA v3 token verification |
|
||||||
|
| `src/widget/widget.types.ts` | Create | Types for widget requests/responses |
|
||||||
|
| `src/auth/session.service.ts` | Modify | Add `setCachePersistent()` method |
|
||||||
|
| `src/app.module.ts` | Modify | Import WidgetModule |
|
||||||
|
| `src/main.ts` | Modify | Serve static widget.js file |
|
||||||
|
|
||||||
|
### Widget Bundle (new package)
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `packages/helix-engage-widget/package.json` | Create | Package config |
|
||||||
|
| `packages/helix-engage-widget/vite.config.ts` | Create | Library mode, IIFE output |
|
||||||
|
| `packages/helix-engage-widget/tsconfig.json` | Create | TypeScript config |
|
||||||
|
| `packages/helix-engage-widget/src/main.ts` | Create | Entry: read data-key, init widget |
|
||||||
|
| `packages/helix-engage-widget/src/api.ts` | Create | HTTP client for widget endpoints |
|
||||||
|
| `packages/helix-engage-widget/src/widget.tsx` | Create | Shadow DOM mount, theming, tab routing |
|
||||||
|
| `packages/helix-engage-widget/src/chat.tsx` | Create | AI chatbot with streaming |
|
||||||
|
| `packages/helix-engage-widget/src/booking.tsx` | Create | Appointment booking flow |
|
||||||
|
| `packages/helix-engage-widget/src/contact.tsx` | Create | Lead capture form |
|
||||||
|
| `packages/helix-engage-widget/src/captcha.ts` | Create | reCAPTCHA v3 integration |
|
||||||
|
| `packages/helix-engage-widget/src/styles.ts` | Create | CSS-in-JS for shadow DOM |
|
||||||
|
| `packages/helix-engage-widget/src/types.ts` | Create | Shared types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Widget Types + Key Service (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.types.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget-keys.service.ts`
|
||||||
|
- Modify: `helix-engage-server/src/auth/session.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add setCachePersistent to SessionService**
|
||||||
|
|
||||||
|
Add a method that sets a Redis key without TTL:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async setCachePersistent(key: string, value: string): Promise<void> {
|
||||||
|
await this.redis.set(key, value);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create widget.types.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.types.ts
|
||||||
|
|
||||||
|
export type WidgetSiteKey = {
|
||||||
|
siteId: string;
|
||||||
|
hospitalName: string;
|
||||||
|
allowedOrigins: string[];
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetInitResponse = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetBookRequest = {
|
||||||
|
departmentId: string;
|
||||||
|
doctorId: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
patientName: string;
|
||||||
|
patientPhone: string;
|
||||||
|
age?: string;
|
||||||
|
gender?: string;
|
||||||
|
chiefComplaint?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetLeadRequest = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
interest?: string;
|
||||||
|
message?: string;
|
||||||
|
captchaToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetChatRequest = {
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
captchaToken?: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create widget-keys.service.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget-keys.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import type { WidgetSiteKey } from './widget.types';
|
||||||
|
|
||||||
|
const KEY_PREFIX = 'widget:keys:';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeysService {
|
||||||
|
private readonly logger = new Logger(WidgetKeysService.name);
|
||||||
|
private readonly secret: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private session: SessionService,
|
||||||
|
) {
|
||||||
|
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
|
||||||
|
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
|
||||||
|
const signature = this.sign(siteId);
|
||||||
|
const key = `${siteId}.${signature}`;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = {
|
||||||
|
siteId,
|
||||||
|
hospitalName,
|
||||||
|
allowedOrigins,
|
||||||
|
active: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
|
||||||
|
const dotIndex = rawKey.indexOf('.');
|
||||||
|
if (dotIndex === -1) return null;
|
||||||
|
|
||||||
|
const siteId = rawKey.substring(0, dotIndex);
|
||||||
|
const signature = rawKey.substring(dotIndex + 1);
|
||||||
|
|
||||||
|
// Verify HMAC
|
||||||
|
const expected = this.sign(siteId);
|
||||||
|
try {
|
||||||
|
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from Redis
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
if (!siteKey.active) return null;
|
||||||
|
|
||||||
|
return siteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
|
||||||
|
if (!origin) return false;
|
||||||
|
if (siteKey.allowedOrigins.length === 0) return true;
|
||||||
|
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listKeys(): Promise<WidgetSiteKey[]> {
|
||||||
|
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
|
||||||
|
const results: WidgetSiteKey[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const data = await this.session.getCache(key);
|
||||||
|
if (data) results.push(JSON.parse(data));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeKey(siteId: string): Promise<boolean> {
|
||||||
|
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||||
|
if (!data) return false;
|
||||||
|
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||||
|
siteKey.active = false;
|
||||||
|
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
|
||||||
|
this.logger.log(`Widget key revoked: ${siteId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sign(data: string): string {
|
||||||
|
return createHmac('sha256', this.secret).update(data).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts
|
||||||
|
git commit -m "feat: widget types + HMAC key service with Redis storage"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Widget Guards (Key + Captcha)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget-key.guard.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/captcha.guard.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create widget-key.guard.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget-key.guard.ts
|
||||||
|
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetKeyGuard implements CanActivate {
|
||||||
|
constructor(private readonly keys: WidgetKeysService) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const key = request.query?.key ?? request.headers['x-widget-key'];
|
||||||
|
|
||||||
|
if (!key) throw new HttpException('Widget key required', 401);
|
||||||
|
|
||||||
|
const siteKey = await this.keys.validateKey(key);
|
||||||
|
if (!siteKey) throw new HttpException('Invalid widget key', 403);
|
||||||
|
|
||||||
|
const origin = request.headers.origin ?? request.headers.referer;
|
||||||
|
if (!this.keys.validateOrigin(siteKey, origin)) {
|
||||||
|
throw new HttpException('Origin not allowed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to request for downstream use
|
||||||
|
request.widgetSiteKey = siteKey;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create captcha.guard.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/captcha.guard.ts
|
||||||
|
|
||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CaptchaGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(CaptchaGuard.name);
|
||||||
|
private readonly secretKey: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (!this.secretKey) {
|
||||||
|
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = request.body?.captchaToken;
|
||||||
|
|
||||||
|
if (!token) throw new HttpException('Captcha token required', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `secret=${this.secretKey}&response=${token}`,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.success || (data.score != null && data.score < 0.3)) {
|
||||||
|
this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
|
||||||
|
throw new HttpException('Captcha verification failed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
this.logger.error(`Captcha verification error: ${err.message}`);
|
||||||
|
return true; // Fail open if captcha service is down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts
|
||||||
|
git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Widget Controller + Service + Module (Sidecar)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.service.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.controller.ts`
|
||||||
|
- Create: `helix-engage-server/src/widget/widget.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/app.module.ts`
|
||||||
|
- Modify: `helix-engage-server/src/main.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create widget.service.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
import { ThemeService } from '../config/theme.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WidgetService {
|
||||||
|
private readonly logger = new Logger(WidgetService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private theme: ThemeService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getInitData(): WidgetInitResponse {
|
||||||
|
const t = this.theme.getTheme();
|
||||||
|
return {
|
||||||
|
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
|
||||||
|
colors: {
|
||||||
|
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
|
||||||
|
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
|
||||||
|
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
|
||||||
|
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
|
||||||
|
},
|
||||||
|
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoctors(): Promise<any[]> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department specialty visitingHours
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
clinic { clinicName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return data.doctors.edges.map((e: any) => e.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSlots(doctorId: string, date: string): Promise<any> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const booked = data.appointments.edges.map((e: any) => {
|
||||||
|
const dt = new Date(e.node.scheduledAt);
|
||||||
|
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
|
||||||
|
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
// Create or find patient
|
||||||
|
let patientId: string | null = null;
|
||||||
|
try {
|
||||||
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
patientId = existing.patients.edges[0]?.node?.id ?? null;
|
||||||
|
} catch { /* continue */ }
|
||||||
|
|
||||||
|
if (!patientId) {
|
||||||
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
patientId = created.createPatient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appointment
|
||||||
|
const appt = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
scheduledAt: req.scheduledAt,
|
||||||
|
durationMin: 30,
|
||||||
|
appointmentType: 'CONSULTATION',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorId: req.doctorId,
|
||||||
|
department: req.departmentId,
|
||||||
|
reasonForVisit: req.chiefComplaint ?? '',
|
||||||
|
patientId,
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create lead
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: req.patientName,
|
||||||
|
contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
patientId,
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
|
||||||
|
|
||||||
|
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||||
|
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||||
|
|
||||||
|
return { appointmentId: appt.createAppointment.id, reference };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: {
|
||||||
|
name: req.name,
|
||||||
|
contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'WEBSITE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: req.interest ?? 'Website Enquiry',
|
||||||
|
} },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
|
||||||
|
return { leadId: data.createLead.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create widget.controller.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { WidgetKeyGuard } from './widget-key.guard';
|
||||||
|
import { CaptchaGuard } from './captcha.guard';
|
||||||
|
import { AiChatController } from '../ai/ai-chat.controller';
|
||||||
|
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
|
|
||||||
|
@Controller('api/widget')
|
||||||
|
export class WidgetController {
|
||||||
|
private readonly logger = new Logger(WidgetController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly widget: WidgetService,
|
||||||
|
private readonly keys: WidgetKeysService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('init')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
init() {
|
||||||
|
return this.widget.getInitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async doctors() {
|
||||||
|
return this.widget.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('slots')
|
||||||
|
@UseGuards(WidgetKeyGuard)
|
||||||
|
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
|
||||||
|
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
|
||||||
|
return this.widget.getSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('book')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async book(@Body() body: WidgetBookRequest) {
|
||||||
|
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
|
||||||
|
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.bookAppointment(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('lead')
|
||||||
|
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||||
|
async lead(@Body() body: WidgetLeadRequest) {
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
throw new HttpException('name and phone required', 400);
|
||||||
|
}
|
||||||
|
return this.widget.createLead(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key management (admin only — no widget key guard, requires JWT)
|
||||||
|
@Post('keys/generate')
|
||||||
|
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||||
|
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
|
||||||
|
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
|
||||||
|
await this.keys.saveKey(siteKey);
|
||||||
|
return { key, siteKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('keys')
|
||||||
|
async listKeys() {
|
||||||
|
return this.keys.listKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('keys/:siteId')
|
||||||
|
async revokeKey(@Param('siteId') siteId: string) {
|
||||||
|
const revoked = await this.keys.revokeKey(siteId);
|
||||||
|
if (!revoked) throw new HttpException('Key not found', 404);
|
||||||
|
return { status: 'revoked' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create widget.module.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/widget/widget.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WidgetController } from './widget.controller';
|
||||||
|
import { WidgetService } from './widget.service';
|
||||||
|
import { WidgetKeysService } from './widget-keys.service';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
||||||
|
controllers: [WidgetController],
|
||||||
|
providers: [WidgetService, WidgetKeysService],
|
||||||
|
exports: [WidgetKeysService],
|
||||||
|
})
|
||||||
|
export class WidgetModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register in app.module.ts**
|
||||||
|
|
||||||
|
Add import:
|
||||||
|
```typescript
|
||||||
|
import { WidgetModule } from './widget/widget.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to imports array:
|
||||||
|
```typescript
|
||||||
|
WidgetModule,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Serve static widget.js from main.ts**
|
||||||
|
|
||||||
|
In `src/main.ts`, after the NestJS app bootstrap, add static file serving for the widget bundle:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { join } from 'path';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
// After app.listen():
|
||||||
|
app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' });
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `helix-engage-server/public/` directory for the widget bundle output.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/widget/ src/app.module.ts src/main.ts public/
|
||||||
|
git commit -m "feat: widget module — endpoints, service, key management, captcha"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Widget Bundle — Project Setup + Entry Point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/helix-engage-widget/package.json`
|
||||||
|
- Create: `packages/helix-engage-widget/vite.config.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/tsconfig.json`
|
||||||
|
- Create: `packages/helix-engage-widget/src/types.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/api.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/main.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create package.json**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create vite.config.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/main.ts',
|
||||||
|
name: 'HelixWidget',
|
||||||
|
fileName: () => 'widget.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: '../../helix-engage-server/public',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: 'terser',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create tsconfig.json**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create types.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/types.ts
|
||||||
|
|
||||||
|
export type WidgetConfig = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Doctor = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: { firstName: string; lastName: string };
|
||||||
|
department: string;
|
||||||
|
specialty: string;
|
||||||
|
visitingHours: string;
|
||||||
|
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeSlot = {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create api.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/api.ts
|
||||||
|
|
||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ messages, captchaToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create main.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/main.ts
|
||||||
|
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { initApi, fetchInit } from './api';
|
||||||
|
import { Widget } from './widget';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
|
||||||
|
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
|
||||||
|
|
||||||
|
const key = script.getAttribute('data-key') ?? '';
|
||||||
|
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
|
||||||
|
|
||||||
|
initApi(baseUrl, key);
|
||||||
|
|
||||||
|
let config: WidgetConfig;
|
||||||
|
try {
|
||||||
|
config = await fetchInit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HelixWidget] Init failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shadow DOM host
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'helix-widget-host';
|
||||||
|
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const mountPoint = document.createElement('div');
|
||||||
|
shadow.appendChild(mountPoint);
|
||||||
|
|
||||||
|
render(<Widget config={config} shadow={shadow} />, mountPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Install dependencies and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/helix-engage-widget && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/helix-engage-widget/
|
||||||
|
git commit -m "feat: widget bundle — project setup, API client, entry point"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Widget UI Components (Preact)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `packages/helix-engage-widget/src/styles.ts`
|
||||||
|
- Create: `packages/helix-engage-widget/src/widget.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/chat.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/booking.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/contact.tsx`
|
||||||
|
- Create: `packages/helix-engage-widget/src/captcha.ts`
|
||||||
|
|
||||||
|
These are the Preact components rendered inside the shadow DOM. Each component is self-contained.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create styles.ts** — CSS string injected into shadow DOM
|
||||||
|
- [ ] **Step 2: Create widget.tsx** — Main shell with bubble, panel, tab routing
|
||||||
|
- [ ] **Step 3: Create chat.tsx** — AI chat with streaming, quick actions, lead capture fallback
|
||||||
|
- [ ] **Step 4: Create booking.tsx** — Step-by-step appointment booking
|
||||||
|
- [ ] **Step 5: Create contact.tsx** — Simple lead capture form
|
||||||
|
- [ ] **Step 6: Create captcha.ts** — Load reCAPTCHA script, get token
|
||||||
|
|
||||||
|
Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build the widget**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/helix-engage-widget && npm run build
|
||||||
|
# Output: ../../helix-engage-server/public/widget.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/helix-engage-widget/src/
|
||||||
|
git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Integration Test + Key Generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None new — testing the full flow
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate a site key**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:4100/api/widget/keys/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the returned `key` value.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test init endpoint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return theme config with brand name, colors, captcha site key.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test widget.js serving**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return 200.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create a test HTML page**
|
||||||
|
|
||||||
|
Create `packages/helix-engage-widget/test.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Widget Test</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Hospital Website</h1>
|
||||||
|
<p>This is a test page for the Helix Engage widget.</p>
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="SITE_KEY_HERE"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
Open in browser, verify the floating bubble appears, themed correctly.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Test booking flow end-to-end**
|
||||||
|
|
||||||
|
Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build sidecar and commit all**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server && npm run build
|
||||||
|
git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- The widget bundle builds into `helix-engage-server/public/widget.js` — Vite outputs directly to the sidecar's public dir
|
||||||
|
- The sidecar serves it via Express static middleware
|
||||||
|
- Site keys use HMAC-SHA256 with `WIDGET_SECRET` env var
|
||||||
|
- Captcha is gated by `RECAPTCHA_SECRET_KEY` env var — if not set, captcha is disabled (dev mode)
|
||||||
|
- All widget endpoints use the server-side API key for platform queries (not the visitor's JWT)
|
||||||
|
- The widget has no dependency on the main helix-engage frontend — completely standalone
|
||||||
|
- Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# Hospital Onboarding & Self-Service Setup
|
||||||
|
|
||||||
|
**Date:** 2026-04-06
|
||||||
|
**Status:** Plan — pending implementation
|
||||||
|
**Owner:** helix-engage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make onboarding a new hospital a one-command devops action plus a guided self-service flow inside the staff portal. After running the script, the hospital admin should be able to log into a fresh workspace and reach a fully operational call center by filling in 6 setup pages — without anyone touching env vars, JSON files, or running shell commands a second time.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Per-tenant secrets management (env vars stay infra-owned for now).
|
||||||
|
- Self-service Cloudflare Turnstile / Ozonetel account provisioning. Operator pastes pre-existing credentials.
|
||||||
|
- Multi-hospital routing inside one sidecar. One sidecar per workspace; multi-tenancy is handled by the platform.
|
||||||
|
- Bulk CSV import of doctors / staff. Single-row form CRUD only.
|
||||||
|
- Email infrastructure for invitations beyond what core already does.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User journey
|
||||||
|
|
||||||
|
### T0 — devops, one-command bootstrap (~30 seconds)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./onboard-hospital.sh \
|
||||||
|
--create \
|
||||||
|
--display-name "Care Hospital" \
|
||||||
|
--subdomain care \
|
||||||
|
--admin-email admin@carehospital.com \
|
||||||
|
--admin-password 'TempCare#2026'
|
||||||
|
```
|
||||||
|
|
||||||
|
Script signs up the admin user, creates and activates the workspace, syncs the helix-engage SDK, mints an API key, writes a sidecar `.env`, and prints a credentials handoff block. Done.
|
||||||
|
|
||||||
|
### T1 — hospital admin first login (~10 minutes)
|
||||||
|
|
||||||
|
Admin opens the workspace URL, signs in with the temp password. App detects an unconfigured workspace and routes them to `/setup`. A 6-step wizard walks them through:
|
||||||
|
|
||||||
|
1. **Hospital identity** — confirm display name, upload logo, pick brand colors → writes to `theme.json`
|
||||||
|
2. **Clinics** — add at least one branch (name, address, phone, timings) → creates Clinic records on platform
|
||||||
|
3. **Doctors** — add at least one doctor (name, specialty, clinic, visiting hours) → creates Doctor records on platform
|
||||||
|
4. **Team** — create supervisors and CC agents **in place** (name, email, temp password, role). If the role is `HelixEngage User` the form also shows a SIP seat dropdown so the admin links the new employee to an Agent profile in the same step. Posts to sidecar `POST /api/team/members` which chains `signUpInWorkspace` (using the workspace's own `inviteHash` server-side — no email is sent) → `updateWorkspaceMember` → `updateWorkspaceMemberRole` → optional `updateAgent`. **Never uses `sendInvitations`** — see `feedback-no-invites` memory for the absolute rule.
|
||||||
|
5. **Telephony** — read-only summary of which workspace members own which SIP seats. Seats themselves are seeded during onboarding (`onboard-hospital.sh` step 5b) and linked to members in step 4. Admin just confirms and advances.
|
||||||
|
6. **AI assistant** — pick provider (OpenAI / Anthropic), model, optional system prompt override → writes to `ai.json`
|
||||||
|
|
||||||
|
After step 6, admin clicks "Finish setup" and lands on the home dashboard. Setup state is recorded in `setup-state.json` so the wizard never auto-shows again.
|
||||||
|
|
||||||
|
### T2 — hospital admin returns later (any time)
|
||||||
|
|
||||||
|
Each setup page is also accessible standalone via the **Settings** menu. Admin can edit any of them at any time. Settings hub shows green checkmarks for completed sections and yellow badges for sections still using defaults.
|
||||||
|
|
||||||
|
### T3 — agents and supervisors join
|
||||||
|
|
||||||
|
The admin hands each employee their email + temp password directly (WhatsApp, in-person, etc.). Employees sign in, land on the home dashboard, and change their password from their profile. They're already role-assigned and (if CC agents) SIP-linked from T1 step 4, so they see the right pages — and can take calls — immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture decisions
|
||||||
|
|
||||||
|
### 1. Script does identity. Portal does configuration.
|
||||||
|
|
||||||
|
- **In script:** anything requiring platform-admin credentials (signup, workspace activation, SDK sync, API key creation). One-time, devops-only.
|
||||||
|
- **In staff portal:** anything that operates inside the workspace (clinics, doctors, team, sidecar config files). Self-serve, repeatable.
|
||||||
|
|
||||||
|
This keeps the script's blast radius small and means the hospital admin never needs platform-admin access.
|
||||||
|
|
||||||
|
### 2. Two distinct frontend → backend patterns
|
||||||
|
|
||||||
|
**Pattern A — Direct GraphQL to platform** (for entities the platform owns)
|
||||||
|
- Clinics, Doctors, Workspace Members
|
||||||
|
- Frontend uses `apiClient.graphql<any>(...)` with the user's JWT
|
||||||
|
- Already established by `settings.tsx` for member listing
|
||||||
|
- No sidecar code needed
|
||||||
|
|
||||||
|
**Pattern B — Sidecar admin endpoints** (for sidecar-owned config files)
|
||||||
|
- Theme (`theme.json`), Widget (`widget.json`), Telephony (`telephony.json`), AI (`ai.json`), Setup state (`setup-state.json`)
|
||||||
|
- Frontend uses `apiClient.fetch('/api/config/...')`
|
||||||
|
- Sidecar persists to disk via `*ConfigService` mirroring `ThemeService`
|
||||||
|
- Already established by `branding-settings.tsx` and `WidgetConfigService`
|
||||||
|
|
||||||
|
**Rule:** if it lives in a workspace schema on the platform, use Pattern A. If it's a sidecar config file, use Pattern B. Don't mix.
|
||||||
|
|
||||||
|
### 3. Telephony config moves out of env vars
|
||||||
|
|
||||||
|
`OZONETEL_*`, `SIP_*`, `EXOTEL_*` env vars become bootstrap defaults that seed `data/telephony.json` on first boot, then never read again. All runtime reads go through `TelephonyConfigService.getConfig()`. Six read sites refactor (auth.controller, ozonetel-agent.service, ozonetel-agent.controller, kookoo-ivr.controller, agent-config.service, maint.controller).
|
||||||
|
|
||||||
|
### 4. AI config moves out of env vars
|
||||||
|
|
||||||
|
Same pattern. `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` stay in env (true secrets), but `AI_PROVIDER` / `AI_MODEL` move to `data/ai.json`. `WidgetChatService` and any other AI-using services read from `AiConfigService`.
|
||||||
|
|
||||||
|
### 5. Setup state lives in its own file
|
||||||
|
|
||||||
|
`data/setup-state.json` tracks completion status for each of the 6 setup steps + a global `wizardDismissed` flag. Frontend reads it on app load to decide whether to show the setup wizard. Each setup page marks its step complete on save.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"wizardDismissed": false,
|
||||||
|
"steps": {
|
||||||
|
"identity": { "completed": false, "completedAt": null },
|
||||||
|
"clinics": { "completed": false, "completedAt": null },
|
||||||
|
"doctors": { "completed": false, "completedAt": null },
|
||||||
|
"team": { "completed": false, "completedAt": null },
|
||||||
|
"telephony": { "completed": false, "completedAt": null },
|
||||||
|
"ai": { "completed": false, "completedAt": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Members are created in place — **never** via email invitation
|
||||||
|
|
||||||
|
Absolute rule (see `feedback-no-invites` in memory): Helix Engage does not use the platform's `sendInvitations` flow for any reason, ever. Hospital admins are expected to onboard employees in person or over WhatsApp, hand out login credentials directly, and have the employee change the password on first login.
|
||||||
|
|
||||||
|
The sidecar exposes `POST /api/team/members` taking `{ firstName, lastName, email, password, roleId, agentId? }`. Server-side it chains:
|
||||||
|
|
||||||
|
1. `signUpInWorkspace(email, password, workspaceId, workspaceInviteHash)` — the platform's `isPublicInviteLinkEnabled` + `inviteHash` values are read once per boot and used to authorize the create. The hash is a server-side secret, never surfaced to the admin UI, and no email is sent.
|
||||||
|
2. `updateWorkspaceMember` — set first name / last name (the signUp mutation doesn't take them).
|
||||||
|
3. `updateWorkspaceMemberRole` — assign the role the admin picked.
|
||||||
|
4. `updateAgent` (optional) — link the new workspace member to the chosen Agent profile if the admin selected a SIP seat.
|
||||||
|
|
||||||
|
The Team wizard step and the `/settings/team` slideout both call this endpoint via the new `EmployeeCreateForm` component. The old `InviteMemberForm` and all `sendInvitations` call sites have been deleted.
|
||||||
|
|
||||||
|
### 7. Roles are auto-synced by SDK
|
||||||
|
|
||||||
|
`HelixEngage Manager`, `HelixEngage Supervisor`, and `HelixEngage User` roles are defined in `FortyTwoApps/helix-engage/src/roles/` and created automatically by `yarn app:sync`. The frontend's role dropdown in the team form queries the platform via `getRoles` and uses real role IDs (no email-pattern hacks). The "is this person a CC agent, so show the SIP seat dropdown?" check matches by the exact label `HelixEngage User` — see `CC_AGENT_ROLE_LABEL` in `wizard-step-team.tsx` / `team-settings.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend changes (helix-engage-server)
|
||||||
|
|
||||||
|
### New services / files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `src/config/setup-state.defaults.ts` | Type + defaults for `data/setup-state.json` |
|
||||||
|
| `src/config/setup-state.service.ts` | Load / get / mark step complete / dismiss wizard |
|
||||||
|
| `src/config/telephony.defaults.ts` | Type + defaults for `data/telephony.json` (Ozonetel + Exotel + SIP) |
|
||||||
|
| `src/config/telephony-config.service.ts` | File-backed CRUD; `onModuleInit` seeds from env vars on first boot |
|
||||||
|
| `src/config/ai.defaults.ts` | Type + defaults for `data/ai.json` |
|
||||||
|
| `src/config/ai-config.service.ts` | File-backed CRUD; seeds from env on first boot |
|
||||||
|
| `src/config/setup-state.controller.ts` | `GET /api/config/setup-state`, `PUT /api/config/setup-state/steps/:step`, `POST /api/config/setup-state/dismiss` |
|
||||||
|
| `src/config/telephony-config.controller.ts` | `GET/PUT /api/config/telephony` with secret masking on GET |
|
||||||
|
| `src/config/ai-config.controller.ts` | `GET/PUT /api/config/ai` with secret masking |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/config/config-theme.module.ts` | Register the 3 new services + 3 new controllers |
|
||||||
|
| `src/config/widget.defaults.ts` | Drop `hospitalName` field (the long-standing duplicate) |
|
||||||
|
| `src/config/widget-config.service.ts` | Inject `ThemeService`, read `brand.hospitalName` from theme at the 2 generateKey call sites |
|
||||||
|
| `src/widget/widget.service.ts` | `getInitData()` reads captcha site key from `WidgetConfigService` instead of `process.env.RECAPTCHA_SITE_KEY` |
|
||||||
|
| `src/auth/agent-config.service.ts:49` | Read `OZONETEL_CAMPAIGN_NAME` from `TelephonyConfigService` |
|
||||||
|
| `src/auth/auth.controller.ts:141, 255` | Read `OZONETEL_AGENT_PASSWORD` from `TelephonyConfigService` |
|
||||||
|
| `src/ozonetel/ozonetel-agent.service.ts:199, 235, 236` | Read `OZONETEL_DID`, `OZONETEL_SIP_ID` from `TelephonyConfigService` |
|
||||||
|
| `src/ozonetel/ozonetel-agent.controller.ts:39, 42, 192` | Same |
|
||||||
|
| `src/ozonetel/kookoo-ivr.controller.ts:11, 12` | Same |
|
||||||
|
| `src/maint/maint.controller.ts:27` | Same |
|
||||||
|
| `src/widget/widget-chat.service.ts` | Read `provider` and `model` from `AiConfigService` instead of `ConfigService` |
|
||||||
|
| `src/ai/ai-provider.ts` | Same — provider/model from config file, API keys still from env |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend changes (helix-engage)
|
||||||
|
|
||||||
|
### New pages
|
||||||
|
|
||||||
|
| Page | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `setup/setup-wizard.tsx` | `/setup` | 6-step wizard, auto-shown on first login when setup incomplete |
|
||||||
|
| `pages/clinics.tsx` | `/settings/clinics` | List + add/edit clinic records (slideout pattern) |
|
||||||
|
| `pages/doctors.tsx` | `/settings/doctors` | List + add/edit doctors, assign to clinics |
|
||||||
|
| `pages/team-settings.tsx` | `/settings/team` | Member list + invite form + role editor (replaces current `settings.tsx` member view) |
|
||||||
|
| `pages/telephony-settings.tsx` | `/settings/telephony` | Ozonetel + Exotel + SIP form (consumes `/api/config/telephony`) |
|
||||||
|
| `pages/ai-settings.tsx` | `/settings/ai` | AI provider/model/prompt form (consumes `/api/config/ai`) |
|
||||||
|
| `pages/widget-settings.tsx` | `/settings/widget` | Widget enabled/embed/captcha form (consumes `/api/config/widget`) |
|
||||||
|
| `pages/settings-hub.tsx` | `/settings` | Index page listing all setup sections with completion badges. Replaces current `settings.tsx`. |
|
||||||
|
|
||||||
|
### Modified pages
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/pages/login.tsx` | After successful login, fetch `/api/config/setup-state`. If incomplete and user is workspace admin, redirect to `/setup`. Otherwise existing flow. |
|
||||||
|
| `src/pages/branding-settings.tsx` | On save, mark `identity` step complete via `PUT /api/config/setup-state/steps/identity` |
|
||||||
|
| `src/components/layout/sidebar.tsx` | Add Settings hub entry; remove direct links to individual settings pages from main nav (move them under Settings) |
|
||||||
|
| `src/providers/router-provider.tsx` | Register the 7 new routes |
|
||||||
|
| `src/pages/integrations.tsx` | Remove the Ozonetel + Exotel cards (functionality moves to `telephony-settings.tsx`); keep WhatsApp/FB/Google/website cards for now |
|
||||||
|
|
||||||
|
### New shared components
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/setup/wizard-shell.tsx` | Layout: progress bar, step navigation, footer with prev/next |
|
||||||
|
| `src/components/setup/wizard-step.tsx` | Single-step container — title, description, content slot, validation hook |
|
||||||
|
| `src/components/setup/section-card.tsx` | Settings hub section card with status badge |
|
||||||
|
| `src/components/forms/clinic-form.tsx` | Reused by clinics page + setup wizard step 2 |
|
||||||
|
| `src/components/forms/doctor-form.tsx` | Reused by doctors page + setup wizard step 3 |
|
||||||
|
| `src/components/forms/invite-member-form.tsx` | Reused by team page + setup wizard step 4 |
|
||||||
|
| `src/components/forms/telephony-form.tsx` | Reused by telephony settings + setup wizard step 5 |
|
||||||
|
| `src/components/forms/ai-form.tsx` | Reused by ai settings + setup wizard step 6 |
|
||||||
|
|
||||||
|
The pattern: each settings page renders the same form component the wizard step renders. Wizard steps just wrap the form in `<WizardStep>` and add prev/next navigation. Standalone settings pages wrap the form in a normal page layout. Form is the source of truth; wizard and settings page are two presentations of the same thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onboarding script changes
|
||||||
|
|
||||||
|
`onboard-hospital.sh` is already 90% there. Three minor changes:
|
||||||
|
|
||||||
|
1. **Drop the `--sidecar-env-out` default behavior** — print a structured "credentials handoff" block at the end with admin email, temp password, workspace URL, sidecar `.env` content. Operator copies what they need.
|
||||||
|
2. **Change the credentials block format** — make it copy-pasteable as a single email body so the operator can email it to the hospital owner directly.
|
||||||
|
3. **Add `setup-state.json` initialization** — the script writes a fresh `setup-state.json` to the sidecar's `data/` directory as part of step 6, so the first frontend load knows nothing is configured yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
Each phase is a coherent commit. Don't ship phases out of order.
|
||||||
|
|
||||||
|
### Phase 1 — Backend foundations (config services + endpoints)
|
||||||
|
|
||||||
|
**Files:** 9 new + 4 modified backend files. No frontend.
|
||||||
|
|
||||||
|
- New services: `setup-state`, `telephony-config`, `ai-config`
|
||||||
|
- New defaults files for each
|
||||||
|
- New controllers for each
|
||||||
|
- Module wiring
|
||||||
|
- Drop `widget.json.hospitalName` (the original duplicate that started this whole thread)
|
||||||
|
- Migrate the 6 Ozonetel read sites to `TelephonyConfigService`
|
||||||
|
- Migrate the AI provider/model reads to `AiConfigService`
|
||||||
|
- First-boot env-var seeding: each new service reads its respective env vars on `onModuleInit` and writes them to its config file if the file doesn't exist
|
||||||
|
|
||||||
|
**Verifies:** sidecar still serves all existing endpoints, env-var-driven Ozonetel still works (because the seeding picks up the same values), `data/telephony.json` and `data/ai.json` exist on first boot.
|
||||||
|
|
||||||
|
**Estimate:** 4-5 hours.
|
||||||
|
|
||||||
|
### Phase 2 — Settings hub + first-run detection
|
||||||
|
|
||||||
|
**Files:** 2 new pages + 4 modified frontend files + new shared `section-card` component.
|
||||||
|
|
||||||
|
- `settings-hub.tsx` replaces `settings.tsx` as the `/settings` route
|
||||||
|
- Move the existing member-list view from `settings.tsx` into a new `team-settings.tsx` (read-only for now; invite + role editing comes in Phase 3)
|
||||||
|
- `login.tsx` fetches setup-state after successful login and redirects to `/setup` if incomplete
|
||||||
|
- `setup/setup-wizard.tsx` shell renders the 6 step containers (with placeholder content for now)
|
||||||
|
- Sidebar redesign: collapse all settings into one Settings entry that opens the hub
|
||||||
|
- Router updates to register the new routes
|
||||||
|
|
||||||
|
**Verifies:** clean login → setup wizard appearance for fresh workspace; Settings hub navigates to existing pages; nothing breaks for already-set-up workspaces.
|
||||||
|
|
||||||
|
**Estimate:** 3-4 hours.
|
||||||
|
|
||||||
|
### Phase 3 — Entity CRUD pages (Pattern A — direct platform GraphQL)
|
||||||
|
|
||||||
|
**Files:** 3 new pages + 3 new form components + 1 modified team page.
|
||||||
|
|
||||||
|
- `clinics.tsx` + `clinic-form.tsx` — list with add/edit slideout
|
||||||
|
- `doctors.tsx` + `doctor-form.tsx` — list with add/edit, clinic dropdown sourced from `clinics`
|
||||||
|
- `team-settings.tsx` becomes interactive — employees are created in place via the sidecar's `POST /api/team/members` endpoint (see architecture decision 6), real role dropdown via `getRoles`, role assignment via `updateWorkspaceMemberRole`. **Never uses `sendInvitations`.**
|
||||||
|
|
||||||
|
**Verifies:** admin can create clinics, doctors, and invite team members from the staff portal without touching the database.
|
||||||
|
|
||||||
|
**Estimate:** 5-6 hours.
|
||||||
|
|
||||||
|
### Phase 4 — Sidecar-config CRUD pages (Pattern B — sidecar admin endpoints)
|
||||||
|
|
||||||
|
**Files:** 3 new pages + 3 new form components.
|
||||||
|
|
||||||
|
- `telephony-settings.tsx` + `telephony-form.tsx` — Ozonetel + Exotel + SIP fields
|
||||||
|
- `ai-settings.tsx` + `ai-form.tsx` — provider, model, temperature, system prompt
|
||||||
|
- `widget-settings.tsx` + `widget-form.tsx` — wraps the existing widget config endpoint with a real form
|
||||||
|
|
||||||
|
**Verifies:** admin can edit telephony, AI, and widget config from the staff portal. Changes take effect without sidecar restart (since services use in-memory cache + file write).
|
||||||
|
|
||||||
|
**Estimate:** 4-5 hours.
|
||||||
|
|
||||||
|
### Phase 5 — Wizard step composition
|
||||||
|
|
||||||
|
**Files:** 6 wizard step components, each thin wrappers around the Phase 3/4 forms.
|
||||||
|
|
||||||
|
- `wizard-step-identity.tsx`
|
||||||
|
- `wizard-step-clinics.tsx`
|
||||||
|
- `wizard-step-doctors.tsx`
|
||||||
|
- `wizard-step-team.tsx`
|
||||||
|
- `wizard-step-telephony.tsx`
|
||||||
|
- `wizard-step-ai.tsx`
|
||||||
|
|
||||||
|
Each wraps the corresponding form, adds wizard validation (required fields enforced for setup completion), and on save calls `PUT /api/config/setup-state/steps/<step>` to mark the step complete.
|
||||||
|
|
||||||
|
**Verifies:** admin can complete the entire setup wizard end-to-end on a fresh workspace. After step 6, redirected to home dashboard. Setup state file shows all 6 steps complete.
|
||||||
|
|
||||||
|
**Estimate:** 2-3 hours.
|
||||||
|
|
||||||
|
### Phase 6 — Polish
|
||||||
|
|
||||||
|
- Onboarding script credentials handoff block format
|
||||||
|
- "Resume setup" CTA on home dashboard if any step is incomplete
|
||||||
|
- Loading states, error toasts, optimistic updates
|
||||||
|
- Setup-state badges on the Settings hub
|
||||||
|
- Validation: clinic count > 0 required for booking flow, doctor count > 0 required for booking flow, etc.
|
||||||
|
- E2E smoke test against the Care Hospital workspace I already created
|
||||||
|
|
||||||
|
**Estimate:** 2-3 hours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Total estimate
|
||||||
|
|
||||||
|
**20-26 hours of focused implementation work** spanning ~30 new files and ~15 modified files. Realistic over 3-4 working days with checkpoints at each phase boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of scope (explicit)
|
||||||
|
|
||||||
|
- Self-service Cloudflare Turnstile signup (operator pastes existing site key)
|
||||||
|
- Self-service Ozonetel account creation (operator pastes credentials)
|
||||||
|
- Bulk import of doctors / staff (single-row form only)
|
||||||
|
- Per-tenant secrets management (env vars stay infra-owned for AI keys, captcha secret, HMAC secret)
|
||||||
|
- Workspace deletion / archival
|
||||||
|
- Multi-hospital admin (one admin per workspace; switching workspaces is platform-level)
|
||||||
|
- Hospital templates ("clone from Ramaiah") — useful follow-up but not required for the first real onboarding
|
||||||
|
- Self-service password reset for employees (handled by the existing platform reset-password flow)
|
||||||
|
- Onboarding analytics / metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions before phase 1
|
||||||
|
|
||||||
|
1. **Sidecar config file hot-reload** — when an admin updates `telephony.json` via the new endpoint, does the change need to take effect immediately (in-memory cache invalidation, no restart) or is a sidecar restart acceptable? Decision affects whether services need a "refresh" hook. **Recommendation: in-memory cache only, no restart needed** — already how `ThemeService` works.
|
||||||
|
|
||||||
|
2. **Setup state visibility** — should the setup-state file be a simple flag set or should it track *who* completed each step and *when*? Recommendation: track `completedAt` timestamp + `completedBy` user id for audit trail.
|
||||||
|
|
||||||
|
3. **Auto-mark "identity" step complete from existing branding** — if the workspace already has a `theme.json` with a non-default `brand.hospitalName`, should the wizard auto-skip step 1? **Recommendation: yes** — don't make admins re-confirm something they already configured.
|
||||||
|
|
||||||
|
4. **What if the admin tries to create an employee whose email already exists on the platform?** `signUpInWorkspace` will surface the platform's "email already exists" error, which the sidecar's `TeamService.extractGraphqlMessage` passes through to the toast. No "find or link existing user" path yet — if this comes up in practice, add a `findUserByEmail` preflight lookup before the `signUpInWorkspace` call.
|
||||||
|
|
||||||
|
5. **Logo upload** — do we accept a URL only (admin pastes a CDN link) or do we need real file upload to MinIO? **Recommendation: URL only for Phase 1**, file upload as Phase 6 polish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`yarn app:sync` may sometimes fail to register HelixEngage roles cleanly** if a workspace was activated but never had its first sync — this would block the team page's role dropdown. Mitigation: script runs sync immediately after activation, before exiting.
|
||||||
|
- **Frontend role queries require user JWT, not API key** — `settings.tsx` already noted this with the "Roles are only accessible via user JWT" comment. The team-settings page has to use direct GraphQL with user auth, not the sidecar proxy.
|
||||||
|
- **Migrating Ozonetel env vars to a config file mid-session can break a running sidecar** if someone's actively using the call desk during deploy. Mitigation: deploy during low-usage window; the new service falls back to env vars if the config file is missing.
|
||||||
|
- **Setup wizard auto-redirect could trap users in a loop** if `setup-state.json` write fails. Mitigation: wizard always has a "Skip for now" link in the top right that sets `wizardDismissed: true`.
|
||||||
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
|||||||
|
# Phase 2: Missed Call Queue + Login Redesign + Button Fix
|
||||||
|
|
||||||
|
**Date**: 2026-03-22
|
||||||
|
**PRD Reference**: US 7 (Missed Call Queue), Login Page Redesign, Button Width Fix
|
||||||
|
**Branch**: `dev`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Missed Call Queue (US 7)
|
||||||
|
|
||||||
|
### 1.1 Data Model
|
||||||
|
|
||||||
|
The existing `Call` entity on the Fortytwo platform is extended with 4 custom fields (already added via admin portal):
|
||||||
|
|
||||||
|
| GraphQL Field Name | DB Column | Type | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `callbackstatus` | `callbackstatus` | SELECT | Lifecycle: `PENDING_CALLBACK`, `CALLBACK_ATTEMPTED`, `CALLBACK_COMPLETED`, `INVALID`, `WRONG_NUMBER` |
|
||||||
|
| `callsourcenumber` | `callsourcenumber` | TEXT | Which DID/branch the patient called |
|
||||||
|
| `missedcallcount` | `missedcallcount` | NUMBER | Dedup counter — same number calling multiple times before callback |
|
||||||
|
| `callbackattemptedat` | `callbackattemptedat` | DATE_TIME | Timestamp of first callback attempt |
|
||||||
|
|
||||||
|
**Important**: Custom fields use **all-lowercase** GraphQL names (not camelCase). Verified via introspection and mutation test on staging.
|
||||||
|
|
||||||
|
Existing fields used:
|
||||||
|
- `callStatus: MISSED` — identifies missed calls
|
||||||
|
- `agentName` — tracks which agent is assigned
|
||||||
|
- `disposition` — records callback outcome
|
||||||
|
- `callerNumber` — caller's phone (PHONES type, accessed as `callerNumber { primaryPhoneNumber }`)
|
||||||
|
- `startedAt` — when the call was missed
|
||||||
|
- `leadId` — linked lead (if matched)
|
||||||
|
|
||||||
|
### 1.2 Sidecar: Missed Queue Service
|
||||||
|
|
||||||
|
Extend the existing `src/worklist/` module (already handles missed call data and is registered in `app.module.ts`).
|
||||||
|
|
||||||
|
**New files**:
|
||||||
|
- `src/worklist/missed-queue.service.ts` — Queue logic (ingestion, dedup, assignment)
|
||||||
|
|
||||||
|
**Modified files**:
|
||||||
|
- `src/worklist/worklist.controller.ts` — Add missed queue endpoints
|
||||||
|
- `src/worklist/worklist.module.ts` — Register MissedQueueService
|
||||||
|
|
||||||
|
**Auth model**:
|
||||||
|
- `GET /api/missed-queue` and `PATCH /api/missed-queue/:id/status` — use agent's forwarded auth token (same as existing worklist endpoints)
|
||||||
|
- Ingestion timer and auto-assignment — use server API key (`PLATFORM_API_KEY`) since these run without a user request
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/api/missed-queue` | Returns missed calls for current agent, grouped by `callbackstatus` |
|
||||||
|
| `POST` | `/api/missed-queue/ingest` | Polls Ozonetel `abandonCalls`, deduplicates, writes to platform |
|
||||||
|
| `PATCH` | `/api/missed-queue/:id/status` | Updates `callbackstatus` on a Call record |
|
||||||
|
| `POST` | `/api/missed-queue/assign` | Assigns oldest unassigned PENDING_CALLBACK call to an agent |
|
||||||
|
|
||||||
|
#### Ingestion Flow (runs every 30s via `setInterval` on service init)
|
||||||
|
|
||||||
|
1. Call `OzonetelAgentService.getAbandonCalls()` with `fromTime`/`toTime` limited to the **last 5 minutes** (the method already supports these parameters). This prevents re-processing the entire day's abandon calls on service restart.
|
||||||
|
2. Normalize caller phone numbers to `+91XXXXXXXXXX` format before any query or write (Ozonetel may return numbers in varying formats like `009919876543210` or `9876543210`).
|
||||||
|
3. For each abandoned call:
|
||||||
|
- Extract `callerID` (phone number, normalized) and `did` (source number)
|
||||||
|
- Query platform: `calls(filter: { callerNumber: { primaryPhoneNumber: { eq: "<normalized_number>" } }, callbackstatus: { eq: PENDING_CALLBACK } })`
|
||||||
|
- **Match found** → `updateCall`: increment `missedcallcount`, update `startedAt` to latest timestamp
|
||||||
|
- **No match** → `createCall`:
|
||||||
|
```graphql
|
||||||
|
mutation { createCall(data: {
|
||||||
|
callStatus: MISSED,
|
||||||
|
direction: INBOUND,
|
||||||
|
callerNumber: { primaryPhoneNumber: "<normalized_number>", primaryPhoneCallingCode: "+91" },
|
||||||
|
callsourcenumber: "<DID>",
|
||||||
|
callbackstatus: PENDING_CALLBACK,
|
||||||
|
missedcallcount: 1,
|
||||||
|
startedAt: "<timestamp>"
|
||||||
|
}) { id } }
|
||||||
|
```
|
||||||
|
4. Track ingested Ozonetel `monitorUCID` values in a Set to avoid re-processing within the same poll cycle
|
||||||
|
|
||||||
|
#### Auto-Assignment (triggered on two events)
|
||||||
|
|
||||||
|
Assignment fires when an agent becomes available via either path:
|
||||||
|
|
||||||
|
1. **Disposition submission** (`POST /api/ozonetel/dispose`): After an agent completes a call and submits disposition, they become Ready. This is the primary trigger — most "agent available" transitions happen here.
|
||||||
|
2. **Manual state change** (`POST /api/ozonetel/agent-state`): When an agent manually toggles to Ready via AgentStatusToggle.
|
||||||
|
|
||||||
|
In both cases, call `MissedQueueService.assignNext(agentName)`:
|
||||||
|
1. Query platform: oldest Call with `callbackstatus: PENDING_CALLBACK` and `agentName` is null/empty, ordered by `startedAt: AscNullsLast`
|
||||||
|
2. If found → `updateCall` setting `agentName` to the available agent
|
||||||
|
3. Use optimistic concurrency: if the update fails (another agent claimed it first), retry with the next oldest call
|
||||||
|
4. Return assigned call to frontend (so it can surface at top of worklist)
|
||||||
|
|
||||||
|
**Note on race conditions**: Since this is a single-instance sidecar, a simple in-memory mutex around the assignment query+update is sufficient to prevent two simultaneous Ready events from claiming the same call.
|
||||||
|
|
||||||
|
#### Status Transitions
|
||||||
|
|
||||||
|
| Trigger | From Status | To Status | Additional Updates |
|
||||||
|
|---------|------------|-----------|-------------------|
|
||||||
|
| Agent clicks call-back | `PENDING_CALLBACK` | `CALLBACK_ATTEMPTED` | Set `callbackattemptedat` |
|
||||||
|
| Disposition: APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED | `CALLBACK_ATTEMPTED` | `CALLBACK_COMPLETED` | — |
|
||||||
|
| Disposition: NO_ANSWER (after max retries) | `CALLBACK_ATTEMPTED` | `CALLBACK_ATTEMPTED` | Stays attempted, agent can retry |
|
||||||
|
| Disposition: WRONG_NUMBER | `CALLBACK_ATTEMPTED` | `WRONG_NUMBER` | — |
|
||||||
|
| Agent marks invalid | Any | `INVALID` | — |
|
||||||
|
|
||||||
|
### 1.3 Sidecar: Worklist Update
|
||||||
|
|
||||||
|
Update `WorklistService.getMissedCalls()` to include the new fields in the query:
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
calls(first: 20, filter: {
|
||||||
|
agentName: { eq: "<agent>" },
|
||||||
|
callStatus: { eq: MISSED },
|
||||||
|
callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] }
|
||||||
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
|
edges { node {
|
||||||
|
id name createdAt
|
||||||
|
direction callStatus agentName
|
||||||
|
callerNumber { primaryPhoneNumber }
|
||||||
|
startedAt endedAt durationSec
|
||||||
|
disposition leadId
|
||||||
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Frontend: Worklist Panel Changes
|
||||||
|
|
||||||
|
**`src/hooks/use-worklist.ts`**:
|
||||||
|
- Add `callbackstatus`, `callsourcenumber`, `missedcallcount`, `callbackattemptedat` to `MissedCall` type
|
||||||
|
- Transform data from sidecar response (fields are already lowercase, minimal mapping needed)
|
||||||
|
|
||||||
|
**`src/components/call-desk/worklist-panel.tsx`**:
|
||||||
|
|
||||||
|
Replace the flat "Missed" tab with status sub-tabs:
|
||||||
|
|
||||||
|
```
|
||||||
|
[All] [Missed] [Callbacks] [Follow-ups] [Leads]
|
||||||
|
│
|
||||||
|
└── [Pending | Attempted | Completed | Invalid]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pending sub-tab** (default view):
|
||||||
|
- FIFO ordered (oldest first, matching `AscNullsLast` sort)
|
||||||
|
- Row content: caller phone, time since missed, missed call count badge (shown if >1), call source number, SLA color indicator
|
||||||
|
- SLA thresholds: green (<15 min), orange (15–30 min), red (>30 min) — existing logic
|
||||||
|
- Click-to-call → triggers callback, sidecar auto-transitions to `CALLBACK_ATTEMPTED`
|
||||||
|
|
||||||
|
**Attempted sub-tab**:
|
||||||
|
- Calls where agent tried calling back but no final resolution yet
|
||||||
|
- Row content: caller phone, time since first attempt (`callbackattemptedat`), last disposition
|
||||||
|
- Click-to-call for retry
|
||||||
|
|
||||||
|
**Completed / Invalid sub-tabs**:
|
||||||
|
- Read-only history of resolved missed calls
|
||||||
|
- Shows: caller phone, final disposition, resolution timestamp
|
||||||
|
|
||||||
|
**Assignment notification**: When auto-assigned, the missed call appears at **top of the worklist** with a highlighted "Missed Call" badge. A toast notification alerts the agent.
|
||||||
|
|
||||||
|
### 1.5 Frontend: Post-Callback Status Update
|
||||||
|
|
||||||
|
When an agent clicks call-back on a missed call:
|
||||||
|
1. Frontend calls `PATCH /api/missed-queue/:id/status` with `{ status: 'CALLBACK_ATTEMPTED' }`
|
||||||
|
2. Normal outbound call flow begins via SIP
|
||||||
|
3. After call ends → disposition form → disposition submitted → sidecar maps disposition to final `callbackstatus` and updates platform
|
||||||
|
|
||||||
|
This integrates with the existing `ActiveCallCard` disposition flow. The frontend must pass the missed Call record ID as `missedCallId` in the disposition request body so the sidecar can look up and update the `callbackstatus`. The dispose endpoint currently receives `{ ucid, disposition, callerPhone, direction, durationSec, leadId, notes }` — add `missedCallId?: string` as an optional field. When present, the sidecar updates the corresponding Call record's `callbackstatus` based on disposition mapping:
|
||||||
|
|
||||||
|
- APPOINTMENT_BOOKED, INFO_PROVIDED, FOLLOW_UP_SCHEDULED, CALLBACK_REQUESTED → `CALLBACK_COMPLETED`
|
||||||
|
- WRONG_NUMBER → `WRONG_NUMBER`
|
||||||
|
- NO_ANSWER → stays `CALLBACK_ATTEMPTED` (agent can retry)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Login Page Redesign
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
Split-panel layout: 60% blue left panel with marketing feature cards (Unified Lead Inbox, Campaign Intelligence, Speed to Contact) + 40% white right panel with login form.
|
||||||
|
|
||||||
|
### Target State
|
||||||
|
- **Full blue background** using `bg-brand-section` (existing brand blue token)
|
||||||
|
- **Centered white card** (~420px max-width, `rounded-xl`, `shadow-xl`)
|
||||||
|
- **Inside the card**:
|
||||||
|
- Helix Engage logo (prominent, centered)
|
||||||
|
- "Global Hospital" subtitle
|
||||||
|
- Google sign-in button with "OR CONTINUE WITH" divider
|
||||||
|
- Email input
|
||||||
|
- Password input with eye toggle
|
||||||
|
- Remember me checkbox + Forgot password link (same row)
|
||||||
|
- Sign in button (full-width within card — standard for login forms)
|
||||||
|
- **Footer**: subtle "Powered by FortyTwo" text below the card
|
||||||
|
- **No left panel, no marketing copy, no feature cards**
|
||||||
|
- **Mobile**: card fills screen width with padding
|
||||||
|
|
||||||
|
### File Changes
|
||||||
|
- `src/pages/login.tsx` — restructure layout, remove left panel, center card
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Button Width Fix
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Buttons in call desk inline forms (disposition, appointment, enquiry, transfer) use `w-full`, spanning the entire container width. This looks awkward in wide panels.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
Change buttons in these forms from `w-full` to `w-auto` with right-aligned layout (`flex justify-end gap-3`).
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
Login page buttons stay `w-full` (narrow container, standard practice).
|
||||||
|
|
||||||
|
### Affected Files
|
||||||
|
- `src/components/call-desk/disposition-form.tsx` — Save Disposition button (confirmed `w-full`)
|
||||||
|
- Other call desk form buttons (appointment, enquiry, transfer) — verify at implementation time, may already be content-width
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### GraphQL Field Naming
|
||||||
|
Custom fields added via admin portal use **all-lowercase** GraphQL names:
|
||||||
|
- `callbackstatus` (not `callbackStatus`)
|
||||||
|
- `callsourcenumber` (not `callSourceNumber`)
|
||||||
|
- `missedcallcount` (not `missedCallCount`)
|
||||||
|
- `callbackattemptedat` (not `callbackAttemptedAt`)
|
||||||
|
|
||||||
|
Managed (app-defined) fields retain camelCase (`callStatus`, `agentName`, etc.).
|
||||||
|
|
||||||
|
### Verified on Staging
|
||||||
|
- Queries: `calls(first: 2) { edges { node { callbackstatus callsourcenumber missedcallcount callbackattemptedat } } }` ✅
|
||||||
|
- Mutations: `updateCall(id: "...", data: { callbackstatus: PENDING_CALLBACK, missedcallcount: 1 })` ✅
|
||||||
|
- Staging DB: `fortytwo_staging`, workspace schema: `workspace_3x7sonctrktrxft4b0bwuc26x`, table: `_call`
|
||||||
|
|
||||||
|
### Dedup Strategy
|
||||||
|
Deduplication is by caller phone number against `PENDING_CALLBACK` records. Once a missed call transitions to any other status, a new missed call from the same number creates a fresh record. This prevents stale dedup.
|
||||||
|
|
||||||
|
### Ozonetel Ingestion Idempotency
|
||||||
|
Each poll queries only the last 5 minutes via `fromTime`/`toTime` parameters, preventing full-day reprocessing on restart. Within a poll cycle, processed `monitorUCID` values are tracked in a `Set<string>` to avoid duplicates. The platform dedup query (phone number + `PENDING_CALLBACK`) provides a second safety net.
|
||||||
|
|
||||||
|
### Phone Number Normalization
|
||||||
|
All phone numbers are normalized to `+91XXXXXXXXXX` format before writes and queries. Ozonetel may return numbers as `009919876543210`, `919876543210`, or `9876543210` — strip leading `0091`/`91`/`0` prefixes, then prepend `+91`.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- **Multiple DIDs**: If a caller dials branch A, then branch B before callback, the records merge (count incremented). The `callsourcenumber` updates to the latest branch. This is intentional — the callback is to the patient, not the branch.
|
||||||
|
- **Agent goes offline after assignment**: Assigned missed calls stay with the agent. No automatic requeue. Supervisors can manually reassign in Phase 3.
|
||||||
|
- **Ingestion poll interval**: 30s, configurable via `MISSED_QUEUE_POLL_INTERVAL_MS` env var.
|
||||||
176
docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
Normal file
176
docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Multi-Agent SIP Credentials + Duplicate Login Lockout
|
||||||
|
|
||||||
|
**Date**: 2026-03-23
|
||||||
|
**Status**: Approved design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Single Ozonetel agent account (`global`) and SIP extension (`523590`) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Model
|
||||||
|
|
||||||
|
**Agent entity** (already created on platform via admin portal):
|
||||||
|
|
||||||
|
| Field (GraphQL) | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `wsmemberId` | Relation | Links to workspace member |
|
||||||
|
| `ozonetelagentid` | Text | Ozonetel agent ID (e.g. "global", "agent2") |
|
||||||
|
| `sipextension` | Text | SIP extension number (e.g. "523590") |
|
||||||
|
| `sippassword` | Text | SIP auth password |
|
||||||
|
| `campaignname` | Text | Ozonetel campaign (e.g. "Inbound_918041763265") |
|
||||||
|
|
||||||
|
Custom fields use **all-lowercase** GraphQL names. One Agent record per CC user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sidecar Changes
|
||||||
|
|
||||||
|
### 2.1 Redis Integration
|
||||||
|
|
||||||
|
Add `ioredis` dependency to `helix-engage-server`. Connect to `REDIS_URL` (default `redis://redis:6379`).
|
||||||
|
|
||||||
|
New service: `src/auth/session.service.ts`
|
||||||
|
|
||||||
|
```
|
||||||
|
lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600
|
||||||
|
isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null
|
||||||
|
refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600
|
||||||
|
unlockSession(agentId) → DEL agent:session:{agentId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Auth Controller — Login Flow
|
||||||
|
|
||||||
|
Modify `POST /auth/login`:
|
||||||
|
|
||||||
|
1. Authenticate with platform → get JWT + user profile + workspace member ID
|
||||||
|
2. Determine role (same as today)
|
||||||
|
3. **If CC agent:**
|
||||||
|
a. Query platform: `agents(filter: { wsmemberId: { eq: "<memberId>" } })` using server API key
|
||||||
|
b. No Agent record → `403: "Agent account not configured. Contact administrator."`
|
||||||
|
c. Check Redis: `isSessionLocked(agent.ozonetelagentid)`
|
||||||
|
d. Locked by different user → `409: "You are already logged in on another device. Please log out there first."`
|
||||||
|
e. Locked by same user → refresh TTL (re-login from same browser)
|
||||||
|
f. Not locked → `lockSession(agent.ozonetelagentid, memberId)`
|
||||||
|
g. Login to Ozonetel with agent's specific credentials
|
||||||
|
h. Return `agentConfig` in response
|
||||||
|
4. **If manager/executive:** No Agent query, no Redis, no SIP. Same as today.
|
||||||
|
|
||||||
|
**Login response** (CC agent):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "...",
|
||||||
|
"refreshToken": "...",
|
||||||
|
"user": { "id": "...", "role": "cc-agent", ... },
|
||||||
|
"agentConfig": {
|
||||||
|
"ozonetelAgentId": "global",
|
||||||
|
"sipExtension": "523590",
|
||||||
|
"sipPassword": "523590",
|
||||||
|
"sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com",
|
||||||
|
"sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444",
|
||||||
|
"campaignName": "Inbound_918041763265"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SIP domain (`blr-pub-rtc4.ozonetel.com`) and WS port (`444`) remain from env vars — these are shared infrastructure, not per-agent.
|
||||||
|
|
||||||
|
### 2.3 Auth Controller — Logout
|
||||||
|
|
||||||
|
Modify `POST /auth/logout` (or add if doesn't exist):
|
||||||
|
1. Resolve agent from JWT
|
||||||
|
2. `unlockSession(agent.ozonetelagentid)`
|
||||||
|
3. Ozonetel agent logout
|
||||||
|
|
||||||
|
### 2.4 Auth Controller — Heartbeat
|
||||||
|
|
||||||
|
New endpoint: `POST /auth/heartbeat`
|
||||||
|
1. Resolve agent from JWT
|
||||||
|
2. `refreshSession(agent.ozonetelagentid)` → extends TTL to 1 hour
|
||||||
|
3. Return `{ status: 'ok' }`
|
||||||
|
|
||||||
|
### 2.5 Agent Config Cache
|
||||||
|
|
||||||
|
On login, store agent config in an in-memory `Map<workspaceMemberId, AgentConfig>`.
|
||||||
|
|
||||||
|
All Ozonetel controller endpoints currently use `this.defaultAgentId`. Change to:
|
||||||
|
1. Resolve workspace member from JWT (already done in worklist controller's `resolveAgentName`)
|
||||||
|
2. Lookup agent config from the in-memory map
|
||||||
|
3. Use the agent's `ozonetelagentid` for Ozonetel API calls
|
||||||
|
|
||||||
|
This avoids querying Redis/platform on every API call.
|
||||||
|
|
||||||
|
Clear the cache entry on logout.
|
||||||
|
|
||||||
|
### 2.6 Config
|
||||||
|
|
||||||
|
New env var: `REDIS_URL` (default: `redis://redis:6379`)
|
||||||
|
|
||||||
|
Existing env vars (`OZONETEL_AGENT_ID`, `OZONETEL_SIP_ID`, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend Changes
|
||||||
|
|
||||||
|
### 3.1 Store Agent Config
|
||||||
|
|
||||||
|
On login, store `agentConfig` from the response in localStorage (`helix_agent_config`).
|
||||||
|
|
||||||
|
On logout, clear it.
|
||||||
|
|
||||||
|
### 3.2 SIP Provider
|
||||||
|
|
||||||
|
`sip-provider.tsx`: Read SIP credentials from stored `agentConfig` instead of env vars.
|
||||||
|
|
||||||
|
```
|
||||||
|
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config'));
|
||||||
|
const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI;
|
||||||
|
const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD;
|
||||||
|
const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER;
|
||||||
|
```
|
||||||
|
|
||||||
|
If no `agentConfig` and no env vars → don't connect SIP.
|
||||||
|
|
||||||
|
### 3.3 Heartbeat
|
||||||
|
|
||||||
|
Add a heartbeat interval in `AppShell` (only for CC agents):
|
||||||
|
- Every 5 minutes: `POST /auth/heartbeat`
|
||||||
|
- If heartbeat fails with 401 → session expired, redirect to login
|
||||||
|
|
||||||
|
### 3.4 Login Error Handling
|
||||||
|
|
||||||
|
Handle new error codes from login:
|
||||||
|
- `403` → "Agent account not configured. Contact administrator."
|
||||||
|
- `409` → "You are already logged in on another device. Please log out there first."
|
||||||
|
|
||||||
|
### 3.5 Logout
|
||||||
|
|
||||||
|
On logout, call `POST /auth/logout` before clearing tokens (so sidecar can clean up Redis + Ozonetel).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Docker Compose
|
||||||
|
|
||||||
|
Add `REDIS_URL` to sidecar environment in `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
sidecar:
|
||||||
|
environment:
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Edge Cases
|
||||||
|
|
||||||
|
- **Sidecar restart**: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily).
|
||||||
|
- **Redis restart**: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry.
|
||||||
|
- **Browser crash (no logout)**: Heartbeat stops → Redis key expires in ≤1 hour → lock clears.
|
||||||
|
- **Same user, same browser re-login**: Detected by comparing `memberId` in Redis → refreshes TTL instead of blocking.
|
||||||
|
- **Agent record deleted while logged in**: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.
|
||||||
191
docs/superpowers/specs/2026-03-24-supervisor-module.md
Normal file
191
docs/superpowers/specs/2026-03-24-supervisor-module.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Supervisor Module — Team Performance, Live Call Monitor, Master Data
|
||||||
|
|
||||||
|
**Date**: 2026-03-24
|
||||||
|
**Jira**: PP-5 (Team Performance), PP-6 (Live Call Monitor)
|
||||||
|
**Status**: Approved design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principle
|
||||||
|
|
||||||
|
No hardcoded/mock data. All data from Ozonetel APIs or platform GraphQL queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Admin Sidebar Nav Restructure
|
||||||
|
|
||||||
|
```
|
||||||
|
SUPERVISOR
|
||||||
|
Dashboard → / (existing team-dashboard.tsx — summary)
|
||||||
|
Team Performance → /team-performance (new — full PP-5)
|
||||||
|
Live Call Monitor → /live-monitor (new — PP-6)
|
||||||
|
|
||||||
|
DATA & REPORTS
|
||||||
|
Lead Master → /leads (existing all-leads.tsx)
|
||||||
|
Patient Master → /patients (existing patients.tsx)
|
||||||
|
Appointment Master → /appointments (existing appointments.tsx)
|
||||||
|
Call Log Master → /call-history (existing call-history.tsx)
|
||||||
|
Call Recordings → /call-recordings (new — filtered calls with recordings)
|
||||||
|
Missed Calls → /missed-calls (new — standalone missed call table)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files**: `sidebar.tsx` (admin nav config), `main.tsx` (routes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Team Performance Dashboard (PP-5)
|
||||||
|
|
||||||
|
**Route**: `/team-performance`
|
||||||
|
**Page**: `src/pages/team-performance.tsx`
|
||||||
|
|
||||||
|
### Section 1: Key Metrics Bar
|
||||||
|
- Active Agents / On Call Now → sidecar (from active calls tracking)
|
||||||
|
- Total Calls → platform `calls` count by date range
|
||||||
|
- Appointments → platform `appointments` count
|
||||||
|
- Missed Calls → platform `calls` where `callStatus: MISSED`
|
||||||
|
- Conversion Rate → appointments / total calls
|
||||||
|
- Time filter: Today | Week | Month | Year | Custom
|
||||||
|
|
||||||
|
### Section 2: Call Breakdown Trends
|
||||||
|
- Left: Inbound vs Outbound line chart (ECharts) by day
|
||||||
|
- Right: Leads vs Missed vs Follow-ups by day
|
||||||
|
- Data: platform `calls` grouped by date + direction
|
||||||
|
|
||||||
|
### Section 3: Agent Performance Table
|
||||||
|
| Column | Source |
|
||||||
|
|--------|--------|
|
||||||
|
| Agent | Agent entity `name` |
|
||||||
|
| Calls | Platform `calls` filtered by `agentName` |
|
||||||
|
| Inbound | Platform `calls` where `direction: INBOUND` |
|
||||||
|
| Missed | Platform `calls` where `callStatus: MISSED` |
|
||||||
|
| Follow-ups | Platform `followUps` filtered by `assignedAgent` |
|
||||||
|
| Leads | Platform `leads` filtered by `assignedAgent` |
|
||||||
|
| Conv% | Derived: appointments / calls |
|
||||||
|
| NPS | Agent entity `npsscore` |
|
||||||
|
| Idle | Ozonetel `getAgentSummary` API |
|
||||||
|
|
||||||
|
Sortable columns. Own time filter (Today/Week/Month/Year/Custom).
|
||||||
|
|
||||||
|
### Section 4: Time Breakdown
|
||||||
|
- Team average: Active / Wrap / Idle / Break totals
|
||||||
|
- Per-agent horizontal stacked bars
|
||||||
|
- Data: Ozonetel `getAgentSummary` per agent
|
||||||
|
- Agents with idle > `maxidleminutes` threshold highlighted red
|
||||||
|
|
||||||
|
### Section 5: NPS + Conversion Metrics
|
||||||
|
- NPS donut chart (average of all agents' `npsscore`)
|
||||||
|
- Per-agent NPS horizontal bars
|
||||||
|
- Call→Appointment % card (big number)
|
||||||
|
- Lead→Contact % card (big number)
|
||||||
|
- Per-agent conversion breakdown below cards
|
||||||
|
|
||||||
|
### Section 6: Performance Alerts
|
||||||
|
- Compare actual metrics vs Agent entity thresholds:
|
||||||
|
- `maxidleminutes` → "Excessive Idle Time"
|
||||||
|
- `minnpsthreshold` → "Low NPS"
|
||||||
|
- `minconversionpercent` → "Low Lead-to-Contact"
|
||||||
|
- Red-highlighted alert cards with agent name, alert type, value
|
||||||
|
|
||||||
|
### Sidecar Endpoint
|
||||||
|
`GET /api/supervisor/team-performance?date=YYYY-MM-DD`
|
||||||
|
- Aggregates Ozonetel `getAgentSummary` across all agents
|
||||||
|
- Returns per-agent time breakdown (active/wrap/idle/break in minutes)
|
||||||
|
- Uses Agent entity to get list of all agent IDs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Live Call Monitor (PP-6)
|
||||||
|
|
||||||
|
**Route**: `/live-monitor`
|
||||||
|
**Page**: `src/pages/live-monitor.tsx`
|
||||||
|
|
||||||
|
### KPI Cards
|
||||||
|
- Active Calls count
|
||||||
|
- On Hold count
|
||||||
|
- Avg Duration
|
||||||
|
|
||||||
|
### Active Calls Table
|
||||||
|
| Column | Source |
|
||||||
|
|--------|--------|
|
||||||
|
| Agent | Ozonetel event `agent_id` → mapped to Agent entity name |
|
||||||
|
| Caller | Event `caller_id` → matched against platform leads/patients |
|
||||||
|
| Type | Event `call_type` (InBound/Manual) |
|
||||||
|
| Department | From matched lead's `interestedService` or "—" |
|
||||||
|
| Duration | Live counter from `event_time` |
|
||||||
|
| Status | active / on-hold |
|
||||||
|
| Actions | Listen / Whisper / Barge buttons (disabled until API confirmed) |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
1. Sidecar subscribes to Ozonetel real-time events on startup
|
||||||
|
- `POST https://subscription.ozonetel.com/events/subscribe`
|
||||||
|
- Body: `{ callEventsURL: "<sidecar-webhook-url>", agentEventsURL: "<sidecar-webhook-url>" }`
|
||||||
|
2. Sidecar receives events at `POST /webhooks/ozonetel/call-event`
|
||||||
|
3. In-memory map: `ucid → { agentId, callerNumber, callType, startTime, status }`
|
||||||
|
- `Calling` / `Answered` → add/update entry
|
||||||
|
- `Disconnect` → remove entry
|
||||||
|
4. `GET /api/supervisor/active-calls` → returns current map
|
||||||
|
5. Frontend polls every 5 seconds
|
||||||
|
|
||||||
|
### Sidecar Changes
|
||||||
|
- New module: `src/supervisor/`
|
||||||
|
- `supervisor.controller.ts` — team-performance + active-calls endpoints
|
||||||
|
- `supervisor.service.ts` — Ozonetel event subscription, active call tracking
|
||||||
|
- `supervisor.module.ts`
|
||||||
|
- New webhook: `POST /webhooks/ozonetel/call-event`
|
||||||
|
- Ozonetel event subscription on `onModuleInit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Master Data Pages
|
||||||
|
|
||||||
|
### Call Recordings (`/call-recordings`)
|
||||||
|
**Page**: `src/pages/call-recordings.tsx`
|
||||||
|
- Query: platform `calls` where `recording` is not null
|
||||||
|
- Table: Agent, Caller, Type, Date, Duration, Recording Player
|
||||||
|
- Search by agent/phone + date filter
|
||||||
|
|
||||||
|
### Missed Calls (`/missed-calls`)
|
||||||
|
**Page**: `src/pages/missed-calls.tsx`
|
||||||
|
- Query: platform `calls` where `callStatus: MISSED`
|
||||||
|
- Table: Caller, Date/Time, Branch (`callsourcenumber`), Agent, Callback Status, SLA
|
||||||
|
- Tabs: All | Pending | Attempted | Completed (filter by `callbackstatus`)
|
||||||
|
- Not filtered by agent — supervisor sees all
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Agent Entity Fields (Already Configured)
|
||||||
|
|
||||||
|
| GraphQL Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `ozonetelagentid` | Text | Ozonetel agent ID |
|
||||||
|
| `sipextension` | Text | SIP extension |
|
||||||
|
| `sippassword` | Text | SIP password |
|
||||||
|
| `campaignname` | Text | Ozonetel campaign |
|
||||||
|
| `npsscore` | Number | Agent NPS score |
|
||||||
|
| `maxidleminutes` | Number | Idle time alert threshold |
|
||||||
|
| `minnpsthreshold` | Number | NPS alert threshold |
|
||||||
|
| `minconversionpercent` | Number | Conversion alert threshold |
|
||||||
|
|
||||||
|
All custom fields use **all-lowercase** GraphQL names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. File Map
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `helix-engage/src/pages/team-performance.tsx` | PP-5 dashboard |
|
||||||
|
| `helix-engage/src/pages/live-monitor.tsx` | PP-6 active call monitor |
|
||||||
|
| `helix-engage/src/pages/call-recordings.tsx` | Call recordings master |
|
||||||
|
| `helix-engage/src/pages/missed-calls.tsx` | Missed calls master |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor.controller.ts` | Supervisor endpoints |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Event subscription + active calls |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor.module.ts` | Module registration |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage/src/components/layout/sidebar.tsx` | Admin nav restructure |
|
||||||
|
| `helix-engage/src/main.tsx` | New routes |
|
||||||
|
| `helix-engage-server/src/app.module.ts` | Import SupervisorModule |
|
||||||
165
docs/superpowers/specs/2026-03-31-csv-lead-import-design.md
Normal file
165
docs/superpowers/specs/2026-03-31-csv-lead-import-design.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# CSV Lead Import — Design Spec
|
||||||
|
|
||||||
|
**Date**: 2026-03-31
|
||||||
|
**Status**: Approved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Supervisors can import leads from a CSV file into an existing campaign. The feature is a modal wizard accessible from the Campaigns page. Leads are created via the platform GraphQL API and linked to the selected campaign. Existing patients are detected by phone number matching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
### Entry Point
|
||||||
|
"Import Leads" button on the Campaigns page (`/campaigns`). Admin role only.
|
||||||
|
|
||||||
|
### Step 1 — Select Campaign (modal opens)
|
||||||
|
- Campaign cards in a grid layout inside the modal
|
||||||
|
- Each card shows: campaign name, platform badge (Facebook/Google/Instagram/Manual), status badge (Active/Paused/Completed), lead count
|
||||||
|
- Click a card to select → proceeds to Step 2
|
||||||
|
- Only ACTIVE and PAUSED campaigns shown (not COMPLETED)
|
||||||
|
|
||||||
|
### Step 2 — Upload & Preview
|
||||||
|
- File drop zone at top of modal (accepts `.csv` only)
|
||||||
|
- On file upload, parse CSV client-side
|
||||||
|
- Show preview table with:
|
||||||
|
- **Column mapping row**: each CSV column header has a dropdown to map to a Lead field. Fuzzy auto-match on load (e.g., "Phone" → contactPhone, "Name" → contactName.firstName, "Email" → contactEmail, "Service" → interestedService)
|
||||||
|
- **Data rows**: all rows displayed (paginated at 20 per page if large file)
|
||||||
|
- **Patient match column** (rightmost): for each row, check phone against existing patients in DataProvider
|
||||||
|
- Green badge: "Existing — {Patient Name}" (phone matched)
|
||||||
|
- Gray badge: "New" (no match)
|
||||||
|
- **Duplicate lead column**: check phone against existing leads
|
||||||
|
- Orange badge: "Duplicate" (phone already exists as a lead)
|
||||||
|
- No badge if clean
|
||||||
|
- Validation:
|
||||||
|
- `contactPhone` mapping is required — show error banner if unmapped
|
||||||
|
- Rows with empty phone values are flagged as "Skip — no phone"
|
||||||
|
- Footer shows summary: "48 leads ready, 3 existing patients, 2 duplicates, 1 skipped"
|
||||||
|
- "Import" button enabled only when contactPhone is mapped and at least 1 valid row exists
|
||||||
|
|
||||||
|
### Step 3 — Import Progress
|
||||||
|
- "Import" button triggers sequential lead creation
|
||||||
|
- Progress bar: "Importing 12 / 48..."
|
||||||
|
- Each lead created via GraphQL mutation:
|
||||||
|
```graphql
|
||||||
|
mutation($data: LeadCreateInput!) {
|
||||||
|
createLead(data: $data) { id }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Data payload per lead:
|
||||||
|
- `name`: "{firstName} {lastName}" or phone if no name
|
||||||
|
- `contactName`: `{ firstName, lastName }` from mapped columns
|
||||||
|
- `contactPhone`: `{ primaryPhoneNumber }` from mapped column (normalized with +91 prefix)
|
||||||
|
- `contactEmail`: `{ primaryEmail }` if mapped
|
||||||
|
- `interestedService`: if mapped
|
||||||
|
- `source`: campaign platform (FACEBOOK_AD, GOOGLE_AD, etc.) or MANUAL
|
||||||
|
- `status`: NEW
|
||||||
|
- `campaignId`: selected campaign ID
|
||||||
|
- `patientId`: if phone matched an existing patient
|
||||||
|
- All other mapped fields set accordingly
|
||||||
|
- Duplicate leads (phone already exists) are skipped
|
||||||
|
- On complete: summary card — "45 created, 3 linked to existing patients, 2 skipped (duplicates), 1 skipped (no phone)"
|
||||||
|
|
||||||
|
### Step 4 — Done
|
||||||
|
- Summary with green checkmark
|
||||||
|
- "Done" button closes modal
|
||||||
|
- Campaigns page refreshes to show updated lead count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Column Mapping — Fuzzy Match Rules
|
||||||
|
|
||||||
|
CSV headers are normalized (lowercase, trim, remove special chars) and matched against Lead field labels:
|
||||||
|
|
||||||
|
| CSV Header Pattern | Maps To | Field Type |
|
||||||
|
|---|---|---|
|
||||||
|
| name, first name, patient name | contactName.firstName | FULL_NAME |
|
||||||
|
| last name, surname | contactName.lastName | FULL_NAME |
|
||||||
|
| phone, mobile, contact number, cell | contactPhone | PHONES |
|
||||||
|
| email, email address | contactEmail | EMAILS |
|
||||||
|
| service, interested in, department, specialty | interestedService | TEXT |
|
||||||
|
| priority | priority | SELECT |
|
||||||
|
| source, lead source, channel | source | SELECT |
|
||||||
|
| notes, comments, remarks | (stored as lead name suffix or skipped) | — |
|
||||||
|
| utm_source, utm_medium, utm_campaign, utm_term, utm_content | utmSource/utmMedium/utmCampaign/utmTerm/utmContent | TEXT |
|
||||||
|
|
||||||
|
Unmapped columns are ignored. User can override any auto-match via dropdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phone Normalization
|
||||||
|
|
||||||
|
Before matching and creating:
|
||||||
|
1. Strip all non-digit characters
|
||||||
|
2. Remove leading `+91` or `91` if 12+ digits
|
||||||
|
3. Take last 10 digits
|
||||||
|
4. Store as `+91{10digits}` on the Lead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patient Matching
|
||||||
|
|
||||||
|
Uses the `patients` array from DataProvider (already loaded in memory):
|
||||||
|
- For each CSV row, normalize the phone number
|
||||||
|
- Check against `patient.phones.primaryPhoneNumber` (last 10 digits)
|
||||||
|
- If match found: set `patientId` on the created Lead, show patient name in preview
|
||||||
|
- If no match: leave `patientId` null, caller resolution will handle it on first call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Duplicate Lead Detection
|
||||||
|
|
||||||
|
Uses the `leads` array from DataProvider:
|
||||||
|
- For each CSV row, check normalized phone against existing `lead.contactPhone[0].number`
|
||||||
|
- If match found: mark as duplicate in preview, skip during import
|
||||||
|
- If no match: create normally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Invalid CSV (no headers, empty file): show error banner in modal, no preview
|
||||||
|
- File too large (>5000 rows): show warning, allow import but warn about duration
|
||||||
|
- Individual mutation failures: log error, continue with remaining rows, show count in summary
|
||||||
|
- Network failure mid-import: show partial result — "23 of 48 imported, import interrupted"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### No new sidecar endpoint needed
|
||||||
|
CSV parsing happens client-side. Lead creation uses the existing GraphQL proxy (`/graphql` → platform). Patient/lead matching uses DataProvider data already in memory.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/campaigns/lead-import-wizard.tsx` | **New** — Modal wizard component (Steps 1-4) |
|
||||||
|
| `src/pages/campaigns.tsx` | **Modified** — Add "Import Leads" button |
|
||||||
|
| `src/lib/csv-parser.ts` | **New** — CSV parsing + column fuzzy matching utility |
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- No new npm packages needed — `FileReader` API + string split for CSV parsing (or use existing `papaparse` if already in node_modules)
|
||||||
|
- Untitled UI components: Modal, Button, Badge, Table, Input (file), FeaturedIcon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Campaign selection via cards
|
||||||
|
- CSV upload and client-side parsing
|
||||||
|
- Fuzzy column mapping with manual override
|
||||||
|
- Preview with patient match + duplicate detection
|
||||||
|
- Sequential lead creation with progress
|
||||||
|
- Phone normalization
|
||||||
|
|
||||||
|
**Out of scope (future):**
|
||||||
|
- Dynamic campaign-specific entity creation (AI-driven schema)
|
||||||
|
- Campaign content/template creation
|
||||||
|
- Bulk update of existing leads from CSV
|
||||||
|
- API-based lead ingestion (Facebook/Google webhooks)
|
||||||
|
- Code generation webhook on schema changes
|
||||||
432
docs/superpowers/specs/2026-03-31-rules-engine-design.md
Normal file
432
docs/superpowers/specs/2026-03-31-rules-engine-design.md
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
# Rules Engine — Design Spec (v2)
|
||||||
|
|
||||||
|
**Date**: 2026-03-31 (revised 2026-04-01)
|
||||||
|
**Status**: Approved
|
||||||
|
**Phase**: 1 (Engine + Storage + API + Priority Rules UI + Worklist Integration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A configurable rules engine that governs how leads flow through the hospital's call center — which leads get called first, which agent handles them, when to escalate, and when to mark them lost. Each hospital defines its own rules. No code changes needed to change behavior.
|
||||||
|
|
||||||
|
**Product pitch**: "Your hospital defines the rules, the call center follows them automatically."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two Rule Types
|
||||||
|
|
||||||
|
The engine supports two categories of rules, each with different behavior and UI:
|
||||||
|
|
||||||
|
### Priority Rules — "Who gets called first?"
|
||||||
|
- Configures worklist ranking via weights, SLA curves, campaign modifiers
|
||||||
|
- **Computed at request time** — scores are ephemeral, not persisted to entities
|
||||||
|
- Time-sensitive (SLA elapsed changes every minute — can't be persisted)
|
||||||
|
- Supervisor sees: weight sliders, SLA thresholds, campaign weights, live worklist preview
|
||||||
|
- No draft/publish needed — changes affect ranking immediately
|
||||||
|
|
||||||
|
### Automation Rules — "What should happen automatically?"
|
||||||
|
- Triggers durable actions when conditions are met: field updates, assignments, notifications
|
||||||
|
- **Writes back to entities** via platform GraphQL mutations (e.g., set lead.priority = HIGH)
|
||||||
|
- Event-driven (fires on lead.created, call.missed, etc.) or scheduled (every 5m)
|
||||||
|
- Supervisor sees: if-this-then-that condition builder with entity/field selectors
|
||||||
|
- **Draft/publish workflow** — rules don't affect live data until published
|
||||||
|
- Sub-types: Assignment, Escalation, Lifecycle
|
||||||
|
|
||||||
|
| Aspect | Priority Rules | Automation Rules |
|
||||||
|
|---|---|---|
|
||||||
|
| When | On worklist request | On entity event / on schedule |
|
||||||
|
| Effect | Ephemeral score for ranking | Durable entity mutation |
|
||||||
|
| Persisted? | No (recomputed each request) | Yes (writes to platform) |
|
||||||
|
| Draft/publish? | No (immediate) | Yes |
|
||||||
|
| UI | Sliders + live preview | Condition builder + draft/publish |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Self-contained NestJS module inside helix-engage-server (sidecar). Designed for extraction into a standalone microservice when needed.
|
||||||
|
|
||||||
|
```
|
||||||
|
helix-engage-server/src/rules-engine/
|
||||||
|
├── rules-engine.module.ts # NestJS module (self-contained)
|
||||||
|
├── rules-engine.service.ts # Core: json-rules-engine wrapper
|
||||||
|
├── rules-engine.controller.ts # REST API: CRUD + evaluate + config
|
||||||
|
├── rules-storage.service.ts # Redis (hot) + JSON file (backup)
|
||||||
|
├── types/
|
||||||
|
│ ├── rule.types.ts # Rule schema (priority + automation)
|
||||||
|
│ ├── fact.types.ts # Fact definitions + computed facts
|
||||||
|
│ └── action.types.ts # Action handler interface
|
||||||
|
├── facts/
|
||||||
|
│ ├── lead-facts.provider.ts # Lead/campaign data facts
|
||||||
|
│ ├── call-facts.provider.ts # Call/SLA data facts (+ computed: ageMinutes, slaElapsed)
|
||||||
|
│ └── agent-facts.provider.ts # Agent availability facts
|
||||||
|
├── actions/
|
||||||
|
│ ├── score.action.ts # Priority scoring action
|
||||||
|
│ ├── assign.action.ts # Lead-to-agent assignment (stub)
|
||||||
|
│ ├── escalate.action.ts # SLA breach alerts (stub)
|
||||||
|
│ └── update.action.ts # Update entity field (stub)
|
||||||
|
├── consumers/
|
||||||
|
│ └── worklist.consumer.ts # Applies scoring rules to worklist
|
||||||
|
└── templates/
|
||||||
|
└── hospital-starter.json # Pre-built rule set for new hospitals
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- `json-rules-engine` (npm) — rule evaluation
|
||||||
|
- Redis — active rule storage, score cache
|
||||||
|
- Platform GraphQL — fact data (leads, calls, campaigns, agents)
|
||||||
|
- No imports from other sidecar modules except via constructor injection
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
- Own Redis namespace: `rules:*`
|
||||||
|
- Own route prefix: `/api/rules/*`
|
||||||
|
- Other modules call `RulesEngineService.evaluate()` — they don't import internals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fact System
|
||||||
|
|
||||||
|
### Design Principle: Entity-Driven Facts
|
||||||
|
Facts should ultimately be driven by entity metadata from the platform — adding a field to an entity automatically makes it available as a fact. This is the long-term goal.
|
||||||
|
|
||||||
|
### Phase 1: Curated Facts + Computed Facts
|
||||||
|
For Phase 1, facts are curated (hardcoded providers) with two categories:
|
||||||
|
|
||||||
|
**Entity field facts** — direct field values from platform entities:
|
||||||
|
- `lead.source`, `lead.status`, `lead.campaignId`, etc.
|
||||||
|
- `call.direction`, `call.status`, `call.callbackStatus`, etc.
|
||||||
|
- `agent.status`, `agent.skills`, etc.
|
||||||
|
|
||||||
|
**Computed facts** — derived values that don't exist as entity fields:
|
||||||
|
- `lead.ageMinutes` — computed from `createdAt`
|
||||||
|
- `call.slaElapsedPercent` — computed from `createdAt` + task type SLA
|
||||||
|
- `call.slaBreached` — computed from slaElapsedPercent > 100
|
||||||
|
- `call.taskType` — inferred from call data (missed_call, follow_up, campaign_lead, etc.)
|
||||||
|
|
||||||
|
### Phase 2: Metadata-Driven Discovery
|
||||||
|
- Query platform metadata API to discover entities and fields dynamically
|
||||||
|
- Each field's type (NUMBER, TEXT, SELECT, BOOLEAN) drives:
|
||||||
|
- Available operators in the condition builder UI
|
||||||
|
- Input type (slider, dropdown with enum values, text, toggle)
|
||||||
|
- Computed facts remain registered in code alongside metadata-driven facts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type RuleType = 'priority' | 'automation';
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
id: string; // UUID
|
||||||
|
ruleType: RuleType; // Priority or Automation
|
||||||
|
name: string; // Human-readable
|
||||||
|
description?: string; // BA-friendly explanation
|
||||||
|
enabled: boolean; // Toggle on/off without deleting
|
||||||
|
priority: number; // Evaluation order (lower = first)
|
||||||
|
|
||||||
|
trigger: RuleTrigger; // When to evaluate
|
||||||
|
conditions: RuleConditionGroup; // What to check
|
||||||
|
action: RuleAction; // What to do
|
||||||
|
|
||||||
|
// Automation rules only
|
||||||
|
status?: 'draft' | 'published'; // Draft/publish workflow
|
||||||
|
|
||||||
|
metadata: {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
category: RuleCategory;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleTrigger =
|
||||||
|
| { type: 'on_request'; request: 'worklist' | 'assignment' }
|
||||||
|
| { type: 'on_event'; event: string }
|
||||||
|
| { type: 'on_schedule'; interval: string }
|
||||||
|
| { type: 'always' };
|
||||||
|
|
||||||
|
type RuleCategory =
|
||||||
|
| 'priority' // Worklist scoring (Priority Rules)
|
||||||
|
| 'assignment' // Lead/call routing to agent (Automation)
|
||||||
|
| 'escalation' // SLA breach handling (Automation)
|
||||||
|
| 'lifecycle' // Lead status transitions (Automation)
|
||||||
|
| 'qualification'; // Lead quality scoring (Automation)
|
||||||
|
|
||||||
|
type RuleConditionGroup = {
|
||||||
|
all?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
any?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleCondition = {
|
||||||
|
fact: string; // Fact name
|
||||||
|
operator: RuleOperator;
|
||||||
|
value: any;
|
||||||
|
path?: string; // JSON path for nested facts
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleOperator =
|
||||||
|
| 'equal' | 'notEqual'
|
||||||
|
| 'greaterThan' | 'greaterThanInclusive'
|
||||||
|
| 'lessThan' | 'lessThanInclusive'
|
||||||
|
| 'in' | 'notIn'
|
||||||
|
| 'contains' | 'doesNotContain'
|
||||||
|
| 'exists' | 'doesNotExist';
|
||||||
|
|
||||||
|
type RuleAction = {
|
||||||
|
type: RuleActionType;
|
||||||
|
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
|
||||||
|
|
||||||
|
// Score action params (Priority Rules)
|
||||||
|
type ScoreActionParams = {
|
||||||
|
weight: number; // 0-10 base weight
|
||||||
|
slaMultiplier?: boolean; // Apply SLA urgency curve
|
||||||
|
campaignMultiplier?: boolean; // Apply campaign weight
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign action params (Automation Rules — stub)
|
||||||
|
type AssignActionParams = {
|
||||||
|
agentId?: string;
|
||||||
|
agentPool?: string[];
|
||||||
|
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Escalate action params (Automation Rules — stub)
|
||||||
|
type EscalateActionParams = {
|
||||||
|
channel: 'toast' | 'notification' | 'sms' | 'email';
|
||||||
|
recipients: 'supervisor' | 'agent' | string[];
|
||||||
|
message: string;
|
||||||
|
severity: 'warning' | 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update action params (Automation Rules — stub)
|
||||||
|
type UpdateActionParams = {
|
||||||
|
entity: string;
|
||||||
|
field: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Rules — Scoring System
|
||||||
|
|
||||||
|
### Formula
|
||||||
|
```
|
||||||
|
finalScore = baseScore × slaMultiplier × campaignMultiplier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base Score
|
||||||
|
Determined by the rule's `weight` param (0-10). Multiple rules can fire for the same item — scores are **summed**.
|
||||||
|
|
||||||
|
### SLA Multiplier (time-sensitive, computed at request time)
|
||||||
|
```
|
||||||
|
if slaElapsed <= 100%: multiplier = (slaElapsed / 100) ^ 1.6
|
||||||
|
if slaElapsed > 100%: multiplier = 1.0 + (excess × 0.05)
|
||||||
|
```
|
||||||
|
Non-linear curve — urgency accelerates as deadline approaches. Continues increasing past breach.
|
||||||
|
|
||||||
|
### Campaign Multiplier
|
||||||
|
```
|
||||||
|
campaignWeight (0-10) / 10 × sourceWeight (0-10) / 10
|
||||||
|
```
|
||||||
|
IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
|
||||||
|
|
||||||
|
### Priority Config (supervisor-editable)
|
||||||
|
```typescript
|
||||||
|
type PriorityConfig = {
|
||||||
|
taskWeights: Record<string, { weight: number; slaMinutes: number }>;
|
||||||
|
campaignWeights: Record<string, number>; // campaignId → 0-10
|
||||||
|
sourceWeights: Record<string, number>; // leadSource → 0-10
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default config (from hospital starter template)
|
||||||
|
const DEFAULT_PRIORITY_CONFIG = {
|
||||||
|
taskWeights: {
|
||||||
|
missed_call: { weight: 9, slaMinutes: 720 }, // 12 hours
|
||||||
|
follow_up: { weight: 8, slaMinutes: 1440 }, // 1 day
|
||||||
|
campaign_lead: { weight: 7, slaMinutes: 2880 }, // 2 days
|
||||||
|
attempt_2: { weight: 6, slaMinutes: 1440 },
|
||||||
|
attempt_3: { weight: 4, slaMinutes: 2880 },
|
||||||
|
},
|
||||||
|
campaignWeights: {}, // Empty = no campaign multiplier
|
||||||
|
sourceWeights: {
|
||||||
|
WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7,
|
||||||
|
INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
This config is what the **Priority Rules UI** edits via sliders. Under the hood, each entry generates a json-rules-engine rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Rules UI (Supervisor Settings)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
Settings page → "Priority" tab with three sections:
|
||||||
|
|
||||||
|
**Section 1: Task Type Weights**
|
||||||
|
| Task Type | Weight (slider 0-10) | SLA (input) |
|
||||||
|
|---|---|---|
|
||||||
|
| Missed Calls | ████████░░ 9 | 12h |
|
||||||
|
| Follow-ups | ███████░░░ 8 | 1d |
|
||||||
|
| Campaign Leads | ██████░░░░ 7 | 2d |
|
||||||
|
| 2nd Attempt | █████░░░░░ 6 | 1d |
|
||||||
|
| 3rd Attempt | ███░░░░░░░ 4 | 2d |
|
||||||
|
|
||||||
|
**Section 2: Campaign Weights**
|
||||||
|
Shows existing campaigns with weight sliders. Default 5.
|
||||||
|
| Campaign | Weight |
|
||||||
|
|---|---|
|
||||||
|
| IVF Awareness | ████████░░ 9 |
|
||||||
|
| Health Checkup | ██████░░░░ 7 |
|
||||||
|
| Cancer Screening | ███████░░░ 8 |
|
||||||
|
|
||||||
|
**Section 3: Source Weights**
|
||||||
|
| Source | Weight |
|
||||||
|
|---|---|
|
||||||
|
| WhatsApp | ████████░░ 9 |
|
||||||
|
| Phone | ███████░░░ 8 |
|
||||||
|
| Facebook Ad | ██████░░░░ 7 |
|
||||||
|
| ... | ... |
|
||||||
|
|
||||||
|
**Section 4: Live Preview**
|
||||||
|
Shows the current worklist re-ranked with the configured weights. As supervisor adjusts sliders, preview updates in real-time (client-side computation using the same scoring formula).
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- Untitled UI Slider (if available) or custom range input
|
||||||
|
- Untitled UI Toggle for enable/disable per task type
|
||||||
|
- Untitled UI Tabs for Priority / Automations
|
||||||
|
- Score badges showing computed values in preview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### Redis Keys
|
||||||
|
```
|
||||||
|
rules:config # JSON array of all Rule objects
|
||||||
|
rules:priority-config # PriorityConfig JSON (slider values)
|
||||||
|
rules:config:backup_path # Path to JSON backup file
|
||||||
|
rules:scores:{itemId} # Cached base score per worklist item
|
||||||
|
rules:scores:version # Incremented on rule change (invalidates all scores)
|
||||||
|
rules:eval:log:{ruleId} # Last evaluation result (debug)
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON File Backup
|
||||||
|
On every rule/config change:
|
||||||
|
1. Write to Redis
|
||||||
|
2. Persist to `data/rules-config.json` + `data/priority-config.json` in sidecar working directory
|
||||||
|
3. On sidecar startup: if Redis is empty, load from JSON files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Priority Config (used by UI sliders)
|
||||||
|
```
|
||||||
|
GET /api/rules/priority-config # Get current priority config
|
||||||
|
PUT /api/rules/priority-config # Update priority config (slider values)
|
||||||
|
POST /api/rules/priority-config/preview # Preview scoring with modified config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule CRUD (for automation rules)
|
||||||
|
```
|
||||||
|
GET /api/rules # List all rules
|
||||||
|
GET /api/rules/:id # Get single rule
|
||||||
|
POST /api/rules # Create rule
|
||||||
|
PUT /api/rules/:id # Update rule
|
||||||
|
DELETE /api/rules/:id # Delete rule
|
||||||
|
PATCH /api/rules/:id/toggle # Enable/disable
|
||||||
|
POST /api/rules/reorder # Change evaluation order
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evaluation
|
||||||
|
```
|
||||||
|
POST /api/rules/evaluate # Evaluate rules against provided facts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
```
|
||||||
|
GET /api/rules/templates # List available rule templates
|
||||||
|
POST /api/rules/templates/:id/apply # Apply a template (creates rules + config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Worklist Integration
|
||||||
|
|
||||||
|
### Current Flow
|
||||||
|
```
|
||||||
|
GET /api/worklist → returns { missedCalls, followUps, marketingLeads } → frontend sorts by priority + createdAt
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Flow
|
||||||
|
```
|
||||||
|
GET /api/worklist → fetch 3 arrays → score each item via RulesEngineService → return with scores → frontend sorts by score
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Change
|
||||||
|
Each worklist item gains:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
...existingFields,
|
||||||
|
score: number; // Computed priority score
|
||||||
|
scoreBreakdown: { // Explainability
|
||||||
|
baseScore: number;
|
||||||
|
slaMultiplier: number;
|
||||||
|
campaignMultiplier: number;
|
||||||
|
rulesApplied: string[]; // Rule names that fired
|
||||||
|
};
|
||||||
|
slaStatus: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
slaElapsedPercent: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Changes
|
||||||
|
- Worklist sorts by `score` descending instead of hardcoded priority
|
||||||
|
- SLA status dot (green/amber/red/dark-red) replaces priority badge
|
||||||
|
- Tooltip on score shows breakdown ("IVF campaign ×0.81, Missed call weight 9, SLA 72% elapsed")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hospital Starter Template
|
||||||
|
|
||||||
|
Pre-configured priority config + automation rules for a typical hospital. Applied on first setup via `POST /api/rules/templates/hospital-starter/apply`.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
1. `PriorityConfig` with default task/campaign/source weights
|
||||||
|
2. Scoring rules in `rules:config` matching the config
|
||||||
|
3. One escalation rule stub (SLA breach → supervisor notification)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
**In scope (Phase 1 — Friday):**
|
||||||
|
- `json-rules-engine` integration in sidecar
|
||||||
|
- Rule schema with `ruleType: 'priority' | 'automation'` distinction
|
||||||
|
- Curated fact providers (lead, call, agent) with computed facts
|
||||||
|
- Score action handler (full) + assign/escalate/update stubs
|
||||||
|
- Redis storage + JSON backup
|
||||||
|
- PriorityConfig CRUD + preview endpoints
|
||||||
|
- Rule CRUD API endpoints
|
||||||
|
- Worklist consumer (scoring integration)
|
||||||
|
- Hospital starter template
|
||||||
|
- **Priority Rules UI** — supervisor settings page with weight sliders, SLA config, live preview
|
||||||
|
- Frontend worklist changes (score display, SLA dots, breakdown tooltip)
|
||||||
|
|
||||||
|
**Out of scope (Phase 2+):**
|
||||||
|
- Automation Rules UI (condition builder with entity/field selectors)
|
||||||
|
- Metadata-driven fact discovery from platform API
|
||||||
|
- Assignment/escalation/update action handlers (stubs in Phase 1)
|
||||||
|
- Event-driven rule evaluation (on_event triggers)
|
||||||
|
- Scheduled rule evaluation (on_schedule triggers)
|
||||||
|
- Draft/publish workflow for automation rules
|
||||||
|
- Multi-tenant rule isolation
|
||||||
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal file
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Design Tokens — Multi-Hospital Theming
|
||||||
|
|
||||||
|
**Date**: 2026-04-02
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A JSON-driven design token system that allows each hospital customer to rebrand Helix Engage by providing a single JSON configuration file. The JSON is served by the sidecar, consumed by the frontend at runtime via a React provider that injects CSS custom properties.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidecar (helix-engage-server)
|
||||||
|
└─ GET /api/config/theme → returns hospital theme JSON
|
||||||
|
└─ theme stored as JSON file at data/theme.json (editable, hot-reloadable)
|
||||||
|
|
||||||
|
Frontend (helix-engage)
|
||||||
|
└─ ThemeTokenProvider (wraps app) → fetches theme JSON on mount
|
||||||
|
└─ Injects CSS custom properties on <html> element
|
||||||
|
└─ Exposes useThemeTokens() hook for content tokens (logo, name, text)
|
||||||
|
└─ Components read colors via existing Tailwind classes (no changes needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme JSON Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "Satoshi, Inter, -apple-system, sans-serif",
|
||||||
|
"display": "General Sans, Inter, -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · Call Center Agent"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{ "label": "Doctor availability", "prompt": "What doctors are available?" },
|
||||||
|
{ "label": "Clinic timings", "prompt": "What are the clinic timings?" },
|
||||||
|
{ "label": "Patient history", "prompt": "Summarize this patient's history" },
|
||||||
|
{ "label": "Treatment packages", "prompt": "What packages are available?" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidecar Implementation
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/config/theme — Returns theme JSON (no auth, public — needed before login)
|
||||||
|
PUT /api/config/theme — Updates theme JSON (auth required, admin only)
|
||||||
|
POST /api/config/theme/reset — Resets to default theme (auth required, admin only)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Stored in `data/theme.json` on the sidecar filesystem
|
||||||
|
- Cached in memory, invalidated on PUT
|
||||||
|
- If file doesn't exist, returns a hardcoded default (Global Hospital theme)
|
||||||
|
- PUT validates the JSON schema before saving
|
||||||
|
- PUT also writes a timestamped backup to `data/theme-backups/`
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- `helix-engage-server/src/config/theme.controller.ts` — REST endpoints
|
||||||
|
- `helix-engage-server/src/config/theme.service.ts` — read/write/validate/backup logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### ThemeTokenProvider
|
||||||
|
|
||||||
|
New provider wrapping the app in `main.tsx`. Responsibilities:
|
||||||
|
|
||||||
|
1. **Fetch** `GET /api/config/theme` on mount (before rendering anything)
|
||||||
|
2. **Inject CSS variables** on `document.documentElement.style`:
|
||||||
|
- `--color-brand-25` through `--color-brand-950` (overrides the Untitled UI brand scale)
|
||||||
|
- `--font-body`, `--font-display` (overrides typography)
|
||||||
|
3. **Store content tokens** in React context (brand name, logo, login text, sidebar text, quick actions)
|
||||||
|
4. **Expose** `useThemeTokens()` hook for components to read content tokens
|
||||||
|
|
||||||
|
### File: `src/providers/theme-token-provider.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
type ThemeTokens = {
|
||||||
|
brand: { name: string; hospitalName: string; logo: string; favicon: string };
|
||||||
|
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
|
||||||
|
sidebar: { title: string; subtitle: string };
|
||||||
|
ai: { quickActions: Array<{ label: string; prompt: string }> };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Variable Injection
|
||||||
|
|
||||||
|
The provider maps `colors.brand.*` to CSS custom properties that Untitled UI already reads:
|
||||||
|
|
||||||
|
```
|
||||||
|
theme.colors.brand["500"] → document.documentElement.style.setProperty('--color-brand-500', value)
|
||||||
|
```
|
||||||
|
|
||||||
|
Since `theme.css` defines `--color-brand-500: var(--color-blue-500)`, setting `--color-brand-500` directly on `<html>` overrides the alias with higher specificity.
|
||||||
|
|
||||||
|
Typography:
|
||||||
|
```
|
||||||
|
theme.typography.body → --font-body
|
||||||
|
theme.typography.display → --font-display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consumers
|
||||||
|
|
||||||
|
Components that currently hardcode hospital-specific content:
|
||||||
|
|
||||||
|
| Component | Current hardcoded value | Token path |
|
||||||
|
|---|---|---|
|
||||||
|
| `login.tsx` line 93 | "Sign in to Helix Engage" | `login.title` |
|
||||||
|
| `login.tsx` line 94 | "Global Hospital" | `login.subtitle` |
|
||||||
|
| `login.tsx` line 92 | `/helix-logo.png` | `brand.logo` |
|
||||||
|
| `login.tsx` line 181 | "Powered by F0rty2.ai" | `login.poweredBy.label` |
|
||||||
|
| `sidebar.tsx` | "Helix Engage" | `sidebar.title` |
|
||||||
|
| `sidebar.tsx` | "Global Hospital · Call Center Agent" | `sidebar.subtitle` |
|
||||||
|
| `ai-chat-panel.tsx` lines 21-25 | Quick action prompts | `ai.quickActions` |
|
||||||
|
| `app-shell.tsx` | favicon | `brand.favicon` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default Theme
|
||||||
|
|
||||||
|
If the sidecar returns no theme (endpoint down, file missing), the frontend uses a hardcoded default matching the current Global Hospital branding. This ensures the app works without a sidecar theme endpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings UI (Supervisor)
|
||||||
|
|
||||||
|
New tab in the Settings page: **Branding**. Visible only to admin role.
|
||||||
|
|
||||||
|
### Sections
|
||||||
|
|
||||||
|
**1. Brand Identity**
|
||||||
|
- Hospital name (text input)
|
||||||
|
- App name (text input)
|
||||||
|
- Logo upload (file input → stores URL)
|
||||||
|
- Favicon upload
|
||||||
|
|
||||||
|
**2. Brand Colors**
|
||||||
|
- 12 color swatches (25 through 950) with hex/rgb input per swatch
|
||||||
|
- Live preview strip showing the full scale
|
||||||
|
- "Reset to default" button per section
|
||||||
|
|
||||||
|
**3. Typography**
|
||||||
|
- Body font family (text input with common font suggestions)
|
||||||
|
- Display font family (text input)
|
||||||
|
|
||||||
|
**4. Login Page**
|
||||||
|
- Title text
|
||||||
|
- Subtitle text
|
||||||
|
- Show Google sign-in (toggle)
|
||||||
|
- Show forgot password (toggle)
|
||||||
|
- Powered-by label + URL
|
||||||
|
|
||||||
|
**5. Sidebar**
|
||||||
|
- Title text
|
||||||
|
- Subtitle template (supports `{role}` placeholder — "Global Hospital · {role}")
|
||||||
|
|
||||||
|
**6. AI Quick Actions**
|
||||||
|
- Editable list of label + prompt pairs
|
||||||
|
- Add / remove / reorder
|
||||||
|
|
||||||
|
### Save Flow
|
||||||
|
- Supervisor edits fields → clicks Save → `PUT /api/config/theme` → sidecar validates + saves + backs up
|
||||||
|
- Frontend re-fetches theme on save → CSS variables update → page reflects changes immediately (no reload needed)
|
||||||
|
|
||||||
|
### File
|
||||||
|
`src/pages/settings.tsx` — new "Branding" tab (or `src/pages/branding-settings.tsx` if settings page is already complex)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Does NOT Change
|
||||||
|
|
||||||
|
- **Tailwind classes** — no changes. Components continue using `text-brand-secondary`, `bg-brand-solid`, etc. The CSS variables they reference are overridden at runtime.
|
||||||
|
- **Component structure** — no layout changes. Only content strings and colors change.
|
||||||
|
- **Untitled UI theme.css** — not modified. The provider overrides are applied inline on `<html>`, higher specificity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Sidecar theme endpoint + JSON file
|
||||||
|
- ThemeTokenProvider + useThemeTokens hook
|
||||||
|
- Login page consuming tokens
|
||||||
|
- Sidebar consuming tokens
|
||||||
|
- AI quick actions consuming tokens
|
||||||
|
- Brand color override via CSS variables
|
||||||
|
- Typography override via CSS variables
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Dark mode customization (inherits from Untitled UI)
|
||||||
|
- Per-role theming
|
||||||
|
- Logo upload to cloud storage (uses URL for now — can be a data URI or hosted path)
|
||||||
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Website Widget — Embeddable AI Chat + Appointment Booking
|
||||||
|
|
||||||
|
**Date**: 2026-04-05
|
||||||
|
**Status**: Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A single JavaScript file that hospitals embed on their website via a `<script>` tag. Renders a floating chat bubble that opens to an AI chatbot (hospital knowledge base), appointment booking flow, and lead capture form. Themed to match the hospital's branding. All write endpoints are captcha-gated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed Code
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://engage-api.srv1477139.hstgr.cloud/widget.js"
|
||||||
|
data-key="a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `data-key` is an HMAC-signed token: `{siteId}.{hmacSignature}`. Cannot be guessed or forged without the server-side secret.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Hospital Website (any tech stack)
|
||||||
|
└─ <script data-key="xxx"> loads widget.js from sidecar
|
||||||
|
└─ Widget initializes:
|
||||||
|
1. GET /api/widget/init?key=xxx → validates key, returns theme + config
|
||||||
|
2. Renders shadow DOM (CSS-isolated from host page)
|
||||||
|
3. All interactions go to /api/widget/* endpoints
|
||||||
|
|
||||||
|
Sidecar (helix-engage-server):
|
||||||
|
└─ src/widget/
|
||||||
|
├── widget.controller.ts — REST endpoints for the widget
|
||||||
|
├── widget.service.ts — lead creation, appointment booking, key validation
|
||||||
|
├── widget.guard.ts — HMAC key validation + origin check
|
||||||
|
├── captcha.guard.ts — reCAPTCHA/Turnstile verification
|
||||||
|
└── widget-keys.service.ts — generate/validate site keys
|
||||||
|
|
||||||
|
Widget Bundle:
|
||||||
|
└─ packages/helix-engage-widget/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.ts — entry point, reads data-key, initializes
|
||||||
|
│ ├── widget.ts — shadow DOM mount, theming, tab routing
|
||||||
|
│ ├── chat.ts — AI chatbot (streaming)
|
||||||
|
│ ├── booking.ts — appointment booking flow
|
||||||
|
│ ├── contact.ts — lead capture form
|
||||||
|
│ ├── captcha.ts — captcha integration
|
||||||
|
│ ├── api.ts — HTTP client for widget endpoints
|
||||||
|
│ └── styles.ts — CSS-in-JS (injected into shadow DOM)
|
||||||
|
├── vite.config.ts — library mode, single IIFE bundle
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidecar Endpoints
|
||||||
|
|
||||||
|
All prefixed with `/api/widget/`. Public endpoints validate the site key. Write endpoints require captcha.
|
||||||
|
|
||||||
|
| Method | Path | Auth | Captcha | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/init` | Key | No | Returns theme, config, captcha site key |
|
||||||
|
| POST | `/chat` | Key | Yes (first message only) | AI chat stream (same knowledge base as agent AI) |
|
||||||
|
| GET | `/doctors` | Key | No | Department + doctor list with visiting hours |
|
||||||
|
| GET | `/slots` | Key | No | Available time slots for a doctor + date |
|
||||||
|
| POST | `/book` | Key | Yes | Create appointment + lead + patient |
|
||||||
|
| POST | `/lead` | Key | Yes | Create lead (contact form submission) |
|
||||||
|
| POST | `/keys/generate` | Admin JWT | No | Generate a new site key for a hospital |
|
||||||
|
| GET | `/keys` | Admin JWT | No | List all site keys |
|
||||||
|
| DELETE | `/keys/:siteId` | Admin JWT | No | Revoke a site key |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Site Key System
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
|
||||||
|
```
|
||||||
|
siteId = uuid v4 (random)
|
||||||
|
payload = siteId
|
||||||
|
signature = HMAC-SHA256(payload, SERVER_SECRET)
|
||||||
|
key = `${siteId}.${signature}`
|
||||||
|
```
|
||||||
|
|
||||||
|
The `SERVER_SECRET` is an environment variable on the sidecar. Never leaves the server.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```
|
||||||
|
input = "a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"
|
||||||
|
[siteId, signature] = input.split('.')
|
||||||
|
expectedSignature = HMAC-SHA256(siteId, SERVER_SECRET)
|
||||||
|
valid = timingSafeEqual(signature, expectedSignature)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Site keys are stored in Redis (already running in the stack):
|
||||||
|
|
||||||
|
```
|
||||||
|
Key: widget:keys:{siteId}
|
||||||
|
Value: JSON { hospitalName, allowedOrigins, active, createdAt }
|
||||||
|
TTL: none (persistent until revoked)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
widget:keys:a8f3e2b1 → {
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"allowedOrigins": ["https://globalhospital.com", "https://www.globalhospital.com"],
|
||||||
|
"createdAt": "2026-04-05T10:00:00Z",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CRUD via `SessionService` (getCache/setCache/deleteCache/scanKeys) — same pattern as caller cache and agent names.
|
||||||
|
|
||||||
|
### Origin Validation
|
||||||
|
|
||||||
|
On every widget request, the sidecar checks:
|
||||||
|
1. Key signature is valid (HMAC)
|
||||||
|
2. `siteId` exists and is active
|
||||||
|
3. `Referer` or `Origin` header matches `allowedOrigins` for this site key
|
||||||
|
4. If origin doesn't match → 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget UI
|
||||||
|
|
||||||
|
### Collapsed State (Floating Bubble)
|
||||||
|
|
||||||
|
- Position: fixed bottom-right, 20px margin
|
||||||
|
- Size: 56px circle
|
||||||
|
- Shows hospital logo (from theme)
|
||||||
|
- Pulse animation on first load
|
||||||
|
- Click → expands panel
|
||||||
|
- Z-index: 999999 (above host page content)
|
||||||
|
|
||||||
|
### Expanded State (Panel)
|
||||||
|
|
||||||
|
- Size: 380px wide × 520px tall
|
||||||
|
- Anchored bottom-right
|
||||||
|
- Shadow DOM container (CSS isolation from host page)
|
||||||
|
- Header: hospital logo + name + close button
|
||||||
|
- Three tabs: Chat (default) | Book | Contact
|
||||||
|
- All styled with brand colors from theme
|
||||||
|
|
||||||
|
### Chat Tab (Default)
|
||||||
|
|
||||||
|
- AI chatbot interface
|
||||||
|
- Streaming responses (same endpoint as agent AI, but with widget system prompt)
|
||||||
|
- Quick action chips: "Doctor availability", "Clinic timings", "Book appointment", "Treatment packages"
|
||||||
|
- If AI detects it can't help → shows: "An agent will call you shortly" + lead capture fields (name, phone)
|
||||||
|
- First message triggers captcha verification (invisible reCAPTCHA v3)
|
||||||
|
|
||||||
|
### Book Tab
|
||||||
|
|
||||||
|
Step-by-step appointment booking:
|
||||||
|
|
||||||
|
1. **Department** — dropdown populated from `/api/widget/doctors`
|
||||||
|
2. **Doctor** — dropdown filtered by department, shows visiting hours
|
||||||
|
3. **Date** — date picker (min: today, max: 30 days)
|
||||||
|
4. **Time Slot** — grid of available slots from `/api/widget/slots`
|
||||||
|
5. **Patient Details** — name, phone, age, gender, chief complaint
|
||||||
|
6. **Captcha** — invisible reCAPTCHA v3 on submit
|
||||||
|
7. **Confirmation** — "Appointment booked! Reference: ABC123. We'll send a confirmation SMS."
|
||||||
|
|
||||||
|
On successful booking:
|
||||||
|
- Creates patient (if new phone number)
|
||||||
|
- Creates lead with `source: 'WEBSITE'`
|
||||||
|
- Creates appointment linked to patient + doctor
|
||||||
|
- Rules engine scores the lead
|
||||||
|
- Pushes to agent worklist
|
||||||
|
- Real-time notification to agents
|
||||||
|
|
||||||
|
### Contact Tab
|
||||||
|
|
||||||
|
Simple lead capture form:
|
||||||
|
- Name (required)
|
||||||
|
- Phone (required)
|
||||||
|
- Interest / Department (dropdown, optional)
|
||||||
|
- Message (textarea, optional)
|
||||||
|
- Captcha on submit
|
||||||
|
- Success: "Thank you! An agent will call you shortly."
|
||||||
|
|
||||||
|
On submit:
|
||||||
|
- Creates lead with `source: 'WEBSITE'`, `interestedService: interest`
|
||||||
|
- Rules engine scores it
|
||||||
|
- Pushes to agent worklist + notification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
Widget fetches theme from `/api/widget/init`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"brand": { "name": "Global Hospital", "logo": "https://..." },
|
||||||
|
"colors": {
|
||||||
|
"primary": "rgb(29 78 216)",
|
||||||
|
"primaryLight": "rgb(219 234 254)",
|
||||||
|
"text": "rgb(15 23 42)",
|
||||||
|
"textLight": "rgb(100 116 139)"
|
||||||
|
},
|
||||||
|
"captchaSiteKey": "6Lc..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Colors are injected as CSS variables inside the shadow DOM:
|
||||||
|
```css
|
||||||
|
:host {
|
||||||
|
--widget-primary: rgb(29 78 216);
|
||||||
|
--widget-primary-light: rgb(219 234 254);
|
||||||
|
--widget-text: rgb(15 23 42);
|
||||||
|
--widget-text-light: rgb(100 116 139);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All widget elements reference these variables. Changing the theme API → widget auto-updates on next load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget System Prompt (AI Chat)
|
||||||
|
|
||||||
|
Different from the agent AI prompt — tailored for website visitors:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a virtual assistant for {hospitalName}.
|
||||||
|
You help website visitors with:
|
||||||
|
- Doctor availability and visiting hours
|
||||||
|
- Clinic locations and timings
|
||||||
|
- Health packages and pricing
|
||||||
|
- Booking appointments
|
||||||
|
- General hospital information
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
1. Be friendly and welcoming — this is the hospital's first impression
|
||||||
|
2. If someone wants to book an appointment, guide them to the Book tab
|
||||||
|
3. If you can't answer a question, say "I'd be happy to have our team call you" and ask for their name and phone number
|
||||||
|
4. Never give medical advice
|
||||||
|
5. Keep responses under 80 words — visitors are scanning, not reading
|
||||||
|
6. Always mention the hospital name naturally in first response
|
||||||
|
|
||||||
|
KNOWLEDGE BASE:
|
||||||
|
{same KB as agent AI — clinics, doctors, packages, insurance}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Captcha
|
||||||
|
|
||||||
|
- **Provider**: Google reCAPTCHA v3 (invisible) or Cloudflare Turnstile
|
||||||
|
- **When**: On first chat message, appointment booking submit, lead form submit
|
||||||
|
- **How**: Widget loads captcha script, gets token, sends with request. Sidecar validates via provider API before processing.
|
||||||
|
- **Fallback**: If captcha fails to load (ad blocker), show a simple challenge or allow with rate limiting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Bundle
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Preact** — 3KB, React-compatible API, sufficient for the widget UI
|
||||||
|
- **Vite** — library mode build, outputs single IIFE bundle
|
||||||
|
- **CSS-in-JS** — styles injected into shadow DOM (no external CSS files)
|
||||||
|
- **Target**: ~60KB gzipped (Preact + UI + styles)
|
||||||
|
|
||||||
|
### Build Output
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
└── widget.js — single IIFE bundle, self-contained
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving
|
||||||
|
|
||||||
|
Sidecar serves `widget.js` as a static file:
|
||||||
|
```
|
||||||
|
GET /widget.js → serves dist/widget.js with Cache-Control: public, max-age=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Flow (all channels)
|
||||||
|
|
||||||
|
```
|
||||||
|
Widget submit (chat/book/contact)
|
||||||
|
→ POST /api/widget/lead or /api/widget/book
|
||||||
|
→ captcha validation
|
||||||
|
→ key + origin validation
|
||||||
|
→ create patient (if new phone)
|
||||||
|
→ create lead (source: WEBSITE, channel metadata)
|
||||||
|
→ rules engine scores lead (source weight, campaign weight)
|
||||||
|
→ push to agent worklist
|
||||||
|
→ WebSocket notification to agents (bell + toast)
|
||||||
|
→ response to widget: success + reference number
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
| Endpoint | Limit |
|
||||||
|
|---|---|
|
||||||
|
| `/init` | 60/min per IP |
|
||||||
|
| `/chat` | 10/min per IP |
|
||||||
|
| `/doctors`, `/slots` | 30/min per IP |
|
||||||
|
| `/book` | 5/min per IP |
|
||||||
|
| `/lead` | 5/min per IP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Widget JS bundle (Preact + shadow DOM + theming)
|
||||||
|
- Sidecar widget endpoints (init, chat, doctors, slots, book, lead)
|
||||||
|
- Site key generation + validation (HMAC)
|
||||||
|
- Captcha integration (reCAPTCHA v3)
|
||||||
|
- Lead creation with worklist integration
|
||||||
|
- Appointment booking end-to-end
|
||||||
|
- Origin validation
|
||||||
|
- Rate limiting
|
||||||
|
- Widget served from sidecar
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Live agent chat in widget (shows "agent will call you" instead)
|
||||||
|
- Widget analytics/tracking dashboard
|
||||||
|
- A/B testing widget variations
|
||||||
|
- Multi-language widget UI
|
||||||
|
- File upload in widget
|
||||||
|
- Payment integration in widget
|
||||||
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# Supervisor Barge / Whisper / Listen — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-12
|
||||||
|
**Branch:** `feature/barge-whisper`
|
||||||
|
**Prereq:** QA validates barge flow in Ozonetel's own admin UI first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Enable supervisors to monitor and intervene in live agent calls directly from Helix Engage's live monitor. Three modes: **Listen** (silent), **Whisper** (agent hears supervisor, patient doesn't), **Barge** (both hear supervisor). Supervisor connects via SIP WebRTC in the browser. Mode switching via DTMF tones.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Connection method | SIP only (PSTN later) | Supervisors are already on browser with headset |
|
||||||
|
| Agent indicator | Whisper/barge only (listen is silent) | Spec says show indicator; listen should be undetectable |
|
||||||
|
| SIP number | Dynamic from Ozonetel pool (apiId 139) | No need to pre-assign per supervisor. 3 SIP IDs available. |
|
||||||
|
| Barge UI location | Live monitor + context panel + barge controls | Supervisor needs call context to intervene effectively |
|
||||||
|
| Access control | Any admin can barge any agent | Flat RBAC, no team hierarchy |
|
||||||
|
| Call end behavior | Auto-disconnect supervisor | No orphaned sessions |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Supervisor Browser │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌────────────────────────────────┐ │
|
||||||
|
│ │ Live Monitor │ │ Context Panel + Barge Controls│ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Agent list │ │ Patient summary / AI insight │ │
|
||||||
|
│ │ Active calls │──│ Appointments / Recent calls │ │
|
||||||
|
│ │ Click → │ │ ─────────────────────────────│ │
|
||||||
|
│ │ │ │ [Connect] │ │
|
||||||
|
│ │ │ │ [Listen] [Whisper] [Barge] │ │
|
||||||
|
│ │ │ │ [Hang up] │ │
|
||||||
|
│ └──────────────┘ └────────────────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ poll /active-calls │ SIP WebRTC (kSip) │
|
||||||
|
│ │ every 5s │ DTMF 4/5/6 │
|
||||||
|
└─────────┼───────────────────────┼────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Sidecar │ │ Ozonetel SIP Gateway │
|
||||||
|
│ │ │ (blr-pub-rtc4.ozonetel) │
|
||||||
|
│ POST /api/supervisor│ │ │
|
||||||
|
│ /barge │ │ SIP INVITE → supervisor │
|
||||||
|
│ /barge-mode │ │ audio mixing │
|
||||||
|
│ │ │ DTMF routing │
|
||||||
|
│ → Ozonetel admin API│ └──────────────────────────┘
|
||||||
|
│ dashboardApi │
|
||||||
|
│ apiId 63, 139 │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Ozonetel Cloud │
|
||||||
|
│ api.cloudagent. │
|
||||||
|
│ ozonetel.com │
|
||||||
|
│ │
|
||||||
|
│ /dashboardApi/ │
|
||||||
|
│ monitor/api │
|
||||||
|
│ apiId 63 → barge │
|
||||||
|
│ apiId 139 → SIP# │
|
||||||
|
│ /auth/login → JWT │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Sidecar — Ozonetel Admin Auth Service
|
||||||
|
|
||||||
|
**New file:** `src/ozonetel/ozonetel-admin-auth.service.ts`
|
||||||
|
|
||||||
|
Manages a persistent Ozonetel admin session for supervisor APIs. Credentials from TelephonyConfig.
|
||||||
|
|
||||||
|
**Config extension** (`telephony.defaults.ts`):
|
||||||
|
```typescript
|
||||||
|
ozonetel: {
|
||||||
|
// ...existing fields
|
||||||
|
adminUsername: string; // NEW
|
||||||
|
adminPassword: string; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. On startup, read `adminUsername` + `adminPassword` from TelephonyConfig
|
||||||
|
2. `GET /api/auth/public-key` → `{ publicKey, keyId }`
|
||||||
|
3. RSA-encrypt credentials using `jsencrypt`
|
||||||
|
4. `POST /auth/login` → JWT token
|
||||||
|
5. Cache token in memory, decode expiry via `jwt-decode`
|
||||||
|
6. Auto-refresh before expiry
|
||||||
|
7. Expose `getAuthHeaders()` for other services
|
||||||
|
|
||||||
|
**Auth headers for all admin API calls:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${jwt}`,
|
||||||
|
'userId': userId,
|
||||||
|
'userName': userName,
|
||||||
|
'isSuperAdmin': 'true',
|
||||||
|
'dAccessType': 'false'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Sidecar — Supervisor Barge Endpoints
|
||||||
|
|
||||||
|
**New file:** `src/supervisor/supervisor-barge.controller.ts`
|
||||||
|
|
||||||
|
Three endpoints proxying to Ozonetel admin API:
|
||||||
|
|
||||||
|
#### `POST /api/supervisor/barge`
|
||||||
|
|
||||||
|
Initiates barge-in on an active call.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request
|
||||||
|
{ ucid: string, agentNumber: string }
|
||||||
|
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||||
|
{
|
||||||
|
apiId: 63,
|
||||||
|
ucid: "<ucid>",
|
||||||
|
action: "CALL_BARGEIN",
|
||||||
|
isSip: true,
|
||||||
|
phoneno: "<dynamic SIP number from pool>",
|
||||||
|
agentNumber: "<agent phone>",
|
||||||
|
cbURL: "<sidecar hostname>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ status: "success", sipNumber: "19810", sipPassword: "19810", sipDomain: "blr-sbc1.ozonetel.com", sipPort: "442" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Before calling barge, fetches an available SIP number:
|
||||||
|
|
||||||
|
#### `GET /api/supervisor/barge/sip-credentials`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI/endpoint/sipnumber/sipSubscribe
|
||||||
|
{ apiId: 139, sipURL: "<sip gateway>" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ sip_number: "19810", password: "19810", pop_location: "blr-sbc1.ozonetel.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/supervisor/barge/end`
|
||||||
|
|
||||||
|
Cleanup: disconnect SIP, clear Redis tracking.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request
|
||||||
|
{ agentId: string, sipId: string }
|
||||||
|
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||||
|
{ apiId: 158, Action: "delete", AgentId: "<agentId>", Sip: "<sipId>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend — Supervisor SIP Client
|
||||||
|
|
||||||
|
**New file:** `src/lib/supervisor-sip-client.ts`
|
||||||
|
|
||||||
|
Lightweight SIP client for supervisor barge sessions. Modeled on Ozonetel's `kSip.tsx` — separate from the agent's `sip-client.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type SupervisorSipClient = {
|
||||||
|
init(domain: string, port: string, number: string, password: string): void;
|
||||||
|
register(): void;
|
||||||
|
isRegistered(): boolean;
|
||||||
|
isCallActive(): boolean;
|
||||||
|
sendDTMF(digit: string): void; // "4"=listen, "5"=whisper, "6"=barge
|
||||||
|
hangup(): void;
|
||||||
|
close(): void;
|
||||||
|
on(event: string, callback: Function): void;
|
||||||
|
off(event: string, callback: Function): void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Events emitted:**
|
||||||
|
- `registered` — SIP registration successful
|
||||||
|
- `registrationFailed` — SIP registration error
|
||||||
|
- `callReceived` — incoming call from Ozonetel (auto-answer)
|
||||||
|
- `callConnected` — barge session active
|
||||||
|
- `callEnded` — call terminated (agent hung up or supervisor hung up)
|
||||||
|
|
||||||
|
**Audio:** Remote audio plays through a hidden `<audio>` element (same pattern as agent SIP). Supervisor's microphone is captured via `getUserMedia`.
|
||||||
|
|
||||||
|
**DTMF mode mapping:**
|
||||||
|
- `"4"` → Listen (supervisor hears all, nobody hears supervisor)
|
||||||
|
- `"5"` → Whisper/Training (agent hears supervisor, patient doesn't)
|
||||||
|
- `"6"` → Barge (both hear supervisor)
|
||||||
|
|
||||||
|
### 4. Frontend — Live Monitor Redesign
|
||||||
|
|
||||||
|
**Modified file:** `src/pages/live-monitor.tsx`
|
||||||
|
|
||||||
|
Current: full-width table with disabled barge buttons.
|
||||||
|
New: split layout — call list on the left, context panel + barge controls on the right.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┬──────────────────────────────┐
|
||||||
|
│ Active Calls (left, 60%) │ Context + Barge (right, 40%)│
|
||||||
|
│ │ │
|
||||||
|
│ ┌─ KPI cards ────────────┐ │ (nothing selected) │
|
||||||
|
│ │ Active: 3 Hold: 1 │ │ "Select a call to monitor" │
|
||||||
|
│ └────────────────────────┘ │ │
|
||||||
|
│ │ ── OR ── │
|
||||||
|
│ ┌─ Table ────────────────┐ │ │
|
||||||
|
│ │ Agent Caller Type Dur│ │ ┌─ Patient Summary ───────┐ │
|
||||||
|
│ │ rekha +9180.. In 2:34│ │ │ Name / Phone / Type │ │
|
||||||
|
│ │ ▶ selected row │ │ │ AI Insight │ │
|
||||||
|
│ │ ganesh +9199.. Out 0:45│ │ │ Appointments │ │
|
||||||
|
│ └────────────────────────┘ │ │ Recent calls │ │
|
||||||
|
│ │ └─────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌─ Barge Controls ───────┐ │
|
||||||
|
│ │ │ [Connect] │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ (after connect:) │ │
|
||||||
|
│ │ │ [Listen] [Whisper] [Barge]│
|
||||||
|
│ │ │ status: Connected 1:23 │ │
|
||||||
|
│ │ │ [Hang up] │ │
|
||||||
|
│ │ └─────────────────────────┘ │
|
||||||
|
└─────────────────────────────┴──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selection flow:**
|
||||||
|
1. Supervisor clicks a call row → row highlights
|
||||||
|
2. Right panel populates with caller context (fetched from platform via lead phone match)
|
||||||
|
3. "Connect" button becomes active
|
||||||
|
4. Click Connect → sidecar fetches SIP credentials → calls barge API → supervisor SIP client registers → auto-answers incoming call
|
||||||
|
5. Status: CONNECTING → CONNECTED
|
||||||
|
6. Mode tabs appear: Listen (default) / Whisper / Barge
|
||||||
|
7. Tab click sends DTMF tone via supervisor SIP client
|
||||||
|
8. Hang up → disconnect SIP, clean up, right panel resets
|
||||||
|
|
||||||
|
### 5. Frontend — Agent Barge Indicator
|
||||||
|
|
||||||
|
**Modified file:** `src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
When supervisor switches to whisper or barge mode, the agent sees an indicator.
|
||||||
|
|
||||||
|
**Detection:** The sidecar's supervisor service emits SSE events. Add a new event type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New SSE event from /api/supervisor/agent-state/stream
|
||||||
|
{ state: "supervisor-whisper", timestamp: "..." }
|
||||||
|
{ state: "supervisor-barge", timestamp: "..." }
|
||||||
|
{ state: "supervisor-left", timestamp: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI:** Small badge on the active call card:
|
||||||
|
- Whisper mode: "Supervisor coaching" badge (blue)
|
||||||
|
- Barge mode: "Supervisor on call" badge (brand)
|
||||||
|
- Listen mode: no indicator (silent)
|
||||||
|
|
||||||
|
**Implementation:** The sidecar tracks barge state per agent. When a supervisor connects and switches mode, the sidecar emits the appropriate SSE event to the agent's stream. The agent's `use-agent-state.ts` hook picks it up and sets a Recoil atom. The `active-call-card.tsx` renders the badge conditionally.
|
||||||
|
|
||||||
|
### 6. Sidecar — Barge State Tracking
|
||||||
|
|
||||||
|
**Modified file:** `src/supervisor/supervisor.service.ts`
|
||||||
|
|
||||||
|
Track which supervisor is barged into which agent, and in what mode.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type BargeSession = {
|
||||||
|
supervisorId: string;
|
||||||
|
agentId: string;
|
||||||
|
sipNumber: string;
|
||||||
|
mode: 'listen' | 'whisper' | 'barge';
|
||||||
|
startedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory map (single sidecar per hospital)
|
||||||
|
private readonly bargeSessions = new Map<string, BargeSession>();
|
||||||
|
```
|
||||||
|
|
||||||
|
When mode changes, emit SSE event to the agent:
|
||||||
|
- `listen` → no event (silent)
|
||||||
|
- `whisper` → emit `supervisor-whisper` to agent's SSE stream
|
||||||
|
- `barge` → emit `supervisor-barge` to agent's SSE stream
|
||||||
|
- disconnect → emit `supervisor-left` to agent's SSE stream
|
||||||
|
|
||||||
|
**New endpoint for mode update:**
|
||||||
|
```typescript
|
||||||
|
POST /api/supervisor/barge/mode
|
||||||
|
{ agentId: string, mode: "listen" | "whisper" | "barge" }
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates the in-memory session and emits the SSE event. The actual audio routing happens via DTMF on the SIP connection (frontend handles that).
|
||||||
|
|
||||||
|
## Data Flow — Full Barge Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Supervisor clicks call row in live monitor
|
||||||
|
└→ Frontend fetches caller context from platform (lead by phone match)
|
||||||
|
└→ Right panel shows patient summary
|
||||||
|
|
||||||
|
2. Supervisor clicks "Connect"
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/sip-credentials
|
||||||
|
└→ Sidecar: calls Ozonetel apiId 139 → gets SIP number/password/domain
|
||||||
|
└→ Frontend: initializes supervisor-sip-client with credentials
|
||||||
|
└→ Frontend: POST /api/supervisor/barge { ucid, agentNumber }
|
||||||
|
└→ Sidecar: calls Ozonetel apiId 63 (CALL_BARGEIN, isSip: true)
|
||||||
|
└→ Ozonetel: bridges SIP number into active call
|
||||||
|
└→ Supervisor SIP client receives incoming call → auto-answers
|
||||||
|
└→ Status: CONNECTED, default mode: Listen (DTMF "4" sent)
|
||||||
|
└→ Sidecar: creates BargeSession in memory
|
||||||
|
|
||||||
|
3. Supervisor clicks "Whisper" tab
|
||||||
|
└→ Frontend: supervisor-sip-client.sendDTMF("5")
|
||||||
|
└→ Ozonetel: routes supervisor audio to agent only
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "whisper" }
|
||||||
|
└→ Sidecar: emits SSE { state: "supervisor-whisper" } to agent
|
||||||
|
└→ Agent: sees "Supervisor coaching" badge
|
||||||
|
|
||||||
|
4. Supervisor clicks "Barge" tab
|
||||||
|
└→ Frontend: supervisor-sip-client.sendDTMF("6")
|
||||||
|
└→ Ozonetel: routes supervisor audio to both
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "barge" }
|
||||||
|
└→ Sidecar: emits SSE { state: "supervisor-barge" } to agent
|
||||||
|
└→ Agent: sees "Supervisor on call" badge
|
||||||
|
|
||||||
|
5. Call ends (agent or patient hangs up)
|
||||||
|
└→ Supervisor SIP client: "callEnded" event fires
|
||||||
|
└→ Frontend: auto-disconnects, calls POST /api/supervisor/barge/end
|
||||||
|
└→ Sidecar: clears BargeSession, emits SSE { state: "supervisor-left" }
|
||||||
|
└→ Agent: badge disappears
|
||||||
|
└→ UI: right panel resets to "Select a call to monitor"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` | Ozonetel admin JWT management |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` | Barge proxy endpoints |
|
||||||
|
| `helix-engage/src/lib/supervisor-sip-client.ts` | Supervisor SIP client (modeled on kSip) |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage-server/src/config/telephony.defaults.ts` | Add `adminUsername`, `adminPassword` |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Add barge session tracking + SSE events |
|
||||||
|
| `helix-engage/src/pages/live-monitor.tsx` | Split layout, context panel, barge controls |
|
||||||
|
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Supervisor indicator badge |
|
||||||
|
| `helix-engage/src/hooks/use-agent-state.ts` | Handle supervisor SSE events |
|
||||||
|
| `helix-engage/src/components/setup/wizard-step-telephony.tsx` | Add admin credential fields |
|
||||||
|
|
||||||
|
### Reference Files (from Ozonetel source — study, don't copy)
|
||||||
|
| File | What to learn |
|
||||||
|
|------|--------------|
|
||||||
|
| `CA-Admin/.../BargeInDrawer/BargeInDrawer.tsx` | Normal barge flow, status states |
|
||||||
|
| `CA-Admin/.../BargeinDrawerSip/BargeinDrawerSip.tsx` | SIP barge, DTMF, continuous barge, session storage |
|
||||||
|
| `CA-Admin/.../utils/ksip.tsx` | SIP client wrapper pattern |
|
||||||
|
| `CA-Admin/.../services/api-service.ts:827-890` | Barge API payloads |
|
||||||
|
| `CA-Admin/.../services/auth-service.ts` | Admin auth flow |
|
||||||
|
| `cloudagent/.../services/websocket.service.js:367-460` | Agent-side barge event handling |
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
1. **Prereq:** QA validates barge in Ozonetel's own admin UI with the 3 SIP IDs
|
||||||
|
2. **Sidecar unit tests:** Admin auth service (login, token refresh, expiry)
|
||||||
|
3. **Sidecar integration test:** Barge endpoint → Ozonetel API (mock or live)
|
||||||
|
4. **Frontend manual test:** Connect → listen → whisper → barge → hang up
|
||||||
|
5. **Agent indicator test:** Verify badge appears on whisper/barge, disappears on listen/disconnect
|
||||||
|
6. **Auto-disconnect test:** Agent ends call → supervisor auto-disconnects
|
||||||
|
7. **Edge cases:** Supervisor navigates away mid-barge, network drop, agent goes to ACW
|
||||||
|
|
||||||
|
## Out of Scope (Future)
|
||||||
|
|
||||||
|
- PSTN barge (call supervisor's phone instead of SIP)
|
||||||
|
- Continuous barge (auto-reconnect to next call same agent handles)
|
||||||
|
- Barge audit logging (who barged whom, when, duration)
|
||||||
|
- Gemini AI whisper (separate feature, separate branch)
|
||||||
|
- Multi-supervisor on same call
|
||||||
162
docs/weekly-status-apr06-11.md
Normal file
162
docs/weekly-status-apr06-11.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Helix Engage — Weekly Status Update
|
||||||
|
|
||||||
|
**Period:** April 6 – April 11, 2026
|
||||||
|
**Team:** Engineering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Major infrastructure milestone — Helix Engage is now running on AWS EC2 with multi-tenant architecture supporting both Ramaiah Hospitals and Global Hospital on a single instance. A full CI/CD pipeline with automated E2E testing and Teams notifications is operational. 17 defects from QA were triaged, 8 fixed and deployed, and a cross-tenant security vulnerability in the telephony layer was discovered and patched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AWS EC2 Deployment (Multi-Tenant)
|
||||||
|
|
||||||
|
**Status: Live**
|
||||||
|
|
||||||
|
Migrated from single-tenant VPS to multi-tenant EC2 architecture:
|
||||||
|
|
||||||
|
- **Instance:** m6i.xlarge, Mumbai (ap-south-1), 15GB RAM
|
||||||
|
- **14 Docker containers** running: platform, 2 sidecars, telephony dispatcher, 4 Redis instances, Caddy, PostgreSQL, ClickHouse, Redpanda, MinIO
|
||||||
|
- **Strict tenant isolation:** each hospital has its own sidecar container, Redis instance, and data volume
|
||||||
|
- **Host-routed Caddy:** cross-tenant webhook routing is physically impossible
|
||||||
|
|
||||||
|
**URLs deployed:**
|
||||||
|
- ramaiah.engage.healix360.net (Ramaiah Hospitals)
|
||||||
|
- global.engage.healix360.net (Global Hospital)
|
||||||
|
- ramaiah.app.healix360.net / global.app.healix360.net (Platform)
|
||||||
|
- telephony.engage.healix360.net (Event dispatcher)
|
||||||
|
- operations.healix360.net (CI/CD dashboard)
|
||||||
|
- git.healix360.net (Git forge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Telephony Event Dispatcher
|
||||||
|
|
||||||
|
**Status: Live**
|
||||||
|
|
||||||
|
Built a NestJS service that routes Ozonetel agent/call events to the correct hospital's sidecar:
|
||||||
|
|
||||||
|
- Ozonetel event subscriptions are **account-level** (not per-campaign) — one URL for all agents
|
||||||
|
- Dispatcher receives all events, looks up `agentId` in Redis, forwards to the correct sidecar
|
||||||
|
- Sidecars self-register on boot with their agent list; heartbeat every 30s, TTL 90s
|
||||||
|
- No manual configuration needed when adding new hospitals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cross-Tenant Security Fix (defaultAgentId)
|
||||||
|
|
||||||
|
**Status: Fixed and deployed**
|
||||||
|
|
||||||
|
Discovered that 6 sidecar endpoints used a hardcoded `OZONETEL_AGENT_ID` env var as a fallback when `agentId` wasn't provided by the frontend. In a multi-tenant setup, this caused Ramaiah sidecar operations to silently affect Global Hospital's agent.
|
||||||
|
|
||||||
|
**Impact:** Agent state changes, call disposition, outbound dialing, performance metrics, and maintenance commands could operate on the wrong hospital's agent with no error or warning.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Removed `defaultAgentId` getter and all hardcoded fallbacks (`agent3`, `Test123$`, `521814`)
|
||||||
|
- All 6 endpoints now require `agentId` from the caller (400 if missing)
|
||||||
|
- Frontend updated to send `agentId` from `localStorage.helix_agent_config` in all calls
|
||||||
|
- `OZONETEL_AGENT_ID` removed from env config entirely
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Defect Fixes (8 of 17)
|
||||||
|
|
||||||
|
| Bug | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| #527 | Appointment creation updates existing patient incorrectly | Fixed |
|
||||||
|
| #529 | Break/Training status doesn't block outbound calls | Fixed |
|
||||||
|
| #531 | Agent can log out during active call | Fixed |
|
||||||
|
| #533 | Redundant "Call History" header | Fixed |
|
||||||
|
| #534 | Redundant "Patients" header | Fixed |
|
||||||
|
| #536 | My Performance shows wrong agent's data | Fixed |
|
||||||
|
| #538 | Supervisor dashboard metrics incorrect | Fixed |
|
||||||
|
| #540 | Ghost calls visible for logged-out agents | Fixed |
|
||||||
|
| #547 | SLA rules not reflected in Call Desk | Fixed (config seeded) |
|
||||||
|
|
||||||
|
**Deferred (by product):** #516 (recordings real-time), #517/#548 (AI transcription), #519 (supervisor call — needs SIP seat), #539 (missed calls real-time), #541 (whisper/barge/listen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. E2E Test Suite (Playwright)
|
||||||
|
|
||||||
|
**Status: 40 tests, all passing**
|
||||||
|
|
||||||
|
Automated smoke tests covering every page for both hospitals:
|
||||||
|
|
||||||
|
- **Login (4):** branding, invalid creds, supervisor login, auth guard
|
||||||
|
- **Ramaiah CC Agent (10):** call desk, call history, patients, appointments, my performance, sidebar, sign-out
|
||||||
|
- **Ramaiah Supervisor (12):** dashboard, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
|
||||||
|
- **Global CC Agent (7):** all pages + sign-out
|
||||||
|
- **Global Supervisor (5):** all pages
|
||||||
|
|
||||||
|
Self-healing: auto-clears agent session locks before login, completes sign-out after tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CI/CD Pipeline (Woodpecker + Gitea)
|
||||||
|
|
||||||
|
**Status: Operational**
|
||||||
|
|
||||||
|
End-to-end CI/CD on EC2:
|
||||||
|
|
||||||
|
- **Gitea** mirrors Azure DevOps repos every 15 minutes
|
||||||
|
- **Woodpecker CI** triggers pipelines on push or manual run
|
||||||
|
- **Frontend pipeline:** TypeScript typecheck → 40 E2E tests → HTML report published to MinIO → Teams notification
|
||||||
|
- **Sidecar pipeline:** Jest unit tests → Teams notification
|
||||||
|
- **Reports:** Playwright HTML reports with screenshots at `operations.healix360.net/reports/{run}/index.html`
|
||||||
|
- **Teams notifications:** Adaptive Cards to "Deployment updates" channel with pass/fail summary + report link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Documentation
|
||||||
|
|
||||||
|
Three docs committed to the repo:
|
||||||
|
|
||||||
|
- **architecture.md** — Multi-tenant topology with Mermaid diagram, telephony dispatcher, failure modes
|
||||||
|
- **developer-operations-runbook.md** — SSH access, accounts, deploy steps, Redis ops, DB access, troubleshooting
|
||||||
|
- **ci-cd-operations.md** — Gitea, Woodpecker, MinIO, Teams notification setup and troubleshooting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Seeding
|
||||||
|
|
||||||
|
- **Ramaiah:** 195 real doctors scraped from msrmh.com, clinics, visit slots, campaign data
|
||||||
|
- **Global:** CC agent accounts (rekha.cc, ganesh.cc), marketing (sanjay), supervisor (dr.ramesh) created with proper roles
|
||||||
|
- **Rules engine:** 6 priority scoring rules seeded (missed call, follow-up, campaign lead, 2nd/3rd attempt, spam deprioritize)
|
||||||
|
- **Seed script:** idempotent `mkMember`, cleanup phase before seeding, runs against any workspace via env vars
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Other Improvements
|
||||||
|
|
||||||
|
- **SIP agent tracing:** Browser console logs `agent=ramaiahadmin ext=524435` on every SIP connect/disconnect/state change for multi-agent debugging
|
||||||
|
- **ACW 3-layer protection:** beforeunload warning → sendBeacon auto-dispose → server 30s timer
|
||||||
|
- **Maint endpoints:** `force-ready` and `unlock-agent` now accept `agentId` from body (was hardcoded)
|
||||||
|
- **Security group automation:** SSH IP auto-updated via AWS CLI when ISP changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Commits (frontend) | 35 |
|
||||||
|
| Commits (sidecar) | 20 |
|
||||||
|
| Commits (SDK app) | 2 |
|
||||||
|
| Bugs fixed | 9 |
|
||||||
|
| E2E tests | 40 |
|
||||||
|
| Docker containers | 17 (14 app + 3 CI) |
|
||||||
|
| DNS records | 6 |
|
||||||
|
| Uptime | EC2 live since Apr 9 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Week Priorities
|
||||||
|
|
||||||
|
1. Merge `feature/omnichannel-widget` → `master` (frontend)
|
||||||
|
2. Frontend Docker image (stop rsync, bake into image)
|
||||||
|
3. Appointment date validation (no past dates, auto-tomorrow after hours)
|
||||||
|
4. Pre-built CI Docker image (skip `yarn install` on every run)
|
||||||
|
5. Deferred defects: #516, #539 (real-time updates)
|
||||||
BIN
docs/weekly-update-apr06-11.pptx
Normal file
BIN
docs/weekly-update-apr06-11.pptx
Normal file
Binary file not shown.
886
docs/weekly-update-mar18-25.html
Normal file
886
docs/weekly-update-mar18-25.html
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Helix Engage — Weekly Update (Mar 18–25, 2026)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
/* ===========================================
|
||||||
|
CSS CUSTOM PROPERTIES (DARK EXECUTIVE THEME)
|
||||||
|
=========================================== */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0b0e17;
|
||||||
|
--bg-secondary: #111827;
|
||||||
|
--bg-card: rgba(255,255,255,0.04);
|
||||||
|
--bg-card-hover: rgba(255,255,255,0.07);
|
||||||
|
--text-primary: #f0f2f5;
|
||||||
|
--text-secondary: #8892a4;
|
||||||
|
--text-muted: #4b5563;
|
||||||
|
--accent-cyan: #22d3ee;
|
||||||
|
--accent-violet: #a78bfa;
|
||||||
|
--accent-emerald: #34d399;
|
||||||
|
--accent-amber: #fbbf24;
|
||||||
|
--accent-rose: #fb7185;
|
||||||
|
--accent-blue: #60a5fa;
|
||||||
|
--glow-cyan: rgba(34,211,238,0.15);
|
||||||
|
--glow-violet: rgba(167,139,250,0.15);
|
||||||
|
--glow-emerald: rgba(52,211,153,0.15);
|
||||||
|
--font-display: 'Space Grotesk', sans-serif;
|
||||||
|
--font-body: 'DM Sans', sans-serif;
|
||||||
|
--slide-padding: clamp(2rem, 6vw, 5rem);
|
||||||
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--duration: 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
BASE RESET
|
||||||
|
=========================================== */
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SLIDE CONTAINER
|
||||||
|
=========================================== */
|
||||||
|
.slide {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--slide-padding);
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PROGRESS BAR
|
||||||
|
=========================================== */
|
||||||
|
.progress-bar {
|
||||||
|
position: fixed; top: 0; left: 0;
|
||||||
|
height: 3px; width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-violet));
|
||||||
|
z-index: 100;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
NAVIGATION DOTS
|
||||||
|
=========================================== */
|
||||||
|
.nav-dots {
|
||||||
|
position: fixed; right: 1.5rem; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.nav-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border: none; cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.nav-dot.active {
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 12px var(--glow-cyan);
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SLIDE COUNTER
|
||||||
|
=========================================== */
|
||||||
|
.slide-counter {
|
||||||
|
position: fixed; bottom: 1.5rem; right: 2rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
z-index: 100;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
REVEAL ANIMATIONS
|
||||||
|
=========================================== */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(35px);
|
||||||
|
transition: opacity var(--duration) var(--ease-out-expo),
|
||||||
|
transform var(--duration) var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
.slide.visible .reveal { opacity: 1; transform: translateY(0); }
|
||||||
|
.reveal:nth-child(1) { transition-delay: 0.08s; }
|
||||||
|
.reveal:nth-child(2) { transition-delay: 0.16s; }
|
||||||
|
.reveal:nth-child(3) { transition-delay: 0.24s; }
|
||||||
|
.reveal:nth-child(4) { transition-delay: 0.32s; }
|
||||||
|
.reveal:nth-child(5) { transition-delay: 0.40s; }
|
||||||
|
.reveal:nth-child(6) { transition-delay: 0.48s; }
|
||||||
|
.reveal:nth-child(7) { transition-delay: 0.56s; }
|
||||||
|
.reveal:nth-child(8) { transition-delay: 0.64s; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal { transition: opacity 0.3s ease; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TYPOGRAPHY
|
||||||
|
=========================================== */
|
||||||
|
h1 { font-family: var(--font-display); font-weight: 700; }
|
||||||
|
h2 { font-family: var(--font-display); font-weight: 600; font-size: clamp(1.6rem, 4vw, 2.5rem); margin-bottom: 0.5em; }
|
||||||
|
h3 { font-family: var(--font-display); font-weight: 500; font-size: 1.1rem; }
|
||||||
|
p, li { line-height: 1.65; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3em 0.9em;
|
||||||
|
border-radius: 100px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TITLE SLIDE
|
||||||
|
=========================================== */
|
||||||
|
.title-slide {
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 30% 70%, rgba(34,211,238,0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 70% 30%, rgba(167,139,250,0.08) 0%, transparent 50%),
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
.title-slide h1 {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4.5rem);
|
||||||
|
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-violet) 50%, var(--accent-emerald) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
.title-slide .subtitle {
|
||||||
|
font-size: clamp(1rem, 2vw, 1.4rem);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
.title-slide .date-range {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
STAT CARDS
|
||||||
|
=========================================== */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1.2rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.8rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.4s var(--ease-out-expo);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.stat-card:hover { background: var(--bg-card-hover); transform: translateY(-4px); }
|
||||||
|
.stat-card:hover::after { opacity: 1; }
|
||||||
|
.stat-number {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
.stat-number.cyan { color: var(--accent-cyan); }
|
||||||
|
.stat-number.violet { color: var(--accent-violet); }
|
||||||
|
.stat-number.emerald { color: var(--accent-emerald); }
|
||||||
|
.stat-number.amber { color: var(--accent-amber); }
|
||||||
|
.stat-label { color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
CONTENT CARDS
|
||||||
|
=========================================== */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1.2rem;
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
}
|
||||||
|
.content-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.3s var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
.content-card:hover { background: var(--bg-card-hover); border-color: rgba(255,255,255,0.1); }
|
||||||
|
.content-card h3 { margin-bottom: 0.6rem; }
|
||||||
|
.content-card ul {
|
||||||
|
list-style: none; padding: 0;
|
||||||
|
}
|
||||||
|
.content-card li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.2em;
|
||||||
|
margin-bottom: 0.45em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.content-card li::before {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
TIMELINE
|
||||||
|
=========================================== */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 2rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(to bottom, var(--accent-cyan), var(--accent-violet), var(--accent-emerald));
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.tl-item {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
.tl-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; left: -2.35rem; top: 0.3em;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border: 2px solid var(--bg-primary);
|
||||||
|
}
|
||||||
|
.tl-date {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 0.15em;
|
||||||
|
}
|
||||||
|
.tl-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.15em;
|
||||||
|
}
|
||||||
|
.tl-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
REPO BADGE
|
||||||
|
=========================================== */
|
||||||
|
.repo-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.badge-frontend { background: rgba(34,211,238,0.15); color: var(--accent-cyan); }
|
||||||
|
.badge-server { background: rgba(167,139,250,0.15); color: var(--accent-violet); }
|
||||||
|
.badge-sdk { background: rgba(52,211,153,0.15); color: var(--accent-emerald); }
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
PILL LIST
|
||||||
|
=========================================== */
|
||||||
|
.pill-list {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.5rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.3em 0.9em;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
SECTION HEADER
|
||||||
|
=========================================== */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.section-icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
KEYBOARD HINT
|
||||||
|
=========================================== */
|
||||||
|
.keyboard-hint {
|
||||||
|
position: fixed; bottom: 1.5rem; left: 2rem;
|
||||||
|
font-size: 0.75rem; color: var(--text-muted);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
animation: hintFade 0.6s 2s forwards;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
@keyframes hintFade { to { opacity: 1; } }
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
CLOSING SLIDE
|
||||||
|
=========================================== */
|
||||||
|
.closing-slide {
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 50%, rgba(34,211,238,0.06) 0%, transparent 60%),
|
||||||
|
var(--bg-primary);
|
||||||
|
}
|
||||||
|
.closing-slide h2 {
|
||||||
|
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||||
|
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-cyan));
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================
|
||||||
|
RESPONSIVE
|
||||||
|
=========================================== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-dots, .keyboard-hint { display: none; }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.card-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="progress-bar" id="progressBar"></div>
|
||||||
|
|
||||||
|
<!-- Navigation dots -->
|
||||||
|
<nav class="nav-dots" id="navDots"></nav>
|
||||||
|
|
||||||
|
<!-- Slide counter -->
|
||||||
|
<div class="slide-counter" id="slideCounter"></div>
|
||||||
|
|
||||||
|
<!-- Keyboard hint -->
|
||||||
|
<div class="keyboard-hint">
|
||||||
|
<span class="key">↑</span><span class="key">↓</span> or <span class="key">Space</span> to navigate
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 1: TITLE
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide title-slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Weekly Engineering Update</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="reveal">Helix Engage</h1>
|
||||||
|
<p class="subtitle reveal">Contact Center CRM · Real-time Telephony · AI Copilot</p>
|
||||||
|
<p class="date-range reveal">March 18 – 25, 2026</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 2: AT A GLANCE
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">At a Glance</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Week in Numbers</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card reveal">
|
||||||
|
<div class="stat-number cyan" data-count="78">0</div>
|
||||||
|
<div class="stat-label">Total Commits</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card reveal">
|
||||||
|
<div class="stat-number violet" data-count="3">0</div>
|
||||||
|
<div class="stat-label">Repositories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card reveal">
|
||||||
|
<div class="stat-number emerald" data-count="8">0</div>
|
||||||
|
<div class="stat-label">Days Active</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card reveal">
|
||||||
|
<div class="stat-number amber" data-count="50">0</div>
|
||||||
|
<div class="stat-label">Frontend Commits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pill-list reveal" style="margin-top:1.5rem; justify-content: center;">
|
||||||
|
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">helix-engage <b>50</b></span>
|
||||||
|
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">helix-engage-server <b>27</b></span>
|
||||||
|
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">FortyTwoApps/SDK <b>1</b></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 3: TELEPHONY & SIP
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: var(--glow-cyan);">📞</div>
|
||||||
|
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Core Infrastructure</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Telephony & SIP Overhaul</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-cyan);">Outbound Calling <span class="repo-badge badge-frontend">Frontend</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>Direct SIP call from browser — no Kookoo bridge needed</li>
|
||||||
|
<li>Immediate call card UI with auto-answer SIP bridge</li>
|
||||||
|
<li>End Call label fix, force active state after auto-answer</li>
|
||||||
|
<li>Reset outboundPending on call end to prevent inbound poisoning</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-violet);">Ozonetel Integration <span class="repo-badge badge-server">Server</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>Ozonetel V3 dial endpoint + webhook handler for call events</li>
|
||||||
|
<li>Kookoo IVR outbound bridging (deprecated → direct SIP)</li>
|
||||||
|
<li>Set Disposition API for ACW release</li>
|
||||||
|
<li>Force Ready endpoint for agent state management</li>
|
||||||
|
<li>Token: 10-min cache, 401 invalidation, refresh on login</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-cyan);">SIP & Agent State <span class="repo-badge badge-frontend">Frontend</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>SIP driven by Agent entity with token refresh</li>
|
||||||
|
<li>Dynamic SIP from agentConfig, logout cleanup, heartbeat</li>
|
||||||
|
<li>Centralised outbound dial into <code>useSip().dialOutbound()</code></li>
|
||||||
|
<li>UCID tracking from SIP headers for Ozonetel disposition</li>
|
||||||
|
<li>Network indicator for connection health</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-violet);">Multi-Agent & Sessions <span class="repo-badge badge-server">Server</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>Multi-agent SIP with Redis session lockout</li>
|
||||||
|
<li>Strict duplicate login lockout — one device per agent</li>
|
||||||
|
<li>Session lock stores IP + timestamp for debugging</li>
|
||||||
|
<li>SSE agent state broadcast for real-time supervisor view</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 4: CALL DESK & UX
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: var(--glow-emerald);">🖥️</div>
|
||||||
|
<span class="label" style="background: var(--glow-emerald); color: var(--accent-emerald);">User Experience</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Call Desk & Agent UX</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-emerald);">Call Desk Redesign</h3>
|
||||||
|
<ul>
|
||||||
|
<li>2-panel layout with collapsible sidebar & inline AI</li>
|
||||||
|
<li>Collapsible context panel, worklist/calls tabs, phone numbers</li>
|
||||||
|
<li>Pinned header & chat input, numpad dialler</li>
|
||||||
|
<li>Ringtone support for incoming calls</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-emerald);">Post-Call Workflow</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Disposition → appointment booking → follow-up creation</li>
|
||||||
|
<li>Disposition returns straight to worklist — no intermediate screens</li>
|
||||||
|
<li>Send disposition to sidecar with UCID for Ozonetel ACW</li>
|
||||||
|
<li>Enquiry in post-call, appointment skip button</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-emerald);">UI Polish</h3>
|
||||||
|
<ul>
|
||||||
|
<li>FontAwesome Pro Duotone icon migration (all icons)</li>
|
||||||
|
<li>Tooltips, sticky headers, roles, search, AI prompts</li>
|
||||||
|
<li>Fix React error #520 (isRowHeader) in production tables</li>
|
||||||
|
<li>AI scroll containment, brand tokens refresh</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 5: FEATURES SHIPPED
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: rgba(251,191,36,0.15);">🚀</div>
|
||||||
|
<span class="label" style="background: rgba(251,191,36,0.15); color: var(--accent-amber);">Features Shipped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Major Features</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-amber);">Supervisor Module</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Team performance analytics page</li>
|
||||||
|
<li>Live monitor with active calls visibility</li>
|
||||||
|
<li>Master data management pages</li>
|
||||||
|
<li>Server: team perf + active calls endpoints</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-amber);">Missed Call Queue (Phase 2)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Missed call queue ingestion & worklist</li>
|
||||||
|
<li>Auto-assignment engine for agents</li>
|
||||||
|
<li>Login redesign with role-based routing</li>
|
||||||
|
<li>Lead lookup for missed callers</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-amber);">Agent Features (Phase 1)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Agent status toggle (Ready / Not Ready / Break)</li>
|
||||||
|
<li>Global search across patients, leads, calls</li>
|
||||||
|
<li>Enquiry form for new patient intake</li>
|
||||||
|
<li>My Performance page + logout modal</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-amber);">Recording Analysis</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Deepgram diarization + AI insights</li>
|
||||||
|
<li>Redis caching layer for analysis results</li>
|
||||||
|
<li>Full-stack: frontend player + server module</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 6: DATA & BACKEND
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: var(--glow-violet);">⚙️</div>
|
||||||
|
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">Backend & Data</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Backend & Data Layer</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-violet);">Platform Data Wiring</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Migrated frontend to Jotai + Vercel AI SDK</li>
|
||||||
|
<li>Corrected all 7 GraphQL queries (field names, LINKS/PHONES)</li>
|
||||||
|
<li>Webhook handler for Ozonetel call records</li>
|
||||||
|
<li>Complete seeder: 5 doctors, appointments linked, agent names match</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-violet);">Server Endpoints</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Call control, recording, CDR, missed calls, live call assist</li>
|
||||||
|
<li>Agent summary, AHT, performance aggregation</li>
|
||||||
|
<li>Token refresh endpoint for auto-renewal</li>
|
||||||
|
<li>Search module with full-text capabilities</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-violet);">Data Pages Built</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Worklist table, call history, patients, dashboard</li>
|
||||||
|
<li>Reports, team dashboard, campaigns, settings</li>
|
||||||
|
<li>Agent detail page, campaign edit slideout</li>
|
||||||
|
<li>Appointments page with data refresh on login</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-emerald);">SDK App <span class="repo-badge badge-sdk">FortyTwoApps</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>Helix Engage SDK app entity definitions</li>
|
||||||
|
<li>Call center CRM object model for Fortytwo platform</li>
|
||||||
|
<li>Foundation for platform-native data integration</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 7: DEPLOYMENT & OPS
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: rgba(251,113,133,0.15);">🛠️</div>
|
||||||
|
<span class="label" style="background: rgba(251,113,133,0.15); color: var(--accent-rose);">Operations</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Deployment & DevOps</h2>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-rose);">Deployment</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Deployed to Hostinger VPS with Docker</li>
|
||||||
|
<li>Switched to global_healthx Ozonetel account</li>
|
||||||
|
<li>Dockerfile for server-side containerization</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-rose);">AI & Testing</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Migrated AI to Vercel AI SDK + OpenAI provider</li>
|
||||||
|
<li>AI flow test script — validates auth, lead, patient, doctor, appointments</li>
|
||||||
|
<li>Live call assist integration</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="content-card reveal">
|
||||||
|
<h3 style="color: var(--accent-rose);">Documentation</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Team onboarding README with architecture guide</li>
|
||||||
|
<li>Supervisor module spec + implementation plan</li>
|
||||||
|
<li>Multi-agent spec + plan</li>
|
||||||
|
<li>Next session plans documented in commits</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 8: TIMELINE
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide">
|
||||||
|
<div class="reveal">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-icon" style="background: var(--glow-cyan);">📅</div>
|
||||||
|
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Day by Day</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="reveal">Development Timeline</h2>
|
||||||
|
<div class="timeline reveal" style="max-height: 60vh; overflow-y: auto; padding-right: 1rem;">
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 18 (Tue)</div>
|
||||||
|
<div class="tl-title">Foundation Day</div>
|
||||||
|
<div class="tl-desc">Call desk redesign, Jotai + Vercel AI SDK migration, seeder with 5 doctors + linked appointments, AI flow test script, deployed to VPS</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 19 (Wed)</div>
|
||||||
|
<div class="tl-title">Data Layer Sprint</div>
|
||||||
|
<div class="tl-desc">All data pages built (worklist, call history, patients, dashboard, reports), post-call workflow (disposition → booking), GraphQL fixes, Kookoo IVR outbound, outbound call UI</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 20 (Thu)</div>
|
||||||
|
<div class="tl-title">Telephony Breakthrough</div>
|
||||||
|
<div class="tl-desc">Direct SIP call from browser replacing Kookoo bridge, UCID tracking, Force Ready, Ozonetel Set Disposition, telephony overhaul</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 21 (Fri)</div>
|
||||||
|
<div class="tl-title">Agent Experience</div>
|
||||||
|
<div class="tl-desc">Phase 1 shipped — agent status toggle, global search, enquiry form, My Performance page, full FontAwesome icon migration, agent summary/AHT endpoints</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 23 (Sun)</div>
|
||||||
|
<div class="tl-title">Scale & Reliability</div>
|
||||||
|
<div class="tl-desc">Phase 2 — missed call queue + auto-assignment, multi-agent SIP with Redis lockout, duplicate login prevention, Patient 360 rewrite, onboarding docs, SDK entity defs</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 24 (Mon)</div>
|
||||||
|
<div class="tl-title">Supervisor Module</div>
|
||||||
|
<div class="tl-desc">Supervisor module with team performance + live monitor + master data, SSE agent state, UUID fix, maintenance module, QA bug sweep, supervisor endpoints</div>
|
||||||
|
</div>
|
||||||
|
<div class="tl-item">
|
||||||
|
<div class="tl-date">MAR 25 (Tue)</div>
|
||||||
|
<div class="tl-title">Intelligence Layer</div>
|
||||||
|
<div class="tl-desc">Call recording analysis with Deepgram diarization + AI insights, SIP driven by Agent entity, token refresh, network indicator</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================================
|
||||||
|
SLIDE 9: CLOSING
|
||||||
|
====================================== -->
|
||||||
|
<section class="slide closing-slide">
|
||||||
|
<h2 class="reveal">78 commits. 8 days. Ship mode. 🚢</h2>
|
||||||
|
<p class="reveal" style="color: var(--text-secondary); margin-top: 0.6em; font-size: 1.1rem; max-width: 600px; margin-inline: auto;">
|
||||||
|
From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.
|
||||||
|
</p>
|
||||||
|
<div class="pill-list reveal" style="justify-content: center; margin-top: 1.5rem;">
|
||||||
|
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">SIP Calling ✓</span>
|
||||||
|
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">Multi-Agent ✓</span>
|
||||||
|
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">Supervisor Module ✓</span>
|
||||||
|
<span class="pill" style="border-color: rgba(251,191,36,0.3); color: var(--accent-amber);">AI Copilot ✓</span>
|
||||||
|
<span class="pill" style="border-color: rgba(251,113,133,0.3); color: var(--accent-rose);">Recording Analysis ✓</span>
|
||||||
|
</div>
|
||||||
|
<p class="reveal" style="color: var(--text-muted); margin-top: 2rem; font-size: 0.8rem;">Satya Suman Sari · FortyTwo Platform</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===========================================
|
||||||
|
SLIDE PRESENTATION CONTROLLER
|
||||||
|
=========================================== -->
|
||||||
|
<script>
|
||||||
|
class SlidePresentation {
|
||||||
|
constructor() {
|
||||||
|
this.slides = document.querySelectorAll('.slide');
|
||||||
|
this.progressBar = document.getElementById('progressBar');
|
||||||
|
this.navDots = document.getElementById('navDots');
|
||||||
|
this.slideCounter = document.getElementById('slideCounter');
|
||||||
|
this.currentSlide = 0;
|
||||||
|
|
||||||
|
this.createNavDots();
|
||||||
|
this.setupObserver();
|
||||||
|
this.setupKeyboard();
|
||||||
|
this.setupTouch();
|
||||||
|
this.animateCounters();
|
||||||
|
this.updateCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Navigation dots --- */
|
||||||
|
createNavDots() {
|
||||||
|
this.slides.forEach((_, i) => {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.classList.add('nav-dot');
|
||||||
|
dot.setAttribute('aria-label', `Go to slide ${i + 1}`);
|
||||||
|
dot.addEventListener('click', () => this.goToSlide(i));
|
||||||
|
this.navDots.appendChild(dot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Intersection Observer for reveal animations --- */
|
||||||
|
setupObserver() {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
const idx = Array.from(this.slides).indexOf(entry.target);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.currentSlide = idx;
|
||||||
|
this.updateProgress();
|
||||||
|
this.updateDots();
|
||||||
|
this.updateCounter();
|
||||||
|
if (idx === 1) this.animateCounters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.45 });
|
||||||
|
|
||||||
|
this.slides.forEach(slide => observer.observe(slide));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Keyboard navigation --- */
|
||||||
|
setupKeyboard() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.goToSlide(Math.max(this.currentSlide - 1, 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Touch swipe support --- */
|
||||||
|
setupTouch() {
|
||||||
|
let startY = 0;
|
||||||
|
document.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; });
|
||||||
|
document.addEventListener('touchend', (e) => {
|
||||||
|
const dy = startY - e.changedTouches[0].clientY;
|
||||||
|
if (Math.abs(dy) > 50) {
|
||||||
|
if (dy > 0) this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
|
||||||
|
else this.goToSlide(Math.max(this.currentSlide - 1, 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToSlide(idx) {
|
||||||
|
this.slides[idx].scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress() {
|
||||||
|
const pct = ((this.currentSlide) / (this.slides.length - 1)) * 100;
|
||||||
|
this.progressBar.style.width = pct + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDots() {
|
||||||
|
this.navDots.querySelectorAll('.nav-dot').forEach((dot, i) => {
|
||||||
|
dot.classList.toggle('active', i === this.currentSlide);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCounter() {
|
||||||
|
this.slideCounter.textContent = `${this.currentSlide + 1} / ${this.slides.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Animate counter numbers --- */
|
||||||
|
animateCounters() {
|
||||||
|
document.querySelectorAll('[data-count]').forEach(el => {
|
||||||
|
const target = parseInt(el.dataset.count);
|
||||||
|
const duration = 1200;
|
||||||
|
const start = performance.now();
|
||||||
|
const animate = (now) => {
|
||||||
|
const elapsed = now - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
el.textContent = Math.round(eased * target);
|
||||||
|
if (progress < 1) requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
new SlidePresentation();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
docs/weekly-update-mar18-25.pptx
Normal file
BIN
docs/weekly-update-mar18-25.pptx
Normal file
Binary file not shown.
1
e2e/.auth/.gitignore
vendored
Normal file
1
e2e/.auth/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.json
|
||||||
56
e2e/agent-login.spec.ts
Normal file
56
e2e/agent-login.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Login feature tests — covers multiple roles.
|
||||||
|
*
|
||||||
|
* These run WITHOUT saved auth state (fresh browser).
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForApp } from './helpers';
|
||||||
|
|
||||||
|
const SUPERVISOR = { email: 'supervisor@ramaiahcare.com', password: 'MrRamaiah@2026' };
|
||||||
|
|
||||||
|
test.describe('Login', () => {
|
||||||
|
|
||||||
|
test('login page renders with branding', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await expect(page.locator('img[alt]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible();
|
||||||
|
await expect(page.locator('form')).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="email"], input[placeholder*="@"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid credentials show error', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('bad@bad.com');
|
||||||
|
await page.locator('input[type="password"]').first().fill('wrongpassword');
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/not found|invalid|incorrect|failed|error|unauthorized/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Supervisor login → lands on app', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill(SUPERVISOR.email);
|
||||||
|
await page.locator('input[type="password"]').first().fill(SUPERVISOR.password);
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// Sidebar should be visible
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unauthenticated user redirected to login', async ({ page }) => {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto('/patients');
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
155
e2e/agent-smoke.spec.ts
Normal file
155
e2e/agent-smoke.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* CC Agent — happy-path smoke tests.
|
||||||
|
*
|
||||||
|
* Role: cc-agent (ccagent@ramaiahcare.com)
|
||||||
|
* Landing: / → Call Desk
|
||||||
|
* Pages: Call Desk, Call History, Patients, Appointments, My Performance
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForApp } from './helpers';
|
||||||
|
|
||||||
|
test.describe('CC Agent Smoke', () => {
|
||||||
|
|
||||||
|
test('lands on Call Desk after login', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(page.locator('aside').first()).toContainText(/Call Center/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Call Desk page loads', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// Call Desk is the landing — just verify we're not on an error page
|
||||||
|
await expect(page.locator('aside').first()).toContainText(/Call Desk/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Call History page loads', async ({ page }) => {
|
||||||
|
await page.goto('/call-history');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// Should show "Call History" title whether or not there are calls
|
||||||
|
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Filter dropdown present
|
||||||
|
await expect(page.locator('text=/All Calls/i').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patients page loads with search', async ({ page }) => {
|
||||||
|
await page.goto('/patients');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||||
|
await expect(search.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patients search filters results', async ({ page }) => {
|
||||||
|
await page.goto('/patients');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||||
|
await search.first().fill('zzz-nonexistent-patient');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
// Should show empty state
|
||||||
|
const noResults = page.locator('text=/no patient|not found|no results/i');
|
||||||
|
const isEmpty = await noResults.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||||
|
expect(isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appointments page loads', async ({ page }) => {
|
||||||
|
await page.goto('/appointments');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('My Performance loads with date controls', async ({ page }) => {
|
||||||
|
// Intercept API to verify agentId is sent
|
||||||
|
const apiHit = page.waitForRequest(
|
||||||
|
(r) => r.url().includes('/api/ozonetel/performance') && r.url().includes('agentId='),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/my-performance');
|
||||||
|
const req = await apiHit;
|
||||||
|
|
||||||
|
const url = new URL(req.url());
|
||||||
|
expect(url.searchParams.get('agentId')?.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Yesterday' })).toBeVisible();
|
||||||
|
|
||||||
|
// Either KPI data or empty state
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Total Calls|No performance data/i').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar has all CC Agent nav items', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
|
||||||
|
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign-out shows confirmation modal and cancel keeps session', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||||
|
if (await accountArea.isVisible()) {
|
||||||
|
await accountArea.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||||
|
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
|
await signOutBtn.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[role="dialog"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(modal).toContainText(/sign out/i);
|
||||||
|
|
||||||
|
// Cancel — should stay logged in
|
||||||
|
await modal.getByRole('button', { name: /cancel/i }).click();
|
||||||
|
await expect(modal).not.toBeVisible();
|
||||||
|
await expect(page).not.toHaveURL(/\/login/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// MUST be the last test — completes sign-out so the agent session is
|
||||||
|
// released and the next test run won't hit "already logged in".
|
||||||
|
test('sign-out completes and redirects to login', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||||
|
if (await accountArea.isVisible()) {
|
||||||
|
await accountArea.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||||
|
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
|
await signOutBtn.click();
|
||||||
|
|
||||||
|
const modal = page.locator('[role="dialog"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Confirm sign out
|
||||||
|
await modal.getByRole('button', { name: /sign out/i }).click();
|
||||||
|
|
||||||
|
// Should redirect to login
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
29
e2e/auth.setup.ts
Normal file
29
e2e/auth.setup.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const authFile = path.join(__dirname, '.auth/agent.json');
|
||||||
|
|
||||||
|
setup('login as CC Agent', async ({ page, request, baseURL }) => {
|
||||||
|
// Clear any stale session lock before login
|
||||||
|
const url = baseURL ?? 'https://ramaiah.engage.healix360.net';
|
||||||
|
await request.post(`${url}/api/maint/unlock-agent`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
|
||||||
|
data: { agentId: 'ramaiahadmin' },
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill('ccagent@ramaiahcare.com');
|
||||||
|
await page.locator('input[type="password"]').first().fill('CcRamaiah@2026');
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
|
||||||
|
// Should land on Call Desk (/ for cc-agent role)
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Sidebar should be visible
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
});
|
||||||
25
e2e/global-setup.ts
Normal file
25
e2e/global-setup.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const authFile = path.join(__dirname, '.auth/global-agent.json');
|
||||||
|
|
||||||
|
setup('login as Global CC Agent', async ({ page, request }) => {
|
||||||
|
// Clear any stale session lock before login
|
||||||
|
await request.post('https://global.engage.healix360.net/api/maint/unlock-agent', {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-maint-otp': '400168' },
|
||||||
|
data: { agentId: 'global' },
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await page.goto('https://global.engage.healix360.net/login');
|
||||||
|
|
||||||
|
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('rekha.cc@globalcare.com');
|
||||||
|
await page.locator('input[type="password"]').first().fill('Global@123');
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
});
|
||||||
120
e2e/global-smoke.spec.ts
Normal file
120
e2e/global-smoke.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Global Hospital — happy-path smoke tests.
|
||||||
|
*
|
||||||
|
* Uses saved auth state from global-setup.ts (same pattern as Ramaiah).
|
||||||
|
* Last test signs out to release the agent session.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, waitForApp } from './helpers';
|
||||||
|
|
||||||
|
const BASE = 'https://global.engage.healix360.net';
|
||||||
|
|
||||||
|
test.describe('Global — CC Agent', () => {
|
||||||
|
|
||||||
|
test('landing page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Call History page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/call-history');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patients page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/patients');
|
||||||
|
await waitForApp(page);
|
||||||
|
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||||
|
await expect(search.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appointments page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/appointments');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('My Performance page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/my-performance');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(page.getByRole('button', { name: 'Today' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar has CC Agent nav items', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/');
|
||||||
|
await waitForApp(page);
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
for (const item of ['Call Desk', 'Call History', 'Patients', 'Appointments', 'My Performance']) {
|
||||||
|
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Last test — sign out to release session
|
||||||
|
test('sign-out completes', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
const accountArea = sidebar.locator('[class*="account"], [class*="avatar"]').last();
|
||||||
|
if (await accountArea.isVisible()) await accountArea.click();
|
||||||
|
|
||||||
|
const signOutBtn = page.locator('button, [role="menuitem"]').filter({ hasText: /sign out/i }).first();
|
||||||
|
if (await signOutBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||||
|
await signOutBtn.click();
|
||||||
|
const modal = page.locator('[role="dialog"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||||
|
await modal.getByRole('button', { name: /sign out/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Global — Supervisor', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/login');
|
||||||
|
await page.locator('input[type="email"], input[placeholder*="@"]').first().fill('dr.ramesh@globalcare.com');
|
||||||
|
await page.locator('input[type="password"]').first().fill('Global@123');
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||||
|
await waitForApp(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('landing page loads', async ({ page }) => {
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patients page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/patients');
|
||||||
|
await waitForApp(page);
|
||||||
|
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||||
|
await expect(search.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appointments page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/appointments');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Campaigns page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/campaigns');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Campaign|No campaign/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings page loads', async ({ page }) => {
|
||||||
|
await page.goto(BASE + '/settings');
|
||||||
|
await waitForApp(page);
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Settings|Configuration/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
15
e2e/helpers.ts
Normal file
15
e2e/helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export async function waitForApp(page: Page) {
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginAs(page: Page, email: string, password: string) {
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.locator('input[type="email"], input[name="email"], input[placeholder*="@"]').first().fill(email);
|
||||||
|
await page.locator('input[type="password"]').first().fill(password);
|
||||||
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||||
|
await expect(page).not.toHaveURL(/\/login/, { timeout: 20_000 });
|
||||||
|
await waitForApp(page);
|
||||||
|
}
|
||||||
121
e2e/supervisor-smoke.spec.ts
Normal file
121
e2e/supervisor-smoke.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Supervisor / Admin — happy-path smoke tests.
|
||||||
|
*
|
||||||
|
* Role: admin (supervisor@ramaiahcare.com)
|
||||||
|
* Landing: / → Dashboard
|
||||||
|
* Pages: Dashboard, Team Performance, Live Monitor,
|
||||||
|
* Leads, Patients, Appointments, Call Log,
|
||||||
|
* Call Recordings, Missed Calls, Campaigns, Settings
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { loginAs, waitForApp } from './helpers';
|
||||||
|
|
||||||
|
const EMAIL = 'supervisor@ramaiahcare.com';
|
||||||
|
const PASSWORD = 'MrRamaiah@2026';
|
||||||
|
|
||||||
|
test.describe('Supervisor Smoke', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAs(page, EMAIL, PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lands on Dashboard after login', async ({ page }) => {
|
||||||
|
await expect(page.locator('aside').first()).toBeVisible();
|
||||||
|
// Verify we're authenticated and on the app
|
||||||
|
await expect(page).not.toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Team Performance loads', async ({ page }) => {
|
||||||
|
await page.goto('/team-performance');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Team|Performance|Agent|No data/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Live Call Monitor loads', async ({ page }) => {
|
||||||
|
await page.goto('/live-monitor');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Live|Monitor|Active|No active/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Leads page loads', async ({ page }) => {
|
||||||
|
await page.goto('/leads');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Lead|No leads/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Patients page loads', async ({ page }) => {
|
||||||
|
await page.goto('/patients');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
const search = page.getByPlaceholder(/search/i).or(page.getByLabel(/search/i));
|
||||||
|
await expect(search.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Appointments page loads', async ({ page }) => {
|
||||||
|
await page.goto('/appointments');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Appointment|Schedule|No appointment/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Call Log page loads', async ({ page }) => {
|
||||||
|
await page.goto('/call-history');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(page.locator('text="Call History"').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Call Recordings page loads', async ({ page }) => {
|
||||||
|
await page.goto('/call-recordings');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Recording|No recording/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Missed Calls page loads', async ({ page }) => {
|
||||||
|
await page.goto('/missed-calls');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Missed|No missed/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Campaigns page loads', async ({ page }) => {
|
||||||
|
await page.goto('/campaigns');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Campaign|No campaign/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings page loads', async ({ page }) => {
|
||||||
|
await page.goto('/settings');
|
||||||
|
await waitForApp(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=/Settings|Configuration/i').first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar has expected nav items', async ({ page }) => {
|
||||||
|
const sidebar = page.locator('aside').first();
|
||||||
|
// Check key items — exact labels depend on the role the sidecar assigns
|
||||||
|
for (const item of ['Patients', 'Appointments', 'Campaigns']) {
|
||||||
|
await expect(sidebar.locator(`text="${item}"`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
351
package-lock.json
generated
351
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "helix-engage",
|
"name": "helix-engage",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.12",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jssip": "^3.13.6",
|
"jssip": "^3.13.6",
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-aria": "^3.46.0",
|
"react-aria": "^3.46.0",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"tailwindcss-react-aria-components": "^2.0.1"
|
"tailwindcss-react-aria-components": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||||
"@types/jssip": "^3.5.3",
|
"@types/jssip": "^3.5.3",
|
||||||
@@ -55,6 +58,76 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ai-sdk/provider": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "http://localhost:4873/@ai-sdk/provider/-/provider-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema": "^0.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/provider-utils": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "http://localhost:4873/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "1.1.3",
|
||||||
|
"nanoid": "^3.3.8",
|
||||||
|
"secure-json-parse": "^2.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/react": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "http://localhost:4873/@ai-sdk/react/-/react-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider-utils": "2.2.8",
|
||||||
|
"@ai-sdk/ui-utils": "1.2.11",
|
||||||
|
"swr": "^2.2.5",
|
||||||
|
"throttleit": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ai-sdk/ui-utils": {
|
||||||
|
"version": "1.2.11",
|
||||||
|
"resolved": "http://localhost:4873/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz",
|
||||||
|
"integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/provider": "1.1.3",
|
||||||
|
"@ai-sdk/provider-utils": "2.2.8",
|
||||||
|
"zod-to-json-schema": "^3.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -1005,6 +1078,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-aria/autocomplete": {
|
"node_modules/@react-aria/autocomplete": {
|
||||||
"version": "3.0.0-rc.6",
|
"version": "3.0.0-rc.6",
|
||||||
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
|
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
|
||||||
@@ -4115,6 +4204,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "http://localhost:4873/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4181,6 +4276,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "http://localhost:4873/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -4646,6 +4750,12 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/https": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "http://localhost:4873/https/-/https-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4657,6 +4767,27 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/image-size": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "http://localhost:4873/image-size/-/image-size-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queue": "6.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"image-size": "bin/image-size.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "http://localhost:4873/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/imurmurhash": {
|
"node_modules/imurmurhash": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
@@ -4668,6 +4799,12 @@
|
|||||||
"node": ">=0.8.19"
|
"node": ">=0.8.19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "http://localhost:4873/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/input-otp": {
|
"node_modules/input-otp": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz",
|
"resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz",
|
||||||
@@ -4715,6 +4852,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "http://localhost:4873/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4796,6 +4939,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "http://localhost:4873/json-schema/-/json-schema-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||||
|
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -4823,6 +4972,18 @@
|
|||||||
"sdp-transform": "^2.14.1"
|
"sdp-transform": "^2.14.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "http://localhost:4873/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4849,6 +5010,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "http://localhost:4873/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
@@ -5272,6 +5442,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "http://localhost:4873/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5312,6 +5488,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",
|
||||||
@@ -5353,6 +5576,33 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pptxgenjs": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "http://localhost:4873/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^22.8.1",
|
||||||
|
"https": "^1.0.0",
|
||||||
|
"image-size": "^1.2.1",
|
||||||
|
"jszip": "^3.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pptxgenjs/node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "http://localhost:4873/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pptxgenjs/node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "http://localhost:4873/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5467,6 +5717,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "http://localhost:4873/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5496,6 +5752,15 @@
|
|||||||
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/queue": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "http://localhost:4873/queue/-/queue-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "~2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "http://localhost:4873/react/-/react-19.2.4.tgz",
|
"resolved": "http://localhost:4873/react/-/react-19.2.4.tgz",
|
||||||
@@ -5681,6 +5946,21 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "http://localhost:4873/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -5725,6 +6005,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "http://localhost:4873/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -5740,6 +6026,12 @@
|
|||||||
"sdp-verify": "checker.js"
|
"sdp-verify": "checker.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/secure-json-parse": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "http://localhost:4873/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz",
|
"resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -5759,6 +6051,12 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "http://localhost:4873/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5837,6 +6135,28 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "http://localhost:4873/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/swr": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "http://localhost:4873/swr/-/swr-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
@@ -5884,6 +6204,18 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/throttleit": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "http://localhost:4873/throttleit/-/throttleit-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -6149,6 +6481,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "http://localhost:4873/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.25.1",
|
||||||
|
"resolved": "http://localhost:4873/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
|
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25 || ^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zrender": {
|
"node_modules/zrender": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.12",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jssip": "^3.13.6",
|
"jssip": "^3.13.6",
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-aria": "^3.46.0",
|
"react-aria": "^3.46.0",
|
||||||
@@ -41,6 +43,7 @@
|
|||||||
"tailwindcss-react-aria-components": "^2.0.1"
|
"tailwindcss-react-aria-components": "^2.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||||
"@types/jssip": "^3.5.3",
|
"@types/jssip": "^3.5.3",
|
||||||
|
|||||||
65
playwright.config.ts
Normal file
65
playwright.config.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
retries: 1,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['html', { open: 'never' }], ['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL ?? 'https://ramaiah.engage.healix360.net',
|
||||||
|
headless: true,
|
||||||
|
screenshot: 'on',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
actionTimeout: 8_000,
|
||||||
|
navigationTimeout: 15_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
// Login tests run first — fresh browser, no saved auth
|
||||||
|
{
|
||||||
|
name: 'login',
|
||||||
|
testMatch: /agent-login\.spec\.ts/,
|
||||||
|
use: { browserName: 'chromium' },
|
||||||
|
},
|
||||||
|
// Auth setup — saves CC agent session for reuse
|
||||||
|
{
|
||||||
|
name: 'agent-setup',
|
||||||
|
testMatch: /auth\.setup\.ts/,
|
||||||
|
},
|
||||||
|
// CC Agent feature tests — reuse saved auth
|
||||||
|
{
|
||||||
|
name: 'cc-agent',
|
||||||
|
dependencies: ['agent-setup'],
|
||||||
|
use: {
|
||||||
|
storageState: path.join(__dirname, 'e2e/.auth/agent.json'),
|
||||||
|
browserName: 'chromium',
|
||||||
|
},
|
||||||
|
testMatch: /agent-smoke\.spec\.ts/,
|
||||||
|
},
|
||||||
|
// Supervisor tests — logs in fresh each run
|
||||||
|
{
|
||||||
|
name: 'supervisor',
|
||||||
|
testMatch: /supervisor-smoke\.spec\.ts/,
|
||||||
|
use: { browserName: 'chromium' },
|
||||||
|
},
|
||||||
|
// Global Hospital — auth setup + smoke tests
|
||||||
|
{
|
||||||
|
name: 'global-setup',
|
||||||
|
testMatch: /global-setup\.ts/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'global',
|
||||||
|
dependencies: ['global-setup'],
|
||||||
|
testMatch: /global-smoke\.spec\.ts/,
|
||||||
|
use: {
|
||||||
|
storageState: path.join(__dirname, 'e2e/.auth/global-agent.json'),
|
||||||
|
browserName: 'chromium',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Helix Engage — Platform Data Seeder
|
* Helix Engage — Platform Data Seeder
|
||||||
* Creates 5 patient stories + 5 doctors with fully linked records.
|
* Creates 2 clinics, 5 doctors with multi-clinic visit slots,
|
||||||
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
|
* 3 patient stories with fully linked records (campaigns, leads,
|
||||||
|
* calls, appointments, follow-ups, lead activities).
|
||||||
*
|
*
|
||||||
* Platform field mapping (SDK name → platform name):
|
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
|
||||||
* Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions,
|
* Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain)
|
||||||
* clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated
|
*
|
||||||
* Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted,
|
* Schema alignment (2026-04-10):
|
||||||
* lastContactedAt→lastContacted, landingPageUrl→landingPage
|
* - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity
|
||||||
* Call: callDirection→direction, durationSeconds→durationSec
|
* - Doctor.portalUserId omitted (workspace member IDs are per-deployment)
|
||||||
* Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room
|
* - Clinic entity added (needed for visit slot FK)
|
||||||
* FollowUp: followUpType→typeCustom, followUpStatus→status
|
|
||||||
* Patient: address→addressCustom
|
|
||||||
* Doctor: isActive→active, branch→branchClinic
|
|
||||||
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
|
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||||
const SUB = 'fortytwo-dev';
|
const SUB = process.env.SEED_SUB ?? 'fortytwo-dev';
|
||||||
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
|
||||||
|
|
||||||
let token = '';
|
let token = '';
|
||||||
@@ -51,28 +49,172 @@ async function mk(entity: string, data: any): Promise<string> {
|
|||||||
return d[`create${cap}`].id;
|
return d[`create${cap}`].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a workspace member (user account) and return its workspace member id.
|
||||||
|
// Uses signUpInWorkspace + updateWorkspaceMember for name + updateWorkspaceMemberRole.
|
||||||
|
// The invite hash and role IDs are fetched once and cached.
|
||||||
|
let _inviteHash = '';
|
||||||
|
let _wsId = '';
|
||||||
|
const _roleIds: Record<string, string> = {};
|
||||||
|
|
||||||
|
async function ensureWorkspaceContext() {
|
||||||
|
if (_wsId) return;
|
||||||
|
const ws = await gql('{ currentWorkspace { id inviteHash } }');
|
||||||
|
_wsId = ws.currentWorkspace.id;
|
||||||
|
_inviteHash = ws.currentWorkspace.inviteHash;
|
||||||
|
const roles = await gql('{ getRoles { id label } }');
|
||||||
|
for (const r of roles.getRoles) _roleIds[r.label] = r.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkMember(email: string, password: string, firstName: string, lastName: string, roleName?: string): Promise<string> {
|
||||||
|
await ensureWorkspaceContext();
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
const existing = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
|
||||||
|
const found = existing.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
|
||||||
|
if (found) {
|
||||||
|
console.log(` (exists) ${email} → ${found.node.id}`);
|
||||||
|
return found.node.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the user + link to workspace
|
||||||
|
await gql(
|
||||||
|
`mutation($email: String!, $password: String!, $workspaceId: UUID!, $workspaceInviteHash: String!) {
|
||||||
|
signUpInWorkspace(email: $email, password: $password, workspaceId: $workspaceId, workspaceInviteHash: $workspaceInviteHash) { workspace { id } }
|
||||||
|
}`,
|
||||||
|
{ email, password, workspaceId: _wsId, workspaceInviteHash: _inviteHash },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the new member id
|
||||||
|
const members = await gql('{ workspaceMembers { edges { node { id userEmail } } } }');
|
||||||
|
const member = members.workspaceMembers.edges.find((e: any) => e.node.userEmail.toLowerCase() === email.toLowerCase());
|
||||||
|
if (!member) throw new Error(`Could not find workspace member for ${email}`);
|
||||||
|
const memberId = member.node.id;
|
||||||
|
|
||||||
|
// Set their display name
|
||||||
|
await gql(
|
||||||
|
`mutation($id: UUID!, $data: WorkspaceMemberUpdateInput!) { updateWorkspaceMember(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: memberId, data: { name: { firstName, lastName } } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assign role if specified
|
||||||
|
if (roleName && _roleIds[roleName]) {
|
||||||
|
await gql(
|
||||||
|
`mutation($wm: UUID!, $role: UUID!) { updateWorkspaceMemberRole(workspaceMemberId: $wm, roleId: $role) { id } }`,
|
||||||
|
{ wm: memberId, role: _roleIds[roleName] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
// Delete in reverse dependency order
|
||||||
|
const entities = ['followUp', 'leadActivity', 'call', 'appointment', 'lead', 'patient', 'doctorVisitSlot', 'doctor', 'campaign', 'clinic'];
|
||||||
|
for (const entity of entities) {
|
||||||
|
const cap = entity[0].toUpperCase() + entity.slice(1);
|
||||||
|
try {
|
||||||
|
const data = await gql(`{ ${entity}s(first: 100) { edges { node { id } } } }`);
|
||||||
|
const ids: string[] = data[`${entity}s`].edges.map((e: any) => e.node.id);
|
||||||
|
if (ids.length === 0) { console.log(` ${cap}: 0 records`); continue; }
|
||||||
|
for (const id of ids) {
|
||||||
|
await gql(`mutation { delete${cap}(id: "${id}") { id } }`);
|
||||||
|
}
|
||||||
|
console.log(` ${cap}: deleted ${ids.length}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(` ${cap}: skip (${err.message?.slice(0, 60)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Seeding Helix Engage demo data...\n');
|
console.log('🌱 Seeding Helix Engage demo data...\n');
|
||||||
await auth();
|
await auth();
|
||||||
console.log('✅ Auth OK\n');
|
console.log('✅ Auth OK\n');
|
||||||
|
|
||||||
// Workspace member IDs — switch based on target platform
|
// Clean slate — remove all existing entity data (not users)
|
||||||
const WM = GQL.includes('srv1477139') ? {
|
console.log('🧹 Clearing existing data...');
|
||||||
drSharma: '107efa70-fd32-4819-8936-994197c6ada1',
|
await clearAll();
|
||||||
drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209',
|
console.log('');
|
||||||
drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c',
|
|
||||||
drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b',
|
await auth();
|
||||||
drSingh: 'b2a00dd2-5bb5-4c29-8fb1-70a681193a4c',
|
|
||||||
} : {
|
|
||||||
drSharma: '251e9b32-3a83-4f3c-a904-fad7e8b840c3',
|
|
||||||
drPatel: '2b1bbf20-3838-434f-9fe9-b98436362230',
|
|
||||||
drKumar: '16109622-9b13-4682-b327-eb611ffa8338',
|
|
||||||
drReddy: '478a9ccb-d231-48fb-a740-0228d3c9325b',
|
|
||||||
drSingh: 'b854b55b-7302-4981-8dfc-bea516abdc86',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// DOCTORS (linked to workspace members)
|
// CLINICS (needed for doctor visit slots)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('🏥 Clinics');
|
||||||
|
const clinicKor = await mk('clinic', {
|
||||||
|
name: 'Global Hospital — Koramangala',
|
||||||
|
clinicName: 'Global Hospital — Koramangala',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
opensAt: '08:00', closesAt: '20:00',
|
||||||
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
|
phone: { primaryPhoneNumber: '8041763265', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
|
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'Koramangala 4th Block' },
|
||||||
|
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
|
||||||
|
});
|
||||||
|
console.log(` Koramangala: ${clinicKor}`);
|
||||||
|
|
||||||
|
const clinicWf = await mk('clinic', {
|
||||||
|
name: 'Global Hospital — Whitefield',
|
||||||
|
clinicName: 'Global Hospital — Whitefield',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
opensAt: '09:00', closesAt: '18:00',
|
||||||
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
|
phone: { primaryPhoneNumber: '8041763400', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
|
addressCustom: { addressCity: 'Bangalore', addressState: 'Karnataka', addressCountry: 'India', addressStreet1: 'ITPL Main Road, Whitefield' },
|
||||||
|
onlineBooking: true, walkInAllowed: true, acceptsCash: 'YES', acceptsCard: 'YES', acceptsUpi: 'YES',
|
||||||
|
});
|
||||||
|
console.log(` Whitefield: ${clinicWf}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// CALL CENTER & MARKETING STAFF
|
||||||
|
//
|
||||||
|
// CC agents (HelixEngage User role) handle inbound/outbound calls.
|
||||||
|
// Marketing executives and supervisors use HelixEngage Supervisor role.
|
||||||
|
// Email domain uses globalcare.com to match the deployment.
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('📞 Call center & marketing staff');
|
||||||
|
const wmRekha = await mkMember('rekha.cc@globalcare.com', 'Global@123', 'Rekha', 'Nair', 'HelixEngage User');
|
||||||
|
console.log(` Rekha (CC Agent): ${wmRekha}`);
|
||||||
|
const wmGanesh = await mkMember('ganesh.cc@globalcare.com', 'Global@123', 'Ganesh', 'Iyer', 'HelixEngage User');
|
||||||
|
console.log(` Ganesh (CC Agent): ${wmGanesh}`);
|
||||||
|
const wmSanjay = await mkMember('sanjay.marketing@globalcare.com', 'Global@123', 'Sanjay', 'Verma', 'HelixEngage Supervisor');
|
||||||
|
console.log(` Sanjay (Marketing): ${wmSanjay}`);
|
||||||
|
const wmRamesh = await mkMember('dr.ramesh@globalcare.com', 'Global@123', 'Ramesh', 'Gupta', 'HelixEngage Supervisor');
|
||||||
|
console.log(` Dr. Ramesh (Supervisor): ${wmRamesh}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DOCTOR WORKSPACE MEMBERS
|
||||||
|
//
|
||||||
|
// Each doctor gets a real platform login so they can access the
|
||||||
|
// portal. Created via signUpInWorkspace, then linked to the Doctor
|
||||||
|
// entity via portalUserId. Email domain matches the deployment.
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('👤 Doctor workspace members (role: HelixEngage Manager)');
|
||||||
|
const wmSharma = await mkMember('dr.sharma@globalcare.com', 'DrSharma@2026', 'Arun', 'Sharma', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Sharma member: ${wmSharma}`);
|
||||||
|
const wmPatel = await mkMember('dr.patel@globalcare.com', 'DrPatel@2026', 'Meena', 'Patel', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Patel member: ${wmPatel}`);
|
||||||
|
const wmKumar = await mkMember('dr.kumar@globalcare.com', 'DrKumar@2026', 'Rajesh', 'Kumar', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Kumar member: ${wmKumar}`);
|
||||||
|
const wmReddy = await mkMember('dr.reddy@globalcare.com', 'DrReddy@2026', 'Lakshmi', 'Reddy', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Reddy member: ${wmReddy}`);
|
||||||
|
const wmSingh = await mkMember('dr.singh@globalcare.com', 'DrSingh@2026', 'Harpreet', 'Singh', 'HelixEngage Manager');
|
||||||
|
console.log(` Dr. Singh member: ${wmSingh}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DOCTORS (linked to workspace members via portalUserId)
|
||||||
|
//
|
||||||
|
// visitingHours was removed — multi-clinic schedules now live
|
||||||
|
// on DoctorVisitSlot (seeded below).
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
console.log('👨⚕️ Doctors');
|
console.log('👨⚕️ Doctors');
|
||||||
const drSharma = await mk('doctor', {
|
const drSharma = await mk('doctor', {
|
||||||
@@ -82,16 +224,15 @@ async function main() {
|
|||||||
specialty: 'Interventional Cardiology',
|
specialty: 'Interventional Cardiology',
|
||||||
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
|
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
|
||||||
yearsOfExperience: 18,
|
yearsOfExperience: 18,
|
||||||
visitingHours: 'Mon/Wed/Fri 10:00 AM – 1:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.sharma@globalhospital.com' },
|
email: { primaryEmail: 'dr.sharma@globalcare.com' },
|
||||||
registrationNumber: 'KMC-45672',
|
registrationNumber: 'KMC-45672',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drSharma,
|
portalUserId: wmSharma,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Sharma (Cardiology, WM: ${WM.drSharma}): ${drSharma}`);
|
console.log(` Dr. Sharma (Cardiology → ${wmSharma}): ${drSharma}`);
|
||||||
|
|
||||||
const drPatel = await mk('doctor', {
|
const drPatel = await mk('doctor', {
|
||||||
name: 'Dr. Meena Patel',
|
name: 'Dr. Meena Patel',
|
||||||
@@ -100,16 +241,15 @@ async function main() {
|
|||||||
specialty: 'Reproductive Medicine & IVF',
|
specialty: 'Reproductive Medicine & IVF',
|
||||||
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
|
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
|
||||||
yearsOfExperience: 15,
|
yearsOfExperience: 15,
|
||||||
visitingHours: 'Tue/Thu/Sat 9:00 AM – 12:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.patel@globalhospital.com' },
|
email: { primaryEmail: 'dr.patel@globalcare.com' },
|
||||||
registrationNumber: 'KMC-38291',
|
registrationNumber: 'KMC-38291',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drPatel,
|
portalUserId: wmPatel,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Patel (Gynecology/IVF, WM: ${WM.drPatel}): ${drPatel}`);
|
console.log(` Dr. Patel (Gynecology/IVF → ${wmPatel}): ${drPatel}`);
|
||||||
|
|
||||||
const drKumar = await mk('doctor', {
|
const drKumar = await mk('doctor', {
|
||||||
name: 'Dr. Rajesh Kumar',
|
name: 'Dr. Rajesh Kumar',
|
||||||
@@ -118,16 +258,15 @@ async function main() {
|
|||||||
specialty: 'Joint Replacement & Sports Medicine',
|
specialty: 'Joint Replacement & Sports Medicine',
|
||||||
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
|
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
|
||||||
yearsOfExperience: 12,
|
yearsOfExperience: 12,
|
||||||
visitingHours: 'Mon–Fri 2:00 PM – 5:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.kumar@globalhospital.com' },
|
email: { primaryEmail: 'dr.kumar@globalcare.com' },
|
||||||
registrationNumber: 'KMC-51003',
|
registrationNumber: 'KMC-51003',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drKumar,
|
portalUserId: wmKumar,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Kumar (Orthopedics, WM: ${WM.drKumar}): ${drKumar}`);
|
console.log(` Dr. Kumar (Orthopedics → ${wmKumar}): ${drKumar}`);
|
||||||
|
|
||||||
const drReddy = await mk('doctor', {
|
const drReddy = await mk('doctor', {
|
||||||
name: 'Dr. Lakshmi Reddy',
|
name: 'Dr. Lakshmi Reddy',
|
||||||
@@ -136,16 +275,15 @@ async function main() {
|
|||||||
specialty: 'Internal Medicine & Preventive Health',
|
specialty: 'Internal Medicine & Preventive Health',
|
||||||
qualifications: 'MBBS, MD (General Medicine)',
|
qualifications: 'MBBS, MD (General Medicine)',
|
||||||
yearsOfExperience: 20,
|
yearsOfExperience: 20,
|
||||||
visitingHours: 'Mon–Sat 9:00 AM – 6:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.reddy@globalhospital.com' },
|
email: { primaryEmail: 'dr.reddy@globalcare.com' },
|
||||||
registrationNumber: 'KMC-22145',
|
registrationNumber: 'KMC-22145',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drReddy,
|
portalUserId: wmReddy,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Reddy (General Medicine, WM: ${WM.drReddy}): ${drReddy}`);
|
console.log(` Dr. Reddy (General Medicine → ${wmReddy}): ${drReddy}`);
|
||||||
|
|
||||||
const drSingh = await mk('doctor', {
|
const drSingh = await mk('doctor', {
|
||||||
name: 'Dr. Harpreet Singh',
|
name: 'Dr. Harpreet Singh',
|
||||||
@@ -154,16 +292,57 @@ async function main() {
|
|||||||
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
|
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
|
||||||
qualifications: 'MBBS, MS (ENT), DNB',
|
qualifications: 'MBBS, MS (ENT), DNB',
|
||||||
yearsOfExperience: 10,
|
yearsOfExperience: 10,
|
||||||
visitingHours: 'Mon/Wed/Fri 11:00 AM – 3:00 PM',
|
|
||||||
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
|
||||||
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
|
||||||
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'dr.singh@globalhospital.com' },
|
email: { primaryEmail: 'dr.singh@globalcare.com' },
|
||||||
registrationNumber: 'KMC-60782',
|
registrationNumber: 'KMC-60782',
|
||||||
active: true,
|
active: true,
|
||||||
portalUserId: WM.drSingh,
|
portalUserId: wmSingh,
|
||||||
});
|
});
|
||||||
console.log(` Dr. Singh (ENT, WM: ${WM.drSingh}): ${drSingh}\n`);
|
console.log(` Dr. Singh (ENT → ${wmSingh}): ${drSingh}\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// DOCTOR VISIT SLOTS (weekly schedule per doctor × clinic)
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
console.log('📅 Visit Slots');
|
||||||
|
const slots: Array<{ doc: string; docName: string; clinic: string; clinicName: string; day: string; start: string; end: string }> = [
|
||||||
|
// Dr. Sharma — Koramangala Mon/Wed/Fri 10:00–13:00
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '10:00', end: '13:00' },
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '10:00', end: '13:00' },
|
||||||
|
{ doc: drSharma, docName: 'Sharma', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '10:00', end: '13:00' },
|
||||||
|
// Dr. Patel — Whitefield Tue/Thu/Sat 9:00–12:00
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '09:00', end: '12:00' },
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '09:00', end: '12:00' },
|
||||||
|
{ doc: drPatel, docName: 'Patel', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '09:00', end: '12:00' },
|
||||||
|
// Dr. Kumar — Koramangala Mon–Fri 14:00–17:00
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'TUESDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'THURSDAY', start: '14:00', end: '17:00' },
|
||||||
|
{ doc: drKumar, docName: 'Kumar', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '14:00', end: '17:00' },
|
||||||
|
// Dr. Reddy — both clinics Mon–Sat
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'MONDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'WEDNESDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicKor, clinicName: 'Kor', day: 'FRIDAY', start: '09:00', end: '13:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'TUESDAY', start: '14:00', end: '18:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'THURSDAY', start: '14:00', end: '18:00' },
|
||||||
|
{ doc: drReddy, docName: 'Reddy', clinic: clinicWf, clinicName: 'WF', day: 'SATURDAY', start: '14:00', end: '18:00' },
|
||||||
|
// Dr. Singh — Whitefield Mon/Wed/Fri 11:00–15:00
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'MONDAY', start: '11:00', end: '15:00' },
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'WEDNESDAY', start: '11:00', end: '15:00' },
|
||||||
|
{ doc: drSingh, docName: 'Singh', clinic: clinicWf, clinicName: 'WF', day: 'FRIDAY', start: '11:00', end: '15:00' },
|
||||||
|
];
|
||||||
|
for (const s of slots) {
|
||||||
|
await mk('doctorVisitSlot', {
|
||||||
|
name: `Dr. ${s.docName} — ${s.day} ${s.start}–${s.end} (${s.clinicName})`,
|
||||||
|
doctorId: s.doc, clinicId: s.clinic,
|
||||||
|
dayOfWeek: s.day, startTime: s.start, endTime: s.end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(` ${slots.length} visit slots created\n`);
|
||||||
|
|
||||||
await auth();
|
await auth();
|
||||||
|
|
||||||
@@ -406,9 +585,10 @@ async function main() {
|
|||||||
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
|
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
|
||||||
|
|
||||||
console.log('🎉 Seed complete!');
|
console.log('🎉 Seed complete!');
|
||||||
console.log(' 5 doctors · 3 campaigns · 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
|
console.log(' 2 clinics · 5 doctors · 20 visit slots · 3 campaigns');
|
||||||
|
console.log(' 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
|
||||||
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
|
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
|
||||||
console.log(' All appointments linked to doctor entities');
|
console.log(' Doctors linked to clinics via visit slots (multi-clinic schedule)');
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
* Prerequisites: doctors already seeded via seed-data.ts
|
* Prerequisites: doctors already seeded via seed-data.ts
|
||||||
*
|
*
|
||||||
* Platform field mapping (SDK name → platform name):
|
* Platform field mapping (SDK name → platform name):
|
||||||
* Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours,
|
* Clinic: address→addressCustom,
|
||||||
* operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours,
|
* per-day booleans openMonday..openSunday + opensAt/closesAt (HH:MM),
|
||||||
* clinicStatus→status, onlineBookingEnabled→onlineBooking,
|
* clinicStatus→status, onlineBookingEnabled→onlineBooking,
|
||||||
* arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash,
|
* arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash,
|
||||||
* paymentCard→acceptsCard, paymentUpi→acceptsUpi
|
* paymentCard→acceptsCard, paymentUpi→acceptsUpi.
|
||||||
|
* requiredDocuments is a RELATION (ClinicRequiredDocument); seed rows
|
||||||
|
* separately — not a string on the Clinic itself.
|
||||||
* HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active
|
* HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active
|
||||||
* InsurancePartner: planTypes→planTypesAccepted
|
* InsurancePartner: planTypes→planTypesAccepted
|
||||||
*/
|
*/
|
||||||
@@ -68,15 +70,16 @@ async function main() {
|
|||||||
},
|
},
|
||||||
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'koramangala@globalhospital.com' },
|
email: { primaryEmail: 'koramangala@globalhospital.com' },
|
||||||
weekdayHours: '8:00 AM – 8:00 PM',
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
saturdayHours: '8:00 AM – 8:00 PM',
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||||
sundayHours: '9:00 AM – 2:00 PM',
|
opensAt: '08:00',
|
||||||
|
closesAt: '20:00',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
walkInAllowed: true,
|
walkInAllowed: true,
|
||||||
onlineBooking: true,
|
onlineBooking: true,
|
||||||
cancellationWindowHours: 4,
|
cancellationWindowHours: 4,
|
||||||
arriveEarlyMin: 15,
|
arriveEarlyMin: 15,
|
||||||
requiredDocuments: 'ID proof + medical records',
|
// requiredDocuments is a relation (ClinicRequiredDocument) — seed separately
|
||||||
acceptsCash: 'YES',
|
acceptsCash: 'YES',
|
||||||
acceptsCard: 'YES',
|
acceptsCard: 'YES',
|
||||||
acceptsUpi: 'YES',
|
acceptsUpi: 'YES',
|
||||||
@@ -95,15 +98,15 @@ async function main() {
|
|||||||
},
|
},
|
||||||
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'whitefield@globalhospital.com' },
|
email: { primaryEmail: 'whitefield@globalhospital.com' },
|
||||||
weekdayHours: '8:00 AM – 8:00 PM',
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
saturdayHours: '8:00 AM – 8:00 PM',
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
sundayHours: 'Closed',
|
opensAt: '08:00',
|
||||||
|
closesAt: '20:00',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
walkInAllowed: true,
|
walkInAllowed: true,
|
||||||
onlineBooking: true,
|
onlineBooking: true,
|
||||||
cancellationWindowHours: 4,
|
cancellationWindowHours: 4,
|
||||||
arriveEarlyMin: 15,
|
arriveEarlyMin: 15,
|
||||||
requiredDocuments: 'ID proof + medical records',
|
|
||||||
acceptsCash: 'YES',
|
acceptsCash: 'YES',
|
||||||
acceptsCard: 'YES',
|
acceptsCard: 'YES',
|
||||||
acceptsUpi: 'YES',
|
acceptsUpi: 'YES',
|
||||||
@@ -122,15 +125,15 @@ async function main() {
|
|||||||
},
|
},
|
||||||
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||||
email: { primaryEmail: 'indiranagar@globalhospital.com' },
|
email: { primaryEmail: 'indiranagar@globalhospital.com' },
|
||||||
weekdayHours: '9:00 AM – 7:00 PM',
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
saturdayHours: '9:00 AM – 7:00 PM',
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||||
sundayHours: '10:00 AM – 1:00 PM',
|
opensAt: '09:00',
|
||||||
|
closesAt: '19:00',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
walkInAllowed: true,
|
walkInAllowed: true,
|
||||||
onlineBooking: true,
|
onlineBooking: true,
|
||||||
cancellationWindowHours: 4,
|
cancellationWindowHours: 4,
|
||||||
arriveEarlyMin: 15,
|
arriveEarlyMin: 15,
|
||||||
requiredDocuments: 'ID proof + medical records',
|
|
||||||
acceptsCash: 'YES',
|
acceptsCash: 'YES',
|
||||||
acceptsCard: 'YES',
|
acceptsCard: 'YES',
|
||||||
acceptsUpi: 'YES',
|
acceptsUpi: 'YES',
|
||||||
|
|||||||
114
scripts/seed-ramaiah-slots.ts
Normal file
114
scripts/seed-ramaiah-slots.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Seed DoctorVisitSlots for all Ramaiah doctors.
|
||||||
|
* Assigns default visiting hours based on department patterns.
|
||||||
|
* Run after seed-ramaiah.ts has populated doctors + clinic.
|
||||||
|
*
|
||||||
|
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah-slots.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||||
|
const SUB = process.env.SEED_SUB ?? 'ramaiah';
|
||||||
|
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
async function gql(query: string, variables?: any) {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB };
|
||||||
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||||
|
const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) });
|
||||||
|
const d: any = await r.json();
|
||||||
|
if (d.errors) throw new Error(d.errors[0].message);
|
||||||
|
return d.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auth() {
|
||||||
|
const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`);
|
||||||
|
const lt = d1.getLoginTokenFromCredentials.loginToken.token;
|
||||||
|
const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`);
|
||||||
|
token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default schedule patterns by department type
|
||||||
|
const schedulePatterns: Record<string, { days: string[]; start: string; end: string }> = {
|
||||||
|
// Surgical departments: morning OPD
|
||||||
|
surgery: { days: ['MONDAY', 'WEDNESDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '13:00' },
|
||||||
|
// Medical departments: afternoon OPD
|
||||||
|
medicine: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '14:00', end: '17:00' },
|
||||||
|
// High-traffic: full day Mon-Sat
|
||||||
|
fullDay: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '17:00' },
|
||||||
|
// Emergency/Critical: all week
|
||||||
|
allWeek: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'], start: '08:00', end: '20:00' },
|
||||||
|
// Specialists: limited days
|
||||||
|
specialist: { days: ['TUESDAY', 'THURSDAY', 'SATURDAY'], start: '10:00', end: '14:00' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPattern(department: string): { days: string[]; start: string; end: string } {
|
||||||
|
const d = department.toLowerCase();
|
||||||
|
if (d.includes('emergency') || d.includes('critical care')) return schedulePatterns.allWeek;
|
||||||
|
if (d.includes('general medicine') || d.includes('paediatrics') || d.includes('obstetrics')) return schedulePatterns.fullDay;
|
||||||
|
if (d.includes('surgery') || d.includes('ortho') || d.includes('neuro')) return schedulePatterns.surgery;
|
||||||
|
if (d.includes('cardiology') || d.includes('nephrology') || d.includes('oncology')) return schedulePatterns.medicine;
|
||||||
|
if (d.includes('dermatology') || d.includes('psychiatry') || d.includes('rheumatology') || d.includes('endocrinology')) return schedulePatterns.specialist;
|
||||||
|
// Default: Mon-Fri mornings
|
||||||
|
return { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '09:00', end: '13:00' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🕐 Seeding visit slots for Ramaiah doctors...\n');
|
||||||
|
await auth();
|
||||||
|
console.log('✅ Auth OK\n');
|
||||||
|
|
||||||
|
// Fetch all doctors
|
||||||
|
const docData = await gql(`{ doctors(first: 500) { edges { node { id name department } } } }`);
|
||||||
|
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
||||||
|
console.log(`📋 Found ${doctors.length} doctors\n`);
|
||||||
|
|
||||||
|
// Fetch clinic
|
||||||
|
const clinicData = await gql(`{ clinics(first: 1) { edges { node { id clinicName } } } }`);
|
||||||
|
const clinicId = clinicData.clinics.edges[0]?.node.id;
|
||||||
|
const clinicName = clinicData.clinics.edges[0]?.node.clinicName ?? 'Clinic';
|
||||||
|
if (!clinicId) { console.error('No clinic found!'); process.exit(1); }
|
||||||
|
console.log(`🏥 Clinic: ${clinicName} (${clinicId})\n`);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < doctors.length; i++) {
|
||||||
|
if (i > 0 && i % 40 === 0) {
|
||||||
|
await auth();
|
||||||
|
console.log(` (re-authed at ${i})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = doctors[i];
|
||||||
|
const pattern = getPattern(doc.department ?? '');
|
||||||
|
|
||||||
|
for (const day of pattern.days) {
|
||||||
|
try {
|
||||||
|
await gql(
|
||||||
|
`mutation($data: DoctorVisitSlotCreateInput!) { createDoctorVisitSlot(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${doc.name} — ${day} ${pattern.start}–${pattern.end}`,
|
||||||
|
doctorId: doc.id,
|
||||||
|
clinicId,
|
||||||
|
dayOfWeek: day,
|
||||||
|
startTime: pattern.start,
|
||||||
|
endTime: pattern.end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
} catch (err: any) {
|
||||||
|
failed++;
|
||||||
|
if (failed <= 5) console.error(` ✗ ${doc.name} ${day}: ${err.message?.slice(0, 60)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((i + 1) % 30 === 0) console.log(` ${i + 1}/${doctors.length} doctors processed (${created} slots)...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ ${created} visit slots created, ${failed} failed`);
|
||||||
|
console.log(` ${doctors.length} doctors × avg ${Math.round(created / doctors.length)} days each`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||||
117
scripts/seed-ramaiah.ts
Normal file
117
scripts/seed-ramaiah.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Ramaiah Hospital Data Seeder
|
||||||
|
*
|
||||||
|
* Seeds clinic + 195 doctors from scraped website data.
|
||||||
|
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah.ts
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||||
|
const SUB = process.env.SEED_SUB ?? 'ramaiah';
|
||||||
|
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
|
||||||
|
const DATA_FILE = process.env.SEED_DATA ?? '/tmp/ramaiah-seed-data.json';
|
||||||
|
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
async function gql(query: string, variables?: any) {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB };
|
||||||
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||||
|
const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) });
|
||||||
|
const d: any = await r.json();
|
||||||
|
if (d.errors) { console.error('❌', d.errors[0].message); throw new Error(d.errors[0].message); }
|
||||||
|
return d.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auth() {
|
||||||
|
const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`);
|
||||||
|
const lt = d1.getLoginTokenFromCredentials.loginToken.token;
|
||||||
|
const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`);
|
||||||
|
token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mk(entity: string, data: any): Promise<string> {
|
||||||
|
const cap = entity[0].toUpperCase() + entity.slice(1);
|
||||||
|
const d = await gql(`mutation($data: ${cap}CreateInput!) { create${cap}(data: $data) { id } }`, { data });
|
||||||
|
return d[`create${cap}`].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding Ramaiah Hospital data...\n');
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
|
||||||
|
console.log(`📁 Loaded ${raw.doctors.length} doctors, ${raw.departments.length} departments\n`);
|
||||||
|
|
||||||
|
await auth();
|
||||||
|
console.log('✅ Auth OK\n');
|
||||||
|
|
||||||
|
// Clinic
|
||||||
|
console.log('🏥 Clinic');
|
||||||
|
const clinicId = await mk('clinic', {
|
||||||
|
name: raw.clinic.name,
|
||||||
|
clinicName: raw.clinic.name,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
opensAt: '08:00',
|
||||||
|
closesAt: '20:00',
|
||||||
|
openMonday: true, openTuesday: true, openWednesday: true,
|
||||||
|
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||||
|
phone: {
|
||||||
|
primaryPhoneNumber: raw.clinic.phone?.replace(/[^0-9]/g, '').slice(-10) ?? '',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
},
|
||||||
|
addressCustom: {
|
||||||
|
addressStreet1: raw.clinic.address?.split(',')[0] ?? 'New BEL Road',
|
||||||
|
addressCity: raw.clinic.city ?? 'Bangalore',
|
||||||
|
addressState: raw.clinic.state ?? 'Karnataka',
|
||||||
|
addressCountry: 'India',
|
||||||
|
addressPostcode: raw.clinic.pincode ?? '560054',
|
||||||
|
},
|
||||||
|
onlineBooking: true,
|
||||||
|
walkInAllowed: true,
|
||||||
|
});
|
||||||
|
console.log(` ${raw.clinic.name}: ${clinicId}\n`);
|
||||||
|
|
||||||
|
// Re-auth (long operation ahead)
|
||||||
|
await auth();
|
||||||
|
|
||||||
|
// Doctors — batch in groups of 20 with re-auth
|
||||||
|
console.log(`👨⚕️ Doctors (${raw.doctors.length})`);
|
||||||
|
let created = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < raw.doctors.length; i++) {
|
||||||
|
// Re-auth every 40 doctors (token may expire on long runs)
|
||||||
|
if (i > 0 && i % 40 === 0) {
|
||||||
|
await auth();
|
||||||
|
console.log(` (re-authed at ${i})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = raw.doctors[i];
|
||||||
|
const firstName = doc.name.replace(/^Dr\.?\s*/i, '').split(' ')[0] ?? '';
|
||||||
|
const lastNameParts = doc.name.replace(/^Dr\.?\s*/i, '').split(' ').slice(1);
|
||||||
|
const lastName = lastNameParts.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mk('doctor', {
|
||||||
|
name: doc.name,
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
department: doc.department ?? 'Other',
|
||||||
|
specialty: doc.designation ?? 'Consultant',
|
||||||
|
qualifications: doc.qualifications ?? '',
|
||||||
|
registrationNumber: '',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
if (created % 20 === 0) console.log(` ${created}/${raw.doctors.length} created...`);
|
||||||
|
} catch (err: any) {
|
||||||
|
failed++;
|
||||||
|
console.error(` ✗ ${doc.name}: ${err.message?.slice(0, 80)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n ✅ ${created} doctors created, ${failed} failed\n`);
|
||||||
|
console.log('🎉 Ramaiah seed complete!');
|
||||||
|
console.log(` 1 clinic · ${created} doctors · ${raw.departments.length} departments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||||
@@ -2,12 +2,11 @@ import type { FC, HTMLAttributes } from "react";
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import type { Placement } from "@react-types/overlays";
|
import type { Placement } from "@react-types/overlays";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume, faSort } from "@fortawesome/pro-duotone-svg-icons";
|
import { faArrowRightFromBracket, faSort, faUser, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
||||||
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
||||||
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
|
|
||||||
import { useFocusManager } from "react-aria";
|
import { useFocusManager } from "react-aria";
|
||||||
import type { DialogProps as AriaDialogProps } from "react-aria-components";
|
import type { DialogProps as AriaDialogProps } from "react-aria-components";
|
||||||
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||||
@@ -32,9 +31,10 @@ type NavAccountType = {
|
|||||||
export const NavAccountMenu = ({
|
export const NavAccountMenu = ({
|
||||||
className,
|
className,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => {
|
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onViewProfile?: () => void; onAccountSettings?: () => void }) => {
|
||||||
const focusManager = useFocusManager();
|
const focusManager = useFocusManager();
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -71,17 +71,19 @@ export const NavAccountMenu = ({
|
|||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)}
|
className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)}
|
||||||
>
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||||
<div className="flex flex-col gap-0.5 py-1.5">
|
<div className="flex flex-col gap-0.5 py-1.5">
|
||||||
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
<NavAccountCardMenuItem label="View profile" icon={IconUser} onClick={() => { close(); onViewProfile?.(); }} />
|
||||||
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} onClick={() => { close(); onAccountSettings?.(); }} />
|
||||||
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={onForceReady} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-1 pb-1.5">
|
<div className="pt-1 pb-1.5">
|
||||||
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={onSignOut} />
|
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -122,13 +124,15 @@ export const NavAccountCard = ({
|
|||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
items = [],
|
items = [],
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
}: {
|
}: {
|
||||||
popoverPlacement?: Placement;
|
popoverPlacement?: Placement;
|
||||||
selectedAccountId?: string;
|
selectedAccountId?: string;
|
||||||
items?: NavAccountType[];
|
items?: NavAccountType[];
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
onForceReady?: () => void;
|
onViewProfile?: () => void;
|
||||||
|
onAccountSettings?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const isDesktop = useBreakpoint("lg");
|
const isDesktop = useBreakpoint("lg");
|
||||||
@@ -141,7 +145,7 @@ export const NavAccountCard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 ring-1 ring-secondary ring-inset">
|
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3">
|
||||||
<AvatarLabelGroup
|
<AvatarLabelGroup
|
||||||
size="md"
|
size="md"
|
||||||
src={selectedAccount.avatar}
|
src={selectedAccount.avatar}
|
||||||
@@ -169,7 +173,7 @@ export const NavAccountCard = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onForceReady={onForceReady} />
|
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onViewProfile={onViewProfile} onAccountSettings={onAccountSettings} />
|
||||||
</AriaPopover>
|
</AriaPopover>
|
||||||
</AriaDialogTrigger>
|
</AriaDialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Badge } from "@/components/base/badges/badges";
|
|||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
const styles = sortCx({
|
const styles = sortCx({
|
||||||
root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
root: "group relative flex w-full cursor-pointer items-center rounded-md outline-focus-ring transition duration-100 ease-linear select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
rootSelected: "bg-active hover:bg-secondary_hover border-l-2 border-l-brand-600 text-brand-secondary",
|
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface NavItemBaseProps {
|
interface NavItemBaseProps {
|
||||||
@@ -48,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
const labelElement = (
|
const labelElement = (
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex-1 text-md font-semibold text-secondary transition-inherit-all group-hover:text-secondary_hover",
|
"flex-1 text-md font-semibold text-white transition-inherit-all",
|
||||||
truncate && "truncate",
|
truncate && "truncate",
|
||||||
current && "text-secondary_hover",
|
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -62,7 +62,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
|
|
||||||
if (type === "collapsible") {
|
if (type === "collapsible") {
|
||||||
return (
|
return (
|
||||||
<summary className={cx("px-3 py-2", styles.root, current && styles.rootSelected)} onClick={onClick}>
|
<summary
|
||||||
|
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
|
onClick={onClick}>
|
||||||
{iconElement}
|
{iconElement}
|
||||||
|
|
||||||
{labelElement}
|
{labelElement}
|
||||||
@@ -80,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cx("py-2 pr-3 pl-10", styles.root, current && styles.rootSelected)}
|
className={cx("py-2 pr-3 pl-10 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
@@ -96,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cx("px-3 py-2", styles.root, current && styles.rootSelected)}
|
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
|||||||
onApply?: () => void;
|
onApply?: () => void;
|
||||||
/** The function to call when the cancel button is clicked. */
|
/** The function to call when the cancel button is clicked. */
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/** Override popover placement — use "top start" in narrow panels
|
||||||
|
* where "bottom start" would overflow the viewport. */
|
||||||
|
popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => {
|
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => {
|
||||||
const formatter = useDateFormatter({
|
const formatter = useDateFormatter({
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -40,7 +43,8 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
|
|||||||
</AriaGroup>
|
</AriaGroup>
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
offset={8}
|
offset={8}
|
||||||
placement="bottom right"
|
placement={popoverPlacement ?? "bottom start"}
|
||||||
|
shouldFlip
|
||||||
className={({ isEntering, isExiting }) =>
|
className={({ isEntering, isExiting }) =>
|
||||||
cx(
|
cx(
|
||||||
"origin-(--trigger-anchor-point) will-change-transform",
|
"origin-(--trigger-anchor-point) will-change-transform",
|
||||||
|
|||||||
73
src/components/application/date-picker/time-picker.tsx
Normal file
73
src/components/application/date-picker/time-picker.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Select } from "@/components/base/select/select";
|
||||||
|
|
||||||
|
// 30-minute increments from 05:00 to 23:00 → 37 slots.
|
||||||
|
// Covers every realistic clinic opening / closing time.
|
||||||
|
// Values are 24-hour HH:MM strings — the same format stored on the
|
||||||
|
// Clinic + DoctorVisitSlot entities in the platform. Labels are
|
||||||
|
// 12-hour format with AM/PM for readability.
|
||||||
|
const TIME_SLOTS = Array.from({ length: 37 }, (_, i) => {
|
||||||
|
const totalMinutes = 5 * 60 + i * 30;
|
||||||
|
const hour = Math.floor(totalMinutes / 60);
|
||||||
|
const minute = totalMinutes % 60;
|
||||||
|
const h12 = hour % 12 || 12;
|
||||||
|
const period = hour >= 12 ? "PM" : "AM";
|
||||||
|
const id = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
const label = `${h12}:${String(minute).padStart(2, "0")} ${period}`;
|
||||||
|
return { id, label };
|
||||||
|
});
|
||||||
|
|
||||||
|
type TimePickerProps = {
|
||||||
|
/** Field label rendered above the select. */
|
||||||
|
label?: string;
|
||||||
|
/** Current value in 24-hour HH:MM format, or null when unset. */
|
||||||
|
value: string | null;
|
||||||
|
/** Called with the new HH:MM string when the user picks a slot. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
isRequired?: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A minimal time-of-day picker built on top of the existing base
|
||||||
|
// Select component. Intentionally dropdown-based rather than the
|
||||||
|
// full DateTimePicker popover pattern from the reference demo —
|
||||||
|
// the clinic + doctor flows only need time, not date, and a
|
||||||
|
// dropdown is faster to use when the agent already knows the time.
|
||||||
|
//
|
||||||
|
// Use this for: clinic.opensAt / closesAt, doctorVisitSlot.startTime /
|
||||||
|
// endTime. For time-AND-date (appointment scheduling), stick with the
|
||||||
|
// existing DatePicker in the same directory.
|
||||||
|
export const TimePicker = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isRequired,
|
||||||
|
isDisabled,
|
||||||
|
placeholder = "Select time",
|
||||||
|
}: TimePickerProps) => (
|
||||||
|
<Select
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
items={TIME_SLOTS}
|
||||||
|
selectedKey={value}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key !== null) onChange(String(key));
|
||||||
|
}}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
{(slot) => <Select.Item id={slot.id} label={slot.label} />}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format a 24-hour HH:MM string as a 12-hour display label (e.g.
|
||||||
|
// "09:30" → "9:30 AM"). Useful on list/detail pages that render
|
||||||
|
// stored clinic hours without re-mounting the picker.
|
||||||
|
export const formatTimeLabel = (hhmm: string | null | undefined): string => {
|
||||||
|
if (!hhmm) return "—";
|
||||||
|
const [h, m] = hhmm.split(":").map(Number);
|
||||||
|
if (Number.isNaN(h) || Number.isNaN(m)) return hhmm;
|
||||||
|
const h12 = h % 12 || 12;
|
||||||
|
const period = h >= 12 ? "PM" : "AM";
|
||||||
|
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
|
||||||
|
};
|
||||||
108
src/components/application/day-selector/day-selector.tsx
Normal file
108
src/components/application/day-selector/day-selector.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
// Keys match the Clinic entity's openMonday..openSunday fields
|
||||||
|
// directly — no translation layer needed when reading/writing the
|
||||||
|
// form value into GraphQL mutations.
|
||||||
|
export type DayKey =
|
||||||
|
| "monday"
|
||||||
|
| "tuesday"
|
||||||
|
| "wednesday"
|
||||||
|
| "thursday"
|
||||||
|
| "friday"
|
||||||
|
| "saturday"
|
||||||
|
| "sunday";
|
||||||
|
|
||||||
|
export type DaySelection = Record<DayKey, boolean>;
|
||||||
|
|
||||||
|
const DAYS: { key: DayKey; label: string }[] = [
|
||||||
|
{ key: "monday", label: "Mon" },
|
||||||
|
{ key: "tuesday", label: "Tue" },
|
||||||
|
{ key: "wednesday", label: "Wed" },
|
||||||
|
{ key: "thursday", label: "Thu" },
|
||||||
|
{ key: "friday", label: "Fri" },
|
||||||
|
{ key: "saturday", label: "Sat" },
|
||||||
|
{ key: "sunday", label: "Sun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type DaySelectorProps = {
|
||||||
|
/** Selected-state for each weekday. */
|
||||||
|
value: DaySelection;
|
||||||
|
/** Fires with the full updated selection whenever a pill is tapped. */
|
||||||
|
onChange: (value: DaySelection) => void;
|
||||||
|
/** Optional heading above the pills. */
|
||||||
|
label?: string;
|
||||||
|
/** Optional helper text below the pills. */
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seven tappable Mon–Sun pills. Used on the Clinic form to pick which
|
||||||
|
// days the clinic is open, since the Clinic entity has seven separate
|
||||||
|
// BOOLEAN fields (openMonday..openSunday) — SDK has no MULTI_SELECT.
|
||||||
|
// Also reusable anywhere else we need a weekly-recurrence picker
|
||||||
|
// (future: follow-up schedules, on-call rotations).
|
||||||
|
export const DaySelector = ({ value, onChange, label, hint }: DaySelectorProps) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-secondary">{label}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS.map(({ key, label: dayLabel }) => {
|
||||||
|
const isSelected = !!value[key];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...value, [key]: !isSelected })}
|
||||||
|
className={cx(
|
||||||
|
"flex h-10 min-w-12 items-center justify-center rounded-full border px-4 text-sm font-semibold transition duration-100 ease-linear",
|
||||||
|
isSelected
|
||||||
|
? "border-brand bg-brand-solid text-white hover:bg-brand-solid_hover"
|
||||||
|
: "border-secondary bg-primary text-secondary hover:border-primary hover:bg-secondary_hover",
|
||||||
|
)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{dayLabel}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hint && <span className="text-xs text-tertiary">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper factories — use these instead of spelling out the empty
|
||||||
|
// object literal everywhere.
|
||||||
|
export const emptyDaySelection = (): DaySelection => ({
|
||||||
|
monday: false,
|
||||||
|
tuesday: false,
|
||||||
|
wednesday: false,
|
||||||
|
thursday: false,
|
||||||
|
friday: false,
|
||||||
|
saturday: false,
|
||||||
|
sunday: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The default new-clinic state: Mon–Sat open, Sun closed. Matches the
|
||||||
|
// typical Indian outpatient hospital schedule.
|
||||||
|
export const defaultDaySelection = (): DaySelection => ({
|
||||||
|
monday: true,
|
||||||
|
tuesday: true,
|
||||||
|
wednesday: true,
|
||||||
|
thursday: true,
|
||||||
|
friday: true,
|
||||||
|
saturday: true,
|
||||||
|
sunday: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format a DaySelection as a compact human-readable string for list
|
||||||
|
// pages (e.g. "Mon–Fri", "Mon–Sat", "Mon Wed Fri"). Collapses
|
||||||
|
// consecutive selected days into ranges.
|
||||||
|
export const formatDaySelection = (sel: DaySelection): string => {
|
||||||
|
const openKeys = DAYS.filter((d) => sel[d.key]).map((d) => d.label);
|
||||||
|
if (openKeys.length === 0) return "Closed";
|
||||||
|
if (openKeys.length === 7) return "Every day";
|
||||||
|
// Monday-Friday, Monday-Saturday shorthand
|
||||||
|
if (openKeys.length === 5 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri") return "Mon–Fri";
|
||||||
|
if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "Mon–Sat";
|
||||||
|
return openKeys.join(" ");
|
||||||
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import type { PaginationRootProps } from "./pagination-base";
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
@@ -23,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
className={({ isSelected }) =>
|
className={({ isSelected }) =>
|
||||||
cx(
|
cx(
|
||||||
"flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
|
"flex size-9 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||||
rounded ? "rounded-full" : "rounded-lg",
|
rounded ? "rounded-full" : "rounded-lg",
|
||||||
isSelected && "bg-primary_hover text-secondary",
|
isSelected && "bg-primary_hover text-secondary",
|
||||||
)
|
)
|
||||||
@@ -34,43 +33,6 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MobilePaginationProps {
|
|
||||||
/** The current page. */
|
|
||||||
page?: number;
|
|
||||||
/** The total number of pages. */
|
|
||||||
total?: number;
|
|
||||||
/** The class name of the pagination component. */
|
|
||||||
className?: string;
|
|
||||||
/** The function to call when the page changes. */
|
|
||||||
onPageChange?: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => {
|
|
||||||
return (
|
|
||||||
<nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
|
|
||||||
<Button
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
iconLeading={ArrowLeft}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange?.(Math.max(0, page - 1))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="text-sm text-fg-secondary">
|
|
||||||
Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
aria-label="Go to next page"
|
|
||||||
iconLeading={ArrowRight}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange?.(Math.min(total, page + 1))}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
||||||
const isDesktop = useBreakpoint("md");
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
@@ -84,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
<div className="hidden flex-1 justify-start md:flex">
|
<div className="hidden flex-1 justify-start md:flex">
|
||||||
<Pagination.PrevTrigger asChild>
|
<Pagination.PrevTrigger asChild>
|
||||||
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
||||||
{isDesktop ? "Previous" : undefined}{" "}
|
{isDesktop ? "Previous" : undefined}
|
||||||
</Button>
|
</Button>
|
||||||
</Pagination.PrevTrigger>
|
</Pagination.PrevTrigger>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -159,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -210,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -235,99 +197,3 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PaginationCardMinimalProps {
|
|
||||||
/** The current page. */
|
|
||||||
page?: number;
|
|
||||||
/** The total number of pages. */
|
|
||||||
total?: number;
|
|
||||||
/** The alignment of the pagination. */
|
|
||||||
align?: "left" | "center" | "right";
|
|
||||||
/** The class name of the pagination component. */
|
|
||||||
className?: string;
|
|
||||||
/** The function to call when the page changes. */
|
|
||||||
onPageChange?: (page: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
|
|
||||||
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
|
|
||||||
|
|
||||||
<nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
|
|
||||||
<div className={cx(align === "center" && "flex flex-1 justify-start")}>
|
|
||||||
<Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={cx(
|
|
||||||
"text-sm font-medium text-fg-secondary",
|
|
||||||
align === "right" && "order-first mr-auto",
|
|
||||||
align === "left" && "order-last ml-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Page {page} of {total}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className={cx(align === "center" && "flex flex-1 justify-end")}>
|
|
||||||
<Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
|
|
||||||
/** The alignment of the pagination. */
|
|
||||||
align?: "left" | "center" | "right";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => {
|
|
||||||
const isDesktop = useBreakpoint("md");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
|
|
||||||
align === "left" && "justify-start",
|
|
||||||
align === "center" && "justify-center",
|
|
||||||
align === "right" && "justify-end",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Pagination.Root {...props} page={page} total={total}>
|
|
||||||
<Pagination.Context>
|
|
||||||
{({ pages }) => (
|
|
||||||
<ButtonGroup size="md">
|
|
||||||
<Pagination.PrevTrigger asChild>
|
|
||||||
<ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
|
|
||||||
</Pagination.PrevTrigger>
|
|
||||||
|
|
||||||
{pages.map((page, index) =>
|
|
||||||
page.type === "page" ? (
|
|
||||||
<Pagination.Item key={index} {...page} asChild>
|
|
||||||
<ButtonGroupItem isSelected={page.isCurrent} className="size-10 items-center justify-center">
|
|
||||||
{page.value}
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</Pagination.Item>
|
|
||||||
) : (
|
|
||||||
<Pagination.Ellipsis key={index}>
|
|
||||||
<ButtonGroupItem className="pointer-events-none size-10 items-center justify-center rounded-none!">
|
|
||||||
…
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</Pagination.Ellipsis>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pagination.NextTrigger asChild>
|
|
||||||
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
|
|
||||||
</Pagination.NextTrigger>
|
|
||||||
</ButtonGroup>
|
|
||||||
)}
|
|
||||||
</Pagination.Context>
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
cx(
|
cx(
|
||||||
"fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
|
"fixed inset-0 z-50 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
|
||||||
state.isEntering && "duration-300 animate-in fade-in",
|
state.isEntering && "duration-300 animate-in fade-in",
|
||||||
state.isExiting && "duration-500 animate-out fade-out",
|
state.isExiting && "duration-500 animate-out fade-out",
|
||||||
typeof props.className === "function" ? props.className(state) : props.className,
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
@@ -81,7 +81,7 @@ const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
|
|||||||
Menu.displayName = "SlideoutMenu";
|
Menu.displayName = "SlideoutMenu";
|
||||||
|
|
||||||
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
|
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
|
||||||
return <div role={role} {...props} className={cx("flex size-full flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
|
return <div role={role} {...props} className={cx("flex flex-1 min-h-0 flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
|
||||||
};
|
};
|
||||||
Content.displayName = "SlideoutContent";
|
Content.displayName = "SlideoutContent";
|
||||||
|
|
||||||
|
|||||||
93
src/components/application/table/column-toggle.tsx
Normal file
93
src/components/application/table/column-toggle.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faColumns3 } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const ColumnsIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faColumns3} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ColumnDef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ColumnToggleProps {
|
||||||
|
columns: ColumnDef[];
|
||||||
|
visibleColumns: Set<string>;
|
||||||
|
onToggle: (columnId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColumnToggle = ({ columns, visibleColumns, onToggle }: ColumnToggleProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={panelRef}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={ColumnsIcon}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 w-56 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-secondary">
|
||||||
|
<span className="text-xs font-semibold text-tertiary">Show/Hide Columns</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto py-1">
|
||||||
|
{columns.map(col => (
|
||||||
|
<button
|
||||||
|
key={col.id}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggle(col.id); }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-primary_hover cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<span className={`flex size-4 shrink-0 items-center justify-center rounded border ${visibleColumns.has(col.id) ? 'bg-brand-solid border-brand text-white' : 'border-primary bg-primary'}`}>
|
||||||
|
{visibleColumns.has(col.id) && <span className="text-[10px]">✓</span>}
|
||||||
|
</span>
|
||||||
|
{col.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useColumnVisibility = (columns: ColumnDef[]) => {
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
|
||||||
|
return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (columnId: string) => {
|
||||||
|
setVisibleColumns(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(columnId)) {
|
||||||
|
next.delete(columnId);
|
||||||
|
} else {
|
||||||
|
next.add(columnId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { visibleColumns, toggle };
|
||||||
|
};
|
||||||
63
src/components/application/table/dynamic-table.tsx
Normal file
63
src/components/application/table/dynamic-table.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||||
|
import { Table } from './table';
|
||||||
|
|
||||||
|
export type DynamicColumn = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
headerRenderer?: () => ReactNode;
|
||||||
|
width?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DynamicRow = {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DynamicTableProps<T extends DynamicRow> {
|
||||||
|
columns: DynamicColumn[];
|
||||||
|
rows: T[];
|
||||||
|
renderCell: (row: T, columnId: string) => ReactNode;
|
||||||
|
rowClassName?: (row: T) => string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
maxRows?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicTable = <T extends DynamicRow>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
renderCell,
|
||||||
|
rowClassName,
|
||||||
|
size = 'sm',
|
||||||
|
maxRows,
|
||||||
|
className,
|
||||||
|
}: DynamicTableProps<T>) => {
|
||||||
|
const displayRows = maxRows ? rows.slice(0, maxRows) : rows;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table size={size} aria-label="Dynamic table" className={className}>
|
||||||
|
<Table.Header>
|
||||||
|
{columns.map(col => (
|
||||||
|
<Table.Head key={col.id} id={col.id} label={col.headerRenderer ? '' : col.label}>
|
||||||
|
{col.headerRenderer?.()}
|
||||||
|
</Table.Head>
|
||||||
|
))}
|
||||||
|
</Table.Header>
|
||||||
|
<AriaTableBody items={displayRows}>
|
||||||
|
{(row) => (
|
||||||
|
<Table.Row
|
||||||
|
id={row.id}
|
||||||
|
className={rowClassName?.(row)}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<Table.Cell key={col.id}>
|
||||||
|
{renderCell(row, col.id)}
|
||||||
|
</Table.Cell>
|
||||||
|
))}
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</AriaTableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
Cell as AriaCell,
|
Cell as AriaCell,
|
||||||
Collection as AriaCollection,
|
Collection as AriaCollection,
|
||||||
Column as AriaColumn,
|
Column as AriaColumn,
|
||||||
|
ColumnResizer as AriaColumnResizer,
|
||||||
Group as AriaGroup,
|
Group as AriaGroup,
|
||||||
|
ResizableTableContainer as AriaResizableTableContainer,
|
||||||
Row as AriaRow,
|
Row as AriaRow,
|
||||||
Table as AriaTable,
|
Table as AriaTable,
|
||||||
TableBody as AriaTableBody,
|
TableBody as AriaTableBody,
|
||||||
@@ -55,7 +57,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
|
|||||||
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size }}>
|
<TableContext.Provider value={{ size }}>
|
||||||
<div {...props} className={cx("overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
|
<div {...props} className={cx("flex flex-col overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
@@ -81,7 +83,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"relative flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
|
"relative shrink-0 flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
|
||||||
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -115,9 +117,9 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
||||||
<div className="overflow-x-auto">
|
<AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
|
||||||
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
|
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
|
||||||
</div>
|
</AriaResizableTableContainer>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -138,7 +140,7 @@ const TableHeader = <T extends object>({ columns, children, bordered = true, cla
|
|||||||
{...props}
|
{...props}
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
cx(
|
cx(
|
||||||
"relative bg-secondary",
|
"relative bg-secondary sticky top-0 z-10",
|
||||||
size === "sm" ? "h-9" : "h-11",
|
size === "sm" ? "h-9" : "h-11",
|
||||||
|
|
||||||
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||||
@@ -168,9 +170,10 @@ TableHeader.displayName = "TableHeader";
|
|||||||
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
||||||
label?: string;
|
label?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
resizable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => {
|
const TableHead = ({ className, tooltip, label, children, resizable = true, ...props }: TableHeadProps) => {
|
||||||
const { selectionBehavior } = useTableOptions();
|
const { selectionBehavior } = useTableOptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -186,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<AriaGroup className="flex items-center gap-1">
|
<AriaGroup className="flex items-center gap-1" role="presentation">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-1 items-center gap-1 truncate">
|
||||||
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
|
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
|
||||||
{typeof children === "function" ? children(state) : children}
|
{typeof children === "function" ? children(state) : children}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,6 +209,12 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
|
|||||||
) : (
|
) : (
|
||||||
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
|
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{resizable && (
|
||||||
|
<AriaColumnResizer
|
||||||
|
className="absolute right-0 top-1 bottom-1 w-[3px] rounded-full bg-tertiary cursor-col-resize touch-none hover:bg-brand-solid focus-visible:bg-brand-solid transition-colors duration-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AriaGroup>
|
</AriaGroup>
|
||||||
)}
|
)}
|
||||||
</AriaColumn>
|
</AriaColumn>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export const AvatarLabelGroup = ({ title, subtitle, className, ...props }: Avata
|
|||||||
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
||||||
<Avatar {...props} />
|
<Avatar {...props} />
|
||||||
<figcaption className="min-w-0 flex-1">
|
<figcaption className="min-w-0 flex-1">
|
||||||
<p className={cx("text-primary", styles[props.size].title)}>{title}</p>
|
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
|
||||||
<p className={cx("truncate text-tertiary", styles[props.size].subtitle)}>{subtitle}</p>
|
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
|
|||||||
14
src/components/base/avatar/base-components/avatar-count.tsx
Normal file
14
src/components/base/avatar/base-components/avatar-count.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
interface AvatarCountProps {
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarCount = ({ count, className }: AvatarCountProps) => (
|
||||||
|
<div className={cx("absolute right-0 bottom-0 p-px", className)}>
|
||||||
|
<div className="flex size-3.5 items-center justify-center rounded-full bg-fg-error-primary text-center text-[10px] leading-[13px] font-bold text-white">
|
||||||
|
{count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -65,7 +65,7 @@ const Group = ({ inputClassName, containerClassName, width, maxLength = 4, ...pr
|
|||||||
aria-label="Enter your pin"
|
aria-label="Enter your pin"
|
||||||
aria-labelledby={"pin-input-label-" + id}
|
aria-labelledby={"pin-input-label-" + id}
|
||||||
aria-describedby={"pin-input-description-" + id}
|
aria-describedby={"pin-input-description-" + id}
|
||||||
containerClassName={cx("flex flex-row gap-3", size === "sm" && "gap-2", heights[size], containerClassName)}
|
containerClassName={cx("flex flex-row items-center gap-3", size === "sm" && "gap-2", heights[size], containerClassName)}
|
||||||
className={cx("w-full! disabled:cursor-not-allowed", inputClassName)}
|
className={cx("w-full! disabled:cursor-not-allowed", inputClassName)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -115,8 +115,8 @@ const FakeCaret = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
|
|||||||
|
|
||||||
const Separator = (props: ComponentPropsWithRef<"p">) => {
|
const Separator = (props: ComponentPropsWithRef<"p">) => {
|
||||||
return (
|
return (
|
||||||
<div role="separator" {...props} className={cx("text-center text-display-xl font-medium text-placeholder_subtle", props.className)}>
|
<div role="separator" {...props} className={cx("flex items-center justify-center text-lg text-placeholder_subtle", props.className)}>
|
||||||
-
|
–
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
49
src/components/base/select/select-shared.tsx
Normal file
49
src/components/base/select/select-shared.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export type SelectItemType = {
|
||||||
|
/** Unique identifier for the item. */
|
||||||
|
id: string | number;
|
||||||
|
/** The primary display text. */
|
||||||
|
label?: string;
|
||||||
|
/** Avatar image URL. */
|
||||||
|
avatarUrl?: string;
|
||||||
|
/** Whether the item is disabled. */
|
||||||
|
isDisabled?: boolean;
|
||||||
|
/** Secondary text displayed alongside the label. */
|
||||||
|
supportingText?: string;
|
||||||
|
/** Leading icon component or element. */
|
||||||
|
icon?: FC | ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CommonProps {
|
||||||
|
/** Helper text displayed below the input. */
|
||||||
|
hint?: string;
|
||||||
|
/** Field label displayed above the input. */
|
||||||
|
label?: string;
|
||||||
|
/** Tooltip text for the help icon next to the label. */
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* The size of the component.
|
||||||
|
* @default "md"
|
||||||
|
*/
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
/** Placeholder text when no value is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Whether to hide the required indicator from the label. */
|
||||||
|
hideRequiredIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sizes = {
|
||||||
|
sm: {
|
||||||
|
root: "py-2 pl-3 pr-2.5 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]",
|
||||||
|
withIcon: "",
|
||||||
|
text: "text-sm",
|
||||||
|
textContainer: "gap-x-1.5",
|
||||||
|
shortcut: "pr-2.5",
|
||||||
|
},
|
||||||
|
md: { root: "py-2 px-3 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-2.5" },
|
||||||
|
lg: { root: "py-2.5 px-3.5 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-3" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectContext = createContext<{ size: "sm" | "md" | "lg" }>({ size: "md" });
|
||||||
@@ -1,29 +1,36 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
faPause, faPlay, faCalendarPlus, faCheckCircle,
|
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
|
||||||
faPhoneArrowRight, faRecordVinyl,
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||||
import { setOutboundPending } from '@/state/sip-manager';
|
import { setOutboundPending } from '@/state/sip-manager';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { DispositionForm } from './disposition-form';
|
import { DispositionModal } from './disposition-modal';
|
||||||
|
import type { CallAction } from './disposition-modal';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { EnquiryForm } from './enquiry-form';
|
||||||
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import type { Lead, CallDisposition } from '@/types/entities';
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
|
|
||||||
|
|
||||||
interface ActiveCallCardProps {
|
interface ActiveCallCardProps {
|
||||||
lead: Lead | null;
|
lead: Lead | null;
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
|
missedCallId?: string | null;
|
||||||
|
onCallComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
@@ -32,19 +39,98 @@ const formatDuration = (seconds: number): string => {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||||
|
const { user } = useAuth();
|
||||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
|
||||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
// Which existing appointment is being edited (null = creating a new one).
|
||||||
|
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
|
||||||
|
// Clicking Edit on a pill sets this; clicking + New clears it.
|
||||||
|
const [editingApptId, setEditingApptId] = useState<string | null>(null);
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
// Capture direction at mount — survives through disposition stage
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
|
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||||
|
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||||
|
// Actions actually recorded during this call. Drives the disposition
|
||||||
|
// modal's priority-lock: if the agent booked an appointment and logged
|
||||||
|
// an enquiry, both badges render and the primary disposition is
|
||||||
|
// locked to APPOINTMENT_BOOKED.
|
||||||
|
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
|
||||||
|
const addActions = (...newActions: CallAction[]) => {
|
||||||
|
setActionsTaken((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const a of newActions) next.add(a);
|
||||||
|
return Array.from(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upcoming appointments for this caller (if returning patient) — drives
|
||||||
|
// the pill row above AppointmentForm so the agent can edit existing
|
||||||
|
// bookings in addition to creating new ones.
|
||||||
|
const { appointments } = useData();
|
||||||
|
const leadAppointments = useMemo(() => {
|
||||||
|
const patientId = (lead as any)?.patientId;
|
||||||
|
if (!patientId) return [];
|
||||||
|
const now = Date.now();
|
||||||
|
return appointments
|
||||||
|
.filter((a) =>
|
||||||
|
a.patientId === patientId
|
||||||
|
&& a.appointmentStatus !== 'CANCELLED'
|
||||||
|
&& a.appointmentStatus !== 'NO_SHOW'
|
||||||
|
&& a.appointmentStatus !== 'COMPLETED'
|
||||||
|
// Only future appointments make sense as reschedule targets.
|
||||||
|
// Past ones can't be edited — they already happened.
|
||||||
|
&& a.scheduledAt
|
||||||
|
&& new Date(a.scheduledAt).getTime() >= now,
|
||||||
|
)
|
||||||
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
|
||||||
|
}, [appointments, lead]);
|
||||||
|
|
||||||
|
const editingAppt = useMemo(
|
||||||
|
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
|
||||||
|
[leadAppointments, editingApptId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending pill click awaiting the reschedule-confirm modal. When the
|
||||||
|
// agent clicks a pill, we store the appointment id here + open the modal.
|
||||||
|
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
|
||||||
|
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
|
||||||
|
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
|
||||||
|
|
||||||
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
|
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||||
|
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
|
const wasAnsweredRef = useRef(callState === 'active');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Sync UCID to localStorage so sendBeacon can fire auto-dispose on page refresh.
|
||||||
|
// Cleared on disposition submit (handleDisposition below) or when call resets to idle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (callUcid) {
|
||||||
|
localStorage.setItem('helix_active_ucid', callUcid);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Don't clear on unmount if disposition hasn't fired — the
|
||||||
|
// beforeunload handler in SipProvider needs it
|
||||||
|
};
|
||||||
|
}, [callUcid]);
|
||||||
|
|
||||||
|
// Detect caller disconnect: call was active and ended without agent pressing End
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
||||||
|
setCallerDisconnected(true);
|
||||||
|
setDispositionOpen(true);
|
||||||
|
}
|
||||||
|
}, [callState, dispositionOpen]);
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
@@ -53,70 +139,71 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
||||||
|
|
||||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
||||||
setSavedDisposition(disposition);
|
// Hangup if still connected
|
||||||
|
if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') {
|
||||||
|
hangup();
|
||||||
|
}
|
||||||
|
|
||||||
// Submit disposition to sidecar — handles Ozonetel ACW release
|
// Submit disposition to sidecar
|
||||||
if (callUcid) {
|
if (callUcid) {
|
||||||
apiClient.post('/api/ozonetel/dispose', {
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
|
const disposePayload = {
|
||||||
ucid: callUcid,
|
ucid: callUcid,
|
||||||
disposition,
|
disposition,
|
||||||
|
agentId: agentCfg.ozonetelAgentId,
|
||||||
callerPhone,
|
callerPhone,
|
||||||
direction: callDirectionRef.current,
|
direction: callDirectionRef.current,
|
||||||
durationSec: callDuration,
|
durationSec: callDuration,
|
||||||
leadId: lead?.id ?? null,
|
leadId: lead?.id ?? null,
|
||||||
|
leadName: fullName || null,
|
||||||
notes,
|
notes,
|
||||||
}).catch((err) => console.warn('Disposition failed:', err));
|
missedCallId: missedCallId ?? undefined,
|
||||||
|
};
|
||||||
|
console.log('[DISPOSE] Sending disposition:', JSON.stringify(disposePayload));
|
||||||
|
apiClient.post('/api/ozonetel/dispose', disposePayload)
|
||||||
|
.then((res) => console.log('[DISPOSE] Response:', JSON.stringify(res)))
|
||||||
|
.catch((err) => console.error('[DISPOSE] Failed:', err));
|
||||||
|
} else {
|
||||||
|
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposition === 'APPOINTMENT_BOOKED') {
|
// Follow-ups are created by the enquiry form (where the agent picks
|
||||||
setPostCallStage('appointment');
|
// the date + context). No second creation here — that was causing
|
||||||
setAppointmentOpen(true);
|
// duplicate entries on every FOLLOW_UP_SCHEDULED call.
|
||||||
} else if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
|
||||||
setPostCallStage('follow-up');
|
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||||
// Create follow-up
|
localStorage.removeItem('helix_active_ucid');
|
||||||
try {
|
|
||||||
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
|
||||||
data: {
|
|
||||||
name: `Follow-up — ${fullName || phoneDisplay}`,
|
|
||||||
typeCustom: 'CALLBACK',
|
|
||||||
status: 'PENDING',
|
|
||||||
assignedAgent: null,
|
|
||||||
priority: 'NORMAL',
|
|
||||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
},
|
|
||||||
}, { silent: true });
|
|
||||||
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
|
|
||||||
} catch {
|
|
||||||
notify.info('Follow-up', 'Could not auto-create follow-up');
|
|
||||||
}
|
|
||||||
setPostCallStage('done');
|
|
||||||
} else {
|
|
||||||
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||||
setPostCallStage('done');
|
handleReset();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAppointmentSaved = () => {
|
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
if (outcome === 'RESCHEDULED') {
|
||||||
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
|
addActions('RESCHEDULE');
|
||||||
if (callState === 'active') {
|
notify.success('Appointment Rescheduled');
|
||||||
setAppointmentBookedDuringCall(true);
|
} else if (outcome === 'CANCELLED') {
|
||||||
|
addActions('CANCEL');
|
||||||
|
notify.success('Appointment Cancelled');
|
||||||
} else {
|
} else {
|
||||||
setPostCallStage('done');
|
addActions('APPOINTMENT');
|
||||||
|
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setPostCallStage(null);
|
setDispositionOpen(false);
|
||||||
setSavedDisposition(null);
|
setCallerDisconnected(false);
|
||||||
|
setActionsTaken([]);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
setCallUcid(null);
|
setCallUcid(null);
|
||||||
setOutboundPending(false);
|
setOutboundPending(false);
|
||||||
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outbound ringing — agent initiated the call
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
@@ -135,7 +222,7 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||||
End Call
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,17 +254,13 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
// Unanswered call (ringing → ended without ever reaching active)
|
||||||
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
|
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
||||||
// Done state
|
|
||||||
if (postCallStage === 'done') {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-success bg-success-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faCheckCircle} className="size-8 text-fg-success-primary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
|
<p className="text-sm font-semibold text-primary">{fullName || 'Missed Call'}</p>
|
||||||
<p className="text-xs text-tertiary mt-1">
|
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
||||||
{savedDisposition ? savedDisposition.replace(/_/g, ' ').toLowerCase() : 'logged'}
|
|
||||||
</p>
|
|
||||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||||
Back to Worklist
|
Back to Worklist
|
||||||
</Button>
|
</Button>
|
||||||
@@ -185,51 +268,14 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appointment booking after disposition
|
// Active call
|
||||||
if (postCallStage === 'appointment') {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
|
wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
|
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||||
<FontAwesomeIcon icon={faCalendarPlus} className="size-6 text-fg-brand-primary mb-2" />
|
{/* Pinned: caller info + controls */}
|
||||||
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
|
<div className="shrink-0 p-4">
|
||||||
<p className="text-xs text-tertiary mt-1">for {fullName || phoneDisplay}</p>
|
|
||||||
</div>
|
|
||||||
<AppointmentForm
|
|
||||||
isOpen={appointmentOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setAppointmentOpen(open);
|
|
||||||
if (!open) setPostCallStage('done');
|
|
||||||
}}
|
|
||||||
callerNumber={callerPhone}
|
|
||||||
leadName={fullName || null}
|
|
||||||
leadId={lead?.id ?? null}
|
|
||||||
onSaved={handleAppointmentSaved}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disposition form
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-primary">Call Ended — {fullName || phoneDisplay}</p>
|
|
||||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call
|
|
||||||
if (callState === 'active') {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
||||||
@@ -240,58 +286,269 @@ export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
|||||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{supervisorPresence === 'whisper' && (
|
||||||
|
<Badge size="sm" color="blue" type="pill-color">Supervisor coaching</Badge>
|
||||||
|
)}
|
||||||
|
{supervisorPresence === 'barge' && (
|
||||||
|
<Badge size="sm" color="brand" type="pill-color">Supervisor on call</Badge>
|
||||||
|
)}
|
||||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
|
||||||
<Button size="sm" color={isMuted ? 'primary-destructive' : 'secondary'}
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />}
|
|
||||||
onClick={toggleMute}>{isMuted ? 'Unmute' : 'Mute'}</Button>
|
|
||||||
<Button size="sm" color={isOnHold ? 'primary-destructive' : 'secondary'}
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />}
|
|
||||||
onClick={toggleHold}>{isOnHold ? 'Resume' : 'Hold'}</Button>
|
|
||||||
<Button size="sm" color="secondary"
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faCalendarPlus} className={className} />}
|
|
||||||
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
|
|
||||||
<Button size="sm" color="secondary"
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
|
|
||||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
|
||||||
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
|
|
||||||
onClick={() => {
|
|
||||||
const action = recordingPaused ? 'unPause' : 'pause';
|
|
||||||
if (callUcid) {
|
|
||||||
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
|
||||||
}
|
|
||||||
setRecordingPaused(!recordingPaused);
|
|
||||||
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
|
||||||
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneHangup} className={className} />}
|
|
||||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transfer dialog */}
|
{/* Call controls */}
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
className={cx(
|
||||||
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||||
|
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleHold}
|
||||||
|
title={isOnHold ? 'Resume' : 'Hold'}
|
||||||
|
className={cx(
|
||||||
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||||
|
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const action = recordingPaused ? 'unPause' : 'pause';
|
||||||
|
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||||
|
setRecordingPaused(!recordingPaused);
|
||||||
|
}}
|
||||||
|
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
||||||
|
className={cx(
|
||||||
|
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
|
||||||
|
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||||
|
|
||||||
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||||
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
|
isDisabled={!wasAnsweredRef.current}
|
||||||
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
|
|
||||||
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||||
|
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable: expanded forms + transfer */}
|
||||||
|
{(appointmentOpen || enquiryOpen || transferOpen) && (
|
||||||
|
<div className="flex flex-col min-h-0 flex-1 border-t border-secondary px-4 pb-4 pt-4">
|
||||||
{transferOpen && callUcid && (
|
{transferOpen && callUcid && (
|
||||||
<TransferDialog
|
<TransferDialog
|
||||||
ucid={callUcid}
|
ucid={callUcid}
|
||||||
|
currentAgentId={JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}').ozonetelAgentId}
|
||||||
onClose={() => setTransferOpen(false)}
|
onClose={() => setTransferOpen(false)}
|
||||||
onTransferred={() => {
|
onTransferred={() => {
|
||||||
setTransferOpen(false);
|
setTransferOpen(false);
|
||||||
hangup();
|
// A transfer implies the original agent handed the call
|
||||||
setPostCallStage('disposition');
|
// off — treat that as a follow-up action so the
|
||||||
|
// disposition pre-locks to FOLLOW_UP_SCHEDULED.
|
||||||
|
addActions('FOLLOWUP');
|
||||||
|
setDispositionOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{appointmentOpen && leadAppointments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingApptId(null)}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
|
||||||
|
!editingApptId
|
||||||
|
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||||
|
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} className="size-3" />
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
{leadAppointments.map((appt) => (
|
||||||
|
<div
|
||||||
|
key={appt.id}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
|
||||||
|
editingApptId === appt.id
|
||||||
|
? 'border-brand bg-brand-primary'
|
||||||
|
: 'border-secondary bg-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-primary">
|
||||||
|
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-tertiary">
|
||||||
|
{appt.doctorName ?? 'Doctor'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingApptId(appt.id)}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Appointment form accessible during call */}
|
{/* Key forces a full remount when switching between
|
||||||
|
pills (or between edit/view modes) so the form's
|
||||||
|
internal state re-initializes from the new
|
||||||
|
existingAppointment prop instead of staying
|
||||||
|
stuck on the first-mounted values. */}
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
|
key={`${editingApptId ?? 'new'}-${apptMode}`}
|
||||||
isOpen={appointmentOpen}
|
isOpen={appointmentOpen}
|
||||||
onOpenChange={setAppointmentOpen}
|
onOpenChange={(open) => {
|
||||||
|
setAppointmentOpen(open);
|
||||||
|
if (!open) { setEditingApptId(null); setApptMode('edit'); }
|
||||||
|
}}
|
||||||
callerNumber={callerPhone}
|
callerNumber={callerPhone}
|
||||||
leadName={fullName || null}
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
onSaved={handleAppointmentSaved}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
|
readOnly={apptMode === 'view'}
|
||||||
|
existingAppointment={editingAppt ? {
|
||||||
|
id: editingAppt.id,
|
||||||
|
scheduledAt: editingAppt.scheduledAt ?? '',
|
||||||
|
doctorName: editingAppt.doctorName ?? '',
|
||||||
|
doctorId: editingAppt.doctorId ?? undefined,
|
||||||
|
department: editingAppt.department ?? '',
|
||||||
|
clinicId: editingAppt.clinicId ?? undefined,
|
||||||
|
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
|
||||||
|
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
|
||||||
|
} : null}
|
||||||
|
onSaved={(outcome) => {
|
||||||
|
setEditingApptId(null);
|
||||||
|
setApptMode('edit');
|
||||||
|
handleAppointmentSaved(outcome);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EnquiryForm
|
||||||
|
isOpen={enquiryOpen}
|
||||||
|
onOpenChange={setEnquiryOpen}
|
||||||
|
callerPhone={callerPhone}
|
||||||
|
leadName={fullName || null}
|
||||||
|
leadId={lead?.id ?? null}
|
||||||
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
|
agentName={user.name}
|
||||||
|
onSaved={(actions) => {
|
||||||
|
setEnquiryOpen(false);
|
||||||
|
addActions(...actions);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reschedule confirm modal — fires when the agent clicks Edit
|
||||||
|
on an upcoming-appointment pill. Yes → open the form in
|
||||||
|
edit mode (fields editable, Save button). No → open in
|
||||||
|
view-only mode (fields disabled, Close button). */}
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={pendingApptId !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
|
||||||
|
isDismissable
|
||||||
|
>
|
||||||
|
<Modal className="sm:max-w-md">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||||
|
<p className="text-sm text-tertiary">
|
||||||
|
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||||
|
Choose "No, just view" to see the details without changing anything.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingApptId) {
|
||||||
|
setEditingApptId(pendingApptId);
|
||||||
|
setApptMode('view');
|
||||||
|
setPendingApptId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No, just view
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (pendingApptId) {
|
||||||
|
setEditingApptId(pendingApptId);
|
||||||
|
setApptMode('edit');
|
||||||
|
setPendingApptId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Yes, reschedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
|
||||||
|
{/* Disposition Modal — the ONLY path to end a call */}
|
||||||
|
<DispositionModal
|
||||||
|
isOpen={dispositionOpen}
|
||||||
|
callerName={fullName || phoneDisplay}
|
||||||
|
callerDisconnected={callerDisconnected}
|
||||||
|
// wasAnsweredRef only flips true once callState reaches
|
||||||
|
// 'active'. Outbound callbacks that never connect keep
|
||||||
|
// this false, which narrows the disposition options to
|
||||||
|
// no-answer outcomes and prevents SLA-gaming dispositions
|
||||||
|
// like Info Provided on a call the customer never took.
|
||||||
|
callAnswered={wasAnsweredRef.current}
|
||||||
|
actionsTaken={actionsTaken}
|
||||||
|
onSubmit={handleDisposition}
|
||||||
|
onDismiss={() => {
|
||||||
|
// Agent wants to continue the call — close modal, call stays active
|
||||||
|
if (!callerDisconnected) {
|
||||||
|
setDispositionOpen(false);
|
||||||
|
} else {
|
||||||
|
// Caller already disconnected — dismiss goes to worklist
|
||||||
|
handleReset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
137
src/components/call-desk/agent-status-toggle.tsx
Normal file
137
src/components/call-desk/agent-status-toggle.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
|
import type { OzonetelState } from '@/hooks/use-agent-state';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type ToggleableStatus = 'ready' | 'break' | 'training';
|
||||||
|
|
||||||
|
const displayConfig: Record<OzonetelState, { label: string; color: string; dotColor: string }> = {
|
||||||
|
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
|
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
|
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
calling: { label: 'Calling', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
'in-call': { label: 'In Call', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
acw: { label: 'Wrapping up', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
|
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOptions: Array<{ key: ToggleableStatus; label: string; color: string; dotColor: string }> = [
|
||||||
|
{ key: 'ready', label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
|
{ key: 'break', label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
|
||||||
|
{ key: 'training', label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type AgentStatusToggleProps = {
|
||||||
|
isRegistered: boolean;
|
||||||
|
connectionStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||||
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
|
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
||||||
|
const { state: ozonetelState } = useAgentState(agentId);
|
||||||
|
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = async (newStatus: ToggleableStatus) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (newStatus === ozonetelState) return;
|
||||||
|
setChanging(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newStatus === 'ready') {
|
||||||
|
console.log('[AGENT-STATE] Changing to Ready');
|
||||||
|
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
||||||
|
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||||
|
} else {
|
||||||
|
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||||
|
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
|
||||||
|
// transit through Ready. Insert a Ready hop whenever we're
|
||||||
|
// moving between two paused sub-states.
|
||||||
|
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
|
||||||
|
if (isPauseToPause) {
|
||||||
|
console.log(`[AGENT-STATE] ${ozonetelState}→${newStatus}: sending Ready first, then Pause(${pauseReason})`);
|
||||||
|
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 400));
|
||||||
|
}
|
||||||
|
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
||||||
|
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
||||||
|
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||||
|
}
|
||||||
|
// Don't setStatus — SSE will push the real state
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AGENT-STATE] Status change failed:', err);
|
||||||
|
notify.error('Status Change Failed', 'Could not update agent status');
|
||||||
|
} finally {
|
||||||
|
setChanging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If SIP isn't connected, show connection status with user-friendly message
|
||||||
|
if (!isRegistered) {
|
||||||
|
const statusMessages: Record<string, string> = {
|
||||||
|
disconnected: 'Telephony unavailable',
|
||||||
|
connecting: 'Connecting to telephony...',
|
||||||
|
connected: 'Registering...',
|
||||||
|
error: 'Telephony error — check VPN',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
||||||
|
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
|
||||||
|
<span className="text-xs font-medium text-tertiary">{statusMessages[connectionStatus] ?? connectionStatus}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
|
||||||
|
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => canToggle && setMenuOpen(!menuOpen)}
|
||||||
|
disabled={changing || !canToggle}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||||
|
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{changing ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||||
|
)}
|
||||||
|
<span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
|
||||||
|
{changing ? 'Changing…' : current.label}
|
||||||
|
</span>
|
||||||
|
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
|
{toggleOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
onClick={() => handleChange(opt.key)}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
opt.key === ozonetelState ? 'bg-active' : 'hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
|
||||||
|
<span className={opt.color}>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
import { useChat } from '@ai-sdk/react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
|
||||||
|
import { AiSuggestions, type Suggestion } from './ai-suggestions';
|
||||||
|
|
||||||
type ChatMessage = {
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CallerContext = {
|
type CallerContext = {
|
||||||
|
type?: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
leadName?: string;
|
leadName?: string;
|
||||||
@@ -19,144 +18,152 @@ type CallerContext = {
|
|||||||
|
|
||||||
interface AiChatPanelProps {
|
interface AiChatPanelProps {
|
||||||
callerContext?: CallerContext;
|
callerContext?: CallerContext;
|
||||||
role?: 'cc-agent' | 'admin' | 'executive';
|
callerSummary?: CallerSummary | null;
|
||||||
|
onChatStart?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_ASK_AGENT = [
|
const SUPERVISOR_QUICK_ACTIONS = [
|
||||||
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
|
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||||
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
|
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||||
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
|
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
|
||||||
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
|
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const QUICK_ASK_MANAGER = [
|
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
|
||||||
{ label: 'Agent performance', template: 'Which agents have the highest appointment conversion rates this week?' },
|
|
||||||
{ label: 'Missed call risks', template: 'Which missed calls have been waiting the longest without a callback?' },
|
|
||||||
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
|
|
||||||
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance — total calls, conversions, missed calls.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelProps) => {
|
|
||||||
const quickButtons = role === 'admin' ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages, scrollToBottom]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text?: string) => {
|
|
||||||
const messageText = (text ?? input).trim();
|
|
||||||
if (messageText.length === 0 || isLoading) return;
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
id: `user-${Date.now()}`,
|
|
||||||
role: 'user',
|
|
||||||
content: messageText,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
|
||||||
setInput('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
|
const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => {
|
||||||
|
const trimmed = content.trim();
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
|
const parsed = JSON.parse(trimmed);
|
||||||
message: messageText,
|
if (parsed.message) {
|
||||||
context: callerContext,
|
return {
|
||||||
|
message: parsed.message,
|
||||||
|
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { message: content, suggestions: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => {
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
const isSupervisor = callerContext?.type === 'supervisor';
|
||||||
|
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||||
|
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chatStartedRef = useRef(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
|
|
||||||
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||||
|
api: `${API_URL}/api/ai/stream`,
|
||||||
|
streamProtocol: 'text',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: { context: callerContext },
|
||||||
});
|
});
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
useEffect(() => {
|
||||||
id: `assistant-${Date.now()}`,
|
if (isLoading) return;
|
||||||
role: 'assistant',
|
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
|
||||||
content: data.reply ?? 'Sorry, I could not process that request.',
|
if (lastAssistant) {
|
||||||
timestamp: new Date(),
|
const parsed = parseAiResponse(lastAssistant.content);
|
||||||
|
if (parsed.suggestions.length > 0) {
|
||||||
|
setSuggestions(parsed.suggestions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = messagesEndRef.current;
|
||||||
|
if (el?.parentElement) {
|
||||||
|
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||||
|
}
|
||||||
|
if (messages.length > 0 && !chatStartedRef.current) {
|
||||||
|
chatStartedRef.current = true;
|
||||||
|
onChatStart?.();
|
||||||
|
}
|
||||||
|
}, [messages, onChatStart]);
|
||||||
|
|
||||||
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const leadId = callerContext?.leadId ?? null;
|
||||||
|
if (!leadId) {
|
||||||
|
if (autoFiredForLeadRef.current !== null) {
|
||||||
|
autoFiredForLeadRef.current = null;
|
||||||
|
setMessages([]);
|
||||||
|
setSuggestions([]);
|
||||||
|
chatStartedRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoFiredForLeadRef.current === leadId) return;
|
||||||
|
autoFiredForLeadRef.current = leadId;
|
||||||
|
setMessages([]);
|
||||||
|
setSuggestions([]);
|
||||||
|
chatStartedRef.current = false;
|
||||||
|
const name = callerContext?.leadName ?? 'this caller';
|
||||||
|
append({
|
||||||
|
role: 'user',
|
||||||
|
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
|
||||||
|
});
|
||||||
|
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||||
|
|
||||||
|
const handleQuickAction = (prompt: string) => {
|
||||||
|
append({ role: 'user', content: prompt });
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, assistantMessage]);
|
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
|
||||||
} catch {
|
append({
|
||||||
const errorMessage: ChatMessage = {
|
role: 'user',
|
||||||
id: `error-${Date.now()}`,
|
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
|
||||||
role: 'assistant',
|
});
|
||||||
content: 'Sorry, I\'m having trouble connecting to the AI service. Please try again.',
|
}, [append]);
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
setMessages((prev) => [...prev, errorMessage]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [input, isLoading, callerContext]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
// Filter out the currently-streaming assistant message (shows raw JSON).
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
// Only display completed assistant messages with parsed content.
|
||||||
e.preventDefault();
|
const displayMessages = messages
|
||||||
sendMessage();
|
.filter((msg, i) => {
|
||||||
|
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(msg => {
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
const parsed = parseAiResponse(msg.content);
|
||||||
|
return { ...msg, content: parsed.message };
|
||||||
}
|
}
|
||||||
}, [sendMessage]);
|
return msg;
|
||||||
|
});
|
||||||
const handleQuickAsk = useCallback((template: string) => {
|
|
||||||
sendMessage(template);
|
|
||||||
}, [sendMessage]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
{/* Caller context banner */}
|
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
|
||||||
{callerContext?.leadName && (
|
|
||||||
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
|
|
||||||
<span className="text-xs text-brand-secondary">
|
|
||||||
Talking to: <span className="font-semibold">{callerContext.leadName}</span>
|
|
||||||
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick ask buttons */}
|
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||||
{messages.length === 0 && (
|
{displayMessages.length === 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
{quickButtons.map((btn) => (
|
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||||
|
<p className="text-xs text-tertiary">{introText}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||||
|
{quickActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={action.label}
|
||||||
onClick={() => handleQuickAsk(btn.template)}
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{btn.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages area */}
|
|
||||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
||||||
{messages.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<FontAwesomeIcon icon={faRobot} className="mb-3 size-8 text-fg-quaternary" />
|
|
||||||
<p className="text-sm text-tertiary">
|
|
||||||
Ask me about doctors, clinics, packages, or patient info.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => (
|
{displayMessages.map((msg) => (
|
||||||
<div
|
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
key={msg.id}
|
<div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
|
||||||
>
|
}`}>
|
||||||
<div
|
|
||||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
|
||||||
msg.role === 'user'
|
|
||||||
? 'bg-brand-solid text-white'
|
|
||||||
: 'bg-secondary text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||||
@@ -183,71 +190,55 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
{!isSupervisor && suggestions.length > 0 && (
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||||
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||||
icon={faUserHeadset}
|
|
||||||
className="ml-2.5 size-3.5 text-fg-quaternary"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Ask the AI assistant..."
|
placeholder="Ask the AI assistant..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage()}
|
type="submit"
|
||||||
disabled={isLoading || input.trim().length === 0}
|
disabled={isLoading || input.trim().length === 0}
|
||||||
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:cursor-not-allowed disabled:bg-disabled"
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:cursor-not-allowed disabled:bg-disabled"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse simple markdown-like text into React nodes (safe, no innerHTML)
|
|
||||||
const parseLine = (text: string): ReactNode[] => {
|
const parseLine = (text: string): ReactNode[] => {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = boldPattern.exec(text)) !== null) {
|
while ((match = boldPattern.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<strong key={match.index} className="font-semibold">
|
|
||||||
{match[1]}
|
|
||||||
</strong>,
|
|
||||||
);
|
|
||||||
lastIndex = boldPattern.lastIndex;
|
lastIndex = boldPattern.lastIndex;
|
||||||
}
|
}
|
||||||
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||||
if (lastIndex < text.length) {
|
|
||||||
parts.push(text.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [text];
|
return parts.length > 0 ? parts : [text];
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageContent = ({ content }: { content: string }) => {
|
const MessageContent = ({ content }: { content: string }) => {
|
||||||
|
if (!content) return null;
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
||||||
|
|
||||||
// Bullet points
|
|
||||||
if (line.trimStart().startsWith('- ')) {
|
if (line.trimStart().startsWith('- ')) {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex gap-1.5 pl-1">
|
<div key={i} className="flex gap-1.5 pl-1">
|
||||||
@@ -256,7 +247,6 @@ const MessageContent = ({ content }: { content: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <p key={i}>{parseLine(line)}</p>;
|
return <p key={i}>{parseLine(line)}</p>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
102
src/components/call-desk/ai-suggestions.tsx
Normal file
102
src/components/call-desk/ai-suggestions.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTag, faArrowUp, faRotate, faClipboardCheck, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
export type Suggestion = {
|
||||||
|
id: string;
|
||||||
|
type: 'upsell' | 'crosssell' | 'retention' | 'operational';
|
||||||
|
title: string;
|
||||||
|
script: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AiSuggestionsProps {
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
onTellMeMore: (suggestion: Suggestion) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS = {
|
||||||
|
upsell: faArrowUp,
|
||||||
|
crosssell: faTag,
|
||||||
|
retention: faRotate,
|
||||||
|
operational: faClipboardCheck,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLORS = {
|
||||||
|
high: 'bg-error-solid',
|
||||||
|
medium: 'bg-warning-solid',
|
||||||
|
low: 'bg-success-solid',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiSuggestions = ({ suggestions, onTellMeMore }: AiSuggestionsProps) => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="flex w-full items-center justify-between px-3 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
Suggestions ({suggestions.length})
|
||||||
|
</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={collapsed ? faChevronDown : faChevronUp}
|
||||||
|
className="size-2.5 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="space-y-1 px-2 pb-2">
|
||||||
|
{suggestions.map((s) => {
|
||||||
|
const isExpanded = expandedId === s.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={cx(
|
||||||
|
'rounded-lg border transition duration-100 ease-linear',
|
||||||
|
isExpanded ? 'border-brand bg-brand-primary' : 'border-secondary hover:border-tertiary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(isExpanded ? null : s.id)}
|
||||||
|
className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={TYPE_ICONS[s.type]}
|
||||||
|
className="size-3 text-fg-brand-secondary shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs font-medium text-primary truncate">
|
||||||
|
{s.title}
|
||||||
|
</span>
|
||||||
|
<span className={cx('size-1.5 rounded-full shrink-0', PRIORITY_COLORS[s.priority])} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-2.5 pb-2.5">
|
||||||
|
<p className="text-xs text-secondary leading-relaxed mb-2">
|
||||||
|
{s.script}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTellMeMore(s);
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-medium text-brand-secondary hover:text-brand-primary transition"
|
||||||
|
>
|
||||||
|
Tell me more →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
88
src/components/call-desk/ai-summary-card.tsx
Normal file
88
src/components/call-desk/ai-summary-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUser, faCalendarCheck, faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
|
||||||
|
export type CallerSummary = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
isNew: boolean;
|
||||||
|
aiSummary?: string | null;
|
||||||
|
leadSource?: string | null;
|
||||||
|
utmCampaign?: string | null;
|
||||||
|
nextAppointment?: { scheduledAt: string; doctorName: string; department: string } | null;
|
||||||
|
lastAppointment?: { scheduledAt: string; status: string; department: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AiSummaryCardProps {
|
||||||
|
caller: CallerSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiSummaryCard = ({ caller }: AiSummaryCardProps) => {
|
||||||
|
if (!caller) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||||
|
<p className="text-xs text-quaternary text-center">Select a patient or receive a call</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-secondary_alt p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-primary">
|
||||||
|
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-primary truncate">{caller.name || caller.phone}</span>
|
||||||
|
<Badge size="sm" color={caller.isNew ? 'brand' : 'success'} type="pill-color">
|
||||||
|
{caller.isNew ? 'New' : 'Returning'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{caller.name && (
|
||||||
|
<span className="text-[10px] text-tertiary">{caller.phone}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caller.aiSummary && (
|
||||||
|
<p className="text-xs text-secondary leading-relaxed line-clamp-2">{caller.aiSummary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(caller.leadSource || caller.utmCampaign) && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{caller.leadSource && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">{caller.leadSource}</Badge>
|
||||||
|
)}
|
||||||
|
{caller.utmCampaign && (
|
||||||
|
<Badge size="sm" color="purple" type="pill-color">{caller.utmCampaign}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{caller.nextAppointment && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-success-primary px-2 py-1">
|
||||||
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-2.5 text-fg-success-primary" />
|
||||||
|
<span className="text-[10px] font-medium text-success-primary">
|
||||||
|
{formatDate(caller.nextAppointment.scheduledAt)} · {caller.nextAppointment.doctorName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{caller.lastAppointment && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg bg-secondary px-2 py-1">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-2.5 text-fg-quaternary" />
|
||||||
|
<span className="text-[10px] text-tertiary">
|
||||||
|
Last: {formatDate(caller.lastAppointment.scheduledAt)} · {caller.lastAppointment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { faCalendarPlus, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
|
||||||
const XClose = faIcon(faXmark);
|
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
|
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||||
|
|
||||||
type ExistingAppointment = {
|
type ExistingAppointment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,8 +18,9 @@ type ExistingAppointment = {
|
|||||||
doctorName: string;
|
doctorName: string;
|
||||||
doctorId?: string;
|
doctorId?: string;
|
||||||
department: string;
|
department: string;
|
||||||
|
clinicId?: string;
|
||||||
reasonForVisit?: string;
|
reasonForVisit?: string;
|
||||||
appointmentStatus: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppointmentFormProps = {
|
type AppointmentFormProps = {
|
||||||
@@ -29,17 +29,23 @@ type AppointmentFormProps = {
|
|||||||
callerNumber?: string | null;
|
callerNumber?: string | null;
|
||||||
leadName?: string | null;
|
leadName?: string | null;
|
||||||
leadId?: string | null;
|
leadId?: string | null;
|
||||||
onSaved?: () => void;
|
patientId?: string | null;
|
||||||
|
// Called after a successful save. Passes back what actually happened so
|
||||||
|
// the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs
|
||||||
|
// CANCELLED each map to distinct disposition outcomes).
|
||||||
|
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
|
||||||
existingAppointment?: ExistingAppointment | null;
|
existingAppointment?: ExistingAppointment | null;
|
||||||
|
// When true, the form shows the existing appointment's data in a
|
||||||
|
// disabled state — no input editing, no Save/Cancel. Only a Close
|
||||||
|
// button. Used by the reschedule-confirm flow when the agent picks
|
||||||
|
// "No, just view" on an upcoming-appointment pill.
|
||||||
|
readOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||||
|
|
||||||
const clinicItems = [
|
// Clinics are fetched dynamically from the platform — no hardcoded list.
|
||||||
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
|
// If the workspace has no clinics configured, the dropdown shows empty.
|
||||||
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
|
|
||||||
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const genderItems = [
|
const genderItems = [
|
||||||
{ id: 'male', label: 'Male' },
|
{ id: 'male', label: 'Male' },
|
||||||
@@ -47,22 +53,8 @@ const genderItems = [
|
|||||||
{ id: 'other', label: 'Other' },
|
{ id: 'other', label: 'Other' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeSlotItems = [
|
// Time slots are fetched from /api/masterdata/slots based on
|
||||||
{ id: '09:00', label: '9:00 AM' },
|
// doctor + date. No hardcoded times.
|
||||||
{ id: '09:30', label: '9:30 AM' },
|
|
||||||
{ id: '10:00', label: '10:00 AM' },
|
|
||||||
{ id: '10:30', label: '10:30 AM' },
|
|
||||||
{ id: '11:00', label: '11:00 AM' },
|
|
||||||
{ id: '11:30', label: '11:30 AM' },
|
|
||||||
{ id: '14:00', label: '2:00 PM' },
|
|
||||||
{ id: '14:30', label: '2:30 PM' },
|
|
||||||
{ id: '15:00', label: '3:00 PM' },
|
|
||||||
{ id: '15:30', label: '3:30 PM' },
|
|
||||||
{ id: '16:00', label: '4:00 PM' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatDeptLabel = (dept: string) =>
|
|
||||||
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
|
|
||||||
export const AppointmentForm = ({
|
export const AppointmentForm = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -70,25 +62,44 @@ export const AppointmentForm = ({
|
|||||||
callerNumber,
|
callerNumber,
|
||||||
leadName,
|
leadName,
|
||||||
leadId,
|
leadId,
|
||||||
|
patientId,
|
||||||
onSaved,
|
onSaved,
|
||||||
existingAppointment,
|
existingAppointment,
|
||||||
|
readOnly = false,
|
||||||
}: AppointmentFormProps) => {
|
}: AppointmentFormProps) => {
|
||||||
const isEditMode = !!existingAppointment;
|
const isEditMode = !!existingAppointment;
|
||||||
|
|
||||||
// Doctor data from platform
|
// Doctor data from platform
|
||||||
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
||||||
|
|
||||||
|
// Initial name captured at form open — used to detect whether the
|
||||||
|
// agent actually changed the name before we commit any destructive
|
||||||
|
// updatePatient / updateLead.contactName mutations.
|
||||||
|
const initialLeadName = (leadName ?? '').trim();
|
||||||
|
|
||||||
// Form state — initialized from existing appointment in edit mode
|
// Form state — initialized from existing appointment in edit mode
|
||||||
const [patientName, setPatientName] = useState(leadName ?? '');
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
|
// The patient-name input is locked by default when there's an
|
||||||
|
// existing caller name (to prevent accidental rename-on-save), and
|
||||||
|
// unlocked only after the agent clicks the Edit button and confirms
|
||||||
|
// in the warning modal. First-time callers with no existing name
|
||||||
|
// start unlocked because there's nothing to protect.
|
||||||
|
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
|
||||||
|
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
|
||||||
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||||
const [age, setAge] = useState('');
|
const [age, setAge] = useState('');
|
||||||
const [gender, setGender] = useState<string | null>(null);
|
const [gender, setGender] = useState<string | null>(null);
|
||||||
const [clinic, setClinic] = useState<string | null>(null);
|
// Preload clinic from the existing appointment when editing — so the
|
||||||
|
// select lands on the right branch instead of being empty and forcing
|
||||||
|
// the agent to re-pick. Only historical rows that predate clinicId
|
||||||
|
// persistence will fall through to the auto-select-from-slot logic.
|
||||||
|
const [clinic, setClinic] = useState<string | null>(existingAppointment?.clinicId ?? null);
|
||||||
|
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||||
const [date, setDate] = useState(() => {
|
const [date, setDate] = useState(() => {
|
||||||
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
|
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
|
||||||
return '';
|
return new Date().toISOString().split('T')[0];
|
||||||
});
|
});
|
||||||
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
||||||
if (existingAppointment?.scheduledAt) {
|
if (existingAppointment?.scheduledAt) {
|
||||||
@@ -98,9 +109,49 @@ export const AppointmentForm = ({
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||||
const [isReturning, setIsReturning] = useState(false);
|
|
||||||
const [source, setSource] = useState('Inbound Call');
|
const [source, setSource] = useState('Inbound Call');
|
||||||
const [agentNotes, setAgentNotes] = useState('');
|
const [agentNotes, setAgentNotes] = useState('');
|
||||||
|
const [timeSlotItems, setTimeSlotItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
|
|
||||||
|
// Fetch available time slots when doctor + date change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctor || !date) {
|
||||||
|
setTimeSlotItems([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
|
||||||
|
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
|
||||||
|
).then(slots => {
|
||||||
|
// Filter by selected clinic — doctor may visit multiple branches
|
||||||
|
const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
|
||||||
|
let items = filtered.map(s => ({ id: s.time, label: s.label }));
|
||||||
|
|
||||||
|
// In edit mode, the saved timeSlot may have been filtered out
|
||||||
|
// (past-slot filter, schedule change, clinic mismatch). Inject
|
||||||
|
// it as a synthetic option so the dropdown still shows the
|
||||||
|
// existing value — otherwise the agent sees a cleared field
|
||||||
|
// and assumes the save-time was lost.
|
||||||
|
if (timeSlot && !items.some(i => i.id === timeSlot)) {
|
||||||
|
items = [{ id: timeSlot, label: `${timeSlot} (current)` }, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeSlotItems(items);
|
||||||
|
// Auto-select clinic from the slot's clinic only if no clinic chosen
|
||||||
|
if (filtered.length === 0 && slots.length > 0 && !clinic) {
|
||||||
|
setClinic(slots[0].clinicId);
|
||||||
|
const autoItems = slots.filter(s => s.clinicId === slots[0].clinicId).map(s => ({ id: s.time, label: s.label }));
|
||||||
|
if (timeSlot && !autoItems.some(i => i.id === timeSlot)) {
|
||||||
|
autoItems.unshift({ id: timeSlot, label: `${timeSlot} (current)` });
|
||||||
|
}
|
||||||
|
setTimeSlotItems(autoItems);
|
||||||
|
}
|
||||||
|
}).catch(() => setTimeSlotItems([]));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
|
||||||
|
// deliberately excluded. Including clinic causes a loop: the effect calls
|
||||||
|
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
|
||||||
|
// timeSlot is only needed for the synthetic "current" option injection which
|
||||||
|
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
|
||||||
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Availability state
|
// Availability state
|
||||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||||
@@ -109,23 +160,28 @@ export const AppointmentForm = ({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch doctors on mount
|
// Fetch doctors on mount. Doctors are hospital-wide — no single
|
||||||
|
// `clinic` field anymore. We pull the full visit-slot list via the
|
||||||
|
// Fetch clinics + doctors from the master data endpoint (Redis-cached).
|
||||||
|
// This is faster than direct GraphQL and returns pre-formatted data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
apiClient.get<Array<{ id: string; name: string; phone: string; address: string }>>('/api/masterdata/clinics')
|
||||||
`{ doctors(first: 50) { edges { node {
|
.then(clinics => {
|
||||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' })));
|
||||||
} } } }`,
|
}).catch(() => {});
|
||||||
).then(data => {
|
}, [isOpen]);
|
||||||
const docs = data.doctors.edges.map(e => ({
|
|
||||||
id: e.node.id,
|
useEffect(() => {
|
||||||
name: e.node.fullName
|
if (!isOpen) return;
|
||||||
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
apiClient.get<Array<{ id: string; name: string; department: string; qualifications: string }>>('/api/masterdata/doctors')
|
||||||
: e.node.name,
|
.then(docs => {
|
||||||
department: e.node.department ?? '',
|
setDoctors(docs.map(d => ({
|
||||||
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
|
id: d.id,
|
||||||
}));
|
name: d.name,
|
||||||
setDoctors(docs);
|
department: d.department,
|
||||||
|
clinic: '', // clinic assignment via visit slots, not on doctor directly
|
||||||
|
})));
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -141,11 +197,11 @@ export const AppointmentForm = ({
|
|||||||
`{ appointments(filter: {
|
`{ appointments(filter: {
|
||||||
doctorId: { eq: "${doctor}" },
|
doctorId: { eq: "${doctor}" },
|
||||||
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
||||||
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
|
}) { edges { node { id scheduledAt durationMin status } } } }`,
|
||||||
).then(data => {
|
).then(data => {
|
||||||
// Filter out cancelled/completed appointments client-side
|
// Filter out cancelled/completed appointments client-side
|
||||||
const activeAppointments = data.appointments.edges.filter(e => {
|
const activeAppointments = data.appointments.edges.filter(e => {
|
||||||
const status = e.node.appointmentStatus;
|
const status = e.node.status;
|
||||||
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
|
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
|
||||||
});
|
});
|
||||||
const slots = activeAppointments.map(e => {
|
const slots = activeAppointments.map(e => {
|
||||||
@@ -175,20 +231,41 @@ export const AppointmentForm = ({
|
|||||||
setTimeSlot(null);
|
setTimeSlot(null);
|
||||||
}, [doctor, date]);
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Derive department and doctor lists from fetched data
|
// Departments from master data (or fallback to deriving from doctors)
|
||||||
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
const [departmentItems, setDepartmentItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
apiClient.get<string[]>('/api/masterdata/departments')
|
||||||
|
.then(depts => setDepartmentItems(depts.map(d => ({ id: d, label: d }))))
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback: derive from doctor list
|
||||||
|
const derived = [...new Set(doctors.map(d => d.department).filter(Boolean))];
|
||||||
|
setDepartmentItems(derived.map(d => ({ id: d, label: d })));
|
||||||
|
});
|
||||||
|
}, [isOpen, doctors]);
|
||||||
|
|
||||||
const filteredDoctors = department
|
const filteredDoctors = department
|
||||||
? doctors.filter(d => d.department === department)
|
? doctors.filter(d => d.department === department)
|
||||||
: doctors;
|
: doctors;
|
||||||
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
// Always include the currently-selected doctor even if the department
|
||||||
|
// filter would exclude them. Needed for edit mode: the saved
|
||||||
|
// Appointment.department may be stored as a display string ("ENT") or
|
||||||
|
// a legacy value that doesn't match the doctor's current department
|
||||||
|
// enum — without this, the Select renders blank.
|
||||||
|
const doctorSelectItems = useMemo(() => {
|
||||||
|
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||||
|
if (doctor && !items.some(i => i.id === doctor)) {
|
||||||
|
const selected = doctors.find(d => d.id === doctor);
|
||||||
|
if (selected) items.unshift({ id: selected.id, label: selected.name });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [filteredDoctors, doctors, doctor]);
|
||||||
|
|
||||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
|
||||||
...slot,
|
...slot,
|
||||||
isDisabled: bookedSlots.includes(slot.id),
|
isDisabled: bookedSlots.includes(slot.id),
|
||||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||||
}));
|
})), [timeSlotItems, bookedSlots]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!date || !timeSlot || !doctor || !department) {
|
if (!date || !timeSlot || !doctor || !department) {
|
||||||
@@ -196,6 +273,12 @@ export const AppointmentForm = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
if (!isEditMode && date < today) {
|
||||||
|
setError('Appointment date cannot be in the past.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -204,9 +287,11 @@ export const AppointmentForm = ({
|
|||||||
const selectedDoctor = doctors.find(d => d.id === doctor);
|
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||||
|
|
||||||
if (isEditMode && existingAppointment) {
|
if (isEditMode && existingAppointment) {
|
||||||
// Update existing appointment
|
// Update existing appointment. Flip status to RESCHEDULED so
|
||||||
|
// the Appointments > Rescheduled tab reflects it and the
|
||||||
|
// patient timeline records the reschedule event.
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||||
updateAppointment(id: $id, data: $data) { id }
|
updateAppointment(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
@@ -217,65 +302,140 @@ export const AppointmentForm = ({
|
|||||||
department: selectedDoctor?.department ?? '',
|
department: selectedDoctor?.department ?? '',
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
reasonForVisit: chiefComplaint || null,
|
||||||
|
status: 'RESCHEDULED',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Propagate name change during reschedule. Same gate as the
|
||||||
|
// create branch — nameChanged implies isNameEditable=true,
|
||||||
|
// which means the agent went through EditPatientConfirmModal.
|
||||||
|
const trimmedName = patientName.trim();
|
||||||
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
|
if (nameChanged) {
|
||||||
|
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
|
||||||
|
if (patientId) {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: patientId, data: { fullName: nameParts } },
|
||||||
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
|
}
|
||||||
|
if (leadId) {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: leadId, data: { contactName: nameParts } },
|
||||||
|
).catch((err: unknown) => console.warn('Failed to update lead name:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notify.success('Appointment Updated');
|
notify.success('Appointment Updated');
|
||||||
} else {
|
} else {
|
||||||
// Double-check slot availability before booking
|
// If no patient record exists yet (new caller), create one now
|
||||||
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
|
let resolvedPatientId = patientId;
|
||||||
`{ appointments(filter: {
|
if (!resolvedPatientId && callerNumber) {
|
||||||
doctorId: { eq: "${doctor}" },
|
const trimmedName = patientName.trim();
|
||||||
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
|
const nameParts = {
|
||||||
}) { edges { node { appointmentStatus } } } }`,
|
firstName: trimmedName.split(' ')[0] || '',
|
||||||
|
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||||
|
};
|
||||||
|
// Normalize phone to +91XXXXXXXXXX format
|
||||||
|
const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10);
|
||||||
|
const phoneE164 = `+91${phoneDigits}`;
|
||||||
|
try {
|
||||||
|
const patientData: Record<string, any> = {
|
||||||
|
fullName: nameParts,
|
||||||
|
phones: { primaryPhoneNumber: phoneE164 },
|
||||||
|
patientType: 'NEW',
|
||||||
|
};
|
||||||
|
if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0];
|
||||||
|
if (gender) patientData.gender = gender.toUpperCase();
|
||||||
|
const created = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: patientData },
|
||||||
);
|
);
|
||||||
const activeBookings = checkResult.appointments.edges.filter(e =>
|
resolvedPatientId = created.createPatient.id;
|
||||||
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
|
} catch (err) {
|
||||||
);
|
console.warn('Failed to create patient:', err);
|
||||||
if (activeBookings.length > 0) {
|
}
|
||||||
setError('This slot was just booked by someone else. Please select a different time.');
|
|
||||||
setIsSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create appointment
|
// Create appointment
|
||||||
await apiClient.graphql(
|
const appointmentData: Record<string, any> = {
|
||||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
|
||||||
createAppointment(data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: 'CONSULTATION',
|
appointmentType: 'CONSULTATION',
|
||||||
appointmentStatus: 'SCHEDULED',
|
status: 'SCHEDULED',
|
||||||
doctorName: selectedDoctor?.name ?? '',
|
doctorName: selectedDoctor?.name ?? '',
|
||||||
department: selectedDoctor?.department ?? '',
|
department: selectedDoctor?.department ?? '',
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
reasonForVisit: chiefComplaint || null,
|
||||||
...(leadId ? { patientId: leadId } : {}),
|
...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
|
||||||
},
|
...(clinic ? { clinicId: clinic } : {}),
|
||||||
},
|
...(agentNotes ? { agentNotes } : {}),
|
||||||
|
...(source ? { source } : {}),
|
||||||
|
};
|
||||||
|
console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData));
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||||
|
createAppointment(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{ data: appointmentData },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update lead status if we have a matched lead
|
// Determine whether the agent actually renamed the patient.
|
||||||
|
// Only a non-empty, changed-from-initial name counts — empty
|
||||||
|
// strings or an unchanged name never trigger the rename
|
||||||
|
// chain, even if the field was unlocked.
|
||||||
|
const trimmedName = patientName.trim();
|
||||||
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
|
|
||||||
|
// Update patient name when the agent explicitly renamed.
|
||||||
|
// `nameChanged` already requires isNameEditable=true (the
|
||||||
|
// agent went through EditPatientConfirmModal), so the
|
||||||
|
// rename intent is unambiguous. Bug #527's silent-overwrite
|
||||||
|
// case can no longer happen because the confirm modal
|
||||||
|
// gates the input.
|
||||||
|
if (nameChanged && patientId) {
|
||||||
|
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
|
||||||
|
apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: patientId, data: { fullName: nameParts } },
|
||||||
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lead status/lastContacted on every appointment book
|
||||||
|
// (those are genuinely about this appointment), but only
|
||||||
|
// touch lead.contactName if the agent explicitly renamed.
|
||||||
|
//
|
||||||
|
// NOTE: field name is `status`, NOT `leadStatus` — the
|
||||||
|
// staging platform schema renamed this. The old name is
|
||||||
|
// rejected by LeadUpdateInput.
|
||||||
if (leadId) {
|
if (leadId) {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) { id }
|
updateLead(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
id: leadId,
|
id: leadId,
|
||||||
data: {
|
data: {
|
||||||
leadStatus: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
lastContactedAt: new Date().toISOString(),
|
lastContacted: new Date().toISOString(),
|
||||||
|
...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
).catch((err: unknown) => console.warn('Failed to update lead:', err));
|
).catch((err: unknown) => console.warn('Failed to update lead:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the agent actually renamed the patient, kick off the
|
||||||
|
// side-effect chain: regenerate the AI summary against the
|
||||||
|
// corrected identity. Fire-and-forget; the save toast
|
||||||
|
// fires immediately regardless.
|
||||||
|
if (nameChanged && leadId) {
|
||||||
|
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSaved?.();
|
onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save appointment:', err);
|
console.error('Failed to save appointment:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||||
@@ -289,16 +449,16 @@ export const AppointmentForm = ({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
`mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||||
updateAppointment(id: $id, data: $data) { id }
|
updateAppointment(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
id: existingAppointment.id,
|
id: existingAppointment.id,
|
||||||
data: { appointmentStatus: 'CANCELLED' },
|
data: { status: 'CANCELLED' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
notify.success('Appointment Cancelled');
|
notify.success('Appointment Cancelled');
|
||||||
onSaved?.();
|
onSaved?.('CANCELLED');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -309,31 +469,9 @@ export const AppointmentForm = ({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Header with close button */}
|
{/* Form fields — scrollable */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
|
|
||||||
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-primary">
|
|
||||||
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-tertiary">
|
|
||||||
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
>
|
|
||||||
<XClose className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form fields */}
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Patient Info — only for new appointments */}
|
{/* Patient Info — only for new appointments */}
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
@@ -344,12 +482,34 @@ export const AppointmentForm = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Patient name — locked by default for existing
|
||||||
|
callers, unlocked for new callers with no
|
||||||
|
prior name on record. The Edit button opens
|
||||||
|
a confirm modal before unlocking; see
|
||||||
|
EditPatientNameModal for the rationale. */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
label="Patient Name"
|
label="Patient Name"
|
||||||
placeholder="Full name"
|
placeholder="Full name"
|
||||||
value={patientName}
|
value={patientName}
|
||||||
onChange={setPatientName}
|
onChange={setPatientName}
|
||||||
|
isDisabled={readOnly || !isNameEditable}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNameEditable && initialLeadName.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faUserPen} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setEditConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -400,37 +560,44 @@ export const AppointmentForm = ({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Select
|
<Select
|
||||||
label="Department / Specialty"
|
label="Department *"
|
||||||
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
|
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
|
||||||
items={departmentItems}
|
items={departmentItems}
|
||||||
selectedKey={department}
|
selectedKey={department}
|
||||||
onSelectionChange={(key) => setDepartment(key as string)}
|
onSelectionChange={(key) => setDepartment(key as string)}
|
||||||
isRequired
|
isDisabled={readOnly || doctors.length === 0}
|
||||||
isDisabled={doctors.length === 0}
|
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Doctor"
|
label="Doctor *"
|
||||||
placeholder={!department ? 'Select department first' : 'Select doctor'}
|
placeholder={!department ? 'Select department first' : 'Select doctor'}
|
||||||
items={doctorSelectItems}
|
items={doctorSelectItems}
|
||||||
selectedKey={doctor}
|
selectedKey={doctor}
|
||||||
onSelectionChange={(key) => setDoctor(key as string)}
|
onSelectionChange={(key) => setDoctor(key as string)}
|
||||||
isRequired
|
isDisabled={readOnly || !department}
|
||||||
isDisabled={!department}
|
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<div className="flex flex-col gap-1">
|
||||||
label="Date"
|
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||||
type="date"
|
<DatePicker
|
||||||
value={date}
|
value={date ? parseDate(date) : null}
|
||||||
onChange={setDate}
|
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||||
isRequired
|
granularity="day"
|
||||||
|
isDisabled={readOnly || !doctor}
|
||||||
|
// Block past dates — appointments can't be booked or
|
||||||
|
// rescheduled into the past. React Aria's DatePicker
|
||||||
|
// honours minValue in both the calendar grid and the
|
||||||
|
// typed-input fallback.
|
||||||
|
minValue={today(getLocalTimeZone())}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Time slot grid */}
|
{/* Time slot grid */}
|
||||||
{doctor && date && (
|
{doctor && date && (
|
||||||
@@ -446,7 +613,7 @@ export const AppointmentForm = ({
|
|||||||
<button
|
<button
|
||||||
key={slot.id}
|
key={slot.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBooked}
|
disabled={readOnly || isBooked}
|
||||||
onClick={() => setTimeSlot(slot.id)}
|
onClick={() => setTimeSlot(slot.id)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
@@ -474,6 +641,7 @@ export const AppointmentForm = ({
|
|||||||
placeholder="Describe the reason for visit..."
|
placeholder="Describe the reason for visit..."
|
||||||
value={chiefComplaint}
|
value={chiefComplaint}
|
||||||
onChange={setChiefComplaint}
|
onChange={setChiefComplaint}
|
||||||
|
isDisabled={readOnly}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -482,13 +650,6 @@ export const AppointmentForm = ({
|
|||||||
<>
|
<>
|
||||||
<div className="border-t border-secondary" />
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
isSelected={isReturning}
|
|
||||||
onChange={setIsReturning}
|
|
||||||
label="Returning Patient"
|
|
||||||
hint="Check if the patient has visited before"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Source / Referral"
|
label="Source / Referral"
|
||||||
placeholder="How did the patient reach us?"
|
placeholder="How did the patient reach us?"
|
||||||
@@ -512,11 +673,12 @@ export const AppointmentForm = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer buttons */}
|
{/* Footer — pinned */}
|
||||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
|
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||||
<div>
|
<div>
|
||||||
{isEditMode && (
|
{isEditMode && !readOnly && (
|
||||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||||
Cancel Appointment
|
Cancel Appointment
|
||||||
</Button>
|
</Button>
|
||||||
@@ -526,11 +688,31 @@ export const AppointmentForm = ({
|
|||||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
{!readOnly && (
|
||||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EditPatientConfirmModal
|
||||||
|
isOpen={editConfirmOpen}
|
||||||
|
onOpenChange={setEditConfirmOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
setIsNameEditable(true);
|
||||||
|
setEditConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
You're about to change the name on this patient's record. This will
|
||||||
|
update their profile across Helix Engage, including past appointments,
|
||||||
|
lead history, and AI summary. Only proceed if the current name is
|
||||||
|
actually wrong — for all other cases, cancel and continue with the
|
||||||
|
appointment as-is.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
225
src/components/call-desk/barge-controls.tsx
Normal file
225
src/components/call-desk/barge-controls.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { supervisorSip } from '@/lib/supervisor-sip-client';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const HangupIcon = faIcon(faPhoneHangup);
|
||||||
|
const HeadsetIcon = faIcon(faHeadset);
|
||||||
|
|
||||||
|
type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended';
|
||||||
|
type BargeMode = 'listen' | 'whisper' | 'barge';
|
||||||
|
|
||||||
|
const MODE_DTMF: Record<BargeMode, string> = { listen: '4', whisper: '5', barge: '6' };
|
||||||
|
|
||||||
|
const MODE_CONFIG: Record<BargeMode, {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: any;
|
||||||
|
activeClass: string;
|
||||||
|
}> = {
|
||||||
|
listen: {
|
||||||
|
label: 'Listen',
|
||||||
|
description: 'Silent monitoring — nobody knows you are here',
|
||||||
|
icon: faHeadset,
|
||||||
|
activeClass: 'border-secondary bg-secondary',
|
||||||
|
},
|
||||||
|
whisper: {
|
||||||
|
label: 'Whisper',
|
||||||
|
description: 'Only the agent can hear you',
|
||||||
|
icon: faCommentDots,
|
||||||
|
activeClass: 'border-brand bg-brand-primary',
|
||||||
|
},
|
||||||
|
barge: {
|
||||||
|
label: 'Barge',
|
||||||
|
description: 'Both agent and patient can hear you',
|
||||||
|
icon: faUsers,
|
||||||
|
activeClass: 'border-error bg-error-primary',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type BargeControlsProps = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
agentNumber: string;
|
||||||
|
agentName: string;
|
||||||
|
onDisconnected?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BargeControls = ({ ucid, agentId, agentNumber, agentName, onDisconnected }: BargeControlsProps) => {
|
||||||
|
const [status, setStatus] = useState<BargeStatus>('idle');
|
||||||
|
const [mode, setMode] = useState<BargeMode>('listen');
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const connectedAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Duration counter
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'connected') return;
|
||||||
|
connectedAtRef.current = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (supervisorSip.isCallActive()) {
|
||||||
|
supervisorSip.close();
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
setStatus('connecting');
|
||||||
|
setMode('listen');
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post<{
|
||||||
|
sipNumber: string;
|
||||||
|
sipPassword: string;
|
||||||
|
sipDomain: string;
|
||||||
|
sipPort: string;
|
||||||
|
}>('/api/supervisor/barge', { ucid, agentId, agentNumber });
|
||||||
|
|
||||||
|
supervisorSip.on('registered', () => {
|
||||||
|
// Ozonetel will send incoming call after SIP registration
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callConnected', () => {
|
||||||
|
setStatus('connected');
|
||||||
|
supervisorSip.sendDTMF('4'); // default: listen mode
|
||||||
|
notify.success('Connected', `Monitoring ${agentName}'s call`);
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callEnded', () => {
|
||||||
|
setStatus('ended');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
onDisconnected?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callFailed', (cause: string) => {
|
||||||
|
setStatus('ended');
|
||||||
|
notify.error('Connection Failed', cause ?? 'Could not connect to call');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('registrationFailed', (cause: string) => {
|
||||||
|
setStatus('ended');
|
||||||
|
notify.error('SIP Registration Failed', cause ?? 'Could not register');
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.init({
|
||||||
|
domain: result.sipDomain,
|
||||||
|
port: result.sipPort,
|
||||||
|
number: result.sipNumber,
|
||||||
|
password: result.sipPassword,
|
||||||
|
});
|
||||||
|
supervisorSip.register();
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('idle');
|
||||||
|
notify.error('Barge Failed', err.message ?? 'Could not initiate barge');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (newMode: BargeMode) => {
|
||||||
|
if (newMode === mode) return;
|
||||||
|
supervisorSip.sendDTMF(MODE_DTMF[newMode]);
|
||||||
|
setMode(newMode);
|
||||||
|
apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHangup = () => {
|
||||||
|
supervisorSip.close();
|
||||||
|
setStatus('ended');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
onDisconnected?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (sec: number) => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idle / ended state
|
||||||
|
if (status === 'idle' || status === 'ended') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-secondary">{status === 'ended' ? 'Session ended' : 'Ready to monitor'}</p>
|
||||||
|
<Button size="sm" color="primary" iconLeading={HeadsetIcon} onClick={handleConnect}>
|
||||||
|
{status === 'ended' ? 'Reconnect' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connecting state
|
||||||
|
if (status === 'connecting') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2 animate-pulse rounded-full bg-warning-solid" />
|
||||||
|
<span className="text-sm font-medium text-warning-primary">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-tertiary">Registering SIP and joining call</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected state
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-success-primary px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-success-solid" />
|
||||||
|
<span className="text-xs font-semibold text-success-primary">Connected</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-success-primary">{formatDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => {
|
||||||
|
const config = MODE_CONFIG[m];
|
||||||
|
const isActive = mode === m;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => handleModeChange(m)}
|
||||||
|
className={cx(
|
||||||
|
'flex flex-1 flex-col items-center gap-1 rounded-lg border-2 px-2 py-2.5 text-center transition duration-100 ease-linear',
|
||||||
|
isActive ? config.activeClass : 'border-secondary hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={config.icon}
|
||||||
|
className={cx('size-4', isActive ? 'text-fg-primary' : 'text-fg-quaternary')}
|
||||||
|
/>
|
||||||
|
<span className={cx('text-xs font-semibold', isActive ? 'text-primary' : 'text-tertiary')}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode description */}
|
||||||
|
<p className="text-center text-xs text-tertiary">{MODE_CONFIG[mode].description}</p>
|
||||||
|
|
||||||
|
{/* Hang up */}
|
||||||
|
<Button size="sm" color="primary-destructive" iconLeading={HangupIcon} onClick={handleHangup} className="w-full">
|
||||||
|
Hang Up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
61
src/components/call-desk/call-control-strip.tsx
Normal file
61
src/components/call-desk/call-control-strip.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faMicrophone, faMicrophoneSlash,
|
||||||
|
faPause, faPlay, faPhoneHangup,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CallControlStrip = () => {
|
||||||
|
const { callState, callDuration, isMuted, isOnHold, toggleMute, toggleHold, hangup } = useSip();
|
||||||
|
|
||||||
|
if (callState !== 'active' && callState !== 'ringing-out') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-success-secondary px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="relative flex size-2">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-success-solid opacity-75" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-success-solid" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-success-primary">Live Call</span>
|
||||||
|
<span className="text-xs font-bold tabular-nums text-success-primary">{formatDuration(callDuration)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
className={cx(
|
||||||
|
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
|
||||||
|
isMuted ? 'bg-error-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleHold}
|
||||||
|
title={isOnHold ? 'Resume' : 'Hold'}
|
||||||
|
className={cx(
|
||||||
|
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
|
||||||
|
isOnHold ? 'bg-warning-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={hangup}
|
||||||
|
title="End Call"
|
||||||
|
className="flex size-7 items-center justify-center rounded-md bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,11 +14,15 @@ interface CallLogProps {
|
|||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
||||||
|
APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' },
|
||||||
|
APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' },
|
||||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||||
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||||
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
|
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
|
|||||||
@@ -1,315 +1,82 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
faPhone,
|
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
faPhoneArrowDown,
|
|
||||||
faPhoneArrowUp,
|
|
||||||
faPhoneHangup,
|
|
||||||
faPhoneXmark,
|
|
||||||
faMicrophoneSlash,
|
|
||||||
faMicrophone,
|
|
||||||
faPause,
|
|
||||||
faCircleCheck,
|
|
||||||
faFloppyDisk,
|
|
||||||
faCalendarPlus,
|
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
const Phone01 = faIcon(faPhone);
|
const Phone01 = faIcon(faPhone);
|
||||||
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
||||||
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
|
|
||||||
const PhoneHangUp = faIcon(faPhoneHangup);
|
|
||||||
const PhoneX = faIcon(faPhoneXmark);
|
const PhoneX = faIcon(faPhoneXmark);
|
||||||
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
|
|
||||||
const Microphone01 = faIcon(faMicrophone);
|
|
||||||
const PauseCircle = faIcon(faPause);
|
|
||||||
const CheckCircle = faIcon(faCircleCheck);
|
const CheckCircle = faIcon(faCircleCheck);
|
||||||
const Save01 = faIcon(faFloppyDisk);
|
|
||||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { useSetAtom } from 'jotai';
|
||||||
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
import { sipCallStateAtom } from '@/state/sip-state';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { CallDisposition } from '@/types/entities';
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
const m = Math.floor(seconds / 60)
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
.toString()
|
|
||||||
.padStart(2, '0');
|
|
||||||
const s = (seconds % 60).toString().padStart(2, '0');
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
return `${m}:${s}`;
|
return `${m}:${s}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusDotColor: Record<string, string> = {
|
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
|
||||||
registered: 'bg-success-500',
|
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
|
||||||
connecting: 'bg-warning-500',
|
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
|
||||||
disconnected: 'bg-quaternary',
|
|
||||||
error: 'bg-error-500',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
registered: 'Ready',
|
|
||||||
connecting: 'Connecting...',
|
|
||||||
disconnected: 'Offline',
|
|
||||||
error: 'Error',
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispositionOptions: Array<{
|
|
||||||
value: CallDisposition;
|
|
||||||
label: string;
|
|
||||||
activeClass: string;
|
|
||||||
defaultClass: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: 'APPOINTMENT_BOOKED',
|
|
||||||
label: 'Appt Booked',
|
|
||||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'FOLLOW_UP_SCHEDULED',
|
|
||||||
label: 'Follow-up',
|
|
||||||
activeClass: 'bg-brand-solid text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'INFO_PROVIDED',
|
|
||||||
label: 'Info Given',
|
|
||||||
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'NO_ANSWER',
|
|
||||||
label: 'No Answer',
|
|
||||||
activeClass: 'bg-warning-solid text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'WRONG_NUMBER',
|
|
||||||
label: 'Wrong #',
|
|
||||||
activeClass: 'bg-secondary-solid text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'CALLBACK_REQUESTED',
|
|
||||||
label: 'Not Interested',
|
|
||||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
|
||||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CallWidget = () => {
|
export const CallWidget = () => {
|
||||||
const {
|
const { callState, callerNumber, callDuration, answer, reject } = useSip();
|
||||||
connectionStatus,
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
callState,
|
const navigate = useNavigate();
|
||||||
callerNumber,
|
const { pathname } = useLocation();
|
||||||
isMuted,
|
|
||||||
isOnHold,
|
|
||||||
callDuration,
|
|
||||||
answer,
|
|
||||||
reject,
|
|
||||||
hangup,
|
|
||||||
toggleMute,
|
|
||||||
toggleHold,
|
|
||||||
} = useSip();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [lastDuration, setLastDuration] = useState(0);
|
|
||||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
|
||||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
|
||||||
const callStartTimeRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Capture duration right before call ends
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === 'active' && callDuration > 0) {
|
if (pathname === '/call-desk') return;
|
||||||
setLastDuration(callDuration);
|
if (callState === 'active' || callState === 'ringing-out') {
|
||||||
|
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
|
||||||
|
navigate('/call-desk');
|
||||||
}
|
}
|
||||||
}, [callState, callDuration]);
|
}, [callState, pathname, navigate]);
|
||||||
|
|
||||||
// Track call start time
|
// Auto-dismiss ended/failed state after 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === 'active' && !callStartTimeRef.current) {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
callStartTimeRef.current = new Date().toISOString();
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[CALL-WIDGET] Auto-dismissing ended/failed state');
|
||||||
|
setCallState('idle');
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (callState === 'idle') {
|
}, [callState, setCallState]);
|
||||||
callStartTimeRef.current = null;
|
|
||||||
}
|
|
||||||
}, [callState]);
|
|
||||||
|
|
||||||
// Look up caller when call becomes active
|
// Log state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
|
if (callState !== 'idle') {
|
||||||
const lookup = async () => {
|
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
||||||
try {
|
|
||||||
const { apiClient } = await import('@/lib/api-client');
|
|
||||||
const token = apiClient.getStoredToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
|
||||||
const res = await fetch(`${API_URL}/api/call/lookup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ phoneNumber: callerNumber }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.matched && data.lead) {
|
|
||||||
setMatchedLead(data.lead);
|
|
||||||
setLeadActivities(data.activities ?? []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Lead lookup failed:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
lookup();
|
|
||||||
}
|
}
|
||||||
}, [callState, callerNumber]);
|
}, [callState, callerNumber]);
|
||||||
|
|
||||||
// Reset state when returning to idle
|
if (callState === 'idle') return null;
|
||||||
useEffect(() => {
|
|
||||||
if (callState === 'idle') {
|
|
||||||
setDisposition(null);
|
|
||||||
setNotes('');
|
|
||||||
setMatchedLead(null);
|
|
||||||
setLeadActivities([]);
|
|
||||||
}
|
|
||||||
}, [callState]);
|
|
||||||
|
|
||||||
const handleSaveAndClose = async () => {
|
// Ringing inbound — answer redirects to Call Desk
|
||||||
if (!disposition) return;
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { apiClient } = await import('@/lib/api-client');
|
|
||||||
|
|
||||||
// 1. Create Call record on platform
|
|
||||||
await apiClient.graphql(
|
|
||||||
`mutation CreateCall($data: CallCreateInput!) {
|
|
||||||
createCall(data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
callDirection: 'INBOUND',
|
|
||||||
callStatus: 'COMPLETED',
|
|
||||||
agentName: user.name,
|
|
||||||
startedAt: callStartTimeRef.current,
|
|
||||||
endedAt: new Date().toISOString(),
|
|
||||||
durationSeconds: callDuration,
|
|
||||||
disposition,
|
|
||||||
callNotes: notes || null,
|
|
||||||
leadId: matchedLead?.id ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).catch(err => console.warn('Failed to create call record:', err));
|
|
||||||
|
|
||||||
// 2. Update lead status if matched
|
|
||||||
if (matchedLead?.id) {
|
|
||||||
const statusMap: Partial<Record<string, string>> = {
|
|
||||||
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
|
|
||||||
FOLLOW_UP_SCHEDULED: 'CONTACTED',
|
|
||||||
INFO_PROVIDED: 'CONTACTED',
|
|
||||||
NO_ANSWER: 'CONTACTED',
|
|
||||||
WRONG_NUMBER: 'LOST',
|
|
||||||
CALLBACK_REQUESTED: 'CONTACTED',
|
|
||||||
NOT_INTERESTED: 'LOST',
|
|
||||||
};
|
|
||||||
const newStatus = statusMap[disposition];
|
|
||||||
if (newStatus) {
|
|
||||||
await apiClient.graphql(
|
|
||||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
|
||||||
updateLead(id: $id, data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
id: matchedLead.id,
|
|
||||||
data: {
|
|
||||||
leadStatus: newStatus,
|
|
||||||
lastContactedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).catch(err => console.warn('Failed to update lead:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create lead activity
|
|
||||||
await apiClient.graphql(
|
|
||||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
|
||||||
createLeadActivity(data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
activityType: 'CALL_RECEIVED',
|
|
||||||
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
performedBy: user.name,
|
|
||||||
channel: 'PHONE',
|
|
||||||
durationSeconds: callDuration,
|
|
||||||
leadId: matchedLead.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
).catch(err => console.warn('Failed to create activity:', err));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(false);
|
|
||||||
hangup();
|
|
||||||
setDisposition(null);
|
|
||||||
setNotes('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
|
|
||||||
const label = statusLabel[connectionStatus] ?? connectionStatus;
|
|
||||||
|
|
||||||
// Idle: collapsed pill
|
|
||||||
if (callState === 'idle') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50',
|
|
||||||
'inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cx('size-2.5 shrink-0 rounded-full', dotColor)} />
|
|
||||||
<span className="text-sm font-semibold text-secondary">{label}</span>
|
|
||||||
<span className="text-sm text-tertiary">Helix Phone</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ringing inbound
|
|
||||||
if (callState === 'ringing-in') {
|
if (callState === 'ringing-in') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||||
<div className="relative animate-bounce">
|
<div className="relative animate-bounce">
|
||||||
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
|
||||||
Incoming Call
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
|
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
|
||||||
Answer
|
Answer
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
||||||
@@ -320,207 +87,25 @@ export const CallWidget = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ringing outbound
|
// Ended / Failed — brief notification
|
||||||
if (callState === 'ringing-out') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
|
||||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
|
||||||
Calling...
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call (full widget)
|
|
||||||
if (callState === 'active') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
|
||||||
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Phone01 className="size-4 text-fg-success-primary" />
|
|
||||||
<span className="text-sm font-semibold text-primary">Active Call</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
|
|
||||||
{formatDuration(callDuration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Caller info */}
|
|
||||||
<div>
|
|
||||||
<span className="text-lg font-bold text-primary">
|
|
||||||
{matchedLead?.contactName
|
|
||||||
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
|
|
||||||
: callerNumber ?? 'Unknown'}
|
|
||||||
</span>
|
|
||||||
{matchedLead && (
|
|
||||||
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Summary */}
|
|
||||||
{matchedLead?.aiSummary && (
|
|
||||||
<div className="rounded-xl bg-brand-primary p-3">
|
|
||||||
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
|
|
||||||
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
|
|
||||||
{matchedLead.aiSuggestedAction && (
|
|
||||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
|
|
||||||
{matchedLead.aiSuggestedAction}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent activity */}
|
|
||||||
{leadActivities.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
|
||||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
|
||||||
<div key={i} className="text-xs text-quaternary">
|
|
||||||
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Call controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={isMuted ? 'primary' : 'secondary'}
|
|
||||||
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
|
|
||||||
onClick={toggleMute}
|
|
||||||
>
|
|
||||||
{isMuted ? 'Unmute' : 'Mute'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={isOnHold ? 'primary' : 'secondary'}
|
|
||||||
iconLeading={PauseCircle}
|
|
||||||
onClick={toggleHold}
|
|
||||||
>
|
|
||||||
{isOnHold ? 'Resume' : 'Hold'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
End
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Appointment */}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={CalendarPlus02}
|
|
||||||
onClick={() => setIsAppointmentOpen(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Book Appointment
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AppointmentForm
|
|
||||||
isOpen={isAppointmentOpen}
|
|
||||||
onOpenChange={setIsAppointmentOpen}
|
|
||||||
callerNumber={callerNumber}
|
|
||||||
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
|
||||||
leadId={matchedLead?.id}
|
|
||||||
onSaved={() => {
|
|
||||||
setIsAppointmentOpen(false);
|
|
||||||
setDisposition('APPOINTMENT_BOOKED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-secondary" />
|
|
||||||
|
|
||||||
{/* Disposition */}
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
{dispositionOptions.map((opt) => {
|
|
||||||
const isSelected = disposition === opt.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDisposition(opt.value)}
|
|
||||||
className={cx(
|
|
||||||
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
|
|
||||||
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
placeholder="Add notes..."
|
|
||||||
value={notes}
|
|
||||||
onChange={(value) => setNotes(value)}
|
|
||||||
rows={2}
|
|
||||||
textAreaClassName="text-xs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={Save01}
|
|
||||||
isDisabled={disposition === null || isSaving}
|
|
||||||
isLoading={isSaving}
|
|
||||||
onClick={handleSaveAndClose}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSaving ? 'Saving...' : 'Save & Close'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ended / Failed
|
|
||||||
if (callState === 'ended' || callState === 'failed') {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
const isEnded = callState === 'ended';
|
const isEnded = callState === 'ended';
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
)}
|
)}>
|
||||||
>
|
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
|
||||||
<CheckCircle
|
|
||||||
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-semibold text-primary">
|
<span className="text-sm font-semibold text-primary">
|
||||||
{isEnded ? 'Call Ended' : 'Call Failed'}
|
{isEnded ? 'Call Ended' : 'Call Failed'}
|
||||||
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
|
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-tertiary">auto-closing...</span>
|
<span className="text-xs text-tertiary">auto-closing...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import type { FC } from 'react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { useSetAtom } from 'jotai';
|
|
||||||
|
|
||||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
|
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
|
||||||
import { setOutboundPending } from '@/state/sip-manager';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
interface ClickToCallButtonProps {
|
interface ClickToCallButtonProps {
|
||||||
@@ -20,33 +16,14 @@ interface ClickToCallButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||||
const { isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
|
||||||
|
|
||||||
const handleDial = async () => {
|
const handleDial = async () => {
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
|
|
||||||
// Show call UI immediately
|
|
||||||
setCallState('ringing-out');
|
|
||||||
setCallerNumber(phoneNumber);
|
|
||||||
setOutboundPending(true);
|
|
||||||
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
|
|
||||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.post<{ ucid?: string; status?: string }>('/api/ozonetel/dial', { phoneNumber });
|
await dialOutbound(phoneNumber);
|
||||||
if (result?.ucid) {
|
|
||||||
setCallUcid(result.ucid);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(safetyTimer);
|
|
||||||
setCallState('idle');
|
|
||||||
setCallerNumber(null);
|
|
||||||
setOutboundPending(false);
|
|
||||||
setCallUcid(null);
|
|
||||||
notify.error('Dial Failed', 'Could not place the call');
|
notify.error('Dial Failed', 'Could not place the call');
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
|
|||||||
@@ -1,172 +1,118 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { AiChatPanel } from './ai-chat-panel';
|
import { AiChatPanel } from './ai-chat-panel';
|
||||||
import { LiveTranscript } from './live-transcript';
|
import type { Appointment } from '@/types/entities';
|
||||||
import { useCallAssist } from '@/hooks/use-call-assist';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
|
||||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
|
||||||
import { cx } from '@/utils/cx';
|
|
||||||
import type { Lead, LeadActivity } from '@/types/entities';
|
|
||||||
|
|
||||||
type ContextTab = 'ai' | 'lead360';
|
export type ContextPanelSubject = {
|
||||||
|
id: string;
|
||||||
|
contactName?: { firstName: string; lastName: string } | null;
|
||||||
|
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||||
|
patientId?: string | null;
|
||||||
|
leadSource?: string | null;
|
||||||
|
leadStatus?: string | null;
|
||||||
|
aiSummary?: string | null;
|
||||||
|
aiSuggestedAction?: string | null;
|
||||||
|
utmCampaign?: string | null;
|
||||||
|
campaignId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface ContextPanelProps {
|
interface ContextPanelProps {
|
||||||
selectedLead: Lead | null;
|
selectedLead: ContextPanelSubject | null;
|
||||||
activities: LeadActivity[];
|
activities: any[];
|
||||||
|
calls: any[];
|
||||||
|
followUps: any[];
|
||||||
|
appointments: Appointment[];
|
||||||
|
patients: any[];
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
isInCall?: boolean;
|
isInCall?: boolean;
|
||||||
callUcid?: string | null;
|
callUcid?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
|
export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
|
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
// Auto-switch to lead 360 when a lead is selected
|
const lead = selectedLead;
|
||||||
useEffect(() => {
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
if (selectedLead) {
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
setActiveTab('lead360');
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
}
|
const phone = lead?.contactPhone?.[0];
|
||||||
}, [selectedLead?.id]);
|
|
||||||
|
|
||||||
const { transcript, suggestions, connected: assistConnected } = useCallAssist(
|
const callerContext = lead ? {
|
||||||
isInCall ?? false,
|
callerPhone: phone?.number ?? callerPhone,
|
||||||
callUcid ?? null,
|
leadId: lead.id,
|
||||||
selectedLead?.id ?? null,
|
leadName: fullName,
|
||||||
callerPhone ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const callerContext = selectedLead ? {
|
|
||||||
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
|
|
||||||
leadId: selectedLead.id,
|
|
||||||
leadName: `${selectedLead.contactName?.firstName ?? ''} ${selectedLead.contactName?.lastName ?? ''}`.trim(),
|
|
||||||
} : callerPhone ? { callerPhone } : undefined;
|
} : callerPhone ? { callerPhone } : undefined;
|
||||||
|
|
||||||
|
const leadAppointments = useMemo(() => {
|
||||||
|
const patientId = lead?.patientId;
|
||||||
|
if (!patientId) return [];
|
||||||
|
return appointments
|
||||||
|
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
||||||
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
}, [appointments, lead]);
|
||||||
|
|
||||||
|
const handleChatStart = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
// Edit mode takes over the whole right panel
|
||||||
|
if (editingAppointment) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingAppointment(null)}
|
||||||
|
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
Back to context
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AppointmentForm
|
||||||
|
isOpen={true}
|
||||||
|
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
||||||
|
callerNumber={callerPhone}
|
||||||
|
leadName={fullName}
|
||||||
|
leadId={lead?.id}
|
||||||
|
patientId={editingAppointment.patientId}
|
||||||
|
existingAppointment={{
|
||||||
|
id: editingAppointment.id,
|
||||||
|
scheduledAt: editingAppointment.scheduledAt ?? '',
|
||||||
|
doctorName: editingAppointment.doctorName ?? '',
|
||||||
|
doctorId: editingAppointment.doctorId ?? undefined,
|
||||||
|
department: editingAppointment.department ?? '',
|
||||||
|
clinicId: editingAppointment.clinicId ?? undefined,
|
||||||
|
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
||||||
|
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
||||||
|
}}
|
||||||
|
onSaved={() => setEditingAppointment(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build callerSummary for the AI coaching panel
|
||||||
|
const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date());
|
||||||
|
const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED');
|
||||||
|
const callerSummary = lead ? {
|
||||||
|
name: fullName,
|
||||||
|
phone: phone?.number ?? callerPhone ?? '',
|
||||||
|
isNew: false,
|
||||||
|
aiSummary: (lead as any).aiSummary ?? null,
|
||||||
|
leadSource: (lead as any).leadSource ?? null,
|
||||||
|
utmCampaign: (lead as any).utmCampaign ?? null,
|
||||||
|
nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null,
|
||||||
|
lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null,
|
||||||
|
} : callerPhone ? {
|
||||||
|
name: '',
|
||||||
|
phone: callerPhone,
|
||||||
|
isNew: true,
|
||||||
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Tab bar */}
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
<div className="flex shrink-0 border-b border-secondary">
|
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('ai')}
|
|
||||||
className={cx(
|
|
||||||
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
|
||||||
activeTab === 'ai'
|
|
||||||
? "border-b-2 border-brand text-brand-secondary"
|
|
||||||
: "text-tertiary hover:text-secondary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
|
|
||||||
AI Assistant
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('lead360')}
|
|
||||||
className={cx(
|
|
||||||
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
|
||||||
activeTab === 'lead360'
|
|
||||||
? "border-b-2 border-brand text-brand-secondary"
|
|
||||||
: "text-tertiary hover:text-secondary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUser} className="size-3.5" />
|
|
||||||
Lead 360
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{activeTab === 'ai' && (
|
|
||||||
isInCall ? (
|
|
||||||
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col p-4">
|
|
||||||
<AiChatPanel callerContext={callerContext} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{activeTab === 'lead360' && (
|
|
||||||
<Lead360Tab lead={selectedLead} activities={activities} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
|
|
||||||
if (!lead) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
|
||||||
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
|
|
||||||
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
|
||||||
const phone = lead.contactPhone?.[0];
|
|
||||||
const email = lead.contactEmail?.[0]?.address;
|
|
||||||
|
|
||||||
const leadActivities = activities
|
|
||||||
.filter((a) => a.leadId === lead.id)
|
|
||||||
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Profile */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
|
||||||
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
|
||||||
{email && <p className="text-xs text-tertiary">{email}</p>}
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
|
|
||||||
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
|
|
||||||
{lead.priority && lead.priority !== 'NORMAL' && (
|
|
||||||
<Badge size="sm" color={lead.priority === 'URGENT' ? 'error' : 'warning'}>{lead.priority}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{lead.interestedService && (
|
|
||||||
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
|
|
||||||
)}
|
|
||||||
{lead.leadScore !== null && lead.leadScore !== undefined && (
|
|
||||||
<p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Insight */}
|
|
||||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
|
||||||
<div className="rounded-lg bg-brand-primary p-3">
|
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
|
|
||||||
</div>
|
|
||||||
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
|
|
||||||
{lead.aiSuggestedAction && (
|
|
||||||
<p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity timeline */}
|
|
||||||
{leadActivities.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{leadActivities.map((a) => (
|
|
||||||
<div key={a.id} className="flex items-start gap-2">
|
|
||||||
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-xs text-primary">{a.summary}</p>
|
|
||||||
<p className="text-[10px] text-quaternary">
|
|
||||||
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
|
|||||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_RESCHEDULED',
|
||||||
|
label: 'Appt Rescheduled',
|
||||||
|
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_CANCELLED',
|
||||||
|
label: 'Appt Cancelled',
|
||||||
|
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'FOLLOW_UP_SCHEDULED',
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
label: 'Follow-up Needed',
|
label: 'Follow-up Needed',
|
||||||
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
|
|||||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'CALLBACK_REQUESTED',
|
value: 'NOT_INTERESTED',
|
||||||
label: 'Not Interested',
|
label: 'Not Interested',
|
||||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'CALLBACK_REQUESTED',
|
||||||
|
label: 'Callback Requested',
|
||||||
|
activeClass: 'bg-utility-blue-600 text-white ring-transparent',
|
||||||
|
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||||
@@ -94,12 +112,13 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={selected === null}
|
disabled={selected === null}
|
||||||
className={cx(
|
className={cx(
|
||||||
'w-full rounded-xl py-3 text-sm font-semibold transition duration-100 ease-linear',
|
'rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
selected !== null
|
selected !== null
|
||||||
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
? 'cursor-pointer bg-brand-solid text-white hover:bg-brand-solid_hover'
|
||||||
: 'cursor-not-allowed bg-disabled text-disabled',
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
@@ -108,5 +127,6 @@ export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFor
|
|||||||
Save & Close Call
|
Save & Close Call
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
294
src/components/call-desk/disposition-modal.tsx
Normal file
294
src/components/call-desk/disposition-modal.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import type { CallDisposition } from '@/types/entities';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY';
|
||||||
|
|
||||||
|
// Maps a recorded action to the disposition it implies. The first action in
|
||||||
|
// the priority list (highest-ranked entry in actionsTaken) becomes the
|
||||||
|
// primary disposition. When any action is present, all other dispositions
|
||||||
|
// are locked out — an agent can't mark a call as "Not Interested" after
|
||||||
|
// they've already booked an appointment.
|
||||||
|
const ACTION_TO_DISPOSITION: Record<CallAction, CallDisposition> = {
|
||||||
|
APPOINTMENT: 'APPOINTMENT_BOOKED',
|
||||||
|
RESCHEDULE: 'APPOINTMENT_RESCHEDULED',
|
||||||
|
CANCEL: 'APPOINTMENT_CANCELLED',
|
||||||
|
FOLLOWUP: 'FOLLOW_UP_SCHEDULED',
|
||||||
|
ENQUIRY: 'INFO_PROVIDED',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_META: Record<CallAction, { label: string; icon: typeof faCalendarCheck; color: 'success' | 'warning' | 'error' | 'brand' | 'blue-light' }> = {
|
||||||
|
APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' },
|
||||||
|
RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' },
|
||||||
|
CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' },
|
||||||
|
FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' },
|
||||||
|
ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority order — highest-rank action wins when multiple are taken. Booked
|
||||||
|
// > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means
|
||||||
|
// no booking, so it ranks below booking/rescheduling; but above a follow-up
|
||||||
|
// because cancellation is a definitive outcome on this call.
|
||||||
|
const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY'];
|
||||||
|
|
||||||
|
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispositionOptions: Array<{
|
||||||
|
value: CallDisposition;
|
||||||
|
label: string;
|
||||||
|
activeClass: string;
|
||||||
|
defaultClass: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_BOOKED',
|
||||||
|
label: 'Appointment Booked',
|
||||||
|
activeClass: 'bg-success-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_RESCHEDULED',
|
||||||
|
label: 'Appt Rescheduled',
|
||||||
|
activeClass: 'bg-warning-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_CANCELLED',
|
||||||
|
label: 'Appt Cancelled',
|
||||||
|
activeClass: 'bg-error-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
|
label: 'Follow-up Needed',
|
||||||
|
activeClass: 'bg-brand-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INFO_PROVIDED',
|
||||||
|
label: 'Info Provided',
|
||||||
|
activeClass: 'bg-utility-blue-light-600 text-white border-transparent',
|
||||||
|
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'NO_ANSWER',
|
||||||
|
label: 'No Answer',
|
||||||
|
activeClass: 'bg-warning-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'WRONG_NUMBER',
|
||||||
|
label: 'Wrong Number',
|
||||||
|
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'NOT_INTERESTED',
|
||||||
|
label: 'Not Interested',
|
||||||
|
activeClass: 'bg-error-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'CALLBACK_REQUESTED',
|
||||||
|
label: 'Callback Requested',
|
||||||
|
activeClass: 'bg-utility-blue-600 text-white border-transparent',
|
||||||
|
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'CALL_DROPPED',
|
||||||
|
label: 'Call Dropped',
|
||||||
|
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type DispositionModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
callerName: string;
|
||||||
|
callerDisconnected: boolean;
|
||||||
|
// True once the call reached the active (answered) state. When false,
|
||||||
|
// the customer never picked up — only no-answer dispositions are
|
||||||
|
// valid; conversation-implying ones (Info Provided, Appointment
|
||||||
|
// Booked, Follow-up, Not Interested) are disabled. Defaults to
|
||||||
|
// true so existing callers don't accidentally lock everything out.
|
||||||
|
callAnswered?: boolean;
|
||||||
|
// Actions actually performed during the call (appointment booked, enquiry
|
||||||
|
// logged, follow-up scheduled). Drives the priority-based disposition
|
||||||
|
// lock — when any action is present, the primary disposition is forced
|
||||||
|
// and the other options are disabled.
|
||||||
|
actionsTaken?: CallAction[];
|
||||||
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dispositions that only make sense when the customer actually connected.
|
||||||
|
// Selecting these on an unanswered call would misrepresent SLA and
|
||||||
|
// conversation metrics.
|
||||||
|
const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet<CallDisposition> = new Set([
|
||||||
|
'INFO_PROVIDED',
|
||||||
|
'APPOINTMENT_BOOKED',
|
||||||
|
'APPOINTMENT_RESCHEDULED',
|
||||||
|
'APPOINTMENT_CANCELLED',
|
||||||
|
'FOLLOW_UP_SCHEDULED',
|
||||||
|
'NOT_INTERESTED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||||
|
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const appliedLockRef = useRef<CallDisposition | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// Rank actionsTaken to pick the primary (highest-priority) action. When
|
||||||
|
// any action is present, that action's disposition becomes locked —
|
||||||
|
// the agent cannot override it to a contradictory outcome.
|
||||||
|
const primaryAction = actionsTaken && actionsTaken.length > 0
|
||||||
|
? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null
|
||||||
|
: null;
|
||||||
|
const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null;
|
||||||
|
|
||||||
|
// Apply the lock once per open — agent can still re-select the same
|
||||||
|
// option, but switching to another value is prevented in the click handler.
|
||||||
|
if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) {
|
||||||
|
appliedLockRef.current = lockedDisposition;
|
||||||
|
setSelected(lockedDisposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selected === null) return;
|
||||||
|
onSubmit(selected, notes);
|
||||||
|
setSelected(null);
|
||||||
|
setNotes('');
|
||||||
|
appliedLockRef.current = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={isOpen}
|
||||||
|
// When the caller disconnected on their own, dismissing the
|
||||||
|
// modal discards the call without any disposition — no record,
|
||||||
|
// no SLA signal. Force a selection in that path. When the
|
||||||
|
// agent opened the modal via End Call (callerDisconnected=false),
|
||||||
|
// dismissing just returns to the active call, so it's safe.
|
||||||
|
isDismissable={!callerDisconnected}
|
||||||
|
onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}
|
||||||
|
>
|
||||||
|
<Modal className="sm:max-w-md">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
|
||||||
|
<FeaturedIcon icon={PhoneHangUpIcon} color={callerDisconnected ? 'warning' : 'error'} theme="light" size="md" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">
|
||||||
|
{callerDisconnected ? 'Call Disconnected' : 'End Call'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-tertiary">
|
||||||
|
{callerDisconnected
|
||||||
|
? `${callerName} disconnected. What was the outcome?`
|
||||||
|
: `Select a reason to end the call with ${callerName}.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disposition options */}
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
{actionsTaken && actionsTaken.length > 0 && (
|
||||||
|
<div className="mb-3 flex flex-col gap-2 rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-tertiary">
|
||||||
|
Actions taken on this call
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => {
|
||||||
|
const meta = ACTION_META[action];
|
||||||
|
return (
|
||||||
|
<Badge key={action} size="sm" color={meta.color} type="pill-color">
|
||||||
|
<FontAwesomeIcon icon={meta.icon} className="size-3 mr-1" />
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{dispositionOptions.map((option) => {
|
||||||
|
const isSelected = selected === option.value;
|
||||||
|
// Two reasons an option can be disabled:
|
||||||
|
// (1) action lock — the agent already booked / scheduled
|
||||||
|
// something, so only the matching disposition is valid.
|
||||||
|
// (2) unanswered call — dispositions that imply the customer
|
||||||
|
// actually spoke with the agent (Info Provided, etc.)
|
||||||
|
// are disabled to prevent SLA-gaming.
|
||||||
|
const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition;
|
||||||
|
const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value);
|
||||||
|
const isDisabled = isLockedOut || isAnsweredOnlyBlocked;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => !isDisabled && setSelected(option.value)}
|
||||||
|
className={cx(
|
||||||
|
'rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
isDisabled && 'cursor-not-allowed opacity-40',
|
||||||
|
!isDisabled && 'cursor-pointer',
|
||||||
|
isSelected
|
||||||
|
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||||
|
: option.defaultClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<TextArea
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Add any notes about this call..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(value) => setNotes(value)}
|
||||||
|
rows={2}
|
||||||
|
textAreaClassName="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-secondary px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={selected === null}
|
||||||
|
className={cx(
|
||||||
|
'w-full rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
selected !== null
|
||||||
|
? 'cursor-pointer bg-error-solid text-white hover:bg-error-solid_hover'
|
||||||
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{callerDisconnected
|
||||||
|
? (selected ? 'Submit & Close' : 'Select a reason')
|
||||||
|
: (selected ? 'End Call & Submit' : 'Select a reason to end call')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
336
src/components/call-desk/enquiry-form.tsx
Normal file
336
src/components/call-desk/enquiry-form.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
type EnquiryFormProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
callerPhone?: string | null;
|
||||||
|
// Pre-populated caller name (from caller-resolution). When set, the
|
||||||
|
// patient-name field is locked behind the Edit-confirm modal to
|
||||||
|
// prevent accidental rename-on-save. When empty or null, the field
|
||||||
|
// starts unlocked because there's no existing name to protect.
|
||||||
|
leadName?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
patientId?: string | null;
|
||||||
|
agentName?: string | null;
|
||||||
|
// Called after a successful save. Passes back the list of actions that
|
||||||
|
// were actually recorded — the parent uses this to drive the disposition
|
||||||
|
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
|
||||||
|
// the agent scheduled a callback.
|
||||||
|
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
|
||||||
|
// Initial name captured at form open — used to detect whether the
|
||||||
|
// agent actually changed the name before committing any updatePatient /
|
||||||
|
// updateLead.contactName mutations. See also appointment-form.tsx.
|
||||||
|
const initialLeadName = (leadName ?? '').trim();
|
||||||
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
|
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
|
||||||
|
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
|
||||||
|
const [source, setSource] = useState('Phone Inquiry');
|
||||||
|
const [queryAsked, setQueryAsked] = useState('');
|
||||||
|
const [isExisting, setIsExisting] = useState(false);
|
||||||
|
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
|
||||||
|
const [department, setDepartment] = useState<string | null>(null);
|
||||||
|
const [doctor, setDoctor] = useState<string | null>(null);
|
||||||
|
const [followUpNeeded, setFollowUpNeeded] = useState(false);
|
||||||
|
const [followUpDate, setFollowUpDate] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch doctors for department/doctor dropdowns
|
||||||
|
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string }>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department
|
||||||
|
} } } }`,
|
||||||
|
).then(data => {
|
||||||
|
setDoctors(data.doctors.edges.map(e => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
})));
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||||
|
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
|
||||||
|
|
||||||
|
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
|
||||||
|
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!patientName.trim() || !queryAsked.trim()) {
|
||||||
|
setError('Please fill in required fields: patient name and query.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve caller. Resolver returns isNew=true when no Lead/
|
||||||
|
// Patient exists for this phone — in that case we create both
|
||||||
|
// records inline with the typed name. Otherwise we update the
|
||||||
|
// existing records.
|
||||||
|
let leadId: string | null = propLeadId ?? null;
|
||||||
|
let resolvedPatientId: string | null = patientId || null;
|
||||||
|
let isNew = false;
|
||||||
|
if ((!leadId || !resolvedPatientId) && registeredPhone) {
|
||||||
|
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||||
|
leadId = leadId || resolved.leadId || null;
|
||||||
|
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
|
||||||
|
isNew = !!resolved.isNew && !leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = patientName.trim();
|
||||||
|
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||||
|
const nameParts = {
|
||||||
|
firstName: trimmedName.split(' ')[0],
|
||||||
|
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with the typed
|
||||||
|
// name. Name is required (validated above).
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('Please enter the patient name.');
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
|
||||||
|
const patientData: Record<string, any> = {
|
||||||
|
name: trimmedName,
|
||||||
|
fullName: nameParts,
|
||||||
|
patientType: 'NEW',
|
||||||
|
};
|
||||||
|
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
|
||||||
|
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: patientData },
|
||||||
|
);
|
||||||
|
resolvedPatientId = pResult.createPatient.id;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to create patient:', err);
|
||||||
|
}
|
||||||
|
const leadData: Record<string, any> = {
|
||||||
|
name: `Enquiry — ${trimmedName}`,
|
||||||
|
contactName: nameParts,
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'CONTACTED',
|
||||||
|
interestedService: queryAsked.substring(0, 100),
|
||||||
|
};
|
||||||
|
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
|
||||||
|
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
|
||||||
|
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: leadData },
|
||||||
|
);
|
||||||
|
leadId = lResult.createLead.id;
|
||||||
|
} else if (leadId) {
|
||||||
|
// Existing lead — update with enquiry details. Only touch
|
||||||
|
// contactName when the agent explicitly renamed (the name
|
||||||
|
// field is locked behind the Edit confirm modal for
|
||||||
|
// existing records).
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'CONTACTED',
|
||||||
|
interestedService: queryAsked.substring(0, 100),
|
||||||
|
...(nameChanged ? { contactName: nameParts } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update linked patient's name when the agent renamed (edit
|
||||||
|
// confirm path) on an existing record. Skipped for isNew
|
||||||
|
// because the patient was just created with the right name.
|
||||||
|
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolvedPatientId,
|
||||||
|
data: {
|
||||||
|
fullName: nameParts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-save side-effect. If the agent actually renamed the
|
||||||
|
// patient, kick off AI summary regen. Fire-and-forget.
|
||||||
|
if (nameChanged && leadId) {
|
||||||
|
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create follow-up if needed
|
||||||
|
if (followUpNeeded) {
|
||||||
|
if (!followUpDate) {
|
||||||
|
setError('Please select a follow-up date.');
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
if (followUpDate < today) {
|
||||||
|
setError('Follow-up date cannot be in the past.');
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `Follow-up — ${patientName}`,
|
||||||
|
typeCustom: 'CALLBACK',
|
||||||
|
status: 'PENDING',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
assignedAgent: agentName ?? undefined,
|
||||||
|
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||||
|
patientId: resolvedPatientId || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ silent: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||||
|
const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
|
||||||
|
if (followUpNeeded) actions.push('FOLLOWUP');
|
||||||
|
onSaved?.(actions);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Form fields — scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Patient name — locked by default for existing callers,
|
||||||
|
unlocked for new callers with no prior name on record.
|
||||||
|
The Edit button opens a confirm modal before unlocking;
|
||||||
|
see EditPatientConfirmModal for the rationale. */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Patient Name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={patientName}
|
||||||
|
onChange={setPatientName}
|
||||||
|
isRequired
|
||||||
|
isDisabled={!isNameEditable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isNameEditable && initialLeadName.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faUserPen} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setEditConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
|
||||||
|
|
||||||
|
<TextArea label="Query Asked" placeholder="What did the caller ask about?" value={queryAsked} onChange={setQueryAsked} rows={3} isRequired />
|
||||||
|
|
||||||
|
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
|
||||||
|
|
||||||
|
{isExisting && (
|
||||||
|
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
|
||||||
|
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
|
||||||
|
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||||
|
{followUpNeeded && (
|
||||||
|
<div className="flex-1 max-w-[180px]">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={followUpDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onChange={(e) => setFollowUpDate(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-label="Follow-up Date"
|
||||||
|
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — pinned */}
|
||||||
|
<div className="shrink-0 flex items-center justify-end gap-3 pt-4 border-t border-secondary">
|
||||||
|
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
|
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditPatientConfirmModal
|
||||||
|
isOpen={editConfirmOpen}
|
||||||
|
onOpenChange={setEditConfirmOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
setIsNameEditable(true);
|
||||||
|
setEditConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
You're about to change the name on this patient's record. This will
|
||||||
|
update their profile across Helix Engage, including past appointments,
|
||||||
|
lead history, and AI summary. Only proceed if the current name is
|
||||||
|
actually wrong — for all other cases, cancel and continue logging the
|
||||||
|
enquiry as-is.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -51,11 +51,15 @@ const ActivityIcon = ({ type }: { type: string }) => {
|
|||||||
|
|
||||||
const dispositionLabels: Record<CallDisposition, string> = {
|
const dispositionLabels: Record<CallDisposition, string> = {
|
||||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||||
|
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
|
||||||
|
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
|
||||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||||
INFO_PROVIDED: 'Info Provided',
|
INFO_PROVIDED: 'Info Provided',
|
||||||
NO_ANSWER: 'No Answer',
|
NO_ANSWER: 'No Answer',
|
||||||
WRONG_NUMBER: 'Wrong Number',
|
WRONG_NUMBER: 'Wrong Number',
|
||||||
CALLBACK_REQUESTED: 'Not Interested',
|
NOT_INTERESTED: 'Not Interested',
|
||||||
|
CALLBACK_REQUESTED: 'Callback Requested',
|
||||||
|
CALL_DROPPED: 'Call Dropped',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { formatTimeFull } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type TranscriptLine = {
|
type TranscriptLine = {
|
||||||
@@ -78,7 +79,7 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
|
|||||||
item.isFinal ? "text-primary" : "text-tertiary italic",
|
item.isFinal ? "text-primary" : "text-tertiary italic",
|
||||||
)}>
|
)}>
|
||||||
<span className="text-xs text-quaternary mr-2">
|
<span className="text-xs text-quaternary mr-2">
|
||||||
{item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
{formatTimeFull(item.timestamp.toISOString())}
|
||||||
</span>
|
</span>
|
||||||
{item.text}
|
{item.text}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { useSetAtom } from 'jotai';
|
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
|
||||||
import { setOutboundPending } from '@/state/sip-manager';
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -13,13 +9,11 @@ type PhoneActionCellProps = {
|
|||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
displayNumber: string;
|
displayNumber: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
onDial?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, onDial }: PhoneActionCellProps) => {
|
||||||
const { isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -41,20 +35,10 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
|||||||
if (!isRegistered || isInCall || dialing) return;
|
if (!isRegistered || isInCall || dialing) return;
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
setCallState('ringing-out');
|
|
||||||
setCallerNumber(phoneNumber);
|
|
||||||
setOutboundPending(true);
|
|
||||||
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
|
onDial?.();
|
||||||
if (result?.ucid) setCallUcid(result.ucid);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
clearTimeout(safetyTimer);
|
|
||||||
setCallState('idle');
|
|
||||||
setCallerNumber(null);
|
|
||||||
setOutboundPending(false);
|
|
||||||
setCallUcid(null);
|
|
||||||
notify.error('Dial Failed', 'Could not place the call');
|
notify.error('Dial Failed', 'Could not place the call');
|
||||||
} finally {
|
} finally {
|
||||||
setDialing(false);
|
setDialing(false);
|
||||||
@@ -90,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
|||||||
{/* Clickable phone number — calls directly */}
|
{/* Clickable phone number — calls directly */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCall}
|
onClick={canCall ? handleCall : undefined}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||||
disabled={!canCall}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-brand-secondary transition duration-100 ease-linear',
|
||||||
canCall
|
canCall
|
||||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
? 'cursor-pointer hover:bg-brand-primary'
|
||||||
: 'cursor-default text-tertiary',
|
: 'cursor-default',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||||
<span className="whitespace-nowrap">{displayNumber}</span>
|
<span className="whitespace-nowrap">{displayNumber}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu trigger — desktop */}
|
{/* Kebab menu trigger — SMS + WhatsApp */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCall}
|
|
||||||
disabled={!canCall}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
|
|
||||||
Call
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSms}
|
onClick={handleSms}
|
||||||
|
|||||||
302
src/components/call-desk/recording-analysis.tsx
Normal file
302
src/components/call-desk/recording-analysis.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faWaveformLines, faSpinner, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type Utterance = {
|
||||||
|
speaker: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Insights = {
|
||||||
|
keyTopics: string[];
|
||||||
|
actionItems: string[];
|
||||||
|
coachingNotes: string[];
|
||||||
|
complianceFlags: string[];
|
||||||
|
patientSatisfaction: string;
|
||||||
|
callOutcome: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Analysis = {
|
||||||
|
transcript: Utterance[];
|
||||||
|
summary: string | null;
|
||||||
|
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||||
|
sentimentScore: number;
|
||||||
|
insights: Insights;
|
||||||
|
durationSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentimentConfig = {
|
||||||
|
positive: { label: 'Positive', color: 'success' as const },
|
||||||
|
neutral: { label: 'Neutral', color: 'gray' as const },
|
||||||
|
negative: { label: 'Negative', color: 'error' as const },
|
||||||
|
mixed: { label: 'Mixed', color: 'warning' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (sec: number): string => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (sec: number | null): string => {
|
||||||
|
if (!sec) return '';
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline audio player for the slideout header
|
||||||
|
const SlideoutPlayer = ({ url }: { url: string }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
|
||||||
|
setPlaying(!playing);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-tertiary">{playing ? 'Playing...' : 'Play recording'}</span>
|
||||||
|
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insights section rendered after analysis completes
|
||||||
|
const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RecordingAnalysisSlideoutProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
recordingUrl: string;
|
||||||
|
callId: string;
|
||||||
|
agentName: string | null;
|
||||||
|
callerNumber: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
durationSec: number | null;
|
||||||
|
disposition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecordingAnalysisSlideout = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
recordingUrl,
|
||||||
|
callId,
|
||||||
|
agentName,
|
||||||
|
callerNumber,
|
||||||
|
direction,
|
||||||
|
startedAt,
|
||||||
|
durationSec,
|
||||||
|
disposition,
|
||||||
|
}: RecordingAnalysisSlideoutProps) => {
|
||||||
|
const [analysis, setAnalysis] = useState<Analysis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasTriggered = useRef(false);
|
||||||
|
|
||||||
|
// Auto-trigger analysis when the slideout opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || hasTriggered.current) return;
|
||||||
|
hasTriggered.current = true;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [isOpen, recordingUrl, callId]);
|
||||||
|
|
||||||
|
const dirLabel = direction === 'INBOUND' ? 'Inbound' : 'Outbound';
|
||||||
|
const dirColor = direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
|
const formattedPhone = callerNumber
|
||||||
|
? formatPhone({ number: callerNumber, callingCode: '+91' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
<SlideoutMenu.Header onClose={close}>
|
||||||
|
<div className="flex flex-col gap-1.5 pr-8">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Call Analysis</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-tertiary">
|
||||||
|
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||||
|
{agentName && <span>{agentName}</span>}
|
||||||
|
{formattedPhone && (
|
||||||
|
<>
|
||||||
|
<span className="text-quaternary">-</span>
|
||||||
|
<span>{formattedPhone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-quaternary">
|
||||||
|
{startedAt && <span>{formatDateOnly(startedAt)}</span>}
|
||||||
|
{durationSec != null && durationSec > 0 && <span>{formatDuration(durationSec)}</span>}
|
||||||
|
{disposition && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">
|
||||||
|
{disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<SlideoutPlayer url={recordingUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Header>
|
||||||
|
|
||||||
|
<SlideoutMenu.Content>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-6 animate-spin text-brand-secondary" />
|
||||||
|
<p className="text-sm text-tertiary">Analyzing recording...</p>
|
||||||
|
<p className="text-xs text-quaternary">Transcribing and generating insights</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-12">
|
||||||
|
<p className="text-sm text-tertiary">Transcription is temporarily unavailable. Please try again.</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={<FontAwesomeIcon icon={faWaveformLines} data-icon className="size-3.5" />}
|
||||||
|
onClick={() => {
|
||||||
|
hasTriggered.current = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && !loading && (
|
||||||
|
<AnalysisResults analysis={analysis} />
|
||||||
|
)}
|
||||||
|
</SlideoutMenu.Content>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SlideoutMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separated analysis results display for readability
|
||||||
|
const AnalysisResults = ({ analysis }: { analysis: Analysis }) => {
|
||||||
|
const sentCfg = sentimentConfig[analysis.sentiment];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Sentiment + topics */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge size="sm" color={sentCfg.color} type="pill-color">{sentCfg.label}</Badge>
|
||||||
|
{analysis.insights.keyTopics.slice(0, 4).map((topic) => (
|
||||||
|
<Badge key={topic} size="sm" color="gray" type="pill-color">{topic}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{analysis.summary && (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Summary</span>
|
||||||
|
<p className="mt-1 text-sm text-primary">{analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call outcome */}
|
||||||
|
<div className="rounded-lg bg-brand-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-brand-tertiary uppercase tracking-wider">Call Outcome</span>
|
||||||
|
<p className="mt-1 text-sm text-primary_on-brand font-medium">{analysis.insights.callOutcome}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<InsightsSection label="Patient Satisfaction">
|
||||||
|
<p className="text-sm text-primary">{analysis.insights.patientSatisfaction}</p>
|
||||||
|
</InsightsSection>
|
||||||
|
|
||||||
|
{analysis.insights.actionItems.length > 0 && (
|
||||||
|
<InsightsSection label="Action Items">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.actionItems.map((item, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.coachingNotes.length > 0 && (
|
||||||
|
<InsightsSection label="Coaching Notes">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.coachingNotes.map((note, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.complianceFlags.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-error-primary uppercase tracking-wider">Compliance Flags</span>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{analysis.insights.complianceFlags.map((flag, i) => (
|
||||||
|
<li key={i} className="text-sm text-error-primary">- {flag}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
{analysis.transcript.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Transcript</span>
|
||||||
|
<div className="mt-2 space-y-2 rounded-lg bg-secondary p-3">
|
||||||
|
{analysis.transcript.map((u, i) => {
|
||||||
|
const isAgent = u.speaker === 0;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="shrink-0 text-xs text-quaternary tabular-nums w-10">{formatTimestamp(u.start)}</span>
|
||||||
|
<span className={cx(
|
||||||
|
'text-xs font-semibold shrink-0 w-16',
|
||||||
|
isAgent ? 'text-brand-secondary' : 'text-success-primary',
|
||||||
|
)}>
|
||||||
|
{isAgent ? 'Agent' : 'Customer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-primary">{u.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,49 +1,157 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPhone, faUserDoctor, faHeadset, faShieldCheck, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
|
|
||||||
|
type TransferTarget = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'agent' | 'supervisor' | 'doctor';
|
||||||
|
department?: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
status?: 'ready' | 'busy' | 'offline' | 'on-call' | 'break';
|
||||||
|
};
|
||||||
|
|
||||||
type TransferDialogProps = {
|
type TransferDialogProps = {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
|
currentAgentId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onTransferred: () => void;
|
onTransferred: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
|
const statusConfig: Record<string, { label: string; dotClass: string }> = {
|
||||||
const [number, setNumber] = useState('');
|
ready: { label: 'Ready', dotClass: 'bg-success-solid' },
|
||||||
const [transferring, setTransferring] = useState(false);
|
'on-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||||
const [stage, setStage] = useState<'input' | 'connected'>('input');
|
'in-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||||
|
busy: { label: 'Busy', dotClass: 'bg-warning-solid' },
|
||||||
|
acw: { label: 'Wrapping', dotClass: 'bg-warning-solid' },
|
||||||
|
break: { label: 'Break', dotClass: 'bg-tertiary' },
|
||||||
|
training: { label: 'Training', dotClass: 'bg-tertiary' },
|
||||||
|
offline: { label: 'Offline', dotClass: 'bg-quaternary' },
|
||||||
|
};
|
||||||
|
|
||||||
const handleConference = async () => {
|
const typeIcons = {
|
||||||
if (!number.trim()) return;
|
agent: faHeadset,
|
||||||
|
supervisor: faShieldCheck,
|
||||||
|
doctor: faUserDoctor,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }: TransferDialogProps) => {
|
||||||
|
const [targets, setTargets] = useState<TransferTarget[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [transferring, setTransferring] = useState(false);
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState<TransferTarget | null>(null);
|
||||||
|
const [connectedTarget, setConnectedTarget] = useState<TransferTarget | null>(null);
|
||||||
|
|
||||||
|
// Fetch transfer targets
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTargets = async () => {
|
||||||
|
try {
|
||||||
|
const [agentsRes, doctorsRes] = await Promise.all([
|
||||||
|
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`),
|
||||||
|
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => a.ozonetelAgentId !== currentAgentId)
|
||||||
|
.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
type: 'agent' as const,
|
||||||
|
phoneNumber: `0${a.sipExtension}`,
|
||||||
|
status: 'offline' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doctors: TransferTarget[] = (doctorsRes.doctors?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((d: any) => d.phone?.primaryPhoneNumber)
|
||||||
|
.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
type: 'doctor' as const,
|
||||||
|
department: d.department?.replace(/_/g, ' '),
|
||||||
|
phoneNumber: `0${d.phone.primaryPhoneNumber}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTargets([...agents, ...doctors]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch transfer targets:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTargets();
|
||||||
|
}, [currentAgentId]);
|
||||||
|
|
||||||
|
// Subscribe to agent state via SSE for live status
|
||||||
|
useEffect(() => {
|
||||||
|
const agentTargets = targets.filter(t => t.type === 'agent');
|
||||||
|
if (agentTargets.length === 0) return;
|
||||||
|
|
||||||
|
// Poll agent states from the supervisor endpoint
|
||||||
|
const fetchStates = async () => {
|
||||||
|
for (const agent of agentTargets) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<any>(`/api/supervisor/agent-state/${agent.phoneNumber.replace(/^0/, '')}`, { silent: true });
|
||||||
|
if (res?.state) {
|
||||||
|
setTargets(prev => prev.map(t =>
|
||||||
|
t.id === agent.id ? { ...t, status: res.state } : t,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStates();
|
||||||
|
const interval = setInterval(fetchStates, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [targets.length]);
|
||||||
|
|
||||||
|
const filtered = search.trim()
|
||||||
|
? targets.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.department ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: targets;
|
||||||
|
|
||||||
|
const agents = filtered.filter(t => t.type === 'agent');
|
||||||
|
const doctors = filtered.filter(t => t.type === 'doctor');
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
const target = selectedTarget;
|
||||||
|
if (!target) return;
|
||||||
setTransferring(true);
|
setTransferring(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ozonetel/call-control', {
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
action: 'CONFERENCE',
|
action: 'CONFERENCE',
|
||||||
ucid,
|
ucid,
|
||||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
conferenceNumber: target.phoneNumber,
|
||||||
});
|
});
|
||||||
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
|
setConnectedTarget(target);
|
||||||
setStage('connected');
|
notify.success('Connected', `Speaking with ${target.name}. Click Complete to transfer.`);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error('Transfer Failed', 'Could not connect to the target number');
|
notify.error('Transfer Failed', `Could not connect to ${target.name}`);
|
||||||
} finally {
|
} finally {
|
||||||
setTransferring(false);
|
setTransferring(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
|
if (!connectedTarget) return;
|
||||||
setTransferring(true);
|
setTransferring(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/ozonetel/call-control', {
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
action: 'KICK_CALL',
|
action: 'KICK_CALL',
|
||||||
ucid,
|
ucid,
|
||||||
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
conferenceNumber: connectedTarget.phoneNumber,
|
||||||
});
|
});
|
||||||
notify.success('Transferred', 'Call transferred successfully');
|
notify.success('Transferred', `Call transferred to ${connectedTarget.name}`);
|
||||||
onTransferred();
|
onTransferred();
|
||||||
} catch {
|
} catch {
|
||||||
notify.error('Transfer Failed', 'Could not complete transfer');
|
notify.error('Transfer Failed', 'Could not complete transfer');
|
||||||
@@ -52,40 +160,138 @@ export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogP
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!connectedTarget) { onClose(); return; }
|
||||||
|
// Disconnect the third party, keep the caller
|
||||||
|
setTransferring(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
|
action: 'KICK_CALL',
|
||||||
|
ucid,
|
||||||
|
conferenceNumber: connectedTarget.phoneNumber,
|
||||||
|
});
|
||||||
|
setConnectedTarget(null);
|
||||||
|
notify.info('Cancelled', 'Transfer cancelled, caller reconnected');
|
||||||
|
} catch {
|
||||||
|
notify.error('Error', 'Could not disconnect third party');
|
||||||
|
} finally {
|
||||||
|
setTransferring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connected state — show target + complete/cancel buttons
|
||||||
|
if (connectedTarget) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-center gap-3 py-8">
|
||||||
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-secondary">
|
||||||
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear">
|
<FontAwesomeIcon icon={typeIcons[connectedTarget.type] ?? faPhone} className="size-4 text-fg-success-primary" />
|
||||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{stage === 'input' ? (
|
<div>
|
||||||
<div className="flex gap-2">
|
<p className="text-sm font-semibold text-primary">Connected to {connectedTarget.name}</p>
|
||||||
<Input
|
<p className="text-xs text-tertiary">Speak privately, then complete the transfer</p>
|
||||||
size="sm"
|
</div>
|
||||||
placeholder="Enter phone number"
|
</div>
|
||||||
value={number}
|
<div className="shrink-0 flex items-center justify-center gap-3 pt-4 border-t border-secondary">
|
||||||
onChange={setNumber}
|
<Button size="sm" color="secondary" onClick={handleCancel} isLoading={transferring}>Cancel</Button>
|
||||||
/>
|
<Button size="sm" color="primary" onClick={handleComplete} isLoading={transferring}>Complete Transfer</Button>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
color="primary"
|
);
|
||||||
isLoading={transferring}
|
}
|
||||||
onClick={handleConference}
|
|
||||||
isDisabled={!number.trim()}
|
// Target selection
|
||||||
>
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Search + actions — pinned */}
|
||||||
|
<div className="shrink-0 flex items-center gap-2 mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input size="sm" placeholder="Search agent, doctor..." icon={SearchIcon} value={search} onChange={setSearch} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button size="sm" color="primary" isLoading={transferring} isDisabled={!selectedTarget} onClick={handleConnect}>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable target list */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-tertiary text-center py-4">Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<>
|
||||||
<span className="text-xs text-tertiary">Connected to {number}</span>
|
{/* Agents */}
|
||||||
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
|
{agents.length > 0 && (
|
||||||
Complete Transfer
|
<div>
|
||||||
</Button>
|
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Agents</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agents.map(agent => {
|
||||||
|
const st = statusConfig[agent.status ?? 'offline'] ?? statusConfig.offline;
|
||||||
|
const isSelected = selectedTarget?.id === agent.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => setSelectedTarget(agent)}
|
||||||
|
disabled={transferring}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FontAwesomeIcon icon={faHeadset} className="size-3.5 text-fg-quaternary" />
|
||||||
|
<span className="text-sm font-medium text-primary">{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cx('size-2 rounded-full', st.dotClass)} />
|
||||||
|
<span className="text-xs text-tertiary">{st.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Doctors */}
|
||||||
|
{doctors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Doctors</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{doctors.map(doc => {
|
||||||
|
const isSelected = selectedTarget?.id === doc.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
onClick={() => setSelectedTarget(doc)}
|
||||||
|
disabled={transferring}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FontAwesomeIcon icon={faUserDoctor} className="size-3.5 text-fg-quaternary" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-primary">{doc.name}</span>
|
||||||
|
{doc.department && <span className="ml-2 text-xs text-tertiary">{doc.department}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{filtered.length === 0 && !loading && (
|
||||||
|
<p className="text-xs text-quaternary text-center py-4">No matching targets</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { SortDescriptor } from 'react-aria-components';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Input } from '@/components/base/input/input';
|
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
||||||
import { PhoneActionCell } from './phone-action-cell';
|
import { PhoneActionCell } from './phone-action-cell';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -35,6 +32,9 @@ type WorklistFollowUp = {
|
|||||||
followUpStatus: string | null;
|
followUpStatus: string | null;
|
||||||
scheduledAt: string | null;
|
scheduledAt: string | null;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
|
patientId?: string | null;
|
||||||
|
patientName?: string;
|
||||||
|
patientPhone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissedCall = {
|
type MissedCall = {
|
||||||
@@ -44,7 +44,27 @@ type MissedCall = {
|
|||||||
callerNumber: { number: string; callingCode: string }[] | null;
|
callerNumber: { number: string; callingCode: string }[] | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
leadName: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
|
callbackStatus: string | null;
|
||||||
|
callSourceNumber: string | null;
|
||||||
|
missedCallCount: number | null;
|
||||||
|
callbackAttemptedAt: string | null;
|
||||||
|
campaign?: { id: string; campaignName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||||
|
|
||||||
|
// Generic selection from any worklist row — the call-desk resolves
|
||||||
|
// lead/patient context from whatever is available on the row.
|
||||||
|
export type WorklistSelection = {
|
||||||
|
rowId: string;
|
||||||
|
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||||
|
lead: WorklistLead | null;
|
||||||
|
phoneRaw: string | null;
|
||||||
|
patientId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface WorklistPanelProps {
|
interface WorklistPanelProps {
|
||||||
@@ -52,11 +72,15 @@ interface WorklistPanelProps {
|
|||||||
followUps: WorklistFollowUp[];
|
followUps: WorklistFollowUp[];
|
||||||
leads: WorklistLead[];
|
leads: WorklistLead[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSelectLead: (lead: WorklistLead) => void;
|
onSelectItem: (selection: WorklistSelection) => void;
|
||||||
selectedLeadId: string | null;
|
selectedItemId: string | null;
|
||||||
|
onDialMissedCall?: (missedCallId: string) => void;
|
||||||
|
// Lifted from internal state — owned by call-desk.tsx so the search
|
||||||
|
// input can live in the PageHeader row alongside other controls.
|
||||||
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = 'all' | 'missed' | 'callbacks' | 'follow-ups';
|
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||||
|
|
||||||
type WorklistRow = {
|
type WorklistRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,11 +95,18 @@ type WorklistRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
patientId: string | null;
|
||||||
originalLead: WorklistLead | null;
|
originalLead: WorklistLead | null;
|
||||||
lastContactedAt: string | null;
|
lastContactedAt: string | null;
|
||||||
contactAttempts: number;
|
contactAttempts: number;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
lastDisposition: string | null;
|
lastDisposition: string | null;
|
||||||
|
missedCallId: string | null;
|
||||||
|
// Rules engine scoring (from sidecar)
|
||||||
|
score?: number;
|
||||||
|
scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] };
|
||||||
|
slaStatus?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
slaElapsedPercent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||||
@@ -93,7 +124,9 @@ const followUpLabel: Record<string, string> = {
|
|||||||
REVIEW_REQUEST: 'Review',
|
REVIEW_REQUEST: 'Review',
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
// SLA for reactive work — missed calls / unanswered leads. Measures time
|
||||||
|
// elapsed since the trigger: longer wait = worse SLA.
|
||||||
|
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||||
@@ -104,6 +137,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
|
|||||||
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
|
||||||
|
// until the scheduled slot. Green when comfortably ahead, warning when
|
||||||
|
// due soon, error when overdue.
|
||||||
|
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
|
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
|
||||||
|
if (minutes < 0) {
|
||||||
|
const overdueMins = -minutes;
|
||||||
|
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
|
||||||
|
const overdueHrs = Math.floor(overdueMins / 60);
|
||||||
|
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
|
||||||
|
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
|
||||||
|
}
|
||||||
|
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
|
||||||
|
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeSla = (
|
||||||
|
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
|
||||||
|
): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
|
if (row.type === 'follow-up' || row.type === 'callback') {
|
||||||
|
// scheduledAt was written into lastContactedAt during row construction.
|
||||||
|
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
|
||||||
|
}
|
||||||
|
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
|
||||||
|
};
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string): string => {
|
const formatTimeAgo = (dateStr: string): string => {
|
||||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
if (minutes < 1) return 'Just now';
|
if (minutes < 1) return 'Just now';
|
||||||
@@ -116,17 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
|
|||||||
const formatDisposition = (disposition: string): string =>
|
const formatDisposition = (disposition: string): string =>
|
||||||
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
const formatSource = (source: string): string => {
|
// formatSource + formatDid kept for reference but no longer rendered
|
||||||
const map: Record<string, string> = {
|
// in the table — SOURCE/BRANCH column removed from display per user
|
||||||
FACEBOOK_AD: 'Facebook',
|
// request. Data stays on the row for future use.
|
||||||
GOOGLE_AD: 'Google',
|
|
||||||
WALK_IN: 'Walk-in',
|
|
||||||
REFERRAL: 'Referral',
|
|
||||||
WEBSITE: 'Website',
|
|
||||||
PHONE_INQUIRY: 'Phone',
|
|
||||||
};
|
|
||||||
return map[source] ?? source.replace(/_/g, ' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconInbound = faIcon(faPhoneArrowDown);
|
const IconInbound = faIcon(faPhoneArrowDown);
|
||||||
const IconOutbound = faIcon(faPhoneArrowUp);
|
const IconOutbound = faIcon(faPhoneArrowUp);
|
||||||
@@ -136,52 +189,68 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
|
|
||||||
for (const call of missedCalls) {
|
for (const call of missedCalls) {
|
||||||
const phone = call.callerNumber?.[0];
|
const phone = call.callerNumber?.[0];
|
||||||
|
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||||
|
const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : '';
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `mc-${call.id}`,
|
id: `mc-${call.id}`,
|
||||||
type: 'missed',
|
type: 'missed',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
name: phone ? formatPhone(phone) : 'Unknown',
|
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
|
||||||
phone: phone ? formatPhone(phone) : '',
|
phone: phone ? formatPhone(phone) : '',
|
||||||
phoneRaw: phone?.number ?? '',
|
phoneRaw: phone?.number ?? '',
|
||||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||||
typeLabel: 'Missed Call',
|
typeLabel: 'Missed Call',
|
||||||
reason: call.startedAt
|
reason: call.startedAt
|
||||||
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}`
|
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||||
: 'Missed call',
|
: 'Missed call',
|
||||||
createdAt: call.createdAt,
|
createdAt: call.createdAt,
|
||||||
taskState: 'PENDING',
|
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||||
leadId: call.leadId,
|
leadId: call.leadId,
|
||||||
|
patientId: (call as any).patientId ?? null,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: call.startedAt ?? call.createdAt,
|
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: null,
|
// Branch column: prefer the campaign name (e.g. "Cervical Cancer
|
||||||
|
// Screening Drive") over the raw DID. Falls back to formatted DID
|
||||||
|
// for organic calls with no campaign.
|
||||||
|
source: call.campaign?.campaignName ?? call.callSourceNumber ?? null,
|
||||||
lastDisposition: call.disposition ?? null,
|
lastDisposition: call.disposition ?? null,
|
||||||
|
missedCallId: call.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fu of followUps) {
|
for (const fu of followUps) {
|
||||||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||||
|
// Sidecar enriches follow-ups with patient name/phone when a
|
||||||
|
// patientId is linked. Fall back to the generic type label when
|
||||||
|
// no patient is attached.
|
||||||
|
const displayName = fu.patientName?.trim() || label;
|
||||||
|
const phoneFormatted = fu.patientPhone
|
||||||
|
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
|
||||||
|
: '';
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `fu-${fu.id}`,
|
id: `fu-${fu.id}`,
|
||||||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||||
name: label,
|
name: displayName,
|
||||||
phone: '',
|
phone: phoneFormatted,
|
||||||
phoneRaw: '',
|
phoneRaw: fu.patientPhone ?? '',
|
||||||
direction: null,
|
direction: null,
|
||||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||||
reason: fu.scheduledAt
|
reason: fu.scheduledAt
|
||||||
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
|
? `Scheduled ${formatShortDate(fu.scheduledAt)}`
|
||||||
: '',
|
: '',
|
||||||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||||
leadId: null,
|
leadId: null,
|
||||||
|
patientId: fu.patientId ?? null,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: null,
|
source: null,
|
||||||
lastDisposition: null,
|
lastDisposition: null,
|
||||||
|
missedCallId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,41 +272,65 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
createdAt: lead.createdAt,
|
createdAt: lead.createdAt,
|
||||||
taskState: 'PENDING',
|
taskState: 'PENDING',
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
|
patientId: (lead as any).patientId ?? null,
|
||||||
originalLead: lead,
|
originalLead: lead,
|
||||||
lastContactedAt: lead.lastContacted ?? null,
|
lastContactedAt: lead.lastContacted ?? null,
|
||||||
contactAttempts: lead.contactAttempts ?? 0,
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
||||||
lastDisposition: null,
|
lastDisposition: null,
|
||||||
|
missedCallId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove rows without a phone number — agent can't act on them
|
// Keep all rows — follow-ups may have no phone and still need to be visible.
|
||||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
// The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
|
||||||
|
const actionableRows = rows;
|
||||||
|
|
||||||
|
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||||
actionableRows.sort((a, b) => {
|
actionableRows.sort((a, b) => {
|
||||||
|
if (a.score != null && b.score != null) return b.score - a.score;
|
||||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
if (pa !== pb) return pa - pb;
|
if (pa !== pb) return pa - pb;
|
||||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionableRows;
|
return actionableRows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
|
||||||
const [tab, setTab] = useState<TabKey>('all');
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
const [search, setSearch] = useState('');
|
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||||
|
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
||||||
|
// ones agents need to act on from the worklist.
|
||||||
|
const missedSubTab: MissedSubTab = 'pending';
|
||||||
|
// Default SLA sort is ascending — the bucket-sorted result puts the
|
||||||
|
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
||||||
|
|
||||||
|
const missedByStatus = useMemo(() => ({
|
||||||
|
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
|
||||||
|
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
|
||||||
|
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
|
||||||
|
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
|
||||||
|
}), [missedCalls]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => buildRows(missedCalls, followUps, leads),
|
() => buildRows(missedCalls, followUps, leads),
|
||||||
[missedCalls, followUps, leads],
|
[missedCalls, followUps, leads],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build rows from sub-tab filtered missed calls when on missed tab
|
||||||
|
const missedSubTabRows = useMemo(
|
||||||
|
() => buildRows(missedByStatus[missedSubTab], [], []),
|
||||||
|
[missedByStatus, missedSubTab],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
let rows = allRows;
|
let rows = allRows;
|
||||||
if (tab === 'missed') rows = rows.filter((r) => r.type === 'missed');
|
if (tab === 'missed') rows = missedSubTabRows;
|
||||||
else if (tab === 'callbacks') rows = rows.filter((r) => r.type === 'callback');
|
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
||||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -246,12 +339,54 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortDescriptor.column) {
|
||||||
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
switch (sortDescriptor.column) {
|
||||||
|
case 'priority': {
|
||||||
|
if (a.score != null && b.score != null) return (a.score - b.score) * dir;
|
||||||
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
|
return (pa - pb) * dir;
|
||||||
|
}
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name) * dir;
|
||||||
|
case 'sla': {
|
||||||
|
// Mixed SLA sort: SLA means different things by row type
|
||||||
|
// (elapsed for reactive, remaining for scheduled). Bucket
|
||||||
|
// rows by urgency, then sort within bucket — Overdue
|
||||||
|
// first, then reactive (oldest-first), then scheduled
|
||||||
|
// (soonest-due first). `dir` flips the whole ordering
|
||||||
|
// so the user can still toggle ascending/descending.
|
||||||
|
const urgencyBucket = (row: WorklistRow): number => {
|
||||||
|
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
|
||||||
|
if (isScheduled) {
|
||||||
|
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
|
||||||
|
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
|
||||||
|
}
|
||||||
|
return 1; // reactive (missed / lead)
|
||||||
|
};
|
||||||
|
const ba = urgencyBucket(a);
|
||||||
|
const bb = urgencyBucket(b);
|
||||||
|
if (ba !== bb) return (ba - bb) * dir;
|
||||||
|
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||||
|
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||||||
|
// Within a bucket, ascending time = most urgent first
|
||||||
|
// (oldest overdue, oldest reactive, soonest upcoming).
|
||||||
|
return (ta - tb) * dir;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [allRows, tab, search]);
|
}, [allRows, tab, search, sortDescriptor, missedSubTabRows]);
|
||||||
|
|
||||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||||
const callbackCount = allRows.filter((r) => r.type === 'callback').length;
|
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
|
||||||
|
|
||||||
// Notification for new missed calls
|
// Notification for new missed calls
|
||||||
const prevMissedCount = useRef(missedCount);
|
const prevMissedCount = useRef(missedCount);
|
||||||
@@ -265,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Reset page when search changes from parent
|
||||||
|
useEffect(() => { setPage(1); }, [search]);
|
||||||
|
|
||||||
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
||||||
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||||||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
@@ -274,7 +411,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
||||||
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
|
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
|
||||||
{ id: 'callbacks' as const, label: 'Callbacks', badge: callbackCount > 0 ? String(callbackCount) : undefined },
|
{ id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined },
|
||||||
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -298,26 +435,29 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
{/* Filter tabs + search */}
|
{/* Filter pills — custom buttons matching All Leads pattern */}
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
{tabItems.map((item) => (
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<button
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
key={item.id}
|
||||||
</TabList>
|
onClick={() => handleTabChange(item.id)}
|
||||||
</Tabs>
|
className={cx(
|
||||||
<div className="w-44 shrink-0">
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
<Input
|
tab === item.id
|
||||||
placeholder="Search..."
|
? 'bg-brand-solid text-white'
|
||||||
icon={SearchLg}
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
size="sm"
|
)}
|
||||||
value={search}
|
>
|
||||||
onChange={handleSearch}
|
{item.label}{item.badge ? ` (${item.badge})` : ''}
|
||||||
aria-label="Search worklist"
|
</button>
|
||||||
/>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||||
|
now only shows pending callbacks. Attempted is redundant once
|
||||||
|
the worklist is the single source of truth. */}
|
||||||
|
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-quaternary">
|
<p className="text-sm text-quaternary">
|
||||||
@@ -325,20 +465,19 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-2 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
||||||
<Table size="sm">
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
|
||||||
<Table.Head label="PATIENT" />
|
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label="SOURCE" className="w-28" />
|
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||||||
<Table.Head label="SLA" className="w-24" />
|
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||||
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
|
const sla = computeSla(row);
|
||||||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
const isSelected = row.id === selectedItemId;
|
||||||
|
|
||||||
// Sub-line: last interaction context
|
// Sub-line: last interaction context
|
||||||
const subLine = row.lastContactedAt
|
const subLine = row.lastContactedAt
|
||||||
@@ -353,13 +492,34 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
isSelected && 'bg-brand-primary',
|
isSelected && 'bg-brand-primary',
|
||||||
)}
|
)}
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
if (row.originalLead) onSelectLead(row.originalLead);
|
onSelectItem({
|
||||||
|
rowId: row.id,
|
||||||
|
type: row.type,
|
||||||
|
lead: row.originalLead,
|
||||||
|
phoneRaw: row.phoneRaw || null,
|
||||||
|
patientId: row.patientId,
|
||||||
|
leadId: row.leadId,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
{row.score != null ? (
|
||||||
|
<div className="flex items-center gap-2" title={row.scoreBreakdown ? `${row.scoreBreakdown.rulesApplied.join(', ')}\nSLA: ×${row.scoreBreakdown.slaMultiplier}\nCampaign: ×${row.scoreBreakdown.campaignMultiplier}` : undefined}>
|
||||||
|
<span className={cx(
|
||||||
|
'size-2.5 rounded-full shrink-0',
|
||||||
|
row.slaStatus === 'low' && 'bg-success-solid',
|
||||||
|
row.slaStatus === 'medium' && 'bg-warning-solid',
|
||||||
|
row.slaStatus === 'high' && 'bg-error-solid',
|
||||||
|
row.slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-xs font-bold tabular-nums text-primary">{row.score.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Badge size="sm" color={priority.color} type="pill-color">
|
<Badge size="sm" color={priority.color} type="pill-color">
|
||||||
{priority.label}
|
{priority.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -385,20 +545,12 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
phoneNumber={row.phoneRaw}
|
phoneNumber={row.phoneRaw}
|
||||||
displayNumber={row.phone}
|
displayNumber={row.phone}
|
||||||
leadId={row.leadId ?? undefined}
|
leadId={row.leadId ?? undefined}
|
||||||
|
onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-quaternary italic">No phone</span>
|
<span className="text-xs text-quaternary italic">No phone</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
|
||||||
{row.source ? (
|
|
||||||
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
|
||||||
{formatSource(row.source)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-quaternary">—</span>
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={sla.color} type="pill-color">
|
<Badge size="sm" color={sla.color} type="pill-color">
|
||||||
{sla.label}
|
{sla.label}
|
||||||
@@ -410,7 +562,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
|
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer',
|
'flex flex-col rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer h-full',
|
||||||
isPaused && 'opacity-60',
|
isPaused && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
|||||||
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
||||||
|
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
|
`mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) {
|
||||||
updateCampaign(id: $id, data: $data) { id }
|
updateCampaign(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAweso
|
|||||||
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
||||||
|
import { formatDateOnly } from '@/lib/format';
|
||||||
import type { Campaign } from '@/types/entities';
|
import type { Campaign } from '@/types/entities';
|
||||||
|
|
||||||
interface CampaignHeroProps {
|
interface CampaignHeroProps {
|
||||||
@@ -15,12 +16,9 @@ interface CampaignHeroProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
||||||
const fmt = (d: string) =>
|
|
||||||
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(d));
|
|
||||||
|
|
||||||
if (!startDate) return '--';
|
if (!startDate) return '--';
|
||||||
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
|
if (!endDate) return `${formatDateOnly(startDate)} \u2014 Ongoing`;
|
||||||
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
|
return `${formatDateOnly(startDate)} \u2014 ${formatDateOnly(endDate)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
||||||
@@ -93,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
View on Platform
|
View on Platform
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
href={`/leads`}
|
|
||||||
>
|
|
||||||
View Leads
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
451
src/components/campaigns/lead-import-wizard.tsx
Normal file
451
src/components/campaigns/lead-import-wizard.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { DynamicTable } from '@/components/application/table/dynamic-table';
|
||||||
|
import type { DynamicColumn, DynamicRow } from '@/components/application/table/dynamic-table';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS } from '@/lib/csv-utils';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Campaign } from '@/types/entities';
|
||||||
|
import type { LeadFieldMapping, CSVRow } from '@/lib/csv-utils';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done';
|
||||||
|
|
||||||
|
const WIZARD_STEPS = [
|
||||||
|
{ key: 'select-campaign', label: 'Select Campaign', number: 1 },
|
||||||
|
{ key: 'map-columns', label: 'Upload & Map', number: 2 },
|
||||||
|
{ key: 'preview', label: 'Preview', number: 3 },
|
||||||
|
{ key: 'done', label: 'Import', number: 4 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => {
|
||||||
|
const activeIndex = currentStep === 'importing'
|
||||||
|
? 3
|
||||||
|
: WIZARD_STEPS.findIndex(s => s.key === currentStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-0 px-6 py-3 border-b border-secondary shrink-0">
|
||||||
|
{WIZARD_STEPS.map((step, i) => {
|
||||||
|
const isComplete = i < activeIndex;
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
const isLast = i === WIZARD_STEPS.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cx(
|
||||||
|
'flex size-7 items-center justify-center rounded-full text-xs font-semibold transition duration-100 ease-linear',
|
||||||
|
isComplete ? 'bg-brand-solid text-white' :
|
||||||
|
isActive ? 'bg-brand-solid text-white ring-4 ring-brand-100' :
|
||||||
|
'bg-secondary text-quaternary',
|
||||||
|
)}>
|
||||||
|
{isComplete ? <FontAwesomeIcon icon={faCheck} className="size-3" /> : step.number}
|
||||||
|
</div>
|
||||||
|
<span className={cx(
|
||||||
|
'text-xs font-medium whitespace-nowrap',
|
||||||
|
isActive ? 'text-brand-secondary' : isComplete ? 'text-primary' : 'text-quaternary',
|
||||||
|
)}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div className={cx(
|
||||||
|
'mx-3 h-px w-12',
|
||||||
|
i < activeIndex ? 'bg-brand-solid' : 'bg-secondary',
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
created: number;
|
||||||
|
linkedToPatient: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
skippedNoPhone: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LeadImportWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
|
||||||
|
const { campaigns, leads, patients, refresh } = useData();
|
||||||
|
const [step, setStep] = useState<ImportStep>('select-campaign');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
|
||||||
|
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const [importProgress, setImportProgress] = useState(0);
|
||||||
|
const [previewPage, setPreviewPage] = useState(1);
|
||||||
|
|
||||||
|
const activeCampaigns = useMemo(() =>
|
||||||
|
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
|
||||||
|
[campaigns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setStep('select-campaign');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
setCsvRows([]);
|
||||||
|
setMapping([]);
|
||||||
|
setResult(null);
|
||||||
|
setImportProgress(0);
|
||||||
|
setPreviewPage(1);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
const { rows, headers } = parseCSV(text);
|
||||||
|
setCsvRows(rows);
|
||||||
|
setMapping(fuzzyMatchColumns(headers));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMappingChange = (csvHeader: string, leadField: string | null) => {
|
||||||
|
setMapping(prev => prev.map(m =>
|
||||||
|
m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patient matching
|
||||||
|
const rowsWithMatch = useMemo(() => {
|
||||||
|
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
|
||||||
|
if (!phoneMapping || csvRows.length === 0) return [];
|
||||||
|
|
||||||
|
const existingLeadPhones = new Set(
|
||||||
|
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
|
||||||
|
);
|
||||||
|
const patientByPhone = new Map(
|
||||||
|
patients.filter(p => p.phones?.primaryPhoneNumber).map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return csvRows.map(row => {
|
||||||
|
const rawPhone = row[phoneMapping.csvHeader] ?? '';
|
||||||
|
const phone = normalizePhone(rawPhone);
|
||||||
|
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
|
||||||
|
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
|
||||||
|
const hasPhone = phone.length === 10;
|
||||||
|
return { row, phone, matchedPatient, isDuplicate, hasPhone };
|
||||||
|
});
|
||||||
|
}, [csvRows, mapping, leads, patients]);
|
||||||
|
|
||||||
|
const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone');
|
||||||
|
const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length;
|
||||||
|
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
|
||||||
|
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
|
||||||
|
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
|
||||||
|
const totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE));
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedCampaign) return;
|
||||||
|
setStep('importing');
|
||||||
|
|
||||||
|
const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length };
|
||||||
|
|
||||||
|
for (let i = 0; i < rowsWithMatch.length; i++) {
|
||||||
|
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
|
||||||
|
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform);
|
||||||
|
if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true });
|
||||||
|
importResult.created++;
|
||||||
|
if (matchedPatient) importResult.linkedToPatient++;
|
||||||
|
} catch { importResult.failed++; }
|
||||||
|
setImportProgress(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(importResult);
|
||||||
|
setStep('done');
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select dropdown items for mapping
|
||||||
|
const mappingOptions = [
|
||||||
|
{ id: '__skip__', label: '— Skip —' },
|
||||||
|
...LEAD_FIELDS.map(f => ({ id: f.field, label: f.label })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||||
|
<Modal className="sm:max-w-5xl">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden" style={{ height: '80vh', minHeight: '500px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Import Leads</h2>
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
{step === 'select-campaign' && 'Select a campaign to import leads into'}
|
||||||
|
{step === 'map-columns' && 'Upload CSV and map columns to lead fields'}
|
||||||
|
{step === 'preview' && `Preview: ${selectedCampaign?.campaignName}`}
|
||||||
|
{step === 'importing' && 'Importing leads...'}
|
||||||
|
{step === 'done' && 'Import complete'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator currentStep={step} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Step 1: Campaign Cards */}
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{activeCampaigns.length === 0 ? (
|
||||||
|
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns.</p>
|
||||||
|
) : activeCampaigns.map(campaign => (
|
||||||
|
<button
|
||||||
|
key={campaign.id}
|
||||||
|
onClick={() => { setSelectedCampaign(campaign); setStep('map-columns'); }}
|
||||||
|
className={cx(
|
||||||
|
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
|
||||||
|
selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
|
||||||
|
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">{campaign.campaignStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Upload + Column Mapping */}
|
||||||
|
{step === 'map-columns' && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
{csvRows.length === 0 ? (
|
||||||
|
<label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-16 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
|
||||||
|
<FontAwesomeIcon icon={faCloudArrowUp} className="size-10 text-fg-quaternary mb-3" />
|
||||||
|
<span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
|
||||||
|
<span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
|
||||||
|
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-primary">{csvRows.length} rows detected — map columns to lead fields:</span>
|
||||||
|
{!phoneIsMapped && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-error-primary">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3" />
|
||||||
|
Phone column required
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{mapping.map(m => (
|
||||||
|
<div key={m.csvHeader} className="flex items-center gap-3 rounded-lg border border-secondary p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold text-primary block">{m.csvHeader}</span>
|
||||||
|
<span className="text-[10px] text-quaternary">CSV column</span>
|
||||||
|
</div>
|
||||||
|
<FontAwesomeIcon icon={faArrowRight} className="size-3 text-fg-quaternary shrink-0" />
|
||||||
|
<div className="w-44 shrink-0">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Skip"
|
||||||
|
items={mappingOptions}
|
||||||
|
selectedKey={m.leadField ?? '__skip__'}
|
||||||
|
onSelectionChange={(key) => handleMappingChange(m.csvHeader, key === '__skip__' ? null : String(key))}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Preview Table */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex shrink-0 items-center gap-4 px-6 py-2 border-b border-secondary text-xs text-tertiary">
|
||||||
|
<span>{rowsWithMatch.length} rows</span>
|
||||||
|
<span className="text-success-primary">{validCount} ready</span>
|
||||||
|
{patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
|
||||||
|
{duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
|
||||||
|
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table — fills remaining space, header pinned, body scrolls */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 px-4 pt-2">
|
||||||
|
<DynamicTable<DynamicRow>
|
||||||
|
columns={[
|
||||||
|
...mapping.filter(m => m.leadField).map(m => ({
|
||||||
|
id: m.csvHeader,
|
||||||
|
label: LEAD_FIELDS.find(f => f.field === m.leadField)?.label ?? m.csvHeader,
|
||||||
|
}) as DynamicColumn),
|
||||||
|
{ id: '__match__', label: 'Patient Match' },
|
||||||
|
]}
|
||||||
|
rows={rowsWithMatch.slice((previewPage - 1) * PAGE_SIZE, previewPage * PAGE_SIZE).map((item, i) => ({ id: `row-${i}`, ...item }))}
|
||||||
|
renderCell={(row, columnId) => {
|
||||||
|
if (columnId === '__match__') {
|
||||||
|
if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
|
||||||
|
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
|
||||||
|
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
|
||||||
|
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
|
||||||
|
}
|
||||||
|
return <span className="text-tertiary truncate block max-w-[200px]">{row.row?.[columnId] ?? ''}</span>;
|
||||||
|
}}
|
||||||
|
rowClassName={(row) => cx(
|
||||||
|
row.isDuplicate && 'bg-warning-primary opacity-60',
|
||||||
|
!row.hasPhone && 'bg-error-primary opacity-40',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination — pinned at bottom */}
|
||||||
|
{totalPreviewPages > 1 && (
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-6 py-2">
|
||||||
|
<span className="text-xs text-tertiary">
|
||||||
|
Page {previewPage} of {totalPreviewPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewPage(Math.max(1, previewPage - 1))}
|
||||||
|
disabled={previewPage === 1}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewPage(Math.min(totalPreviewPages, previewPage + 1))}
|
||||||
|
disabled={previewPage === totalPreviewPages}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4a: Importing */}
|
||||||
|
{step === 'importing' && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
|
||||||
|
<p className="text-sm font-semibold text-primary">Importing leads...</p>
|
||||||
|
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
|
||||||
|
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-brand-solid transition-all duration-200" style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4b: Done */}
|
||||||
|
{step === 'done' && result && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
|
||||||
|
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
|
||||||
|
<div className="rounded-lg bg-success-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-success-primary">{result.created}</p>
|
||||||
|
<p className="text-xs text-tertiary">Created</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-brand-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
|
||||||
|
<p className="text-xs text-tertiary">Linked</p>
|
||||||
|
</div>
|
||||||
|
{result.skippedDuplicate > 0 && (
|
||||||
|
<div className="rounded-lg bg-warning-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
|
||||||
|
<p className="text-xs text-tertiary">Duplicates</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
|
||||||
|
<p className="text-xs text-tertiary">Failed</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
|
||||||
|
)}
|
||||||
|
{step === 'map-columns' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
|
||||||
|
{csvRows.length > 0 && (
|
||||||
|
<Button size="sm" color="primary" onClick={() => { setPreviewPage(1); setStep('preview'); }} isDisabled={!phoneIsMapped}>
|
||||||
|
Preview {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => setStep('map-columns')}>Back to Mapping</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleImport} isDisabled={validCount === 0}>
|
||||||
|
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,14 +26,27 @@ interface AgentTableProps {
|
|||||||
|
|
||||||
export const AgentTable = ({ calls }: AgentTableProps) => {
|
export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||||
const agents = useMemo(() => {
|
const agents = useMemo(() => {
|
||||||
const agentMap = new Map<string, Call[]>();
|
// Bucket by authoritative agent.id when present (from CDR enrichment);
|
||||||
|
// fall back to raw agentName for legacy rows that haven't been
|
||||||
|
// enriched yet. Skips rows with no agent info at all.
|
||||||
|
const agentMap = new Map<string, { displayName: string; calls: Call[] }>();
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const agent = call.agentName ?? 'Unknown';
|
let key: string;
|
||||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
let displayName: string;
|
||||||
agentMap.get(agent)!.push(call);
|
if (call.agent?.id) {
|
||||||
|
key = call.agent.id;
|
||||||
|
displayName = call.agent.name ?? call.agent.ozonetelAgentId ?? 'Unknown';
|
||||||
|
} else if (call.agentName) {
|
||||||
|
key = `legacy:${call.agentName}`;
|
||||||
|
displayName = call.agentName;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!agentMap.has(key)) agentMap.set(key, { displayName, calls: [] });
|
||||||
|
agentMap.get(key)!.calls.push(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
return Array.from(agentMap.entries()).map(([key, { displayName, calls: agentCalls }]) => {
|
||||||
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||||
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||||
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
@@ -43,11 +56,11 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||||
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||||
const nameParts = name.split(' ');
|
const nameParts = displayName.split(' ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: name,
|
id: key,
|
||||||
name,
|
name: displayName,
|
||||||
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
||||||
inbound, outbound, missed, total, avgHandle, conversion,
|
inbound, outbound, missed, total, avgHandle, conversion,
|
||||||
};
|
};
|
||||||
@@ -82,7 +95,7 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
|||||||
{(agent) => (
|
{(agent) => (
|
||||||
<Table.Row id={agent.id}>
|
<Table.Row id={agent.id}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
<Link to={`/agent/${encodeURIComponent(agent.id)}`} className="no-underline">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Avatar size="xs" initials={agent.initials} />
|
<Avatar size="xs" initials={agent.initials} />
|
||||||
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
||||||
|
|||||||
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
// Shared rollup surfaces for the supervisor dashboard: agent performance
|
||||||
|
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
|
||||||
|
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
|
||||||
|
// so both the Team Dashboard and the legacy Team Performance page render
|
||||||
|
// identically from a single data fetch.
|
||||||
|
|
||||||
|
type DateRange = 'today' | 'week' | 'month' | 'year';
|
||||||
|
|
||||||
|
type AgentPerf = {
|
||||||
|
name: string;
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
npsScore: number | null;
|
||||||
|
maxIdleMinutes: number | null;
|
||||||
|
minNpsThreshold: number | null;
|
||||||
|
minConversionPercent: number | null;
|
||||||
|
calls: number;
|
||||||
|
inbound: number;
|
||||||
|
missed: number;
|
||||||
|
followUps: number;
|
||||||
|
leads: number;
|
||||||
|
appointments: number;
|
||||||
|
convPercent: number;
|
||||||
|
idleMinutes: number;
|
||||||
|
activeMinutes: number;
|
||||||
|
wrapMinutes: number;
|
||||||
|
breakMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
||||||
|
const now = new Date();
|
||||||
|
const lte = now.toISOString();
|
||||||
|
const start = new Date(now);
|
||||||
|
if (range === 'today') start.setHours(0, 0, 0, 0);
|
||||||
|
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
|
||||||
|
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
|
||||||
|
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
|
||||||
|
return { gte: start.toISOString(), lte };
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseTime = (timeStr: string): number => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const parts = timeStr.split(':').map(Number);
|
||||||
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSupervisorRollup = (range: DateRange) => {
|
||||||
|
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const { gte, lte } = getDateRange(range);
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||||
|
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
|
||||||
|
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
||||||
|
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
||||||
|
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const teamAgents = teamData?.agents ?? [];
|
||||||
|
|
||||||
|
let agentPerfs: AgentPerf[];
|
||||||
|
|
||||||
|
if (teamAgents.length > 0) {
|
||||||
|
agentPerfs = teamAgents.map((agent: any) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => {
|
||||||
|
if (c.agentId && c.agentId === agent.id) return true;
|
||||||
|
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
|
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
|
const tb = agent.timeBreakdown;
|
||||||
|
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||||
|
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||||
|
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||||
|
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: agent.name ?? agent.ozonetelAgentId,
|
||||||
|
ozonetelAgentId: agent.ozonetelAgentId,
|
||||||
|
npsScore: agent.npsScore,
|
||||||
|
maxIdleMinutes: agent.maxIdleMinutes,
|
||||||
|
minNpsThreshold: agent.minNpsThreshold,
|
||||||
|
minConversionPercent: agent.minConversionPercent,
|
||||||
|
calls: totalCalls,
|
||||||
|
inbound,
|
||||||
|
missed,
|
||||||
|
followUps: agentFollowUps.length,
|
||||||
|
leads: agentLeads.length,
|
||||||
|
appointments: agentAppts,
|
||||||
|
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
||||||
|
idleMinutes: Math.round(idleSec / 60),
|
||||||
|
activeMinutes: Math.round(activeSec / 60),
|
||||||
|
wrapMinutes: Math.round(wrapSec / 60),
|
||||||
|
breakMinutes: Math.round(breakSec / 60),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const byKey = new Map<string, { key: string; name: string }>();
|
||||||
|
for (const c of calls) {
|
||||||
|
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
|
||||||
|
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
|
||||||
|
}
|
||||||
|
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => {
|
||||||
|
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
|
||||||
|
return c.agent?.id === key;
|
||||||
|
});
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ozonetelAgentId: name,
|
||||||
|
npsScore: null,
|
||||||
|
maxIdleMinutes: null,
|
||||||
|
minNpsThreshold: null,
|
||||||
|
minConversionPercent: null,
|
||||||
|
calls: totalCalls,
|
||||||
|
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||||
|
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||||
|
followUps: agentFollowUps.length,
|
||||||
|
leads: agentLeads.length,
|
||||||
|
appointments: completed,
|
||||||
|
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||||
|
idleMinutes: 0,
|
||||||
|
activeMinutes: 0,
|
||||||
|
wrapMinutes: 0,
|
||||||
|
breakMinutes: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAgents(agentPerfs);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load supervisor rollup:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
return { agents, loading };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
|
||||||
|
<Table size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="Agent" isRowHeader />
|
||||||
|
<Table.Head label="Calls" />
|
||||||
|
<Table.Head label="Inbound" />
|
||||||
|
<Table.Head label="Missed" />
|
||||||
|
<Table.Head label="Follow-ups" />
|
||||||
|
<Table.Head label="Leads" />
|
||||||
|
<Table.Head label="Conv%" />
|
||||||
|
<Table.Head label="NPS" />
|
||||||
|
<Table.Head label="Idle" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={agents}>
|
||||||
|
{(agent) => (
|
||||||
|
<Table.Row id={agent.ozonetelAgentId || agent.name}>
|
||||||
|
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
|
||||||
|
{agent.convPercent}%
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||||
|
{agent.npsScore ?? '—'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||||
|
{agent.idleMinutes}m
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
|
||||||
|
const teamAvg = useMemo(() => {
|
||||||
|
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
|
||||||
|
return {
|
||||||
|
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
|
||||||
|
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
|
||||||
|
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
|
||||||
|
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
|
||||||
|
};
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
|
||||||
|
// totals varied wildly, making the visual width comparison meaningless.
|
||||||
|
// Rendered as a table so the numbers speak for themselves; team-average
|
||||||
|
// row sits at the top as the reference point.
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
||||||
|
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
||||||
|
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
||||||
|
)}
|
||||||
|
<Table size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="Agent" isRowHeader />
|
||||||
|
<Table.Head label="Active" />
|
||||||
|
<Table.Head label="Wrap" />
|
||||||
|
<Table.Head label="Idle" />
|
||||||
|
<Table.Head label="Break" />
|
||||||
|
<Table.Head label="Total" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body
|
||||||
|
items={[
|
||||||
|
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
|
||||||
|
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{(item) => {
|
||||||
|
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
|
||||||
|
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
|
||||||
|
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
|
||||||
|
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
|
||||||
|
const total = active + wrap + idle + breakM;
|
||||||
|
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
|
||||||
|
return (
|
||||||
|
<Table.Row id={item.id}>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
|
||||||
|
{idle}m
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
|
||||||
|
const avgNps = useMemo(() => {
|
||||||
|
const withNps = agents.filter(a => a.npsScore != null);
|
||||||
|
if (withNps.length === 0) return 0;
|
||||||
|
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const npsOption = useMemo(() => ({
|
||||||
|
tooltip: { trigger: 'item' },
|
||||||
|
series: [{
|
||||||
|
type: 'gauge', startAngle: 180, endAngle: 0,
|
||||||
|
min: 0, max: 100,
|
||||||
|
pointer: { show: false },
|
||||||
|
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
|
||||||
|
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
|
||||||
|
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
|
||||||
|
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
|
||||||
|
data: [{ value: avgNps }],
|
||||||
|
}],
|
||||||
|
}), [avgNps]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
|
||||||
|
{agents.every(a => a.npsScore == null) ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||||
|
<div className="space-y-1 mt-2">
|
||||||
|
{agents.filter(a => a.npsScore != null).map(a => (
|
||||||
|
<div key={a.name} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
||||||
|
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
|
||||||
|
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
|
||||||
|
<div className="flex gap-3 mb-4">
|
||||||
|
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
|
||||||
|
<p className="text-xs text-tertiary">Call → Appointment</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-brand-secondary">
|
||||||
|
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-tertiary">Lead → Contact</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agents.map(a => (
|
||||||
|
<div key={a.name} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-secondary w-28 truncate">{a.name}</span>
|
||||||
|
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
|
||||||
|
const alerts = useMemo(() => {
|
||||||
|
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||||
|
for (const a of agents) {
|
||||||
|
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
|
||||||
|
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||||
|
}
|
||||||
|
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||||
|
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
|
||||||
|
}
|
||||||
|
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||||
|
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
if (alerts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-error-primary mb-3">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
|
||||||
|
Performance Alerts ({alerts.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{alerts.map((alert, i) => (
|
||||||
|
<div key={i} className={cx(
|
||||||
|
'flex items-center justify-between rounded-lg px-4 py-3',
|
||||||
|
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
|
||||||
|
<span className="text-sm font-medium text-primary">{alert.agent}</span>
|
||||||
|
<span className="text-sm text-secondary">— {alert.type}</span>
|
||||||
|
</div>
|
||||||
|
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
src/components/forms/ai-form.tsx
Normal file
136
src/components/forms/ai-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
|
||||||
|
// AI assistant form — mirrors AiConfig in
|
||||||
|
// helix-engage-server/src/config/ai.defaults.ts. API keys stay in env vars
|
||||||
|
// (true secrets, rotated at the infra level); everything the admin can safely
|
||||||
|
// adjust lives here: provider choice, model, temperature, and an optional
|
||||||
|
// system-prompt addendum appended to the hospital-specific prompts that the
|
||||||
|
// WidgetChatService generates.
|
||||||
|
|
||||||
|
export type AiProvider = 'openai' | 'anthropic';
|
||||||
|
|
||||||
|
export type AiFormValues = {
|
||||||
|
provider: AiProvider;
|
||||||
|
model: string;
|
||||||
|
temperature: string;
|
||||||
|
systemPromptAddendum: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyAiFormValues = (): AiFormValues => ({
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
temperature: '0.7',
|
||||||
|
systemPromptAddendum: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROVIDER_ITEMS = [
|
||||||
|
{ id: 'openai', label: 'OpenAI' },
|
||||||
|
{ id: 'anthropic', label: 'Anthropic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Recommended model presets per provider. Admin can still type any model
|
||||||
|
// string they want — these are suggestions, not the only options.
|
||||||
|
export const MODEL_SUGGESTIONS: Record<AiProvider, string[]> = {
|
||||||
|
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||||
|
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiFormProps = {
|
||||||
|
value: AiFormValues;
|
||||||
|
onChange: (value: AiFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiForm = ({ value, onChange }: AiFormProps) => {
|
||||||
|
const patch = (updates: Partial<AiFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
const suggestions = MODEL_SUGGESTIONS[value.provider];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Provider & model</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Choose the AI vendor powering the website widget chat and call-summary features.
|
||||||
|
Changing providers takes effect immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Provider"
|
||||||
|
placeholder="Select provider"
|
||||||
|
items={PROVIDER_ITEMS}
|
||||||
|
selectedKey={value.provider}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
const next = key as AiProvider;
|
||||||
|
// When switching providers, also reset the model to the first
|
||||||
|
// suggested model for that provider — saves the admin a second
|
||||||
|
// edit step and avoids leaving an OpenAI model selected while
|
||||||
|
// provider=anthropic.
|
||||||
|
patch({
|
||||||
|
provider: next,
|
||||||
|
model: MODEL_SUGGESTIONS[next][0],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Model"
|
||||||
|
placeholder="Model identifier"
|
||||||
|
value={value.model}
|
||||||
|
onChange={(v) => patch({ model: v })}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{suggestions.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model}
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch({ model })}
|
||||||
|
className={`rounded-md border px-2 py-1 text-xs transition duration-100 ease-linear ${
|
||||||
|
value.model === model
|
||||||
|
? 'border-brand bg-brand-secondary text-brand-secondary'
|
||||||
|
: 'border-secondary bg-primary text-tertiary hover:bg-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{model}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Temperature"
|
||||||
|
type="number"
|
||||||
|
placeholder="0.7"
|
||||||
|
hint="0 = deterministic, 1 = balanced, 2 = very creative"
|
||||||
|
value={value.temperature}
|
||||||
|
onChange={(v) => patch({ temperature: v })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">System prompt addendum</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Optional — gets appended to the hospital-specific prompts the widget generates
|
||||||
|
automatically from your doctors and clinics. Use this to add tone guidelines,
|
||||||
|
escalation rules, or topics the assistant should avoid. Leave blank for the default
|
||||||
|
behaviour.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TextArea
|
||||||
|
label="Additional instructions"
|
||||||
|
placeholder="e.g. Always respond in the patient's language. Never quote specific medication dosages; refer them to a doctor for prescriptions."
|
||||||
|
value={value.systemPromptAddendum}
|
||||||
|
onChange={(v) => patch({ systemPromptAddendum: v })}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
517
src/components/forms/clinic-form.tsx
Normal file
517
src/components/forms/clinic-form.tsx
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { parseDate, getLocalTimeZone, today } from '@internationalized/date';
|
||||||
|
import type { DateValue } from 'react-aria-components';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
|
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||||
|
import {
|
||||||
|
DaySelector,
|
||||||
|
defaultDaySelection,
|
||||||
|
type DaySelection,
|
||||||
|
} from '@/components/application/day-selector/day-selector';
|
||||||
|
|
||||||
|
// Reusable clinic form used by /settings/clinics slideout and the /setup
|
||||||
|
// wizard step. The parent owns form state + the save flow so it can decide
|
||||||
|
// how to orchestrate the multi-step create chain (one createClinic, then one
|
||||||
|
// createHoliday per holiday, then one createClinicRequiredDocument per doc).
|
||||||
|
//
|
||||||
|
// Schema (matches the Clinic entity in
|
||||||
|
// FortyTwoApps/helix-engage/src/objects/clinic.object.ts, column names
|
||||||
|
// derived from SDK labels — that's why opensAt/closesAt and not openTime/
|
||||||
|
// closeTime):
|
||||||
|
// - clinicName (TEXT)
|
||||||
|
// - address (ADDRESS → addressCustomAddress*)
|
||||||
|
// - phone (PHONES)
|
||||||
|
// - email (EMAILS)
|
||||||
|
// - openMonday..openSunday (7 BOOLEANs)
|
||||||
|
// - opensAt / closesAt (TEXT, HH:MM)
|
||||||
|
// - status (SELECT enum)
|
||||||
|
// - walkInAllowed / onlineBooking (BOOLEAN)
|
||||||
|
// - cancellationWindowHours / arriveEarlyMin (NUMBER)
|
||||||
|
//
|
||||||
|
// Plus two child entities populated separately:
|
||||||
|
// - Holiday (one record per closure date)
|
||||||
|
// - ClinicRequiredDocument (one record per required doc type)
|
||||||
|
|
||||||
|
export type ClinicStatus = 'ACTIVE' | 'TEMPORARILY_CLOSED' | 'PERMANENTLY_CLOSED';
|
||||||
|
|
||||||
|
// Matches the SELECT enum on ClinicRequiredDocument. Keep in sync with
|
||||||
|
// FortyTwoApps/helix-engage/src/objects/clinic-required-document.object.ts.
|
||||||
|
export type DocumentType =
|
||||||
|
| 'ID_PROOF'
|
||||||
|
| 'AADHAAR'
|
||||||
|
| 'PAN'
|
||||||
|
| 'REFERRAL_LETTER'
|
||||||
|
| 'PRESCRIPTION'
|
||||||
|
| 'INSURANCE_CARD'
|
||||||
|
| 'PREVIOUS_REPORTS'
|
||||||
|
| 'PHOTO'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
ID_PROOF: 'Government ID',
|
||||||
|
AADHAAR: 'Aadhaar Card',
|
||||||
|
PAN: 'PAN Card',
|
||||||
|
REFERRAL_LETTER: 'Referral Letter',
|
||||||
|
PRESCRIPTION: 'Prescription',
|
||||||
|
INSURANCE_CARD: 'Insurance Card',
|
||||||
|
PREVIOUS_REPORTS: 'Previous Reports',
|
||||||
|
PHOTO: 'Passport Photo',
|
||||||
|
OTHER: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOCUMENT_TYPE_ORDER: DocumentType[] = [
|
||||||
|
'ID_PROOF',
|
||||||
|
'AADHAAR',
|
||||||
|
'PAN',
|
||||||
|
'REFERRAL_LETTER',
|
||||||
|
'PRESCRIPTION',
|
||||||
|
'INSURANCE_CARD',
|
||||||
|
'PREVIOUS_REPORTS',
|
||||||
|
'PHOTO',
|
||||||
|
'OTHER',
|
||||||
|
];
|
||||||
|
|
||||||
|
export type ClinicHolidayEntry = {
|
||||||
|
// Populated on the existing record when editing; undefined for freshly
|
||||||
|
// added holidays the user hasn't saved yet. Used by the parent to
|
||||||
|
// decide create vs update vs delete on save.
|
||||||
|
id?: string;
|
||||||
|
date: string; // ISO yyyy-MM-dd
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ClinicFormValues = {
|
||||||
|
// Core clinic fields
|
||||||
|
clinicName: string;
|
||||||
|
addressStreet1: string;
|
||||||
|
addressStreet2: string;
|
||||||
|
addressCity: string;
|
||||||
|
addressState: string;
|
||||||
|
addressPostcode: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
// Schedule — simple pattern
|
||||||
|
openDays: DaySelection;
|
||||||
|
opensAt: string | null;
|
||||||
|
closesAt: string | null;
|
||||||
|
// Status + booking policy
|
||||||
|
status: ClinicStatus;
|
||||||
|
walkInAllowed: boolean;
|
||||||
|
onlineBooking: boolean;
|
||||||
|
cancellationWindowHours: string;
|
||||||
|
arriveEarlyMin: string;
|
||||||
|
// Children (persisted via separate mutations)
|
||||||
|
requiredDocumentTypes: DocumentType[];
|
||||||
|
holidays: ClinicHolidayEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyClinicFormValues = (): ClinicFormValues => ({
|
||||||
|
clinicName: '',
|
||||||
|
addressStreet1: '',
|
||||||
|
addressStreet2: '',
|
||||||
|
addressCity: '',
|
||||||
|
addressState: '',
|
||||||
|
addressPostcode: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
openDays: defaultDaySelection(),
|
||||||
|
opensAt: '09:00',
|
||||||
|
closesAt: '18:00',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
walkInAllowed: true,
|
||||||
|
onlineBooking: true,
|
||||||
|
cancellationWindowHours: '24',
|
||||||
|
arriveEarlyMin: '15',
|
||||||
|
requiredDocumentTypes: [],
|
||||||
|
holidays: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS_ITEMS = [
|
||||||
|
{ id: 'ACTIVE', label: 'Active' },
|
||||||
|
{ id: 'TEMPORARILY_CLOSED', label: 'Temporarily closed' },
|
||||||
|
{ id: 'PERMANENTLY_CLOSED', label: 'Permanently closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the payload for `createClinic` / `updateClinic`. Holidays and
|
||||||
|
// required-documents are NOT included here — they're child records with
|
||||||
|
// their own mutations, orchestrated by the parent component after the
|
||||||
|
// clinic itself has been created and its id is known.
|
||||||
|
export const clinicCoreToGraphQLInput = (v: ClinicFormValues): Record<string, unknown> => {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
clinicName: v.clinicName.trim(),
|
||||||
|
status: v.status,
|
||||||
|
walkInAllowed: v.walkInAllowed,
|
||||||
|
onlineBooking: v.onlineBooking,
|
||||||
|
openMonday: v.openDays.monday,
|
||||||
|
openTuesday: v.openDays.tuesday,
|
||||||
|
openWednesday: v.openDays.wednesday,
|
||||||
|
openThursday: v.openDays.thursday,
|
||||||
|
openFriday: v.openDays.friday,
|
||||||
|
openSaturday: v.openDays.saturday,
|
||||||
|
openSunday: v.openDays.sunday,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column names on the platform come from the SDK `label`, not
|
||||||
|
// `name`. "Opens At" → opensAt, "Closes At" → closesAt.
|
||||||
|
if (v.opensAt) input.opensAt = v.opensAt;
|
||||||
|
if (v.closesAt) input.closesAt = v.closesAt;
|
||||||
|
|
||||||
|
const hasAddress =
|
||||||
|
v.addressStreet1 || v.addressCity || v.addressState || v.addressPostcode;
|
||||||
|
if (hasAddress) {
|
||||||
|
input.addressCustom = {
|
||||||
|
addressStreet1: v.addressStreet1 || null,
|
||||||
|
addressStreet2: v.addressStreet2 || null,
|
||||||
|
addressCity: v.addressCity || null,
|
||||||
|
addressState: v.addressState || null,
|
||||||
|
addressPostcode: v.addressPostcode || null,
|
||||||
|
addressCountry: 'India',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.phone.trim()) {
|
||||||
|
input.phone = {
|
||||||
|
primaryPhoneNumber: v.phone.trim(),
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
additionalPhones: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.email.trim()) {
|
||||||
|
input.email = {
|
||||||
|
primaryEmail: v.email.trim(),
|
||||||
|
additionalEmails: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.cancellationWindowHours.trim()) {
|
||||||
|
const n = Number(v.cancellationWindowHours);
|
||||||
|
if (!Number.isNaN(n)) input.cancellationWindowHours = n;
|
||||||
|
}
|
||||||
|
if (v.arriveEarlyMin.trim()) {
|
||||||
|
const n = Number(v.arriveEarlyMin);
|
||||||
|
if (!Number.isNaN(n)) input.arriveEarlyMin = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: build HolidayCreateInput payloads. Use after the clinic has
|
||||||
|
// been created and its id is known.
|
||||||
|
export const holidayInputsFromForm = (
|
||||||
|
v: ClinicFormValues,
|
||||||
|
clinicId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.holidays.map((h) => ({
|
||||||
|
date: h.date,
|
||||||
|
reasonLabel: h.label.trim() || null, // column name matches the SDK label "Reason / Label"
|
||||||
|
clinicId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper: build ClinicRequiredDocumentCreateInput payloads. One per
|
||||||
|
// selected document type.
|
||||||
|
export const requiredDocInputsFromForm = (
|
||||||
|
v: ClinicFormValues,
|
||||||
|
clinicId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.requiredDocumentTypes.map((t) => ({
|
||||||
|
documentType: t,
|
||||||
|
clinicId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ClinicFormProps = {
|
||||||
|
value: ClinicFormValues;
|
||||||
|
onChange: (value: ClinicFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||||
|
const patch = (updates: Partial<ClinicFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
// Required-docs add/remove handlers. The user picks a type from the
|
||||||
|
// dropdown; it gets added to the list; the pill row below shows
|
||||||
|
// selected types with an X to remove. Dropdown filters out
|
||||||
|
// already-selected types so the user can't pick duplicates.
|
||||||
|
const availableDocTypes = DOCUMENT_TYPE_ORDER.filter(
|
||||||
|
(t) => !value.requiredDocumentTypes.includes(t),
|
||||||
|
).map((t) => ({ id: t, label: DOCUMENT_TYPE_LABELS[t] }));
|
||||||
|
|
||||||
|
const addDocType = (type: DocumentType) => {
|
||||||
|
if (value.requiredDocumentTypes.includes(type)) return;
|
||||||
|
patch({ requiredDocumentTypes: [...value.requiredDocumentTypes, type] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDocType = (type: DocumentType) => {
|
||||||
|
patch({
|
||||||
|
requiredDocumentTypes: value.requiredDocumentTypes.filter((t) => t !== type),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Holiday add/remove handlers. Freshly-added entries have no `id`
|
||||||
|
// field; the parent's save flow treats those as "create".
|
||||||
|
const addHoliday = () => {
|
||||||
|
const todayIso = today(getLocalTimeZone()).toString();
|
||||||
|
patch({ holidays: [...value.holidays, { date: todayIso, label: '' }] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHoliday = (index: number, updates: Partial<ClinicHolidayEntry>) => {
|
||||||
|
const next = [...value.holidays];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
patch({ holidays: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHoliday = (index: number) => {
|
||||||
|
patch({ holidays: value.holidays.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
label="Clinic name"
|
||||||
|
isRequired
|
||||||
|
placeholder="e.g. Main Hospital Campus"
|
||||||
|
value={value.clinicName}
|
||||||
|
onChange={(v) => patch({ clinicName: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
placeholder="Select status"
|
||||||
|
items={STATUS_ITEMS}
|
||||||
|
selectedKey={value.status}
|
||||||
|
onSelectionChange={(key) => patch({ status: key as ClinicStatus })}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Address
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Street address"
|
||||||
|
placeholder="Street / building / landmark"
|
||||||
|
value={value.addressStreet1}
|
||||||
|
onChange={(v) => patch({ addressStreet1: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Area / locality (optional)"
|
||||||
|
placeholder="Area, neighbourhood"
|
||||||
|
value={value.addressStreet2}
|
||||||
|
onChange={(v) => patch({ addressStreet2: v })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="City"
|
||||||
|
placeholder="Bengaluru"
|
||||||
|
value={value.addressCity}
|
||||||
|
onChange={(v) => patch({ addressCity: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="State"
|
||||||
|
placeholder="Karnataka"
|
||||||
|
value={value.addressState}
|
||||||
|
onChange={(v) => patch({ addressState: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Postcode"
|
||||||
|
placeholder="560034"
|
||||||
|
value={value.addressPostcode}
|
||||||
|
onChange={(v) => patch({ addressPostcode: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Contact
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="9876543210"
|
||||||
|
value={value.phone}
|
||||||
|
onChange={(v) => patch({ phone: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="branch@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visiting hours — day pills + single time range */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Visiting hours
|
||||||
|
</p>
|
||||||
|
<DaySelector
|
||||||
|
label="Open days"
|
||||||
|
hint="Pick the days this clinic is open. The time range below applies to every selected day."
|
||||||
|
value={value.openDays}
|
||||||
|
onChange={(openDays) => patch({ openDays })}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TimePicker
|
||||||
|
label="Opens at"
|
||||||
|
value={value.opensAt}
|
||||||
|
onChange={(opensAt) => patch({ opensAt })}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="Closes at"
|
||||||
|
value={value.closesAt}
|
||||||
|
onChange={(closesAt) => patch({ closesAt })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holiday closures */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Holiday closures (optional)
|
||||||
|
</p>
|
||||||
|
{value.holidays.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No holidays configured. Add dates when this clinic is closed (Diwali,
|
||||||
|
Republic Day, maintenance days, etc.).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{value.holidays.map((h, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-end gap-2 rounded-lg border border-secondary bg-secondary p-3"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-secondary">
|
||||||
|
Date
|
||||||
|
</span>
|
||||||
|
<DatePicker
|
||||||
|
value={h.date ? parseDate(h.date) : null}
|
||||||
|
onChange={(dv: DateValue | null) =>
|
||||||
|
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||||
|
}
|
||||||
|
// Holidays must be today or in the future — you
|
||||||
|
// can't observe a holiday that already passed.
|
||||||
|
minValue={today(getLocalTimeZone())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="Reason"
|
||||||
|
placeholder="e.g. Diwali"
|
||||||
|
value={h.label}
|
||||||
|
onChange={(label) => updateHoliday(idx, { label })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary-destructive"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => removeHoliday(idx)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addHoliday}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
Add holiday
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking policy */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Booking policy
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Walk-ins allowed"
|
||||||
|
isSelected={value.walkInAllowed}
|
||||||
|
onChange={(checked) => patch({ walkInAllowed: checked })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Accept online bookings"
|
||||||
|
isSelected={value.onlineBooking}
|
||||||
|
onChange={(checked) => patch({ onlineBooking: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Cancel window (hours)"
|
||||||
|
type="number"
|
||||||
|
value={value.cancellationWindowHours}
|
||||||
|
onChange={(v) => patch({ cancellationWindowHours: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Arrive early (min)"
|
||||||
|
type="number"
|
||||||
|
value={value.arriveEarlyMin}
|
||||||
|
onChange={(v) => patch({ arriveEarlyMin: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required documents — multi-select → pills */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Required documents (optional)
|
||||||
|
</p>
|
||||||
|
{availableDocTypes.length > 0 && (
|
||||||
|
<Select
|
||||||
|
label="Add a required document"
|
||||||
|
placeholder="Pick a document type..."
|
||||||
|
items={availableDocTypes}
|
||||||
|
selectedKey={null}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key) addDocType(key as DocumentType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{value.requiredDocumentTypes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{value.requiredDocumentTypes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeDocType(t)}
|
||||||
|
className="group flex items-center gap-2 rounded-full border border-brand bg-brand-secondary px-3 py-1.5 text-sm font-medium text-brand-secondary transition hover:bg-brand-primary_hover"
|
||||||
|
>
|
||||||
|
{DOCUMENT_TYPE_LABELS[t]}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrash}
|
||||||
|
className="size-3 text-fg-quaternary group-hover:text-fg-error-primary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value.requiredDocumentTypes.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No required documents. Patients won't be asked to bring anything.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
401
src/components/forms/doctor-form.tsx
Normal file
401
src/components/forms/doctor-form.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { TimePicker } from '@/components/application/date-picker/time-picker';
|
||||||
|
|
||||||
|
// Doctor form — hospital-wide profile with multi-clinic, multi-day
|
||||||
|
// visiting schedule. Each row in the "visiting schedule" section maps
|
||||||
|
// to one DoctorVisitSlot child record. The parent component owns the
|
||||||
|
// mutation orchestration (create doctor, then create each slot).
|
||||||
|
//
|
||||||
|
// Previously the form had a single `clinicId` dropdown + a free-text
|
||||||
|
// `visitingHours` textarea. Both dropped — doctors are now hospital-
|
||||||
|
// wide, and their presence at each clinic is expressed via the
|
||||||
|
// DoctorVisitSlot records.
|
||||||
|
|
||||||
|
export type DoctorDepartment =
|
||||||
|
| 'CARDIOLOGY'
|
||||||
|
| 'GYNECOLOGY'
|
||||||
|
| 'ORTHOPEDICS'
|
||||||
|
| 'GENERAL_MEDICINE'
|
||||||
|
| 'ENT'
|
||||||
|
| 'DERMATOLOGY'
|
||||||
|
| 'PEDIATRICS'
|
||||||
|
| 'ONCOLOGY';
|
||||||
|
|
||||||
|
// Matches the DoctorVisitSlot.dayOfWeek SELECT enum on the SDK entity.
|
||||||
|
export type DayOfWeek =
|
||||||
|
| 'MONDAY'
|
||||||
|
| 'TUESDAY'
|
||||||
|
| 'WEDNESDAY'
|
||||||
|
| 'THURSDAY'
|
||||||
|
| 'FRIDAY'
|
||||||
|
| 'SATURDAY'
|
||||||
|
| 'SUNDAY';
|
||||||
|
|
||||||
|
export type DoctorVisitSlotEntry = {
|
||||||
|
// Populated on existing records when editing; undefined for
|
||||||
|
// freshly-added rows. Used by the parent to decide create vs
|
||||||
|
// update vs delete on save.
|
||||||
|
id?: string;
|
||||||
|
clinicId: string;
|
||||||
|
dayOfWeek: DayOfWeek | '';
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DoctorFormValues = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
department: DoctorDepartment | '';
|
||||||
|
specialty: string;
|
||||||
|
qualifications: string;
|
||||||
|
yearsOfExperience: string;
|
||||||
|
consultationFeeNew: string;
|
||||||
|
consultationFeeFollowUp: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
registrationNumber: string;
|
||||||
|
active: boolean;
|
||||||
|
// Multi-clinic, multi-day visiting schedule. One entry per slot.
|
||||||
|
visitSlots: DoctorVisitSlotEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyDoctorFormValues = (): DoctorFormValues => ({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
department: '',
|
||||||
|
specialty: '',
|
||||||
|
qualifications: '',
|
||||||
|
yearsOfExperience: '',
|
||||||
|
consultationFeeNew: '',
|
||||||
|
consultationFeeFollowUp: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
registrationNumber: '',
|
||||||
|
active: true,
|
||||||
|
visitSlots: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEPARTMENT_ITEMS: { id: DoctorDepartment; label: string }[] = [
|
||||||
|
{ id: 'CARDIOLOGY', label: 'Cardiology' },
|
||||||
|
{ id: 'GYNECOLOGY', label: 'Gynecology' },
|
||||||
|
{ id: 'ORTHOPEDICS', label: 'Orthopedics' },
|
||||||
|
{ id: 'GENERAL_MEDICINE', label: 'General medicine' },
|
||||||
|
{ id: 'ENT', label: 'ENT' },
|
||||||
|
{ id: 'DERMATOLOGY', label: 'Dermatology' },
|
||||||
|
{ id: 'PEDIATRICS', label: 'Pediatrics' },
|
||||||
|
{ id: 'ONCOLOGY', label: 'Oncology' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAY_ITEMS: { id: DayOfWeek; label: string }[] = [
|
||||||
|
{ id: 'MONDAY', label: 'Monday' },
|
||||||
|
{ id: 'TUESDAY', label: 'Tuesday' },
|
||||||
|
{ id: 'WEDNESDAY', label: 'Wednesday' },
|
||||||
|
{ id: 'THURSDAY', label: 'Thursday' },
|
||||||
|
{ id: 'FRIDAY', label: 'Friday' },
|
||||||
|
{ id: 'SATURDAY', label: 'Saturday' },
|
||||||
|
{ id: 'SUNDAY', label: 'Sunday' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build the createDoctor / updateDoctor mutation payload. Visit slots
|
||||||
|
// are persisted via a separate mutation chain — see the parent
|
||||||
|
// component's handleSave.
|
||||||
|
export const doctorCoreToGraphQLInput = (v: DoctorFormValues): Record<string, unknown> => {
|
||||||
|
const input: Record<string, unknown> = {
|
||||||
|
fullName: {
|
||||||
|
firstName: v.firstName.trim(),
|
||||||
|
lastName: v.lastName.trim(),
|
||||||
|
},
|
||||||
|
active: v.active,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (v.department) input.department = v.department;
|
||||||
|
if (v.specialty.trim()) input.specialty = v.specialty.trim();
|
||||||
|
if (v.qualifications.trim()) input.qualifications = v.qualifications.trim();
|
||||||
|
if (v.yearsOfExperience.trim()) {
|
||||||
|
const n = Number(v.yearsOfExperience);
|
||||||
|
if (!Number.isNaN(n)) input.yearsOfExperience = n;
|
||||||
|
}
|
||||||
|
if (v.consultationFeeNew.trim()) {
|
||||||
|
const n = Number(v.consultationFeeNew);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
input.consultationFeeNew = {
|
||||||
|
amountMicros: Math.round(n * 1_000_000),
|
||||||
|
currencyCode: 'INR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.consultationFeeFollowUp.trim()) {
|
||||||
|
const n = Number(v.consultationFeeFollowUp);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
input.consultationFeeFollowUp = {
|
||||||
|
amountMicros: Math.round(n * 1_000_000),
|
||||||
|
currencyCode: 'INR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.phone.trim()) {
|
||||||
|
input.phone = {
|
||||||
|
primaryPhoneNumber: v.phone.trim(),
|
||||||
|
primaryPhoneCountryCode: 'IN',
|
||||||
|
primaryPhoneCallingCode: '+91',
|
||||||
|
additionalPhones: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (v.email.trim()) {
|
||||||
|
input.email = {
|
||||||
|
primaryEmail: v.email.trim(),
|
||||||
|
additionalEmails: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (v.registrationNumber.trim()) input.registrationNumber = v.registrationNumber.trim();
|
||||||
|
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build one DoctorVisitSlotCreateInput per complete slot. Drops any
|
||||||
|
// half-filled rows silently — the form can't validate mid-entry
|
||||||
|
// without blocking the user.
|
||||||
|
export const visitSlotInputsFromForm = (
|
||||||
|
v: DoctorFormValues,
|
||||||
|
doctorId: string,
|
||||||
|
): Array<Record<string, unknown>> =>
|
||||||
|
v.visitSlots
|
||||||
|
.filter((s) => s.clinicId && s.dayOfWeek && s.startTime && s.endTime)
|
||||||
|
.map((s) => ({
|
||||||
|
doctorId,
|
||||||
|
clinicId: s.clinicId,
|
||||||
|
dayOfWeek: s.dayOfWeek,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ClinicOption = { id: string; label: string };
|
||||||
|
|
||||||
|
type DoctorFormProps = {
|
||||||
|
value: DoctorFormValues;
|
||||||
|
onChange: (value: DoctorFormValues) => void;
|
||||||
|
clinics: ClinicOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DoctorForm = ({ value, onChange, clinics }: DoctorFormProps) => {
|
||||||
|
const patch = (updates: Partial<DoctorFormValues>) => onChange({ ...value, ...updates });
|
||||||
|
|
||||||
|
// Visit-slot handlers — add/edit/remove inline inside the form.
|
||||||
|
const addSlot = () => {
|
||||||
|
patch({
|
||||||
|
visitSlots: [
|
||||||
|
...value.visitSlots,
|
||||||
|
{ clinicId: clinics[0]?.id ?? '', dayOfWeek: '', startTime: '09:00', endTime: '13:00' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSlot = (index: number, updates: Partial<DoctorVisitSlotEntry>) => {
|
||||||
|
const next = [...value.visitSlots];
|
||||||
|
next[index] = { ...next[index], ...updates };
|
||||||
|
patch({ visitSlots: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlot = (index: number) => {
|
||||||
|
patch({ visitSlots: value.visitSlots.filter((_, i) => i !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
isRequired
|
||||||
|
placeholder="Ananya"
|
||||||
|
value={value.firstName}
|
||||||
|
onChange={(v) => patch({ firstName: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
isRequired
|
||||||
|
placeholder="Rao"
|
||||||
|
value={value.lastName}
|
||||||
|
onChange={(v) => patch({ lastName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Department"
|
||||||
|
placeholder="Select department"
|
||||||
|
items={DEPARTMENT_ITEMS}
|
||||||
|
selectedKey={value.department || null}
|
||||||
|
onSelectionChange={(key) => patch({ department: (key as DoctorDepartment) || '' })}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Specialty"
|
||||||
|
placeholder="e.g. Interventional cardiology"
|
||||||
|
value={value.specialty}
|
||||||
|
onChange={(v) => patch({ specialty: v })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Qualifications"
|
||||||
|
placeholder="MBBS, MD"
|
||||||
|
value={value.qualifications}
|
||||||
|
onChange={(v) => patch({ qualifications: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Experience (years)"
|
||||||
|
type="number"
|
||||||
|
value={value.yearsOfExperience}
|
||||||
|
onChange={(v) => patch({ yearsOfExperience: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visiting schedule — one row per clinic/day slot */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
|
||||||
|
Visiting schedule
|
||||||
|
</p>
|
||||||
|
{clinics.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
|
||||||
|
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
You need at least one clinic before you can schedule doctor visits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{value.visitSlots.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
No visit slots. Add rows for each clinic + day this doctor visits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{value.visitSlots.map((slot, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border border-secondary bg-secondary p-3"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select
|
||||||
|
label="Clinic"
|
||||||
|
placeholder="Select clinic"
|
||||||
|
items={clinics}
|
||||||
|
selectedKey={slot.clinicId || null}
|
||||||
|
onSelectionChange={(key) =>
|
||||||
|
updateSlot(idx, { clinicId: (key as string) || '' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
label="Day"
|
||||||
|
placeholder="Select day"
|
||||||
|
items={DAY_ITEMS}
|
||||||
|
selectedKey={slot.dayOfWeek || null}
|
||||||
|
onSelectionChange={(key) =>
|
||||||
|
updateSlot(idx, { dayOfWeek: (key as DayOfWeek) || '' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TimePicker
|
||||||
|
label="Start time"
|
||||||
|
value={slot.startTime}
|
||||||
|
onChange={(startTime) => updateSlot(idx, { startTime })}
|
||||||
|
/>
|
||||||
|
<TimePicker
|
||||||
|
label="End time"
|
||||||
|
value={slot.endTime}
|
||||||
|
onChange={(endTime) => updateSlot(idx, { endTime })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="tertiary-destructive"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faTrash} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => removeSlot(idx)}
|
||||||
|
>
|
||||||
|
Remove slot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addSlot}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
Add visit slot
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="New consult fee (₹)"
|
||||||
|
type="number"
|
||||||
|
placeholder="800"
|
||||||
|
value={value.consultationFeeNew}
|
||||||
|
onChange={(v) => patch({ consultationFeeNew: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Follow-up fee (₹)"
|
||||||
|
type="number"
|
||||||
|
placeholder="500"
|
||||||
|
value={value.consultationFeeFollowUp}
|
||||||
|
onChange={(v) => patch({ consultationFeeFollowUp: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">Contact</p>
|
||||||
|
<Input
|
||||||
|
label="Phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="9876543210"
|
||||||
|
value={value.phone}
|
||||||
|
onChange={(v) => patch({ phone: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="doctor@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Registration number"
|
||||||
|
placeholder="Medical council reg no."
|
||||||
|
value={value.registrationNumber}
|
||||||
|
onChange={(v) => patch({ registrationNumber: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Accepting appointments"
|
||||||
|
isSelected={value.active}
|
||||||
|
onChange={(checked) => patch({ active: checked })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
Inactive doctors are hidden from appointment booking and call-desk transfer lists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
205
src/components/forms/employee-create-form.tsx
Normal file
205
src/components/forms/employee-create-form.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faEye, faEyeSlash, faRotate } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
|
||||||
|
// In-place employee creation form used by the Team wizard step and
|
||||||
|
// the /settings/team slideout. Replaces the multi-email InviteMemberForm
|
||||||
|
// — this project never uses email invitations, all employees are
|
||||||
|
// created directly with a temp password that the admin hands out.
|
||||||
|
//
|
||||||
|
// Two modes:
|
||||||
|
//
|
||||||
|
// - 'create': all fields editable. The temp password is auto-generated
|
||||||
|
// on form mount (parent does this) and revealed via an eye icon. A
|
||||||
|
// refresh icon next to the eye lets the admin re-roll the password
|
||||||
|
// before saving.
|
||||||
|
//
|
||||||
|
// - 'edit': email is read-only (it's the login id, can't change),
|
||||||
|
// password field is hidden entirely (no reset-password from the
|
||||||
|
// wizard). Only firstName/lastName/role can change.
|
||||||
|
//
|
||||||
|
// SIP seat assignment is intentionally NOT in this form — it lives
|
||||||
|
// exclusively in the Telephony wizard step, so there's a single source
|
||||||
|
// of truth for "who is on which seat" and admins don't have to remember
|
||||||
|
// two places to manage the same thing.
|
||||||
|
|
||||||
|
export type RoleOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
supportingText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmployeeCreateFormValues = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyEmployeeCreateFormValues: EmployeeCreateFormValues = {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
roleId: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Random temp password generator. Skips visually-ambiguous chars
|
||||||
|
// (0/O/1/l/I) so admins can read the password back over a phone call
|
||||||
|
// without typo risk. 11 alphanumerics + 1 symbol = 12 chars total.
|
||||||
|
export const generateTempPassword = (): string => {
|
||||||
|
const chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
const symbols = '!@#$';
|
||||||
|
let pwd = '';
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
pwd += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
}
|
||||||
|
pwd += symbols[Math.floor(Math.random() * symbols.length)];
|
||||||
|
return pwd;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmployeeCreateFormProps = {
|
||||||
|
value: EmployeeCreateFormValues;
|
||||||
|
onChange: (value: EmployeeCreateFormValues) => void;
|
||||||
|
roles: RoleOption[];
|
||||||
|
// 'create' = full form, 'edit' = name + role only.
|
||||||
|
mode?: 'create' | 'edit';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Eye / eye-slash button rendered inside the password field's
|
||||||
|
// trailing slot. Stays internal to this form since password reveal
|
||||||
|
// is the only place we need it right now.
|
||||||
|
const EyeButton = ({ visible, onClick, title }: { visible: boolean; onClick: () => void; title: string }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RegenerateButton = ({ onClick }: { onClick: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title="Generate a new password"
|
||||||
|
aria-label="Generate a new password"
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faRotate} className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kept simple — name + contact + creds + role. No avatar, no phone,
|
||||||
|
// no title. The goal is to get employees onto the system fast; they
|
||||||
|
// can fill in the rest from their own profile page later.
|
||||||
|
export const EmployeeCreateForm = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
roles,
|
||||||
|
mode = 'create',
|
||||||
|
}: EmployeeCreateFormProps) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const patch = (partial: Partial<EmployeeCreateFormValues>) =>
|
||||||
|
onChange({ ...value, ...partial });
|
||||||
|
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="First name"
|
||||||
|
placeholder="Priya"
|
||||||
|
value={value.firstName}
|
||||||
|
onChange={(v) => patch({ firstName: v })}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Last name"
|
||||||
|
placeholder="Sharma"
|
||||||
|
value={value.lastName}
|
||||||
|
onChange={(v) => patch({ lastName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="priya@hospital.com"
|
||||||
|
value={value.email}
|
||||||
|
onChange={(v) => patch({ email: v })}
|
||||||
|
isRequired={!isEdit}
|
||||||
|
isReadOnly={isEdit}
|
||||||
|
isDisabled={isEdit}
|
||||||
|
hint={
|
||||||
|
isEdit
|
||||||
|
? 'Email is the login id and cannot be changed.'
|
||||||
|
: 'This is the login id for the employee. Cannot be changed later.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-secondary">
|
||||||
|
Temporary password <span className="text-error-primary">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 rounded-lg border border-secondary bg-primary px-3 shadow-xs focus-within:border-brand focus-within:ring-2 focus-within:ring-brand-100">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={value.password}
|
||||||
|
onChange={(e) => patch({ password: e.target.value })}
|
||||||
|
placeholder="Auto-generated"
|
||||||
|
className="flex-1 bg-transparent py-2 font-mono text-sm text-primary placeholder:text-placeholder outline-none"
|
||||||
|
/>
|
||||||
|
<EyeButton
|
||||||
|
visible={showPassword}
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
title={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
/>
|
||||||
|
<RegenerateButton
|
||||||
|
onClick={() => {
|
||||||
|
patch({ password: generateTempPassword() });
|
||||||
|
setShowPassword(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Auto-generated. Click the refresh icon to roll a new one. Share with the
|
||||||
|
employee directly — they should change it after first login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
placeholder={roles.length === 0 ? 'No roles available' : 'Select a role'}
|
||||||
|
isDisabled={roles.length === 0}
|
||||||
|
items={roles}
|
||||||
|
selectedKey={value.roleId || null}
|
||||||
|
onSelectionChange={(key) => patch({ roleId: (key as string) || '' })}
|
||||||
|
isRequired
|
||||||
|
>
|
||||||
|
{(item) => (
|
||||||
|
<Select.Item
|
||||||
|
id={item.id}
|
||||||
|
label={item.label}
|
||||||
|
supportingText={item.supportingText}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-3 text-xs text-tertiary">
|
||||||
|
SIP seats are managed in the <b>Telephony</b> step — create the employee here
|
||||||
|
first, then assign them a seat there.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
src/components/forms/telephony-form.tsx
Normal file
202
src/components/forms/telephony-form.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
|
||||||
|
// Telephony form — covers Ozonetel cloud-call-center, the Ozonetel WebRTC
|
||||||
|
// gateway, and Exotel REST API credentials. Mirrors the TelephonyConfig shape
|
||||||
|
// in helix-engage-server/src/config/telephony.defaults.ts.
|
||||||
|
//
|
||||||
|
// Secrets (ozonetel.agentPassword, exotel.apiToken) come back from the GET
|
||||||
|
// endpoint as the sentinel '***masked***' — the form preserves that sentinel
|
||||||
|
// untouched unless the admin actually edits the field, in which case the
|
||||||
|
// backend overwrites the stored value. This is the same convention used by
|
||||||
|
// TelephonyConfigService.getMaskedConfig / updateConfig.
|
||||||
|
|
||||||
|
export type TelephonyFormValues = {
|
||||||
|
ozonetel: {
|
||||||
|
agentId: string;
|
||||||
|
agentPassword: string;
|
||||||
|
did: string;
|
||||||
|
sipId: string;
|
||||||
|
campaignName: string;
|
||||||
|
adminUsername: string;
|
||||||
|
adminPassword: string;
|
||||||
|
};
|
||||||
|
sip: {
|
||||||
|
domain: string;
|
||||||
|
wsPort: string;
|
||||||
|
};
|
||||||
|
exotel: {
|
||||||
|
apiKey: string;
|
||||||
|
apiToken: string;
|
||||||
|
accountSid: string;
|
||||||
|
subdomain: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
|
||||||
|
ozonetel: {
|
||||||
|
agentId: '',
|
||||||
|
agentPassword: '',
|
||||||
|
did: '',
|
||||||
|
sipId: '',
|
||||||
|
campaignName: '',
|
||||||
|
adminUsername: '',
|
||||||
|
adminPassword: '',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: '444',
|
||||||
|
},
|
||||||
|
exotel: {
|
||||||
|
apiKey: '',
|
||||||
|
apiToken: '',
|
||||||
|
accountSid: '',
|
||||||
|
subdomain: 'api.exotel.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type TelephonyFormProps = {
|
||||||
|
value: TelephonyFormValues;
|
||||||
|
onChange: (value: TelephonyFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
|
||||||
|
const patchOzonetel = (updates: Partial<TelephonyFormValues['ozonetel']>) =>
|
||||||
|
onChange({ ...value, ozonetel: { ...value.ozonetel, ...updates } });
|
||||||
|
const patchSip = (updates: Partial<TelephonyFormValues['sip']>) =>
|
||||||
|
onChange({ ...value, sip: { ...value.sip, ...updates } });
|
||||||
|
const patchExotel = (updates: Partial<TelephonyFormValues['exotel']>) =>
|
||||||
|
onChange({ ...value, exotel: { ...value.exotel, ...updates } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Ozonetel Cloud Agent</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Outbound dialing, SIP registration, and agent provisioning. Get these values from your
|
||||||
|
Ozonetel dashboard under Admin → Users and Numbers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Agent ID"
|
||||||
|
placeholder="e.g. agent001"
|
||||||
|
value={value.ozonetel.agentId}
|
||||||
|
onChange={(v) => patchOzonetel({ agentId: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Agent password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.ozonetel.agentPassword}
|
||||||
|
onChange={(v) => patchOzonetel({ agentPassword: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Default DID"
|
||||||
|
placeholder="Primary hospital number"
|
||||||
|
value={value.ozonetel.did}
|
||||||
|
onChange={(v) => patchOzonetel({ did: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="SIP ID"
|
||||||
|
placeholder="Softphone extension"
|
||||||
|
value={value.ozonetel.sipId}
|
||||||
|
onChange={(v) => patchOzonetel({ sipId: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Campaign name"
|
||||||
|
placeholder="CloudAgent campaign for outbound dial"
|
||||||
|
value={value.ozonetel.campaignName}
|
||||||
|
onChange={(v) => patchOzonetel({ campaignName: v })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-tertiary">
|
||||||
|
Ozonetel portal admin credentials — required for supervisor barge/whisper/listen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Admin username"
|
||||||
|
placeholder="Ozonetel portal admin login"
|
||||||
|
value={value.ozonetel.adminUsername}
|
||||||
|
onChange={(v) => patchOzonetel({ adminUsername: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Admin password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.ozonetel.adminPassword}
|
||||||
|
onChange={(v) => patchOzonetel({ adminPassword: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">SIP Gateway (WebRTC)</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Used by the staff portal softphone. Defaults work for most Indian Ozonetel tenants — only
|
||||||
|
change if Ozonetel support instructs you to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="SIP domain"
|
||||||
|
placeholder="blr-pub-rtc4.ozonetel.com"
|
||||||
|
value={value.sip.domain}
|
||||||
|
onChange={(v) => patchSip({ domain: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="WebSocket port"
|
||||||
|
placeholder="444"
|
||||||
|
value={value.sip.wsPort}
|
||||||
|
onChange={(v) => patchSip({ wsPort: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Exotel (SMS + inbound numbers)</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Optional — only required if you use Exotel for SMS or want inbound number management from
|
||||||
|
this portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="API key"
|
||||||
|
placeholder="Exotel API key"
|
||||||
|
value={value.exotel.apiKey}
|
||||||
|
onChange={(v) => patchExotel({ apiKey: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="API token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.exotel.apiToken}
|
||||||
|
onChange={(v) => patchExotel({ apiToken: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Account SID"
|
||||||
|
placeholder="Exotel account SID"
|
||||||
|
value={value.exotel.accountSid}
|
||||||
|
onChange={(v) => patchExotel({ accountSid: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Subdomain"
|
||||||
|
placeholder="api.exotel.com"
|
||||||
|
value={value.exotel.subdomain}
|
||||||
|
onChange={(v) => patchExotel({ subdomain: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
src/components/forms/widget-form.tsx
Normal file
167
src/components/forms/widget-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlus, faTrash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
|
||||||
|
// Widget form — mirrors WidgetConfig from
|
||||||
|
// helix-engage-server/src/config/widget.defaults.ts. The site key and site ID
|
||||||
|
// are read-only (generated / rotated by the backend), the rest are editable.
|
||||||
|
//
|
||||||
|
// allowedOrigins is an origin allowlist — an empty list means "any origin"
|
||||||
|
// which is useful for testing but should be tightened in production.
|
||||||
|
|
||||||
|
export type WidgetFormValues = {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
allowedOrigins: string[];
|
||||||
|
embed: {
|
||||||
|
loginPage: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyWidgetFormValues = (): WidgetFormValues => ({
|
||||||
|
enabled: true,
|
||||||
|
url: '',
|
||||||
|
allowedOrigins: [],
|
||||||
|
embed: {
|
||||||
|
loginPage: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type WidgetFormProps = {
|
||||||
|
value: WidgetFormValues;
|
||||||
|
onChange: (value: WidgetFormValues) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidgetForm = ({ value, onChange }: WidgetFormProps) => {
|
||||||
|
const [originDraft, setOriginDraft] = useState('');
|
||||||
|
|
||||||
|
const addOrigin = () => {
|
||||||
|
const trimmed = originDraft.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (value.allowedOrigins.includes(trimmed)) {
|
||||||
|
setOriginDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({ ...value, allowedOrigins: [...value.allowedOrigins, trimmed] });
|
||||||
|
setOriginDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOrigin = (origin: string) => {
|
||||||
|
onChange({ ...value, allowedOrigins: value.allowedOrigins.filter((o) => o !== origin) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Activation</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
When disabled, widget.js returns an empty response and the script no-ops on the
|
||||||
|
embedding page. Use this as a kill switch if something goes wrong in production.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Website widget enabled"
|
||||||
|
isSelected={value.enabled}
|
||||||
|
onChange={(checked) => onChange({ ...value, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Hosting</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Public base URL where widget.js is served from. Leave blank to use the same origin as
|
||||||
|
this sidecar (the common case).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Public URL"
|
||||||
|
placeholder="https://widget.hospital.com"
|
||||||
|
value={value.url}
|
||||||
|
onChange={(v) => onChange({ ...value, url: v })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Allowed origins</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Origins where the widget may be embedded. An empty list means any origin is accepted
|
||||||
|
(test mode). In production, list every hospital website + staging environment
|
||||||
|
explicitly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="https://hospital.com"
|
||||||
|
value={originDraft}
|
||||||
|
onChange={setOriginDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPlus} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={addOrigin}
|
||||||
|
isDisabled={!originDraft.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{value.allowedOrigins.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-secondary bg-secondary p-4 text-center text-xs text-tertiary">
|
||||||
|
Any origin allowed — widget runs in test mode.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2 rounded-lg border border-secondary bg-secondary p-3">
|
||||||
|
{value.allowedOrigins.map((origin) => (
|
||||||
|
<li
|
||||||
|
key={origin}
|
||||||
|
className="flex items-center justify-between rounded-md bg-primary px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-primary">{origin}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeOrigin(origin)}
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-error-primary"
|
||||||
|
title="Remove origin"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="size-3" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Embed surfaces</h3>
|
||||||
|
<p className="mt-1 text-xs text-tertiary">
|
||||||
|
Where inside this application the widget should auto-render. Keep these off if you
|
||||||
|
only plan to embed it on your public hospital website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-secondary bg-secondary p-4">
|
||||||
|
<Toggle
|
||||||
|
label="Show on staff login page"
|
||||||
|
hint="Useful for smoke-testing without a public landing page."
|
||||||
|
isSelected={value.embed.loginPage}
|
||||||
|
onChange={(checked) =>
|
||||||
|
onChange({ ...value, embed: { ...value.embed, loginPage: checked } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user