1 Commits

Author SHA1 Message Date
moulichand16
932f8ecb2f added platform integration with script and added invisible captcha 2026-04-08 16:27:40 +05:30
70 changed files with 678 additions and 10316 deletions

4
.gitignore vendored
View File

@@ -23,7 +23,3 @@ dist-ssr
*.sln
*.sw?
.env
# Playwright
test-results/
playwright-report/

View File

@@ -1,22 +0,0 @@
# Woodpecker CI pipeline for Helix Engage
#
# Triggered on push or manual run.
when:
- event: [push, manual]
steps:
typecheck:
image: node:22-slim
commands:
- npm ci
- npx tsc --noEmit
e2e-tests:
image: mcr.microsoft.com/playwright:v1.52.0-noble
commands:
- npm ci
- npx playwright install chromium
- npx playwright test --reporter=list
environment:
E2E_BASE_URL: https://ramaiah.engage.healix360.net

View File

@@ -1,248 +0,0 @@
# 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

View File

@@ -2,285 +2,326 @@
## 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)
└── engage.healix360.net → 404 (no catchall)
Caddy (reverse proxy, TLS, static files)
├── engage.srv1477139.hstgr.cloud → /srv/engage (static frontend)
├── engage-api.srv1477139.hstgr.cloud → sidecar:4100
── *.srv1477139.hstgr.cloud → server:4000 (platform)
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)
├── telephonyEvent 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)
Docker Compose stack:
├── caddy — Reverse proxy + TLS
├── server — FortyTwo platform (ECR image)
├── worker — Background jobs
├── sidecar — Helix Engage NestJS API (ECR image)
├── db — PostgreSQL 16
├── redisSession + cache
├── clickhouse — Analytics
├── minio — Object storage
── redpanda — Event bus (Kafka)
```
---
## EC2 Access
## VPS Access
```bash
# SSH into EC2
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
# SSH into the VPS
sshpass -p 'SasiSuman@2007' ssh -o StrictHostKeyChecking=no root@148.230.67.184
# Or with SSH key (if configured)
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184
```
| Detail | Value |
|---|---|
| Host | `13.234.31.194` |
| User | `ubuntu` |
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
| Docker compose dir | `/opt/fortytwo` |
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
| Caddyfile | `/opt/fortytwo/Caddyfile` |
### SSH Key Setup
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
Create a decrypted copy for non-interactive use:
```bash
# One-time setup
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
chmod 600 /tmp/ramaiah-ec2-key
# Verify
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
```
### Handy alias
```bash
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
```
---
| Host | 148.230.67.184 |
| User | root |
| Password | SasiSuman@2007 |
| Docker compose dir | /opt/fortytwo |
| Frontend static files | /opt/fortytwo/helix-engage-frontend |
| Caddyfile | /opt/fortytwo/Caddyfile |
## 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` |
---
| Frontend | https://engage.srv1477139.hstgr.cloud |
| Sidecar API | https://engage-api.srv1477139.hstgr.cloud |
| Platform | https://fortytwo-dev.srv1477139.hstgr.cloud |
## 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` |
| CC Agent | rekha.cc@globalhospital.com | Global@123 |
| CC Agent | ganesh.cc@globalhospital.com | Global@123 |
| Marketing | sanjay.marketing@globalhospital.com | Global@123 |
| Admin/Supervisor | dr.ramesh@globalhospital.com | Global@123 |
---
## Local Development
## Local Testing
Always test locally before deploying to staging.
### Frontend (Vite dev server)
```bash
cd helix-engage
npm run dev # http://localhost:5173
npx tsc --noEmit # Type check
npm run build # Production build
# Start dev server (hot reload)
npm run dev
# → http://localhost:5173
# Type check (catches production build errors)
npx tsc --noEmit
# Production build (same as deploy)
npm run 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
# Remote sidecar (default — uses deployed backend)
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
# Local sidecar
# Local sidecar (for testing sidecar changes)
# VITE_API_URL=http://localhost:4100
# VITE_SIDECAR_URL=http://localhost:4100
# Split — theme endpoint local, everything else remote
# VITE_THEME_API_URL=http://localhost:4100
```
**Important:** When `VITE_API_URL` points to `localhost:4100`, login and GraphQL only work if the local sidecar can reach the platform. The local sidecar's `.env` must have valid `PLATFORM_GRAPHQL_URL` and `PLATFORM_API_KEY`.
### Sidecar (NestJS dev server)
```bash
cd helix-engage-server
npm run start:dev # http://localhost:4100 (watch mode)
npm run build # Build only
# Start with watch mode (auto-restart on changes)
npm run start:dev
# → http://localhost:4100
# Build only (no run)
npm run build
# Production start
npm run start:prod
```
Sidecar `.env` must have:
The 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
PLATFORM_GRAPHQL_URL=... # Platform GraphQL endpoint
PLATFORM_API_KEY=... # Platform API key for server-to-server calls
PLATFORM_WORKSPACE_SUBDOMAIN=fortytwo-dev
REDIS_URL=redis://localhost:6379 # Local Redis required
```
### Local Docker stack (full environment)
For testing with a local platform + database + Redis:
```bash
cd helix-engage-local
# First time — pull images + start
./deploy-local.sh up
# Deploy frontend to local stack
./deploy-local.sh frontend
# Deploy sidecar to local stack
./deploy-local.sh sidecar
# Both
./deploy-local.sh all
# Logs
./deploy-local.sh logs
# Stop
./deploy-local.sh down
```
Local stack URLs:
- Platform: `http://localhost:5001`
- Sidecar: `http://localhost:5100`
- Frontend: `http://localhost:5080`
### Pre-deploy checklist
1. `npx tsc --noEmit` — passes (frontend)
Before running `deploy.sh`:
1. `npx tsc --noEmit` — passes with no errors (frontend)
2. `npm run build` — succeeds (sidecar)
3. Test the changed feature locally
3. Test the changed feature locally (dev server or local stack)
4. Check `package.json` for new dependencies → decides quick vs full deploy
---
## Deployment
### Frontend
### Prerequisites (local machine)
```bash
cd helix-engage && npm run build
# Required tools
brew install sshpass # SSH with password
aws configure # AWS CLI (for ECR)
docker desktop # Docker with buildx
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"cd /opt/fortytwo && sudo docker compose restart caddy"
# Verify AWS access
aws sts get-caller-identity # Should show account 043728036361
```
### Sidecar (quick — code only, no new dependencies)
### Path 1: Quick Deploy (no new dependencies)
Use when only code changes — no new npm packages.
```bash
cd helix-engage-server
cd /path/to/fortytwo-eap
aws ecr get-login-password --region ap-south-1 | \
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# Deploy frontend only
bash deploy.sh frontend
# Deploy sidecar only
bash deploy.sh sidecar
# Deploy both
bash deploy.sh all
```
**What it does:**
- Frontend: `npm run build` → tar `dist/` → SCP to VPS → extract to `/opt/fortytwo/helix-engage-frontend`
- Sidecar: `nest build` → tar `dist/` + `src/` → docker cp into running container → `docker compose restart sidecar`
### Path 2: Full Deploy (new dependencies)
Use when `package.json` changed (new npm packages added).
```bash
cd /path/to/fortytwo-eap/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 cross-platform image and push
docker buildx build --platform linux/amd64 \
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
--push .
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
# 3. Pull and restart on VPS
ECR_TOKEN=$(aws ecr get-login-password --region ap-south-1)
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
echo '$ECR_TOKEN' | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
cd /opt/fortytwo
docker compose pull sidecar
docker compose up -d sidecar
"
```
### How to decide
### How to decide which path
```
Did package.json change?
├── YES → ECR build + push + pull (above)
└── NO Same steps (ECR is the only deploy path for EC2)
├── YES → Path 2 (ECR build + push + pull)
└── NO → Path 1 (deploy.sh)
```
---
## 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
### Sidecar 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"
# SSH into VPS first, or run remotely:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 30"
# 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"
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 -f --tail 10"
# 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"
# Filter for errors
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 100 2>&1 | grep -i error"
# 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}}'"
# Via deploy.sh
bash deploy.sh logs
```
### Healthy startup
### Caddy logs
Look for these in sidecar logs:
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-caddy-1 --tail 30"
```
### Platform server logs
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-server-1 --tail 30"
```
### All container status
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
```
---
## Health Checks
### Sidecar healthy startup
Look for these lines in sidecar logs:
```
[NestApplication] Nest application successfully started
Helix Engage Server running on port 4100
[SessionService] Redis connected
[ThemeService] Theme loaded from file (or "Using default theme")
[RulesStorageService] Initialized empty rules config
```
### 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` |
| `Cannot find module 'xxx'` | Missing npm dependency | Path 2 deploy (rebuild ECR image) |
| `UndefinedModuleException` | Circular dependency or missing import | Fix code, redeploy |
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose restart redis sidecar` |
| `Forbidden resource` | Platform permission issue | Check user roles |
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling |
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling frequency |
---
## Redis Operations
## Redis Cache Operations
### Clear caller resolution cache
```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"
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli KEYS 'caller:*'"
# Clear agent session lock (fixes "already logged in from another device")
$SSH "$REDIS DEL agent:session:ramaiahadmin"
# Clear all caller cache
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'caller:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
# List all keys
$SSH "$REDIS KEYS '*'"
### Clear recording analysis cache
# Clear caller cache (stale patient names)
$SSH "$REDIS --scan --pattern 'caller:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'call:analysis:*' | xargs -r docker exec -i fortytwo-staging-redis-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 agent name cache
# Clear recording analysis cache
$SSH "$REDIS --scan --pattern 'call:analysis:*' | xargs -r docker exec -i ramaiah-prod-redis-ramaiah-1 redis-cli DEL"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'agent:name:*' | xargs -r docker exec -i fortytwo-staging-redis-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"
### Clear all session/cache keys
# Nuclear: flush all sidecar Redis
$SSH "$REDIS FLUSHDB"
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli FLUSHDB"
```
---
@@ -288,8 +329,7 @@ $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"
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-db-1 psql -U fortytwo -d fortytwo_staging"
```
### Useful queries
@@ -314,68 +354,70 @@ JOIN core."role" r ON r.id = rt."roleId";
---
## Troubleshooting
## Rollback
### "Already logged in from another device"
### Frontend rollback
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"
```
The previous frontend build is overwritten. To rollback:
1. Checkout the previous git commit
2. `npm run build`
3. `bash deploy.sh frontend`
### Agent stuck in ACW / Wrapping Up
### Sidecar rollback (quick deploy)
Same as frontend — checkout previous commit, rebuild, redeploy.
### Sidecar rollback (ECR)
```bash
curl -X POST https://ramaiah.engage.healix360.net/api/maint/force-ready \
-H "Content-Type: application/json" \
-d '{"agentId": "ramaiahadmin"}'
```
# Tag the current image as rollback
# Then re-tag the previous image as :alpha
# Or use a specific tag/digest
### 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"}}'
# On VPS:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
cd /opt/fortytwo
docker compose restart sidecar
"
```
---
## Rollback
## Theme Management
### Frontend
### View current theme
```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/config/theme | python3 -m json.tool
```
Checkout previous commit → `npm run build` → rsync to EC2.
### Reset theme to defaults
```bash
curl -s -X POST https://engage-api.srv1477139.hstgr.cloud/api/config/theme/reset | python3 -m json.tool
```
### Sidecar
Checkout previous commit → ECR build + push → pull on EC2.
For immediate rollback, re-tag a known-good ECR image as `:alpha` and pull.
### Theme backups
Stored on the sidecar container at `/app/data/theme-backups/`. Each save creates a timestamped backup.
---
## Git Repositories
| Repo | Azure DevOps | Branch |
| Repo | Azure DevOps URL | 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` |
| Frontend | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage` | `dev` |
| Sidecar | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server` | `dev` |
| SDK App | `FortyTwoApps/helix-engage/` (in fortytwo-eap monorepo) | `dev` |
### Commit and push pattern
```bash
# Frontend
cd helix-engage
git add -A && git commit -m "feat: description" && git push origin dev
# Sidecar
cd helix-engage-server
git add -A && git commit -m "feat: description" && git push origin dev
```
---
@@ -383,7 +425,7 @@ For immediate rollback, re-tag a known-good ECR image as `:alpha` and pull.
| 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) |
| Registry | 043728036361.dkr.ecr.ap-south-1.amazonaws.com |
| Repository | fortytwo-eap/helix-engage-sidecar |
| Tag | alpha |
| Region | ap-south-1 (Mumbai) |

View File

@@ -1,979 +0,0 @@
# 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

View File

@@ -1,348 +0,0 @@
# 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`.

View File

@@ -1,337 +0,0 @@
# 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

View File

@@ -1 +0,0 @@
*.json

View File

@@ -1,56 +0,0 @@
/**
* 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 });
});
});

View File

@@ -1,155 +0,0 @@
/**
* 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 });
}
});
});

View File

@@ -1,29 +0,0 @@
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 });
});

View File

@@ -1,25 +0,0 @@
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 });
});

View File

@@ -1,120 +0,0 @@
/**
* 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 });
});
});

View File

@@ -1,15 +0,0 @@
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);
}

View File

@@ -1,121 +0,0 @@
/**
* 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();
}
});
});

64
package-lock.json generated
View File

@@ -42,7 +42,6 @@
"tailwindcss-react-aria-components": "^2.0.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",
@@ -1078,22 +1077,6 @@
"@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": {
"version": "3.0.0-rc.6",
"resolved": "http://localhost:4873/@react-aria/autocomplete/-/autocomplete-3.0.0-rc.6.tgz",
@@ -5488,53 +5471,6 @@
"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": {
"version": "8.5.8",
"resolved": "http://localhost:4873/postcss/-/postcss-8.5.8.tgz",

View File

@@ -43,7 +43,6 @@
"tailwindcss-react-aria-components": "^2.0.1"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/jssip": "^3.5.3",

View File

@@ -1,65 +0,0 @@
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',
},
},
],
});

View File

@@ -1,21 +1,23 @@
/**
* Helix Engage — Platform Data Seeder
* Creates 2 clinics, 5 doctors with multi-clinic visit slots,
* 3 patient stories with fully linked records (campaigns, leads,
* calls, appointments, follow-ups, lead activities).
* Creates 5 patient stories + 5 doctors with fully linked records.
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
*
* Run: cd helix-engage && npx tsx scripts/seed-data.ts
* Env: SEED_GQL (graphql url), SEED_ORIGIN (workspace origin), SEED_SUB (workspace subdomain)
*
* Schema alignment (2026-04-10):
* - Doctor.visitingHours removed → replaced by DoctorVisitSlot entity
* - Doctor.portalUserId omitted (workspace member IDs are per-deployment)
* - Clinic entity added (needed for visit slot FK)
* Platform field mapping (SDK name → platform name):
* Campaign: campaignType→typeCustom, campaignStatus→status, impressionCount→impressions,
* clickCount→clicks, contactedCount→contacted, convertedCount→converted, leadCount→leadsGenerated
* Lead: leadSource→source, leadStatus→status, firstContactedAt→firstContacted,
* lastContactedAt→lastContacted, landingPageUrl→landingPage
* Call: callDirection→direction, durationSeconds→durationSec
* Appointment: durationMinutes→durationMin, appointmentStatus→status, roomNumber→room
* FollowUp: followUpType→typeCustom, followUpStatus→status
* Patient: address→addressCustom
* Doctor: isActive→active, branch→branchClinic
* NOTE: callNotes/visitNotes/clinicalNotes are RICH_TEXT — read-only, cannot seed
*/
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
const SUB = process.env.SEED_SUB ?? 'fortytwo-dev';
const SUB = 'fortytwo-dev';
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
let token = '';
@@ -49,172 +51,28 @@ async function mk(entity: string, data: any): Promise<string> {
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() {
console.log('🌱 Seeding Helix Engage demo data...\n');
await auth();
console.log('✅ Auth OK\n');
// Clean slate — remove all existing entity data (not users)
console.log('🧹 Clearing existing data...');
await clearAll();
console.log('');
await auth();
// Workspace member IDs — switch based on target platform
const WM = GQL.includes('srv1477139') ? {
drSharma: '107efa70-fd32-4819-8936-994197c6ada1',
drPatel: '7e1fe368-1f23-4a10-8c2f-3e9c3846b209',
drKumar: 'b86ff7d3-57de-44e5-aa13-e5da848a960c',
drReddy: 'b82693b6-701c-4783-8d02-cc137c9c306b',
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',
};
// ═══════════════════════════════════════════
// 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).
// DOCTORS (linked to workspace members)
// ═══════════════════════════════════════════
console.log('👨‍⚕️ Doctors');
const drSharma = await mk('doctor', {
@@ -224,15 +82,16 @@ async function main() {
specialty: 'Interventional Cardiology',
qualifications: 'MBBS, MD (Medicine), DM (Cardiology), FACC',
yearsOfExperience: 18,
visitingHours: 'Mon/Wed/Fri 10:00 AM 1:00 PM',
consultationFeeNew: { amountMicros: 800_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 500_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100001', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.sharma@globalcare.com' },
email: { primaryEmail: 'dr.sharma@globalhospital.com' },
registrationNumber: 'KMC-45672',
active: true,
portalUserId: wmSharma,
portalUserId: WM.drSharma,
});
console.log(` Dr. Sharma (Cardiology ${wmSharma}): ${drSharma}`);
console.log(` Dr. Sharma (Cardiology, WM: ${WM.drSharma}): ${drSharma}`);
const drPatel = await mk('doctor', {
name: 'Dr. Meena Patel',
@@ -241,15 +100,16 @@ async function main() {
specialty: 'Reproductive Medicine & IVF',
qualifications: 'MBBS, MS (OBG), Fellowship in Reproductive Medicine',
yearsOfExperience: 15,
visitingHours: 'Tue/Thu/Sat 9:00 AM 12:00 PM',
consultationFeeNew: { amountMicros: 700_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100002', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.patel@globalcare.com' },
email: { primaryEmail: 'dr.patel@globalhospital.com' },
registrationNumber: 'KMC-38291',
active: true,
portalUserId: wmPatel,
portalUserId: WM.drPatel,
});
console.log(` Dr. Patel (Gynecology/IVF ${wmPatel}): ${drPatel}`);
console.log(` Dr. Patel (Gynecology/IVF, WM: ${WM.drPatel}): ${drPatel}`);
const drKumar = await mk('doctor', {
name: 'Dr. Rajesh Kumar',
@@ -258,15 +118,16 @@ async function main() {
specialty: 'Joint Replacement & Sports Medicine',
qualifications: 'MBBS, MS (Ortho), Fellowship in Arthroplasty',
yearsOfExperience: 12,
visitingHours: 'MonFri 2:00 PM 5:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100003', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.kumar@globalcare.com' },
email: { primaryEmail: 'dr.kumar@globalhospital.com' },
registrationNumber: 'KMC-51003',
active: true,
portalUserId: wmKumar,
portalUserId: WM.drKumar,
});
console.log(` Dr. Kumar (Orthopedics ${wmKumar}): ${drKumar}`);
console.log(` Dr. Kumar (Orthopedics, WM: ${WM.drKumar}): ${drKumar}`);
const drReddy = await mk('doctor', {
name: 'Dr. Lakshmi Reddy',
@@ -275,15 +136,16 @@ async function main() {
specialty: 'Internal Medicine & Preventive Health',
qualifications: 'MBBS, MD (General Medicine)',
yearsOfExperience: 20,
visitingHours: 'MonSat 9:00 AM 6:00 PM',
consultationFeeNew: { amountMicros: 500_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 300_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100004', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.reddy@globalcare.com' },
email: { primaryEmail: 'dr.reddy@globalhospital.com' },
registrationNumber: 'KMC-22145',
active: true,
portalUserId: wmReddy,
portalUserId: WM.drReddy,
});
console.log(` Dr. Reddy (General Medicine ${wmReddy}): ${drReddy}`);
console.log(` Dr. Reddy (General Medicine, WM: ${WM.drReddy}): ${drReddy}`);
const drSingh = await mk('doctor', {
name: 'Dr. Harpreet Singh',
@@ -292,57 +154,16 @@ async function main() {
specialty: 'Otorhinolaryngology & Head/Neck Surgery',
qualifications: 'MBBS, MS (ENT), DNB',
yearsOfExperience: 10,
visitingHours: 'Mon/Wed/Fri 11:00 AM 3:00 PM',
consultationFeeNew: { amountMicros: 600_000_000, currencyCode: 'INR' },
consultationFeeFollowUp: { amountMicros: 400_000_000, currencyCode: 'INR' },
phone: { primaryPhoneNumber: '9900100005', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'dr.singh@globalcare.com' },
email: { primaryEmail: 'dr.singh@globalhospital.com' },
registrationNumber: 'KMC-60782',
active: true,
portalUserId: wmSingh,
portalUserId: WM.drSingh,
});
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:0013: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:0012: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 MonFri 14:0017: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 MonSat
{ 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:0015: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`);
console.log(` Dr. Singh (ENT, WM: ${WM.drSingh}): ${drSingh}\n`);
await auth();
@@ -585,10 +406,9 @@ async function main() {
console.log(' Vijay — appointment reminder (tomorrow 9am)\n');
console.log('🎉 Seed complete!');
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(' 5 doctors · 3 campaigns · 3 patients · 5 leads · 6 appointments · 10 calls · 22 activities · 2 follow-ups');
console.log(' Demo phones: Priya=9949879837, Ravi=6309248884');
console.log(' Doctors linked to clinics via visit slots (multi-clinic schedule)');
console.log(' All appointments linked to doctor entities');
}
main().catch(e => { console.error('💥', e.message); process.exit(1); });

View File

@@ -1,117 +0,0 @@
/**
* 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); });

View File

@@ -1,73 +0,0 @@
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}`;
};

View File

@@ -1,108 +0,0 @@
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 MonSun 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: MonSat 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. "MonFri", "MonSat", "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 "MonFri";
if (openKeys.length === 6 && openKeys.join(",") === "Mon,Tue,Wed,Thu,Fri,Sat") return "MonSat";
return openKeys.join(" ");
};

View File

@@ -56,18 +56,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
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')) {
@@ -90,11 +78,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Submit disposition to sidecar
if (callUcid) {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const disposePayload = {
ucid: callUcid,
disposition,
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: callDirectionRef.current,
durationSec: callDuration,
@@ -129,9 +115,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}
}
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
localStorage.removeItem('helix_active_ucid');
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
handleReset();
};
@@ -323,7 +306,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
agentName={user.name}

View File

@@ -46,12 +46,12 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
try {
if (newStatus === 'ready') {
console.log('[AGENT-STATE] Changing to Ready');
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
}
// Don't setStatus — SSE will push the real state

View File

@@ -1,6 +1,4 @@
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';
@@ -10,7 +8,6 @@ import { parseDate } from '@internationalized/date';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
type ExistingAppointment = {
id: string;
@@ -35,8 +32,11 @@ type AppointmentFormProps = {
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
// Clinics are fetched dynamically from the platform — no hardcoded list.
// If the workspace has no clinics configured, the dropdown shows empty.
const clinicItems = [
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
];
const genderItems = [
{ id: 'male', label: 'Male' },
@@ -44,8 +44,22 @@ const genderItems = [
{ id: 'other', label: 'Other' },
];
// Time slots are fetched from /api/masterdata/slots based on
// doctor + date. No hardcoded times.
const timeSlotItems = [
{ id: '09:00', label: '9:00 AM' },
{ 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 = ({
isOpen,
@@ -62,25 +76,12 @@ export const AppointmentForm = ({
// Doctor data from platform
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
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 [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null);
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [date, setDate] = useState(() => {
@@ -97,24 +98,6 @@ export const AppointmentForm = ({
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [source, setSource] = useState('Inbound Call');
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 => {
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label })));
// Auto-select clinic from the slot's clinic
if (slots.length > 0 && !clinic) {
setClinic(slots[0].clinicId);
}
}).catch(() => setTimeSlotItems([]));
}, [doctor, date]);
// Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -123,29 +106,24 @@ export const AppointmentForm = ({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 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.
// Fetch doctors on mount
useEffect(() => {
if (!isOpen) return;
apiClient.get<Array<{ id: string; name: string; phone: string; address: string }>>('/api/masterdata/clinics')
.then(clinics => {
setClinicItems(clinics.map(c => ({ id: c.id, label: c.name || 'Unnamed Clinic' })));
}).catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
apiClient.get<Array<{ id: string; name: string; department: string; qualifications: string }>>('/api/masterdata/doctors')
.then(docs => {
setDoctors(docs.map(d => ({
id: d.id,
name: d.name,
department: d.department,
clinic: '', // clinic assignment via visit slots, not on doctor directly
})));
}).catch(() => {});
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department clinic { id name clinicName }
} } } }`,
).then(data => {
const docs = 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 ?? '',
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
}));
setDoctors(docs);
}).catch(() => {});
}, [isOpen]);
// Fetch booked slots when doctor + date selected
@@ -194,18 +172,9 @@ export const AppointmentForm = ({
setTimeSlot(null);
}, [doctor, date]);
// Departments from master data (or fallback to deriving from doctors)
const [departmentItems, setDepartmentItems] = useState<Array<{ id: string; label: string }>>([]);
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]);
// Derive department and doctor lists from fetched data
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
const filteredDoctors = department
? doctors.filter(d => d.department === department)
@@ -276,28 +245,22 @@ export const AppointmentForm = ({
},
);
// 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 if we have a name and a linked patient
if (patientId && patientName.trim()) {
await apiClient.graphql(
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
updatePatient(id: $id, data: $data) { id }
}`,
{
id: patientId,
data: {
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// DO NOT update the shared Patient entity when name changes
// during appointment creation. The Patient record is shared
// across all appointments — modifying it here would
// retroactively change the name on all past appointments.
// The patient name for THIS appointment is stored on the
// Appointment entity itself (via doctorName/department).
// Bug #527: removed updatePatient() call.
// 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.
// Update lead status + name if we have a matched lead
if (leadId) {
await apiClient.graphql(
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
@@ -306,26 +269,16 @@ export const AppointmentForm = ({
{
id: leadId,
data: {
status: 'APPOINTMENT_SET',
lastContacted: new Date().toISOString(),
...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
leadStatus: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(),
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}),
},
},
).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 AND invalidate the Redis caller
// resolution cache so the next incoming call from this
// phone picks up fresh data. Both are fire-and-forget —
// the save toast fires immediately either way.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} else if (callerNumber) {
// No rename but still invalidate the cache so status +
// lastContacted updates propagate cleanly to the next
// lookup.
// Invalidate caller cache so next lookup gets the real name
if (callerNumber) {
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
}
}
@@ -377,34 +330,12 @@ export const AppointmentForm = ({
</span>
</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
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
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="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
/>
<div className="grid grid-cols-2 gap-3">
<Input
@@ -582,24 +513,6 @@ export const AppointmentForm = ({
</Button>
</div>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;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>
);
};

View File

@@ -1,12 +1,9 @@
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';
@@ -14,11 +11,6 @@ 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;
@@ -26,14 +18,8 @@ type EnquiryFormProps = {
};
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);
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState('');
const [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState('');
const [isExisting, setIsExisting] = useState(false);
@@ -86,44 +72,29 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
leadId = resolved.leadId;
}
// 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;
const nameParts = {
firstName: trimmedName.split(' ')[0],
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
if (leadId) {
// Update existing lead with enquiry details. Only touches
// contactName if the agent explicitly renamed — otherwise
// we leave the existing caller identity alone.
// Update existing lead with enquiry details
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: leadId,
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
...(nameChanged ? { contactName: nameParts } : {}),
},
},
);
} else {
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
// No phone provided — create a new lead (rare edge case)
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
@@ -133,29 +104,21 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
);
}
// Update linked patient's name ONLY if the agent explicitly
// renamed. Fixes the long-standing bug where typing a name
// into this form silently overwrote the existing patient
// record.
if (nameChanged && patientId) {
// Update patient name if we have a name and a linked patient
if (patientId && patientName.trim()) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{
id: patientId,
data: {
fullName: nameParts,
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Post-save side-effects. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation.
// Otherwise just invalidate the cache so the status update
// propagates.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
// Invalidate caller cache so next lookup gets the real name
if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
}
@@ -199,34 +162,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
{/* 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="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
@@ -270,24 +206,6 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
{isSaving ? 'Saving...' : 'Log Enquiry'}
</Button>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;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>
);
};

View File

@@ -1,136 +0,0 @@
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>
);
};

View File

@@ -1,514 +0,0 @@
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() : '' })
}
/>
</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>
);
};

View File

@@ -1,401 +0,0 @@
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>
);
};

View File

@@ -1,205 +0,0 @@
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>
);
};

View File

@@ -1,177 +0,0 @@
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;
};
sip: {
domain: string;
wsPort: string;
};
exotel: {
apiKey: string;
apiToken: string;
accountSid: string;
subdomain: string;
};
};
export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
ozonetel: {
agentId: '',
agentPassword: '',
did: '',
sipId: '',
campaignName: '',
},
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 })}
/>
</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>
);
};

View File

@@ -1,167 +0,0 @@
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>
);
};

View File

@@ -9,7 +9,6 @@ import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
@@ -93,6 +92,23 @@ export const AppShell = ({ children }: AppShellProps) => {
</div>
) : undefined;
// Load external script for all authenticated users
useEffect(() => {
// Expose API URL to external script
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
(window as any).HELIX_API_URL = apiUrl;
const script = document.createElement('script');
script.src = `https://cdn.jsdelivr.net/gh/moulichand16/Test@d0a79d0/script.js`;
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
delete (window as any).HELIX_API_URL;
};
}, []);
// Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => {
if (!isCCAgent) return;
@@ -142,7 +158,6 @@ export const AppShell = ({ children }: AppShellProps) => {
)}
</div>
)}
<ResumeSetupBanner />
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}

View File

@@ -18,6 +18,7 @@ import {
faChartLine,
faFileAudio,
faPhoneMissed,
faSlidersUp,
} from "@fortawesome/pro-duotone-svg-icons";
import { faIcon } from "@/lib/icon-wrapper";
import { useAtom } from "jotai";
@@ -52,6 +53,7 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
const IconSlidersUp = faIcon(faSlidersUp);
type NavSection = {
label: string;
@@ -77,9 +79,10 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Marketing', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
]},
// Settings hub absorbs branding, rules, team, clinics, doctors,
// telephony, ai, widget — one entry, navigates to the hub which
// links to each section page.
{ label: 'Configuration', items: [
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
{ label: 'Branding', href: '/branding', icon: IconGear },
]},
{ label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear },
]},

View File

@@ -1,84 +0,0 @@
import type { ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button';
// Generic confirmation modal shown before any destructive edit to a
// patient's record. Used by the call-desk forms (appointment, enquiry)
// to gate the patient-name rename flow, but intentionally non-specific:
// any page that needs a "are you sure you want to change this patient
// field?" confirm should reuse this modal instead of building its own.
//
// The lock-by-default + explicit-confirm gate is deliberately heavy
// because patient edits cascade workspace-wide — they hit past
// appointments, lead history, AI summaries, and the Redis
// caller-resolution cache. The default path should always be "don't
// touch the record"; the only way to actually commit a change is
// clicking an Edit button, reading this prompt, and confirming.
//
// Styling matches the sign-out confirmation in sidebar.tsx — same
// warning circle, same button layout — so the weight of the action
// reads immediately.
type EditPatientConfirmModalProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
/** Modal heading. Defaults to "Edit patient details?". */
title?: string;
/** Body copy explaining the consequences of the edit. Accepts any
* ReactNode so callers can inline markup / inline the specific
* field being edited. A sensible generic default is provided. */
description?: ReactNode;
/** Confirm-button label. Defaults to "Yes, edit details". */
confirmLabel?: string;
};
const DEFAULT_TITLE = 'Edit patient details?';
const DEFAULT_DESCRIPTION = (
<>
You&apos;re about to change a detail on this patient&apos;s record. The update will cascade
across Helix Engage past appointments, lead history, and the AI summary all reflect
the new value. Only proceed if the current data is actually wrong; for all other
cases, cancel and continue with the current record.
</>
);
const DEFAULT_CONFIRM_LABEL = 'Yes, edit details';
export const EditPatientConfirmModal = ({
isOpen,
onOpenChange,
onConfirm,
title = DEFAULT_TITLE,
description = DEFAULT_DESCRIPTION,
confirmLabel = DEFAULT_CONFIRM_LABEL,
}: EditPatientConfirmModalProps) => (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="max-w-md">
<Dialog>
<div className="rounded-xl bg-primary p-6 shadow-xl">
<div className="flex flex-col items-center text-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
<FontAwesomeIcon icon={faUserPen} className="size-5 text-fg-warning-primary" />
</div>
<div>
<h3 className="text-lg font-semibold text-primary">{title}</h3>
<p className="mt-1 text-sm text-tertiary">{description}</p>
</div>
<div className="flex w-full gap-3">
<Button size="md" color="secondary" className="flex-1" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="md" color="primary-destructive" className="flex-1" onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</div>
</Dialog>
</Modal>
</ModalOverlay>
);

View File

@@ -59,18 +59,14 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
onOpenChange(false);
setOtp('');
} else {
// Standard sidecar endpoint — include agentId from agent config
const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
// Standard sidecar endpoint
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-maint-otp': otp,
},
body: JSON.stringify(payload),
...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}),
});
const data = await res.json();
if (res.ok) {

View File

@@ -1,83 +0,0 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
import { useAuth } from '@/providers/auth-provider';
// Dismissible banner shown across the top of authenticated pages when
// the hospital workspace has incomplete setup steps AND the admin has
// already dismissed the auto-wizard. This is the "nudge" layer —
// a persistent reminder that setup is still outstanding, without the
// intrusion of the full-page wizard.
//
// Visibility rules:
// - Admin users only (other roles can't complete setup)
// - At least one setup step is still `completed: false`
// - `setup-state.wizardDismissed === true` (otherwise the wizard
// auto-shows on next login and this banner would be redundant)
// - Not dismissed in the current browser session (resets on reload)
export const ResumeSetupBanner = () => {
const { isAdmin } = useAuth();
const [state, setState] = useState<SetupState | null>(null);
const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
);
useEffect(() => {
if (!isAdmin || dismissed) return;
getSetupState()
.then(setState)
.catch(() => {
// Non-fatal — if setup-state isn't reachable, just
// skip the banner. The wizard still works.
});
}, [isAdmin, dismissed]);
if (!isAdmin || !state || dismissed) return null;
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
if (incompleteCount === 0) return null;
// If the wizard hasn't been dismissed yet, the first-run redirect
// in login.tsx handles pushing the admin into /setup — no need
// for this nudge.
if (!state.wizardDismissed) return null;
const handleDismiss = () => {
sessionStorage.setItem('helix_resume_setup_dismissed', '1');
setDismissed(true);
};
return (
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-brand bg-brand-primary px-4 py-2">
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faCircleInfo} className="size-4 text-brand-primary" />
<span className="text-sm text-primary">
<b>Finish setting up your hospital</b> {incompleteCount} step
{incompleteCount === 1 ? '' : 's'} still need your attention.
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
color="primary"
href="/setup"
iconTrailing={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)}
>
Resume setup
</Button>
<button
type="button"
onClick={handleDismiss}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary transition duration-100 ease-linear"
title="Dismiss for this session"
>
<FontAwesomeIcon icon={faXmark} className="size-3" />
</button>
</div>
</div>
);
};

View File

@@ -1,67 +0,0 @@
import { Link } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowRight, faCircleCheck, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
type SectionStatus = 'complete' | 'incomplete' | 'unknown';
type SectionCardProps = {
title: string;
description: string;
icon: any;
iconColor?: string;
href: string;
status?: SectionStatus;
};
// Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
// dedicated page. The status badge mirrors the wizard's setup-state so an
// admin can see at a glance which sections still need attention.
export const SectionCard = ({
title,
description,
icon,
iconColor = 'text-brand-primary',
href,
status = 'unknown',
}: SectionCardProps) => {
return (
<Link
to={href}
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-primary">{title}</h3>
<p className="mt-1 text-xs text-tertiary">{description}</p>
</div>
</div>
<FontAwesomeIcon
icon={faArrowRight}
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
</div>
{status !== 'unknown' && (
<div className="mt-4 flex items-center gap-2 border-t border-secondary pt-3">
{status === 'complete' ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faCircleCheck} className="size-3.5" />
Configured
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-warning-primary">
<FontAwesomeIcon icon={faCircleExclamation} className="size-3.5" />
Setup needed
</span>
)}
</div>
)}
</Link>
);
};

View File

@@ -1,27 +0,0 @@
import { createContext } from 'react';
// Context that lets each WizardStep render content into the wizard
// shell's right pane via a portal — without lifting per-step data
// fetching up to the page. The shell sets `rightPaneEl` to the
// `<aside>` DOM node once it mounts; child WizardStep components read
// it and createPortal their `rightPane` prop into it.
//
// Why a portal and not a state-lifted prop on WizardShell:
// - The right pane is tightly coupled to the active step's data
// (e.g. "list of clinics created so far") which lives in the step
// component's state. Lifting that state to the page would mean
// duplicating the data-fetching layer, OR re-querying everything
// from the page.
// - Trying to pass `rightPane: ReactNode` upward via callbacks
// either causes a one-frame flash (useEffect) or violates the
// "no setState during render" rule.
// - Portals are React-native, no extra render cycles, and the
// DOM target is already part of the layout.
export type WizardLayoutContextValue = {
rightPaneEl: HTMLElement | null;
};
export const WizardLayoutContext = createContext<WizardLayoutContextValue>({
rightPaneEl: null,
});

View File

@@ -1,426 +0,0 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBuilding,
faCircle,
faCircleCheck,
faCopy,
faHeadset,
faPenToSquare,
faPhone,
faRobot,
faStethoscope,
faUser,
faUsers,
} from '@fortawesome/pro-duotone-svg-icons';
// Reusable right-pane preview components for the onboarding wizard.
// Each one is a pure presentation component that takes already-fetched
// data as props — the parent step component owns the state + fetches
// + refetches after a successful save. Keeping the panes data-only
// means the active step can pass the same source of truth to both
// the middle (form) pane and this preview without two GraphQL queries
// running side by side.
// Shared title/empty state primitives so every pane has the same
// visual rhythm.
const PaneCard = ({
title,
count,
children,
}: {
title: string;
count?: number;
children: React.ReactNode;
}) => (
<div className="rounded-xl border border-secondary bg-primary shadow-xs">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">{title}</p>
{typeof count === 'number' && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs font-semibold text-tertiary">
{count}
</span>
)}
</div>
{children}
</div>
);
const EmptyState = ({ message }: { message: string }) => (
<div className="px-4 py-6 text-center text-xs text-tertiary">{message}</div>
);
// ---------------------------------------------------------------------------
// Identity step — short "about this step" card. Explains what the
// admin is configuring and where it shows up in the staff portal so
// the right pane stays useful even when there's nothing to list yet.
// ---------------------------------------------------------------------------
const IDENTITY_BULLETS: { title: string; body: string }[] = [
{
title: 'Hospital name',
body: 'Shown on the staff portal sidebar, the login screen, and every patient-facing widget greeting.',
},
{
title: 'Logo',
body: 'Used as the avatar at the top of the staff portal and on the website widget header. Square images work best.',
},
{
title: 'Brand identity',
body: 'Colors, fonts and login copy live on the full Branding page — open it from Settings any time after setup.',
},
];
export const IdentityRightPane = () => (
<PaneCard title="About this step">
<div className="px-4 py-4">
<p className="text-sm text-tertiary">
This is how patients and staff first see your hospital across Helix Engage.
Get the basics in now you can polish branding later.
</p>
<ul className="mt-4 flex flex-col gap-3">
{IDENTITY_BULLETS.map((b) => (
<li key={b.title} className="flex items-start gap-2.5">
<FontAwesomeIcon
icon={faCircleCheck}
className="mt-0.5 size-4 shrink-0 text-fg-brand-primary"
/>
<div>
<p className="text-sm font-semibold text-primary">{b.title}</p>
<p className="text-xs text-tertiary">{b.body}</p>
</div>
</li>
))}
</ul>
</div>
</PaneCard>
);
// ---------------------------------------------------------------------------
// Clinics step — list of clinics created so far.
// ---------------------------------------------------------------------------
export type ClinicSummary = {
id: string;
clinicName: string | null;
addressCity?: string | null;
clinicStatus?: string | null;
};
export const ClinicsRightPane = ({ clinics }: { clinics: ClinicSummary[] }) => (
<PaneCard title="Clinics added" count={clinics.length}>
{clinics.length === 0 ? (
<EmptyState message="No clinics yet — add your first one in the form on the left." />
) : (
<ul className="divide-y divide-secondary">
{clinics.map((c) => (
<li key={c.id} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faBuilding} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{c.clinicName ?? 'Unnamed clinic'}
</p>
<p className="truncate text-xs text-tertiary">
{c.addressCity ?? 'No city'}
{c.clinicStatus && ` · ${c.clinicStatus.toLowerCase()}`}
</p>
</div>
</li>
))}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// Doctors step — grouped by department, since the user explicitly asked
// for "doctors grouped by department" earlier in the design discussion.
// ---------------------------------------------------------------------------
export type DoctorSummary = {
id: string;
fullName: { firstName: string | null; lastName: string | null } | null;
department?: string | null;
specialty?: string | null;
};
const doctorDisplayName = (d: DoctorSummary): string => {
const first = d.fullName?.firstName?.trim() ?? '';
const last = d.fullName?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : 'Unnamed';
};
export const DoctorsRightPane = ({ doctors }: { doctors: DoctorSummary[] }) => {
// Group by department. Doctors with no department land in
// "Unassigned" so they're not silently dropped.
const grouped: Record<string, DoctorSummary[]> = {};
for (const d of doctors) {
const key = d.department?.trim() || 'Unassigned';
(grouped[key] ??= []).push(d);
}
const sortedKeys = Object.keys(grouped).sort();
return (
<PaneCard title="Doctors added" count={doctors.length}>
{doctors.length === 0 ? (
<EmptyState message="No doctors yet — add your first one in the form on the left." />
) : (
<div className="divide-y divide-secondary">
{sortedKeys.map((dept) => (
<div key={dept} className="px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
{dept}{' '}
<span className="text-tertiary">({grouped[dept].length})</span>
</p>
<ul className="mt-2 flex flex-col gap-2">
{grouped[dept].map((d) => (
<li
key={d.id}
className="flex items-start gap-2.5 text-sm text-primary"
>
<FontAwesomeIcon
icon={faStethoscope}
className="mt-0.5 size-3.5 text-fg-quaternary"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{doctorDisplayName(d)}
</p>
{d.specialty && (
<p className="truncate text-xs text-tertiary">
{d.specialty}
</p>
)}
</div>
</li>
))}
</ul>
</div>
))}
</div>
)}
</PaneCard>
);
};
// ---------------------------------------------------------------------------
// Team step — list of employees with role + SIP badge.
// ---------------------------------------------------------------------------
export type TeamMemberSummary = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
roleLabel: string | null;
sipExtension: string | null;
// True if this row represents the currently logged-in admin —
// suppresses the edit/copy icons since admins shouldn't edit
// themselves from the wizard.
isCurrentUser: boolean;
// True if the parent has the plaintext temp password in memory
// (i.e. this employee was created in the current session).
// Drives whether the copy icon shows.
canCopyCredentials: boolean;
};
const memberDisplayName = (m: TeamMemberSummary): string => {
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
// Tiny icon button shared between the edit and copy actions on the
// employee row. Kept inline since it's only used here and the styling
// matches the existing right-pane density.
const RowIconButton = ({
icon,
title,
onClick,
}: {
icon: typeof faPenToSquare;
title: string;
onClick: () => void;
}) => (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-quaternary hover:bg-secondary_hover hover:text-fg-tertiary_hover"
>
<FontAwesomeIcon icon={icon} className="size-3.5" />
</button>
);
export const TeamRightPane = ({
members,
onEdit,
onCopy,
}: {
members: TeamMemberSummary[];
onEdit?: (memberId: string) => void;
onCopy?: (memberId: string) => void;
}) => (
<PaneCard title="Employees" count={members.length}>
{members.length === 0 ? (
<EmptyState message="No employees yet — create your first one in the form on the left." />
) : (
<ul className="divide-y divide-secondary">
{members.map((m) => (
<li key={m.id} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faUser} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{memberDisplayName(m)}
</p>
<p className="truncate text-xs text-tertiary">
{m.userEmail}
{m.roleLabel && ` · ${m.roleLabel}`}
</p>
{m.sipExtension && (
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-success-secondary px-2 py-0.5 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faHeadset} className="size-3" />
{m.sipExtension}
</span>
)}
</div>
{/* Admin row gets neither button — admins
shouldn't edit themselves from here, and
their password isn't in our session
memory anyway. */}
{!m.isCurrentUser && (
<div className="flex shrink-0 items-center gap-1">
{m.canCopyCredentials && onCopy && (
<RowIconButton
icon={faCopy}
title="Copy login credentials"
onClick={() => onCopy(m.id)}
/>
)}
{onEdit && (
<RowIconButton
icon={faPenToSquare}
title="Edit employee"
onClick={() => onEdit(m.id)}
/>
)}
</div>
)}
</li>
))}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// Telephony step — live SIP → member mapping.
// ---------------------------------------------------------------------------
export type SipSeatSummary = {
id: string;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMember: {
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
const seatMemberLabel = (m: SipSeatSummary['workspaceMember']): string => {
if (!m) return 'Unassigned';
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
export const TelephonyRightPane = ({ seats }: { seats: SipSeatSummary[] }) => (
<PaneCard title="SIP seats" count={seats.length}>
{seats.length === 0 ? (
<EmptyState message="No SIP seats configured — contact support to provision seats." />
) : (
<ul className="divide-y divide-secondary">
{seats.map((seat) => {
const isAssigned = seat.workspaceMember !== null;
return (
<li key={seat.id} className="flex items-start gap-3 px-4 py-3">
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-full ${
isAssigned
? 'bg-brand-secondary text-brand-secondary'
: 'bg-secondary text-quaternary'
}`}
>
<FontAwesomeIcon icon={faPhone} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
Ext {seat.sipExtension ?? '—'}
</p>
<p className="truncate text-xs text-tertiary">
{seatMemberLabel(seat.workspaceMember)}
</p>
</div>
{!isAssigned && (
<span className="inline-flex shrink-0 items-center rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-tertiary">
Available
</span>
)}
</li>
);
})}
</ul>
)}
</PaneCard>
);
// ---------------------------------------------------------------------------
// AI step — static cards for each configured actor with last-edited info.
// Filled in once the backend prompt config refactor lands.
// ---------------------------------------------------------------------------
export type AiActorSummary = {
key: string;
label: string;
description: string;
lastEditedAt: string | null;
isCustom: boolean;
};
export const AiRightPane = ({ actors }: { actors: AiActorSummary[] }) => (
<PaneCard title="AI personas" count={actors.length}>
{actors.length === 0 ? (
<EmptyState message="Loading personas…" />
) : (
<ul className="divide-y divide-secondary">
{actors.map((a) => (
<li key={a.key} className="flex items-start gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-primary">
{a.label}
</p>
<p className="truncate text-xs text-tertiary">
{a.isCustom
? `Edited ${a.lastEditedAt ? new Date(a.lastEditedAt).toLocaleDateString() : 'recently'}`
: 'Default'}
</p>
</div>
</li>
))}
</ul>
)}
</PaneCard>
);
// Suppress unused-import warnings for icons reserved for future use.
void faCircle;
void faUsers;

View File

@@ -1,159 +0,0 @@
import { useState, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faCircle } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { cx } from '@/utils/cx';
import { SETUP_STEP_NAMES, SETUP_STEP_LABELS, type SetupStepName, type SetupState } from '@/lib/setup-state';
import { WizardLayoutContext } from './wizard-layout-context';
type WizardShellProps = {
state: SetupState;
activeStep: SetupStepName;
onSelectStep: (step: SetupStepName) => void;
onDismiss: () => void;
// Form column (middle pane). The active step component renders
// its form into this slot. The right pane is filled via the
// WizardLayoutContext + a portal — see wizard-step.tsx.
children: ReactNode;
};
// Layout shell for the onboarding wizard. Three-pane layout:
// left — step navigator (fixed width)
// middle — form (flexible, the focus column)
// right — preview pane fed by the active step component (sticky,
// hides below xl breakpoint)
//
// The whole shell is `fixed inset-0` so the document body cannot
// scroll while the wizard is mounted — fixes the double-scrollbar
// bug where the body was rendered taller than the viewport and
// scrolled alongside the form column. The form and preview columns
// each scroll independently inside the shell.
//
// The header has a "Skip for now" affordance that dismisses the
// wizard for this workspace; once dismissed it never auto-shows
// again on login.
export const WizardShell = ({
state,
activeStep,
onSelectStep,
onDismiss,
children,
}: WizardShellProps) => {
const completedCount = SETUP_STEP_NAMES.filter((s) => state.steps[s].completed).length;
const totalSteps = SETUP_STEP_NAMES.length;
const progressPct = Math.round((completedCount / totalSteps) * 100);
// Callback ref → state — guarantees that consumers re-render once
// the aside is mounted (a plain useRef would not propagate the
// attached node back through the context). The element is also
// updated to null on unmount so the context is always honest about
// whether the slot is currently available for portals.
const [rightPaneEl, setRightPaneEl] = useState<HTMLElement | null>(null);
return (
<WizardLayoutContext.Provider value={{ rightPaneEl }}>
<div className="fixed inset-0 z-50 flex flex-col bg-primary">
{/* Header — pinned. Progress bar always visible (grey
track when 0%), sits flush under the title row. */}
<header className="shrink-0 border-b border-secondary bg-primary">
<div className="mx-auto flex w-full max-w-screen-2xl items-center justify-between gap-6 px-8 pt-4 pb-3">
<div className="flex items-center gap-4">
<div>
<h1 className="text-lg font-bold text-primary">Set up your hospital</h1>
<p className="text-xs text-tertiary">
{completedCount} of {totalSteps} steps complete · finish setup to start
using your workspace
</p>
</div>
</div>
<Button color="link-gray" size="sm" onClick={onDismiss}>
Skip for now
</Button>
</div>
<div className="mx-auto w-full max-w-screen-2xl px-8 pb-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-brand-solid transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
</header>
{/* Body — three columns inside a fixed-height flex row.
min-h-0 on the row + each column lets the inner
overflow-y-auto actually take effect. */}
<div className="mx-auto flex min-h-0 w-full max-w-screen-2xl flex-1 gap-6 px-8 py-6">
{/* Left — step navigator. Scrolls if it overflows on
very short viewports, but in practice it fits. */}
<nav className="w-60 shrink-0 overflow-y-auto">
<ol className="flex flex-col gap-1">
{SETUP_STEP_NAMES.map((step, idx) => {
const meta = SETUP_STEP_LABELS[step];
const status = state.steps[step];
const isActive = step === activeStep;
const isComplete = status.completed;
return (
<li key={step}>
<button
type="button"
onClick={() => onSelectStep(step)}
className={cx(
'group flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition',
isActive
? 'border-brand bg-brand-primary'
: 'border-transparent hover:bg-secondary',
)}
>
<span className="mt-0.5 shrink-0">
<FontAwesomeIcon
icon={isComplete ? faCircleCheck : faCircle}
className={cx(
'size-5',
isComplete
? 'text-success-primary'
: 'text-quaternary',
)}
/>
</span>
<span className="flex-1">
<span className="block text-xs font-medium text-tertiary">
Step {idx + 1}
</span>
<span
className={cx(
'block text-sm font-semibold',
isActive ? 'text-brand-primary' : 'text-primary',
)}
>
{meta.title}
</span>
</span>
</button>
</li>
);
})}
</ol>
</nav>
{/* Middle — form column. min-w-0 prevents children from
forcing the column wider than its flex basis (long
inputs, etc.). overflow-y-auto so it scrolls
independently of the right pane. */}
<main className="flex min-w-0 flex-1 flex-col overflow-y-auto">{children}</main>
{/* Right — preview pane. Always rendered as a stable
portal target (so the active step's WizardStep can
createPortal into it via WizardLayoutContext).
Hidden below xl breakpoint (1280px) so the wizard
collapses cleanly to two columns on smaller screens.
Independent scroll. */}
<aside
ref={setRightPaneEl}
className="hidden w-80 shrink-0 overflow-y-auto xl:block"
/>
</div>
</div>
</WizardLayoutContext.Provider>
);
};

View File

@@ -1,434 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPenToSquare, faRotateLeft, faRobot } from '@fortawesome/pro-duotone-svg-icons';
import { WizardStep } from './wizard-step';
import { AiRightPane, type AiActorSummary } from './wizard-right-panes';
import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form';
import { Button } from '@/components/base/buttons/button';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
import type { WizardStepComponentProps } from './wizard-step-types';
// AI step (post-prompt-config rework). The middle pane has two sections:
//
// 1. Provider / model / temperature picker — same as before, drives the
// provider that all actors use under the hood.
// 2. AI personas — list of 7 actor cards, each with name, description,
// truncated current template, and an Edit button. Edit triggers a
// confirmation modal warning about unintended consequences, then
// opens a slideout with the full template + a "variables you can
// use" reference + Save / Reset.
//
// The right pane shows the same 7 personas as compact "last edited" cards
// so the admin can scan recent activity at a glance.
//
// Backend wiring lives in helix-engage-server/src/config/ai.defaults.ts
// (DEFAULT_AI_PROMPTS) + ai-config.service.ts (renderPrompt / updatePrompt
// / resetPrompt). The 7 service files (widget chat, CC agent helper,
// supervisor, lead enrichment, call insight, call assist, recording
// analysis) all call AiConfigService.renderPrompt(actor, vars) so any
// edit here lands instantly.
type ServerPromptConfig = {
label: string;
description: string;
variables: { key: string; description: string }[];
template: string;
defaultTemplate: string;
lastEditedAt: string | null;
lastEditedBy: string | null;
};
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
prompts?: Record<string, ServerPromptConfig>;
};
// Display order for the actor cards. Mirrors AI_ACTOR_KEYS in
// ai.defaults.ts so the wizard renders personas in the same order
// admins see them documented elsewhere.
const ACTOR_ORDER = [
'widgetChat',
'ccAgentHelper',
'supervisorChat',
'leadEnrichment',
'callInsight',
'callAssist',
'recordingAnalysis',
] as const;
const truncate = (s: string, max: number): string =>
s.length > max ? s.slice(0, max).trimEnd() + '…' : s;
export const WizardStepAi = (props: WizardStepComponentProps) => {
const { user } = useAuth();
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [prompts, setPrompts] = useState<Record<string, ServerPromptConfig>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Edit flow state — three phases:
// 1. confirmingActor: which actor's Edit button was just clicked
// (drives the confirmation modal)
// 2. editingActor: which actor's slideout is open (only set after
// the user confirms past the warning prompt)
// 3. draftTemplate: the current textarea contents in the slideout
const [confirmingActor, setConfirmingActor] = useState<string | null>(null);
const [editingActor, setEditingActor] = useState<string | null>(null);
const [draftTemplate, setDraftTemplate] = useState('');
const [savingPrompt, setSavingPrompt] = useState(false);
const fetchConfig = useCallback(async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai', { silent: true });
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: '',
});
setPrompts(data.prompts ?? {});
} catch (err) {
console.error('[wizard/ai] fetch failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const handleSaveProviderConfig = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
const temperature = Number(values.temperature);
setSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)),
});
notify.success('AI settings saved', 'Provider and model updated.');
await fetchConfig();
if (!props.isCompleted) {
await props.onComplete('ai');
}
} catch (err) {
console.error('[wizard/ai] save provider failed', err);
} finally {
setSaving(false);
}
};
// Confirmation modal → slideout flow.
const handleEditClick = (actor: string) => {
setConfirmingActor(actor);
};
const handleConfirmEdit = () => {
if (!confirmingActor) return;
const prompt = prompts[confirmingActor];
if (!prompt) return;
setEditingActor(confirmingActor);
setDraftTemplate(prompt.template);
setConfirmingActor(null);
};
const handleSavePrompt = async (close: () => void) => {
if (!editingActor) return;
if (!draftTemplate.trim()) {
notify.error('Prompt cannot be empty');
return;
}
setSavingPrompt(true);
try {
await apiClient.put(`/api/config/ai/prompts/${editingActor}`, {
template: draftTemplate,
editedBy: user?.email ?? null,
});
notify.success('Prompt updated', `${prompts[editingActor]?.label ?? editingActor} saved`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] save prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
const handleResetPrompt = async (close: () => void) => {
if (!editingActor) return;
setSavingPrompt(true);
try {
await apiClient.post(`/api/config/ai/prompts/${editingActor}/reset`);
notify.success('Prompt reset', `${prompts[editingActor]?.label ?? editingActor} restored to default`);
await fetchConfig();
close();
setEditingActor(null);
} catch (err) {
console.error('[wizard/ai] reset prompt failed', err);
} finally {
setSavingPrompt(false);
}
};
// Build the right-pane summary entries from the loaded prompts.
// `isCustom` is true when the template differs from the shipped
// default OR when the audit fields are populated — either way the
// admin has touched it.
const actorSummaries = useMemo<AiActorSummary[]>(() => {
return ACTOR_ORDER.filter((key) => prompts[key]).map((key) => {
const p = prompts[key];
return {
key,
label: p.label,
description: p.description,
lastEditedAt: p.lastEditedAt,
isCustom: p.template !== p.defaultTemplate || p.lastEditedAt !== null,
};
});
}, [prompts]);
const editingPrompt = editingActor ? prompts[editingActor] : null;
const confirmingLabel = confirmingActor ? prompts[confirmingActor]?.label : '';
return (
<WizardStep
step="ai"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSaveProviderConfig}
onFinish={props.onFinish}
saving={saving}
rightPane={<AiRightPane actors={actorSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading AI settings</p>
) : (
<div className="flex flex-col gap-8">
<section>
<h3 className="mb-3 text-sm font-semibold text-primary">Provider & model</h3>
<AiForm value={values} onChange={setValues} />
</section>
<section>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-primary">AI personas</h3>
<span className="text-xs text-tertiary">
{actorSummaries.length} configurable prompts
</span>
</div>
<p className="mb-4 text-xs text-tertiary">
Each persona below is a different AI surface in Helix Engage. Editing a
prompt changes how that persona sounds and what rules it follows. Defaults
are tuned for hospital call centers only edit if you have a specific
reason and can test the result.
</p>
<ul className="flex flex-col gap-3">
{ACTOR_ORDER.map((key) => {
const prompt = prompts[key];
if (!prompt) return null;
const isCustom =
prompt.template !== prompt.defaultTemplate ||
prompt.lastEditedAt !== null;
return (
<li
key={key}
className="rounded-xl border border-secondary bg-primary p-4 shadow-xs"
>
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-secondary text-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="truncate text-sm font-semibold text-primary">
{prompt.label}
</h4>
<p className="mt-0.5 text-xs text-tertiary">
{prompt.description}
</p>
</div>
{isCustom && (
<span className="shrink-0 rounded-full bg-warning-secondary px-2 py-0.5 text-xs font-medium text-warning-primary">
Edited
</span>
)}
</div>
<p className="mt-3 line-clamp-3 rounded-lg border border-secondary bg-secondary p-3 font-mono text-xs leading-relaxed text-tertiary">
{truncate(prompt.template, 220)}
</p>
<div className="mt-3 flex justify-end">
<Button
size="sm"
color="secondary"
onClick={() => handleEditClick(key)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon
icon={faPenToSquare}
className={className}
/>
)}
>
Edit
</Button>
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
)}
{/* Confirmation modal — reused from the patient-edit gate. */}
<EditPatientConfirmModal
isOpen={confirmingActor !== null}
onOpenChange={(open) => {
if (!open) setConfirmingActor(null);
}}
onConfirm={handleConfirmEdit}
title={`Edit ${confirmingLabel} prompt?`}
description={
<>
Modifying this prompt can affect call quality, lead summaries, and supervisor
insights in ways that are hard to predict. The defaults are tuned for hospital
call centers only edit if you have a specific reason and can test the
result. You can always reset back to default from the editor.
</>
}
confirmLabel="Yes, edit prompt"
/>
{/* Slideout editor — only opens after the warning is confirmed. */}
<SlideoutMenu
isOpen={editingActor !== null}
onOpenChange={(open) => {
if (!open) setEditingActor(null);
}}
isDismissable
>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon
icon={faRobot}
className="size-5 text-fg-brand-primary"
/>
</div>
<div>
<h2 className="text-lg font-semibold text-primary">
Edit {editingPrompt?.label}
</h2>
<p className="text-sm text-tertiary">
{editingPrompt?.description}
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-secondary">
Prompt template
</label>
<textarea
value={draftTemplate}
onChange={(e) => setDraftTemplate(e.target.value)}
rows={18}
className="mt-1.5 w-full resize-y rounded-lg border border-secondary bg-primary p-3 font-mono text-xs text-primary outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
/>
<p className="mt-1 text-xs text-tertiary">
Variables wrapped in <code>{'{{double-braces}}'}</code> get
substituted at runtime with live data.
</p>
</div>
{editingPrompt?.variables && editingPrompt.variables.length > 0 && (
<div className="rounded-lg border border-secondary bg-secondary p-3">
<p className="text-xs font-semibold uppercase tracking-wider text-quaternary">
Variables you can use
</p>
<ul className="mt-2 flex flex-col gap-1.5">
{editingPrompt.variables.map((v) => (
<li key={v.key} className="flex items-start gap-2 text-xs">
<code className="shrink-0 rounded bg-primary px-1.5 py-0.5 font-mono text-brand-primary">
{`{{${v.key}}}`}
</code>
<span className="text-tertiary">
{v.description}
</span>
</li>
))}
</ul>
</div>
)}
{editingPrompt?.lastEditedAt && (
<p className="text-xs text-tertiary">
Last edited{' '}
{new Date(editingPrompt.lastEditedAt).toLocaleString()}
{editingPrompt.lastEditedBy && ` by ${editingPrompt.lastEditedBy}`}
</p>
)}
</div>
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-between gap-3">
<Button
size="md"
color="link-gray"
isDisabled={savingPrompt}
onClick={() => handleResetPrompt(close)}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
>
Reset to default
</Button>
<div className="flex items-center gap-3">
<Button
size="md"
color="secondary"
isDisabled={savingPrompt}
onClick={close}
>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={savingPrompt}
showTextWhileLoading
onClick={() => handleSavePrompt(close)}
>
{savingPrompt ? 'Saving…' : 'Save prompt'}
</Button>
</div>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</WizardStep>
);
};

View File

@@ -1,171 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { WizardStep } from './wizard-step';
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
import {
ClinicForm,
clinicCoreToGraphQLInput,
holidayInputsFromForm,
requiredDocInputsFromForm,
emptyClinicFormValues,
type ClinicFormValues,
} from '@/components/forms/clinic-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Clinic step — presents a single-clinic form. On save the wizard runs
// a three-stage create chain:
// 1. createClinic (main record → get id)
// 2. createHoliday × N (one per holiday entry)
// 3. createClinicRequiredDocument × N (one per required doc type)
//
// This mirrors what the /settings/clinics list page does, minus the
// delete-old-first step (wizard is always creating, never updating).
// Failures inside the chain throw up through onComplete so the user
// sees the error loud, and the wizard stays on the current step.
export const WizardStepClinics = (props: WizardStepComponentProps) => {
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
const [saving, setSaving] = useState(false);
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
const fetchClinics = useCallback(async () => {
try {
// Field names match what the platform actually exposes:
// - the SDK ADDRESS field is named "address" but the
// platform mounts it as `addressCustom` (composite type
// with addressCity / addressStreet / etc.)
// - the SDK SELECT field labelled "Status" lands as plain
// `status: ClinicStatusEnum`, NOT `clinicStatus`
// Verified via __type introspection — keep this query
// pinned to the actual schema to avoid silent empty fetches.
type ClinicNode = {
id: string;
clinicName: string | null;
addressCustom: { addressCity: string | null } | null;
status: string | null;
};
const data = await apiClient.graphql<{
clinics: { edges: { node: ClinicNode }[] };
}>(
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
edges { node {
id clinicName
addressCustom { addressCity }
status
} }
} }`,
undefined,
{ silent: true },
);
// Flatten into the shape ClinicsRightPane expects.
setClinics(
data.clinics.edges.map((e) => ({
id: e.node.id,
clinicName: e.node.clinicName,
addressCity: e.node.addressCustom?.addressCity ?? null,
clinicStatus: e.node.status,
})),
);
} catch (err) {
console.error('[wizard/clinics] fetch failed', err);
}
}, []);
useEffect(() => {
fetchClinics();
}, [fetchClinics]);
const handleSave = async () => {
if (!values.clinicName.trim()) {
notify.error('Clinic name is required');
return;
}
setSaving(true);
try {
// 1. Core clinic record
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
`mutation CreateClinic($data: ClinicCreateInput!) {
createClinic(data: $data) { id }
}`,
{ data: clinicCoreToGraphQLInput(values) },
);
const clinicId = res.createClinic.id;
// 2. Holidays
if (values.holidays.length > 0) {
const holidayInputs = holidayInputsFromForm(values, clinicId);
await Promise.all(
holidayInputs.map((data) =>
apiClient.graphql(
`mutation CreateHoliday($data: HolidayCreateInput!) {
createHoliday(data: $data) { id }
}`,
{ data },
),
),
);
}
// 3. Required documents
if (values.requiredDocumentTypes.length > 0) {
const docInputs = requiredDocInputsFromForm(values, clinicId);
await Promise.all(
docInputs.map((data) =>
apiClient.graphql(
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
createClinicRequiredDocument(data: $data) { id }
}`,
{ data },
),
),
);
}
notify.success('Clinic added', values.clinicName);
await fetchClinics();
// Mark complete on first successful create. Don't auto-advance —
// admins typically add multiple clinics in one sitting; the
// Continue button on the wizard nav handles forward motion.
if (!props.isCompleted) {
await props.onComplete('clinics');
}
setValues(emptyClinicFormValues());
} catch (err) {
console.error('[wizard/clinics] save failed', err);
} finally {
setSaving(false);
}
};
// Same trick as the Team step: once at least one clinic exists,
// flip isCompleted=true so the WizardStep renders the "Continue"
// button as the primary action — the form stays open below for
// adding more clinics.
const pretendCompleted = props.isCompleted || clinics.length > 0;
return (
<WizardStep
step="clinics"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<ClinicsRightPane clinics={clinics} />}
>
<ClinicForm value={values} onChange={setValues} />
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving ? 'Adding…' : 'Add clinic'}
</button>
</div>
</WizardStep>
);
};

View File

@@ -1,150 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { WizardStep } from './wizard-step';
import { DoctorsRightPane, type DoctorSummary } from './wizard-right-panes';
import {
DoctorForm,
doctorCoreToGraphQLInput,
visitSlotInputsFromForm,
emptyDoctorFormValues,
type DoctorFormValues,
} from '@/components/forms/doctor-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Doctor step — mirrors the clinics step but also fetches the clinic list
// for the DoctorForm's clinic dropdown. If there are no clinics yet we let
// the admin know they need to complete step 2 first (the wizard doesn't
// force ordering, but a doctor without a clinic is useless).
type ClinicLite = { id: string; clinicName: string | null };
export const WizardStepDoctors = (props: WizardStepComponentProps) => {
const [values, setValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
const [clinics, setClinics] = useState<ClinicLite[]>([]);
const [doctors, setDoctors] = useState<DoctorSummary[]>([]);
const [loadingClinics, setLoadingClinics] = useState(true);
const [saving, setSaving] = useState(false);
const fetchData = useCallback(async () => {
try {
const data = await apiClient.graphql<{
clinics: { edges: { node: ClinicLite }[] };
doctors: { edges: { node: DoctorSummary }[] };
}>(
`{
clinics(first: 100) { edges { node { id clinicName } } }
doctors(first: 100, orderBy: { createdAt: DescNullsLast }) {
edges { node { id fullName { firstName lastName } department specialty } }
}
}`,
undefined,
{ silent: true },
);
setClinics(data.clinics.edges.map((e) => e.node));
setDoctors(data.doctors.edges.map((e) => e.node));
} catch (err) {
console.error('[wizard/doctors] fetch failed', err);
setClinics([]);
setDoctors([]);
} finally {
setLoadingClinics(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const clinicOptions = useMemo(
() => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })),
[clinics],
);
const handleSave = async () => {
if (!values.firstName.trim() || !values.lastName.trim()) {
notify.error('First and last name are required');
return;
}
setSaving(true);
try {
// 1. Core doctor record
const res = await apiClient.graphql<{ createDoctor: { id: string } }>(
`mutation CreateDoctor($data: DoctorCreateInput!) {
createDoctor(data: $data) { id }
}`,
{ data: doctorCoreToGraphQLInput(values) },
);
const doctorId = res.createDoctor.id;
// 2. Visit slots (doctor can be at multiple clinics on
// multiple days with different times each).
const slotInputs = visitSlotInputsFromForm(values, doctorId);
if (slotInputs.length > 0) {
await Promise.all(
slotInputs.map((data) =>
apiClient.graphql(
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
createDoctorVisitSlot(data: $data) { id }
}`,
{ data },
),
),
);
}
notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`);
await fetchData();
if (!props.isCompleted) {
await props.onComplete('doctors');
}
setValues(emptyDoctorFormValues());
} catch (err) {
console.error('[wizard/doctors] save failed', err);
} finally {
setSaving(false);
}
};
const pretendCompleted = props.isCompleted || doctors.length > 0;
return (
<WizardStep
step="doctors"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<DoctorsRightPane doctors={doctors} />}
>
{loadingClinics ? (
<p className="text-sm text-tertiary">Loading clinics</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 assign doctors. Go back to the{' '}
<b>Clinics</b> step and add a branch first.
</p>
</div>
) : (
<>
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving ? 'Adding…' : 'Add doctor'}
</button>
</div>
</>
)}
</WizardStep>
);
};

View File

@@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Input } from '@/components/base/input/input';
import { WizardStep } from './wizard-step';
import { IdentityRightPane } from './wizard-right-panes';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Minimal identity step — just the two most important fields (hospital name
// and logo URL). Full branding (colors, fonts, login copy) is handled on the
// /branding page and linked from here. Keeping the wizard lean means admins
// can clear setup in under ten minutes; the branding page is there whenever
// they want to polish further.
const THEME_API_URL =
import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const WizardStepIdentity = (props: WizardStepComponentProps) => {
const [hospitalName, setHospitalName] = useState('');
const [logoUrl, setLogoUrl] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
fetch(`${THEME_API_URL}/api/config/theme`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data?.brand) {
setHospitalName(data.brand.hospitalName ?? '');
setLogoUrl(data.brand.logo ?? '');
}
})
.catch(() => {
// non-fatal — admin can fill in fresh values
})
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
if (!hospitalName.trim()) {
notify.error('Hospital name is required');
return;
}
setSaving(true);
try {
const response = await fetch(`${THEME_API_URL}/api/config/theme`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
brand: {
hospitalName: hospitalName.trim(),
logo: logoUrl.trim() || undefined,
},
}),
});
if (!response.ok) throw new Error(`PUT /api/config/theme failed: ${response.status}`);
notify.success('Identity saved', 'Hospital name and logo updated.');
await props.onComplete('identity');
props.onAdvance();
} catch (err) {
notify.error('Save failed', 'Could not update hospital identity. Please try again.');
console.error('[wizard/identity] save failed', err);
} finally {
setSaving(false);
}
};
return (
<WizardStep
step="identity"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<IdentityRightPane />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading current branding</p>
) : (
<div className="flex flex-col gap-5">
<Input
label="Hospital name"
isRequired
placeholder="e.g. Ramaiah Memorial Hospital"
value={hospitalName}
onChange={setHospitalName}
/>
<Input
label="Logo URL"
placeholder="https://yourhospital.com/logo.png"
hint="Paste a URL to your hospital logo. Square images work best."
value={logoUrl}
onChange={setLogoUrl}
/>
{logoUrl && (
<div className="flex items-center gap-3 rounded-lg border border-secondary bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary">Preview:</span>
<img
src={logoUrl}
alt="Logo preview"
className="size-10 rounded-lg border border-secondary bg-primary object-contain p-1"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
</div>
)}
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-4">
<p className="text-xs text-tertiary">
Need to pick brand colors, fonts, or customise the login page copy? Open the full{' '}
<Link to="/branding" className="font-semibold text-brand-primary underline">
branding settings
</Link>{' '}
page after completing setup.
</p>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -1,441 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { WizardStep } from './wizard-step';
import { TeamRightPane, type TeamMemberSummary } from './wizard-right-panes';
import {
EmployeeCreateForm,
emptyEmployeeCreateFormValues,
generateTempPassword,
type EmployeeCreateFormValues,
type RoleOption,
} from '@/components/forms/employee-create-form';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
import type { WizardStepComponentProps } from './wizard-step-types';
// Team step (post-rework) — creates workspace members directly from
// the portal via the sidecar's /api/team/members endpoint. The admin
// enters name + email + temp password + role. SIP seat assignment is
// NOT done here — it lives exclusively in the Telephony wizard step
// so admins manage one thing in one place.
//
// Edit mode: clicking the pencil icon on an employee row in the right
// pane loads that member back into the form (name + role only — email,
// password and SIP seat are not editable here). Save in edit mode
// fires PUT /api/team/members/:id instead of POST.
//
// Email invitations are NOT used anywhere in this flow. The admin is
// expected to share the temp password with the employee directly.
// Recently-created employees keep their plaintext password in
// component state so the right pane's copy icon can paste a
// shareable credentials block to the clipboard. Page reload clears
// that state — only employees created in the current session show
// the copy icon. Older members get only the edit icon.
// In-memory record of an employee the admin just created in this
// session. Holds the plaintext temp password so the copy-icon flow
// works without ever sending the password back from the server.
type CreatedMemberRecord = {
id: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
tempPassword: string;
};
type RoleRow = {
id: string;
label: string;
description: string | null;
canBeAssignedToUsers: boolean;
};
type AgentRow = {
id: string;
name: string | null;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMemberId: string | null;
workspaceMember: {
id: string;
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
type WorkspaceMemberRow = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
// Platform returns `null` (not an empty array) for members with no
// role assigned — touching `.roles[0]` directly throws. Always
// optional-chain reads.
roles: { id: string; label: string }[] | null;
};
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
// Build the credentials block that gets copied to the clipboard. Two
// lines (login url + email) plus the temp password — formatted so
// the admin can paste it straight into WhatsApp / SMS. Login URL is
// derived from the current browser origin since the wizard is always
// loaded from the workspace's own URL (or Vite dev), so this matches
// what the employee will use.
const buildCredentialsBlock = (email: string, tempPassword: string): string => {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return `Login: ${origin}/login\nEmail: ${email}\nTemporary password: ${tempPassword}`;
};
export const WizardStepTeam = (props: WizardStepComponentProps) => {
const { user } = useAuth();
const currentUserEmail = user?.email ?? null;
// Initialise the form with a fresh temp password so the admin
// doesn't have to click "regenerate" before saving the very first
// employee.
const [values, setValues] = useState<EmployeeCreateFormValues>(() => ({
...emptyEmployeeCreateFormValues,
password: generateTempPassword(),
}));
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [roles, setRoles] = useState<RoleOption[]>([]);
// Agents are still fetched (even though we don't show a SIP seat
// picker here) because the right-pane summary needs each member's
// current SIP extension to show the green badge.
const [agents, setAgents] = useState<AgentRow[]>([]);
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
const [createdMembers, setCreatedMembers] = useState<CreatedMemberRecord[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const isEditing = editingMemberId !== null;
const fetchRolesAndAgents = useCallback(async () => {
try {
const data = await apiClient.graphql<{
getRoles: RoleRow[];
agents: { edges: { node: AgentRow }[] };
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
}>(
`{
getRoles { id label description canBeAssignedToUsers }
agents(first: 100) {
edges { node {
id name sipExtension ozonetelAgentId workspaceMemberId
workspaceMember { id name { firstName lastName } userEmail }
} }
}
workspaceMembers(first: 200) {
edges { node {
id userEmail name { firstName lastName }
roles { id label }
} }
}
}`,
undefined,
{ silent: true },
);
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
setRoles(
assignable.map((r) => ({
id: r.id,
label: r.label,
supportingText: r.description ?? undefined,
})),
);
setAgents(data.agents.edges.map((e) => e.node));
setMembers(
data.workspaceMembers.edges
.map((e) => e.node)
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
);
} catch (err) {
console.error('[wizard/team] fetch roles/agents failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchRolesAndAgents();
}, [fetchRolesAndAgents]);
// Reset form back to a fresh "create" state with a new auto-gen
// password. Used after both create-success and edit-cancel.
const resetForm = () => {
setEditingMemberId(null);
setValues({
...emptyEmployeeCreateFormValues,
password: generateTempPassword(),
});
};
const handleSaveCreate = async () => {
const firstName = values.firstName.trim();
const email = values.email.trim();
if (!firstName) {
notify.error('First name is required');
return;
}
if (!email) {
notify.error('Email is required');
return;
}
if (!values.password) {
notify.error('Temporary password is required');
return;
}
if (!values.roleId) {
notify.error('Pick a role');
return;
}
setSaving(true);
try {
const created = await apiClient.post<{
id: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
}>('/api/team/members', {
firstName,
lastName: values.lastName.trim(),
email,
password: values.password,
roleId: values.roleId,
});
// Stash the plaintext temp password alongside the created
// member so the copy-icon can build a credentials block
// later. The password is NOT sent back from the server —
// we hold the only copy in this component's memory.
setCreatedMembers((prev) => [
...prev,
{ ...created, tempPassword: values.password },
]);
notify.success(
'Employee created',
`${firstName} ${values.lastName.trim()}`.trim() || email,
);
await fetchRolesAndAgents();
resetForm();
if (!props.isCompleted) {
await props.onComplete('team');
}
} catch (err) {
console.error('[wizard/team] create failed', err);
} finally {
setSaving(false);
}
};
const handleSaveUpdate = async () => {
if (!editingMemberId) return;
const firstName = values.firstName.trim();
if (!firstName) {
notify.error('First name is required');
return;
}
if (!values.roleId) {
notify.error('Pick a role');
return;
}
setSaving(true);
try {
await apiClient.put(`/api/team/members/${editingMemberId}`, {
firstName,
lastName: values.lastName.trim(),
roleId: values.roleId,
});
notify.success(
'Employee updated',
`${firstName} ${values.lastName.trim()}`.trim() || values.email,
);
await fetchRolesAndAgents();
resetForm();
} catch (err) {
console.error('[wizard/team] update failed', err);
} finally {
setSaving(false);
}
};
const handleSave = isEditing ? handleSaveUpdate : handleSaveCreate;
// Right-pane edit handler — populate the form with the picked
// member's data and switch into edit mode. Email is preserved as
// the row's email (read-only in edit mode); password is cleared
// since the form hides the field anyway.
const handleEditMember = (memberId: string) => {
const member = members.find((m) => m.id === memberId);
if (!member) return;
const firstRole = member.roles?.[0] ?? null;
setEditingMemberId(memberId);
setValues({
firstName: member.name?.firstName ?? '',
lastName: member.name?.lastName ?? '',
email: member.userEmail,
password: '',
roleId: firstRole?.id ?? '',
});
};
// Right-pane copy handler — build the shareable credentials block
// and put it on the clipboard. Only fires for members in the
// createdMembers in-memory map; rows without a known temp password
// don't show the icon at all.
const handleCopyCredentials = async (memberId: string) => {
const member = members.find((m) => m.id === memberId);
if (!member) return;
// Three-tier fallback:
// 1. In-browser memory (createdMembers state) — populated when
// the admin created this employee in the current session,
// survives until refresh. Fastest path, no network call.
// 2. Sidecar Redis cache via GET /api/team/members/:id/temp-password
// — populated for any member created via this endpoint
// within the last 24h, survives reloads.
// 3. Cache miss → tell the admin the password is no longer
// recoverable and direct them to the platform reset flow.
const fromMemory =
createdMembers.find(
(c) => c.userEmail.toLowerCase() === member.userEmail.toLowerCase(),
) ?? createdMembers.find((c) => c.id === memberId);
let tempPassword = fromMemory?.tempPassword ?? null;
if (!tempPassword) {
try {
const res = await apiClient.get<{ password: string | null }>(
`/api/team/members/${memberId}/temp-password`,
{ silent: true },
);
tempPassword = res.password;
} catch (err) {
console.error('[wizard/team] temp-password fetch failed', err);
}
}
if (!tempPassword) {
notify.error(
'Password unavailable',
'The temp password expired (>24h). Reset the password from settings to mint a new one.',
);
return;
}
const block = buildCredentialsBlock(member.userEmail, tempPassword);
try {
await navigator.clipboard.writeText(block);
notify.success('Copied', 'Credentials copied to clipboard');
} catch (err) {
console.error('[wizard/team] clipboard write failed', err);
notify.error('Copy failed', 'Could not write to clipboard');
}
};
// Trick: we lie to WizardStep about isCompleted so that once at
// least one employee exists, the primary wizard button flips to
// "Continue" and the create form stays available below for more
// adds.
const pretendCompleted = props.isCompleted || members.length > 0 || createdMembers.length > 0;
// Build the right pane summary. Every non-admin row gets the
// copy icon — `canCopyCredentials: true` unconditionally — and
// the click handler figures out at action time whether to read
// from in-browser memory or the sidecar's Redis cache. If both
// are empty (>24h old), the click toasts a "password expired"
// message instead of silently failing.
const teamSummaries = useMemo<TeamMemberSummary[]>(
() =>
members.map((m) => {
const seat = agents.find((a) => a.workspaceMemberId === m.id);
const firstRole = m.roles?.[0] ?? null;
return {
id: m.id,
userEmail: m.userEmail,
name: m.name,
roleLabel: firstRole?.label ?? null,
sipExtension: seat?.sipExtension ?? null,
isCurrentUser: currentUserEmail !== null && m.userEmail === currentUserEmail,
canCopyCredentials: true,
};
}),
[members, agents, currentUserEmail],
);
return (
<WizardStep
step="team"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={
<TeamRightPane
members={teamSummaries}
onEdit={handleEditMember}
onCopy={handleCopyCredentials}
/>
}
>
{loading ? (
<p className="text-sm text-tertiary">Loading team settings</p>
) : (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
{isEditing ? (
<p>
Editing an existing employee. You can change their name and role.
To change their SIP seat, go to the <b>Telephony</b> step.
</p>
) : (
<p>
Create employees in-place. Each person gets an auto-generated
temporary password that you share directly no email
invitations are sent. Click the eye icon to reveal it before
you save. After creating CC agents, head to the <b>Telephony</b>{' '}
step to assign them SIP seats.
</p>
)}
</div>
<EmployeeCreateForm
value={values}
onChange={setValues}
roles={roles}
mode={isEditing ? 'edit' : 'create'}
/>
<div className="flex items-center justify-end gap-3">
{isEditing && (
<Button size="md" color="secondary" isDisabled={saving} onClick={resetForm}>
Cancel
</Button>
)}
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving
? isEditing
? 'Updating…'
: 'Creating…'
: isEditing
? 'Update employee'
: 'Create employee'}
</button>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -1,322 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faTrash } from '@fortawesome/pro-duotone-svg-icons';
import { WizardStep } from './wizard-step';
import { TelephonyRightPane, type SipSeatSummary } from './wizard-right-panes';
import { Select } from '@/components/base/select/select';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { WizardStepComponentProps } from './wizard-step-types';
// Telephony step (post-3-pane rework). The middle pane is now an
// assign/unassign editor: pick a SIP seat, pick a workspace member,
// click Assign — or pick an already-mapped seat and click Unassign.
// The right pane shows the live current state (read-only mapping
// summary). Editing here calls updateAgent to set/clear
// workspaceMemberId, then refetches.
//
// SIP seats themselves are pre-provisioned by onboard-hospital.sh
// (see step 5b) — admins can't add or delete seats from this UI,
// only link them to people. To add a new seat, contact support.
type AgentRow = {
id: string;
name: string | null;
sipExtension: string | null;
ozonetelAgentId: string | null;
workspaceMemberId: string | null;
workspaceMember: {
id: string;
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
} | null;
};
type WorkspaceMemberRow = {
id: string;
userEmail: string;
name: { firstName: string | null; lastName: string | null } | null;
};
const AI_EMAIL_SUFFIX = '@ai.fortytwo.local';
const memberDisplayName = (m: {
name: { firstName: string | null; lastName: string | null } | null;
userEmail: string;
}): string => {
const first = m.name?.firstName?.trim() ?? '';
const last = m.name?.lastName?.trim() ?? '';
const full = `${first} ${last}`.trim();
return full.length > 0 ? full : m.userEmail;
};
export const WizardStepTelephony = (props: WizardStepComponentProps) => {
const [agents, setAgents] = useState<AgentRow[]>([]);
const [members, setMembers] = useState<WorkspaceMemberRow[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Editor state — which seat is selected, which member to assign.
const [selectedSeatId, setSelectedSeatId] = useState<string>('');
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
const fetchData = useCallback(async () => {
try {
const data = await apiClient.graphql<{
agents: { edges: { node: AgentRow }[] };
workspaceMembers: { edges: { node: WorkspaceMemberRow }[] };
}>(
`{
agents(first: 100) {
edges { node {
id name sipExtension ozonetelAgentId workspaceMemberId
workspaceMember { id name { firstName lastName } userEmail }
} }
}
workspaceMembers(first: 200) {
edges { node {
id userEmail name { firstName lastName }
} }
}
}`,
undefined,
{ silent: true },
);
setAgents(data.agents.edges.map((e) => e.node));
setMembers(
data.workspaceMembers.edges
.map((e) => e.node)
.filter((m) => !m.userEmail.endsWith(AI_EMAIL_SUFFIX)),
);
} catch (err) {
console.error('[wizard/telephony] fetch failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// Map every agent to a SipSeatSummary for the right pane. Single
// source of truth — both panes read from `agents`.
const seatSummaries = useMemo<SipSeatSummary[]>(
() =>
agents.map((a) => ({
id: a.id,
sipExtension: a.sipExtension,
ozonetelAgentId: a.ozonetelAgentId,
workspaceMember: a.workspaceMember,
})),
[agents],
);
// Pre-compute lookups for the editor — which member already owns
// each seat, and which members are already taken (so the dropdown
// can hide them).
const takenMemberIds = useMemo(
() =>
new Set(
agents
.filter((a) => a.workspaceMemberId !== null)
.map((a) => a.workspaceMemberId!),
),
[agents],
);
const seatItems = useMemo(
() =>
agents.map((a) => ({
id: a.id,
label: `Ext ${a.sipExtension ?? '—'}`,
supportingText: a.workspaceMember
? `Currently: ${memberDisplayName(a.workspaceMember)}`
: 'Unassigned',
})),
[agents],
);
// Members dropdown — when a seat is selected and the seat is
// currently mapped, force the member field to show the current
// owner so the admin can see who they're displacing. When seat
// is unassigned, only show free members (the takenMemberIds
// filter).
const memberItems = useMemo(() => {
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
const currentOwnerId = selectedSeat?.workspaceMemberId ?? null;
return members
.filter((m) => m.id === currentOwnerId || !takenMemberIds.has(m.id))
.map((m) => ({
id: m.id,
label: memberDisplayName(m),
supportingText: m.userEmail,
}));
}, [members, agents, selectedSeatId, takenMemberIds]);
// When the admin picks a seat, default the member dropdown to
// whoever currently owns it (if anyone) so Unassign just works.
useEffect(() => {
if (!selectedSeatId) {
setSelectedMemberId('');
return;
}
const seat = agents.find((a) => a.id === selectedSeatId);
setSelectedMemberId(seat?.workspaceMemberId ?? '');
}, [selectedSeatId, agents]);
const selectedSeat = agents.find((a) => a.id === selectedSeatId);
const isCurrentlyMapped = selectedSeat?.workspaceMemberId !== null && selectedSeat?.workspaceMemberId !== undefined;
const updateSeat = async (seatId: string, workspaceMemberId: string | null) => {
setSaving(true);
try {
await apiClient.graphql(
`mutation UpdateAgent($id: UUID!, $data: AgentUpdateInput!) {
updateAgent(id: $id, data: $data) { id workspaceMemberId }
}`,
{ id: seatId, data: { workspaceMemberId } },
);
await fetchData();
// Mark the step complete on first successful action so
// the wizard can advance. Subsequent edits don't re-mark.
if (!props.isCompleted) {
await props.onComplete('telephony');
}
// Clear editor selection so the admin starts the next
// assign from scratch.
setSelectedSeatId('');
setSelectedMemberId('');
} catch (err) {
console.error('[wizard/telephony] updateAgent failed', err);
} finally {
setSaving(false);
}
};
const handleAssign = () => {
if (!selectedSeatId || !selectedMemberId) {
notify.error('Pick a seat and a member to assign');
return;
}
updateSeat(selectedSeatId, selectedMemberId);
};
const handleUnassign = () => {
if (!selectedSeatId) return;
updateSeat(selectedSeatId, null);
};
const pretendCompleted = props.isCompleted || agents.some((a) => a.workspaceMemberId !== null);
return (
<WizardStep
step="telephony"
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={async () => {
if (!props.isCompleted) {
await props.onComplete('telephony');
}
props.onAdvance();
}}
onFinish={props.onFinish}
saving={saving}
rightPane={<TelephonyRightPane seats={seatSummaries} />}
>
{loading ? (
<p className="text-sm text-tertiary">Loading SIP seats</p>
) : agents.length === 0 ? (
<div className="rounded-lg border border-secondary bg-secondary p-6 text-sm text-tertiary">
<p className="font-medium text-primary">No SIP seats configured</p>
<p className="mt-1">
This hospital has no pre-provisioned agent profiles. Contact support to
add SIP seats, then come back to finish setup.
</p>
</div>
) : (
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
<p>
Pick a SIP seat and assign it to a workspace member. To free up a seat,
select it and click <b>Unassign</b>. The right pane shows the live
mapping what you change here updates there immediately.
</p>
</div>
<Select
label="SIP seat"
placeholder="Select a seat"
items={seatItems}
selectedKey={selectedSeatId || null}
onSelectionChange={(key) => setSelectedSeatId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<Select
label="Workspace member"
placeholder={
!selectedSeatId
? 'Pick a seat first'
: memberItems.length === 0
? 'No available members'
: 'Select a member'
}
isDisabled={!selectedSeatId || memberItems.length === 0}
items={memberItems}
selectedKey={selectedMemberId || null}
onSelectionChange={(key) => setSelectedMemberId((key as string) || '')}
>
{(item) => (
<Select.Item
id={item.id}
label={item.label}
supportingText={item.supportingText}
/>
)}
</Select>
<div className="flex items-center justify-end gap-3">
{isCurrentlyMapped && (
<Button
color="secondary-destructive"
size="md"
isDisabled={saving}
onClick={handleUnassign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faTrash} className={className} />
)}
>
Unassign
</Button>
)}
<Button
color="primary"
size="md"
isDisabled={saving || !selectedSeatId || !selectedMemberId}
onClick={handleAssign}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faHeadset} className={className} />
)}
>
{selectedSeat?.workspaceMemberId === selectedMemberId
? 'Already assigned'
: isCurrentlyMapped
? 'Reassign'
: 'Assign'}
</Button>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -1,20 +0,0 @@
import type { SetupStepName } from '@/lib/setup-state';
// Shared prop shape for every wizard step. The parent (setup-wizard.tsx)
// dispatches to the right component based on activeStep; each component
// handles its own data loading, form state, and save action, then calls
// onComplete + onAdvance when the user clicks "Mark complete".
export type WizardStepComponentProps = {
isCompleted: boolean;
isLast: boolean;
onPrev: (() => void) | null;
onNext: (() => void) | null;
// Called by each step after a successful save. Parent handles both the
// markSetupStepComplete API call AND the local state update so the left
// nav reflects the new completion immediately.
onComplete: (step: SetupStepName) => Promise<void>;
// Move to the next step (used after a successful save, or directly via
// the Next button when the step is already complete).
onAdvance: () => void;
onFinish: () => void;
};

View File

@@ -1,142 +0,0 @@
import { useContext, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowRight, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { SETUP_STEP_LABELS, type SetupStepName } from '@/lib/setup-state';
import { WizardLayoutContext } from './wizard-layout-context';
type WizardStepProps = {
step: SetupStepName;
isCompleted: boolean;
isLast: boolean;
onPrev: (() => void) | null;
onNext: (() => void) | null;
onMarkComplete: () => void;
onFinish: () => void;
saving?: boolean;
children: ReactNode;
// Optional content for the wizard shell's right preview pane.
// Portaled into the shell's <aside> via WizardLayoutContext when
// both are mounted. Each step component declares this inline so
// the per-step data fetching stays in one place.
rightPane?: ReactNode;
};
// Single-step wrapper. The parent picks which step is active and supplies
// the form content as children. The step provides title, description,
// "mark complete" CTA, and prev/next/finish navigation. In Phase 5 the
// children will be real form components from the corresponding settings
// pages — for now they're placeholders.
export const WizardStep = ({
step,
isCompleted,
isLast,
onPrev,
onNext,
onMarkComplete,
onFinish,
saving = false,
children,
rightPane,
}: WizardStepProps) => {
const meta = SETUP_STEP_LABELS[step];
const { rightPaneEl } = useContext(WizardLayoutContext);
return (
<>
{rightPane && rightPaneEl && createPortal(rightPane, rightPaneEl)}
<div className="rounded-xl border border-secondary bg-primary p-8 shadow-xs">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-primary">{meta.title}</h2>
<p className="mt-1 text-sm text-tertiary">{meta.description}</p>
</div>
{isCompleted && (
<span className="inline-flex items-center gap-2 rounded-full bg-success-primary px-3 py-1 text-xs font-medium text-success-primary">
<FontAwesomeIcon icon={faCircleCheck} className="size-3.5" />
Complete
</span>
)}
</div>
<div className="mb-8">{children}</div>
<div className="flex items-center justify-between gap-4 border-t border-secondary pt-6">
<Button
color="secondary"
size="md"
isDisabled={!onPrev}
onClick={onPrev ?? undefined}
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowLeft} className={className} />
)}
>
Previous
</Button>
{/* One primary action at the bottom — never two
competing buttons. Previously the wizard showed
Mark complete + Next side-by-side, and users
naturally clicked Next (rightmost = "continue"),
skipping the save+complete chain entirely. Result
was every step staying at 0/6.
New behaviour: a single button whose label and
handler depend on completion state.
- !isCompleted, not last → "Save and continue"
calls onMarkComplete (which does save +
complete + advance via the step component's
handleSave). Forces the agent through the
completion path.
- !isCompleted, last → "Save and finish"
same chain, plus onFinish at the end.
- isCompleted, not last → "Continue"
calls onNext (pure navigation).
- isCompleted, last → "Finish setup"
calls onFinish.
Free-form navigation is still available via the
left-side step nav, so users can revisit completed
steps without re-saving. */}
<div className="flex items-center gap-3">
{!isCompleted ? (
<Button
color="primary"
size="md"
isLoading={saving}
showTextWhileLoading
onClick={onMarkComplete}
iconTrailing={
isLast
? undefined
: ({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)
}
>
{isLast ? 'Save and finish' : 'Save and continue'}
</Button>
) : isLast ? (
<Button color="primary" size="md" onClick={onFinish}>
Finish setup
</Button>
) : (
<Button
color="primary"
size="md"
isDisabled={!onNext}
onClick={onNext ?? undefined}
iconTrailing={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowRight} className={className} />
)}
>
Continue
</Button>
)}
</div>
</div>
</div>
</>
);
};

View File

@@ -212,16 +212,6 @@ export const apiClient = {
return handleResponse<T>(response, options?.silent, doFetch);
},
async put<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const doFetch = () => fetch(`${API_URL}${path}`, {
method: 'PUT',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
const response = await doFetch();
return handleResponse<T>(response, options?.silent, doFetch);
},
// Health check — silent, no toasts
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
try {

View File

@@ -14,14 +14,26 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
aiSummary aiSuggestedAction
} } } }`;
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt
campaignName typeCustom status platform
startDate endDate
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50) { edges { node {
id
name
createdAt
updatedAt
status
typeCustom
platform
startDate
endDate
budget { amountMicros currencyCode }
amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl { primaryLinkUrl }
impressions
clicks
targetCount
contacted
converted
leadsGenerated
externalCampaignId
platformUrl { primaryLinkUrl }
} } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
@@ -71,7 +83,7 @@ export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ schedu
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id }
doctor { id clinic { clinicName } }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {

View File

@@ -1,83 +0,0 @@
import { apiClient } from './api-client';
// Mirror of the sidecar SetupState shape — keep in sync with
// helix-engage-server/src/config/setup-state.defaults.ts. Any change to the
// step list there must be reflected here.
export type SetupStepName =
| 'identity'
| 'clinics'
| 'doctors'
| 'team'
| 'telephony'
| 'ai';
export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
'identity',
'clinics',
'doctors',
'team',
'telephony',
'ai',
] as const;
export type SetupStepStatus = {
completed: boolean;
completedAt: string | null;
completedBy: string | null;
};
export type SetupState = {
version?: number;
updatedAt?: string;
wizardDismissed: boolean;
steps: Record<SetupStepName, SetupStepStatus>;
wizardRequired: boolean;
};
// Human-friendly labels for the wizard UI + settings hub badges. Kept here
// next to the type so adding a new step touches one file.
export const SETUP_STEP_LABELS: Record<SetupStepName, { title: string; description: string }> = {
identity: {
title: 'Hospital Identity',
description: 'Confirm your hospital name, upload your logo, and pick brand colors.',
},
clinics: {
title: 'Clinics',
description: 'Add your physical branches with addresses and visiting hours.',
},
doctors: {
title: 'Doctors',
description: 'Add clinicians, assign them to clinics, and set their schedules.',
},
team: {
title: 'Team',
description: 'Invite supervisors and call-center agents to your workspace.',
},
telephony: {
title: 'Telephony',
description: 'Connect Ozonetel and Exotel for inbound and outbound calls.',
},
ai: {
title: 'AI Assistant',
description: 'Choose your AI provider and customise the assistant prompts.',
},
};
export const getSetupState = () =>
apiClient.get<SetupState>('/api/config/setup-state', { silent: true });
export const markSetupStepComplete = (step: SetupStepName, completedBy?: string) =>
apiClient.put<SetupState>(`/api/config/setup-state/steps/${step}`, {
completed: true,
completedBy,
});
export const markSetupStepIncomplete = (step: SetupStepName) =>
apiClient.put<SetupState>(`/api/config/setup-state/steps/${step}`, { completed: false });
export const dismissSetupWizard = () =>
apiClient.post<SetupState>('/api/config/setup-state/dismiss');
export const resetSetupState = () =>
apiClient.post<SetupState>('/api/config/setup-state/reset');

View File

@@ -53,14 +53,14 @@ export function transformLeads(data: any): Lead[] {
export function transformCampaigns(data: any): Campaign[] {
return extractEdges(data, 'campaigns').map((n) => ({
id: n.id,
createdAt: n.createdAt,
updatedAt: n.updatedAt,
campaignName: n.campaignName ?? n.name,
campaignType: n.typeCustom,
campaignStatus: n.status,
platform: n.platform,
startDate: n.startDate,
endDate: n.endDate,
createdAt: n.createdAt ?? null,
updatedAt: n.updatedAt ?? null,
campaignName: n.name ?? 'Untitled Campaign',
campaignType: n.typeCustom ?? null,
campaignStatus: n.status ?? 'ACTIVE',
platform: n.platform ?? null,
startDate: n.startDate ?? null,
endDate: n.endDate ?? null,
budget: n.budget ? { amountMicros: n.budget.amountMicros, currencyCode: n.budget.currencyCode } : null,
amountSpent: n.amountSpent ? { amountMicros: n.amountSpent.amountMicros, currencyCode: n.amountSpent.currencyCode } : null,
impressionCount: n.impressions ?? 0,
@@ -69,7 +69,7 @@ export function transformCampaigns(data: any): Campaign[] {
contactedCount: n.contacted ?? 0,
convertedCount: n.converted ?? 0,
leadCount: n.leadsGenerated ?? 0,
externalCampaignId: n.externalCampaignId,
externalCampaignId: n.externalCampaignId ?? null,
platformUrl: n.platformUrl?.primaryLinkUrl ?? null,
}));
}
@@ -168,7 +168,7 @@ export function transformAppointments(data: any): Appointment[] {
patientId: n.patient?.id ?? null,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
clinicName: n.department ?? null,
clinicName: n.doctor?.clinic?.clinicName ?? null,
}));
}

View File

@@ -1,15 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router";
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
import { AppShell } from "@/components/layout/app-shell";
import { AuthGuard } from "@/components/layout/auth-guard";
import { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard";
const AdminSetupGuard = () => {
const { isAdmin } = useAuth();
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
};
import { RoleRouter } from "@/components/layout/role-router";
import { NotFound } from "@/pages/not-found";
import { AllLeadsPage } from "@/pages/all-leads";
@@ -37,12 +30,6 @@ import { ProfilePage } from "@/pages/profile";
import { AccountSettingsPage } from "@/pages/account-settings";
import { RulesSettingsPage } from "@/pages/rules-settings";
import { BrandingSettingsPage } from "@/pages/branding-settings";
import { TeamSettingsPage } from "@/pages/team-settings";
import { ClinicsPage } from "@/pages/clinics";
import { DoctorsPage } from "@/pages/doctors";
import { TelephonySettingsPage } from "@/pages/telephony-settings";
import { AiSettingsPage } from "@/pages/ai-settings";
import { WidgetSettingsPage } from "@/pages/widget-settings";
import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider";
@@ -62,11 +49,6 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<AuthGuard />}>
{/* Setup wizard — admin only, fullscreen, no AppShell.
CC agents and other non-admin roles are redirected to
the call desk — they can't complete setup anyway. */}
<Route path="/setup" element={<AdminSetupGuard />} />
<Route
element={
<AppShell>
@@ -92,16 +74,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} />
{/* Settings hub + section pages */}
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} />
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
<Route path="/agent/:id" element={<AgentDetailPage />} />
<Route path="/patient/:id" element={<Patient360Page />} />
<Route path="/profile" element={<ProfilePage />} />

View File

@@ -1,159 +0,0 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRobot, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
AiForm,
emptyAiFormValues,
type AiFormValues,
type AiProvider,
} from '@/components/forms/ai-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/ai — Pattern B page for the AI assistant config. Backed by
// /api/config/ai which is file-backed (data/ai.json) and hot-reloaded through
// AiConfigService — no restart needed.
//
// Temperature is a string in the form for input UX (so users can partially
// type '0.', '0.5', etc) then clamped to 0..2 on save.
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
systemPromptAddendum?: string;
};
const clampTemperature = (raw: string): number => {
const n = Number(raw);
if (Number.isNaN(n)) return 0.7;
return Math.min(2, Math.max(0, n));
};
export const AiSettingsPage = () => {
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai');
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: data.systemPromptAddendum ?? '',
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
setIsSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: clampTemperature(values.temperature),
systemPromptAddendum: values.systemPromptAddendum,
});
notify.success('AI settings updated', 'Changes are live for new conversations.');
markSetupStepComplete('ai').catch(() => {});
await loadConfig();
} catch (err) {
console.error('[ai] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset AI settings to defaults? The system prompt addendum will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/ai/reset');
notify.info('AI reset', 'Provider, model, and prompt have been restored to defaults.');
await loadConfig();
} catch (err) {
console.error('[ai] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="AI Assistant" subtitle="Choose provider, model, and conversational guidelines" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">API keys live in environment variables</p>
<p className="text-xs text-tertiary">
The actual OPENAI_API_KEY and ANTHROPIC_API_KEY are set at deploy time and
can't be edited here. If you change the provider, make sure the matching key
is configured on the sidecar or the assistant will silently fall back to the
other provider.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading AI settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<AiForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -58,7 +58,7 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id }
doctor { clinic { clinicName } }
} } } }`;
const formatDate = (iso: string): string => formatDateOnly(iso);
@@ -103,7 +103,7 @@ export const AppointmentsPage = () => {
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase();
const branch = (a.department ?? '').toLowerCase();
const branch = (a.doctor?.clinic?.clinicName ?? '').toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
});
}
@@ -177,7 +177,7 @@ export const AppointmentsPage = () => {
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
: 'Unknown';
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
const branch = appt.department ?? '—';
const branch = appt.doctor?.clinic?.clinicName ?? '—';
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';

View File

@@ -16,6 +16,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { formatShortDate, formatPhone } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring';
@@ -173,6 +174,8 @@ export const CallHistoryPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
<div className="flex flex-1 flex-col overflow-hidden p-7">
<TableCard.Root size="md" className="flex-1 min-h-0">
<TableCard.Header

View File

@@ -1,474 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBuilding, faPlus, faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { Table, TableCard } from '@/components/application/table/table';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { TopBar } from '@/components/layout/top-bar';
import {
ClinicForm,
clinicCoreToGraphQLInput,
holidayInputsFromForm,
requiredDocInputsFromForm,
emptyClinicFormValues,
type ClinicFormValues,
type ClinicStatus,
type DocumentType,
type ClinicHolidayEntry,
} from '@/components/forms/clinic-form';
import { formatTimeLabel } from '@/components/application/date-picker/time-picker';
import { formatDaySelection, type DaySelection } from '@/components/application/day-selector/day-selector';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/clinics — list + add/edit slideout. Schema aligns with the
// reworked Clinic entity in helix-engage/src/objects/clinic.object.ts:
// - openMonday..openSunday (7 BOOLEANs) for the weekly pattern
// - opensAt/closesAt (TEXT, HH:MM) for the shared daily time range
// - two child entities: Holiday (closures) and ClinicRequiredDocument
// (required-doc selection per clinic)
//
// Save flow:
// 1. createClinic / updateClinic (main record)
// 2. Fire child mutations in parallel:
// - For holidays: delete-all-recreate on edit (simple, idempotent)
// - For required docs: diff old vs new, delete removed, create added
// -- Fetched shapes from the platform ----------------------------------------
type ClinicNode = {
id: string;
clinicName: string | null;
status: ClinicStatus | null;
addressCustom: {
addressStreet1: string | null;
addressStreet2: string | null;
addressCity: string | null;
addressState: string | null;
addressPostcode: string | null;
} | null;
phone: { primaryPhoneNumber: string | null } | null;
email: { primaryEmail: string | null } | null;
openMonday: boolean | null;
openTuesday: boolean | null;
openWednesday: boolean | null;
openThursday: boolean | null;
openFriday: boolean | null;
openSaturday: boolean | null;
openSunday: boolean | null;
opensAt: string | null;
closesAt: string | null;
walkInAllowed: boolean | null;
onlineBooking: boolean | null;
cancellationWindowHours: number | null;
arriveEarlyMin: number | null;
// Reverse-side collections. Platform exposes them as Relay edges.
holidays?: {
edges: Array<{
node: { id: string; date: string | null; reasonLabel: string | null };
}>;
};
clinicRequiredDocuments?: {
edges: Array<{ node: { id: string; documentType: DocumentType | null } }>;
};
};
const CLINICS_QUERY = `{
clinics(first: 100) {
edges {
node {
id
clinicName
status
addressCustom {
addressStreet1 addressStreet2 addressCity addressState addressPostcode
}
phone { primaryPhoneNumber }
email { primaryEmail }
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
opensAt closesAt
walkInAllowed onlineBooking
cancellationWindowHours arriveEarlyMin
holidays(first: 50) {
edges { node { id date reasonLabel } }
}
clinicRequiredDocuments(first: 50) {
edges { node { id documentType } }
}
}
}
}
}`;
// -- Helpers -----------------------------------------------------------------
const toDaySelection = (c: ClinicNode): DaySelection => ({
monday: !!c.openMonday,
tuesday: !!c.openTuesday,
wednesday: !!c.openWednesday,
thursday: !!c.openThursday,
friday: !!c.openFriday,
saturday: !!c.openSaturday,
sunday: !!c.openSunday,
});
const toFormValues = (clinic: ClinicNode): ClinicFormValues => ({
clinicName: clinic.clinicName ?? '',
addressStreet1: clinic.addressCustom?.addressStreet1 ?? '',
addressStreet2: clinic.addressCustom?.addressStreet2 ?? '',
addressCity: clinic.addressCustom?.addressCity ?? '',
addressState: clinic.addressCustom?.addressState ?? '',
addressPostcode: clinic.addressCustom?.addressPostcode ?? '',
phone: clinic.phone?.primaryPhoneNumber ?? '',
email: clinic.email?.primaryEmail ?? '',
openDays: toDaySelection(clinic),
opensAt: clinic.opensAt ?? null,
closesAt: clinic.closesAt ?? null,
status: clinic.status ?? 'ACTIVE',
walkInAllowed: clinic.walkInAllowed ?? true,
onlineBooking: clinic.onlineBooking ?? true,
cancellationWindowHours:
clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
arriveEarlyMin: clinic.arriveEarlyMin != null ? String(clinic.arriveEarlyMin) : '',
requiredDocumentTypes:
clinic.clinicRequiredDocuments?.edges
.map((e) => e.node.documentType)
.filter((t): t is DocumentType => t !== null) ?? [],
holidays:
clinic.holidays?.edges
.filter((e) => e.node.date) // date is required on create but platform may have nulls from earlier
.map(
(e): ClinicHolidayEntry => ({
id: e.node.id,
date: e.node.date ?? '',
label: e.node.reasonLabel ?? '',
}),
) ?? [],
});
const statusLabel: Record<ClinicStatus, string> = {
ACTIVE: 'Active',
TEMPORARILY_CLOSED: 'Temporarily closed',
PERMANENTLY_CLOSED: 'Permanently closed',
};
const statusColor: Record<ClinicStatus, 'success' | 'warning' | 'gray'> = {
ACTIVE: 'success',
TEMPORARILY_CLOSED: 'warning',
PERMANENTLY_CLOSED: 'gray',
};
// Save-flow helpers — each mutation is a thin wrapper so handleSave
// reads linearly.
const createClinicMutation = (data: Record<string, unknown>) =>
apiClient.graphql<{ createClinic: { id: string } }>(
`mutation CreateClinic($data: ClinicCreateInput!) {
createClinic(data: $data) { id }
}`,
{ data },
);
const updateClinicMutation = (id: string, data: Record<string, unknown>) =>
apiClient.graphql<{ updateClinic: { id: string } }>(
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
updateClinic(id: $id, data: $data) { id }
}`,
{ id, data },
);
const createHolidayMutation = (data: Record<string, unknown>) =>
apiClient.graphql(
`mutation CreateHoliday($data: HolidayCreateInput!) {
createHoliday(data: $data) { id }
}`,
{ data },
);
const deleteHolidayMutation = (id: string) =>
apiClient.graphql(
`mutation DeleteHoliday($id: UUID!) { deleteHoliday(id: $id) { id } }`,
{ id },
);
const createRequiredDocMutation = (data: Record<string, unknown>) =>
apiClient.graphql(
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
createClinicRequiredDocument(data: $data) { id }
}`,
{ data },
);
const deleteRequiredDocMutation = (id: string) =>
apiClient.graphql(
`mutation DeleteClinicRequiredDocument($id: UUID!) {
deleteClinicRequiredDocument(id: $id) { id }
}`,
{ id },
);
// -- Page --------------------------------------------------------------------
export const ClinicsPage = () => {
const [clinics, setClinics] = useState<ClinicNode[]>([]);
const [loading, setLoading] = useState(true);
const [slideoutOpen, setSlideoutOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ClinicNode | null>(null);
const [formValues, setFormValues] = useState<ClinicFormValues>(emptyClinicFormValues);
const [isSaving, setIsSaving] = useState(false);
const fetchClinics = useCallback(async () => {
try {
const data = await apiClient.graphql<{ clinics: { edges: { node: ClinicNode }[] } }>(
CLINICS_QUERY,
);
setClinics(data.clinics.edges.map((e) => e.node));
} catch {
// toast already shown by apiClient
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchClinics();
}, [fetchClinics]);
const handleAdd = () => {
setEditTarget(null);
setFormValues(emptyClinicFormValues());
setSlideoutOpen(true);
};
const handleEdit = (clinic: ClinicNode) => {
setEditTarget(clinic);
setFormValues(toFormValues(clinic));
setSlideoutOpen(true);
};
const handleSave = async (close: () => void) => {
if (!formValues.clinicName.trim()) {
notify.error('Clinic name is required');
return;
}
setIsSaving(true);
try {
const coreInput = clinicCoreToGraphQLInput(formValues);
// 1. Upsert the clinic itself.
let clinicId: string;
if (editTarget) {
await updateClinicMutation(editTarget.id, coreInput);
clinicId = editTarget.id;
} else {
const res = await createClinicMutation(coreInput);
clinicId = res.createClinic.id;
notify.success('Clinic added', `${formValues.clinicName} has been added.`);
markSetupStepComplete('clinics').catch(() => {});
}
// 2. Holidays — delete-all-recreate. Simple, always correct.
if (editTarget?.holidays?.edges?.length) {
await Promise.all(
editTarget.holidays.edges.map((e) => deleteHolidayMutation(e.node.id)),
);
}
if (formValues.holidays.length > 0) {
const holidayInputs = holidayInputsFromForm(formValues, clinicId);
await Promise.all(holidayInputs.map((data) => createHolidayMutation(data)));
}
// 3. Required docs — delete-all-recreate for symmetry.
if (editTarget?.clinicRequiredDocuments?.edges?.length) {
await Promise.all(
editTarget.clinicRequiredDocuments.edges.map((e) =>
deleteRequiredDocMutation(e.node.id),
),
);
}
if (formValues.requiredDocumentTypes.length > 0) {
const docInputs = requiredDocInputsFromForm(formValues, clinicId);
await Promise.all(docInputs.map((data) => createRequiredDocMutation(data)));
}
if (editTarget) {
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
}
await fetchClinics();
close();
} catch (err) {
console.error('[clinics] save failed', err);
} finally {
setIsSaving(false);
}
};
const activeCount = useMemo(
() => clinics.filter((c) => c.status === 'ACTIVE').length,
[clinics],
);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Clinics" subtitle="Manage hospital branches and visiting hours" />
<div className="flex-1 overflow-y-auto p-6">
<TableCard.Root size="sm">
<TableCard.Header
title="Clinic branches"
badge={clinics.length}
description={`${activeCount} active`}
contentTrailing={
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={handleAdd}
>
Add clinic
</Button>
}
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading clinics...</p>
</div>
) : clinics.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<div className="flex size-12 items-center justify-center rounded-full bg-brand-secondary">
<FontAwesomeIcon icon={faBuilding} className="size-5 text-fg-brand-primary" />
</div>
<p className="text-sm font-semibold text-primary">No clinics yet</p>
<p className="max-w-xs text-center text-xs text-tertiary">
Add your first clinic branch to start booking appointments and assigning doctors.
</p>
<Button size="sm" color="primary" onClick={handleAdd}>
Add clinic
</Button>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="NAME" isRowHeader />
<Table.Head label="ADDRESS" />
<Table.Head label="CONTACT" />
<Table.Head label="HOURS" />
<Table.Head label="STATUS" />
<Table.Head label="" />
</Table.Header>
<Table.Body items={clinics}>
{(clinic) => {
const addressLine = [
clinic.addressCustom?.addressStreet1,
clinic.addressCustom?.addressCity,
]
.filter(Boolean)
.join(', ');
const status = clinic.status ?? 'ACTIVE';
const dayLabel = formatDaySelection(toDaySelection(clinic));
const hoursLabel =
clinic.opensAt && clinic.closesAt
? `${formatTimeLabel(clinic.opensAt)}${formatTimeLabel(clinic.closesAt)}`
: 'Not set';
return (
<Table.Row id={clinic.id}>
<Table.Cell>
<span className="text-sm font-medium text-primary">
{clinic.clinicName ?? 'Unnamed clinic'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">{addressLine || '—'}</span>
</Table.Cell>
<Table.Cell>
<div className="flex flex-col">
<span className="text-sm text-primary">
{clinic.phone?.primaryPhoneNumber ?? '—'}
</span>
<span className="text-xs text-tertiary">
{clinic.email?.primaryEmail ?? ''}
</span>
</div>
</Table.Cell>
<Table.Cell>
<div className="flex flex-col">
<span className="text-sm text-primary">{dayLabel}</span>
<span className="text-xs text-tertiary">{hoursLabel}</span>
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={statusColor[status]} type="pill-color">
{statusLabel[status]}
</Badge>
</Table.Cell>
<Table.Cell>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} />
)}
onClick={() => handleEdit(clinic)}
>
Edit
</Button>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</TableCard.Root>
</div>
<SlideoutMenu isOpen={slideoutOpen} onOpenChange={setSlideoutOpen} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faBuilding} className="size-5 text-fg-brand-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-primary">
{editTarget ? 'Edit clinic' : 'Add clinic'}
</h2>
<p className="text-sm text-tertiary">
{editTarget
? 'Update branch details, hours, and policy'
: 'Add a new hospital branch'}
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<ClinicForm value={formValues} onChange={setFormValues} />
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-end gap-3">
<Button size="md" color="secondary" onClick={close}>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={isSaving}
showTextWhileLoading
onClick={() => handleSave(close)}
>
{isSaving ? 'Saving...' : editTarget ? 'Save changes' : 'Add clinic'}
</Button>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</div>
);
};

View File

@@ -1,472 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faStethoscope, faPlus, faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { Table, TableCard } from '@/components/application/table/table';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { TopBar } from '@/components/layout/top-bar';
import {
DoctorForm,
doctorCoreToGraphQLInput,
visitSlotInputsFromForm,
emptyDoctorFormValues,
type DoctorDepartment,
type DoctorFormValues,
type DayOfWeek,
type DoctorVisitSlotEntry,
} from '@/components/forms/doctor-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/doctors — list + add/edit slideout. Doctors are hospital-
// wide; their multi-clinic visiting schedule is modelled as a list of
// DoctorVisitSlot child records fetched through the reverse relation.
//
// Save flow mirrors clinics.tsx: create/update the core doctor row,
// then delete-all-recreate the visit slots. Slots are recreated in
// parallel (Promise.all). Pre-existing slots from the fetched record
// get their id propagated into form state so edit mode shows the
// current schedule.
type DoctorNode = {
id: string;
fullName: { firstName: string | null; lastName: string | null } | null;
department: DoctorDepartment | null;
specialty: string | null;
qualifications: string | null;
yearsOfExperience: number | null;
consultationFeeNew: { amountMicros: number | null; currencyCode: string | null } | null;
consultationFeeFollowUp: { amountMicros: number | null; currencyCode: string | null } | null;
phone: { primaryPhoneNumber: string | null } | null;
email: { primaryEmail: string | null } | null;
registrationNumber: string | null;
active: boolean | null;
// Reverse-side relation to DoctorVisitSlot records.
doctorVisitSlots?: {
edges: Array<{
node: {
id: string;
clinicId: string | null;
clinic: { id: string; clinicName: string | null } | null;
dayOfWeek: DayOfWeek | null;
startTime: string | null;
endTime: string | null;
};
}>;
};
};
type ClinicLite = { id: string; clinicName: string | null };
const DOCTORS_QUERY = `{
doctors(first: 100) {
edges {
node {
id
fullName { firstName lastName }
department specialty qualifications yearsOfExperience
consultationFeeNew { amountMicros currencyCode }
consultationFeeFollowUp { amountMicros currencyCode }
phone { primaryPhoneNumber }
email { primaryEmail }
registrationNumber
active
doctorVisitSlots(first: 50) {
edges {
node {
id
clinicId
clinic { id clinicName }
dayOfWeek
startTime
endTime
}
}
}
}
}
}
clinics(first: 100) { edges { node { id clinicName } } }
}`;
const departmentLabel: Record<DoctorDepartment, string> = {
CARDIOLOGY: 'Cardiology',
GYNECOLOGY: 'Gynecology',
ORTHOPEDICS: 'Orthopedics',
GENERAL_MEDICINE: 'General medicine',
ENT: 'ENT',
DERMATOLOGY: 'Dermatology',
PEDIATRICS: 'Pediatrics',
ONCOLOGY: 'Oncology',
};
const dayLabel: Record<DayOfWeek, string> = {
MONDAY: 'Mon',
TUESDAY: 'Tue',
WEDNESDAY: 'Wed',
THURSDAY: 'Thu',
FRIDAY: 'Fri',
SATURDAY: 'Sat',
SUNDAY: 'Sun',
};
const toFormValues = (doctor: DoctorNode): DoctorFormValues => ({
firstName: doctor.fullName?.firstName ?? '',
lastName: doctor.fullName?.lastName ?? '',
department: doctor.department ?? '',
specialty: doctor.specialty ?? '',
qualifications: doctor.qualifications ?? '',
yearsOfExperience: doctor.yearsOfExperience != null ? String(doctor.yearsOfExperience) : '',
consultationFeeNew: doctor.consultationFeeNew?.amountMicros
? String(Math.round(doctor.consultationFeeNew.amountMicros / 1_000_000))
: '',
consultationFeeFollowUp: doctor.consultationFeeFollowUp?.amountMicros
? String(Math.round(doctor.consultationFeeFollowUp.amountMicros / 1_000_000))
: '',
phone: doctor.phone?.primaryPhoneNumber ?? '',
email: doctor.email?.primaryEmail ?? '',
registrationNumber: doctor.registrationNumber ?? '',
active: doctor.active ?? true,
visitSlots:
doctor.doctorVisitSlots?.edges.map(
(e): DoctorVisitSlotEntry => ({
id: e.node.id,
clinicId: e.node.clinicId ?? e.node.clinicId ?? '',
dayOfWeek: e.node.dayOfWeek ?? '',
startTime: e.node.startTime ?? null,
endTime: e.node.endTime ?? null,
}),
) ?? [],
});
const formatFee = (money: { amountMicros: number | null } | null): string => {
if (!money?.amountMicros) return '—';
return `${Math.round(money.amountMicros / 1_000_000).toLocaleString('en-IN')}`;
};
// Compact "clinics + days" summary for the list row. Groups by clinic
// so "Koramangala: Mon Wed / Whitefield: Tue Thu Fri" is one string.
const summariseVisitSlots = (
doctor: DoctorNode,
clinicNameById: Map<string, string>,
): string => {
const edges = doctor.doctorVisitSlots?.edges ?? [];
if (edges.length === 0) return 'No slots';
const byClinic = new Map<string, DayOfWeek[]>();
for (const e of edges) {
const cid = e.node.clinicId ?? e.node.clinicId;
if (!cid || !e.node.dayOfWeek) continue;
if (!byClinic.has(cid)) byClinic.set(cid, []);
byClinic.get(cid)!.push(e.node.dayOfWeek);
}
const parts: string[] = [];
for (const [cid, days] of byClinic.entries()) {
const name = clinicNameById.get(cid) ?? 'Unknown clinic';
const dayStr = days.map((d) => dayLabel[d]).join(' ');
parts.push(`${name}: ${dayStr}`);
}
return parts.join(' · ');
};
// -- Mutation helpers --------------------------------------------------------
const createDoctorMutation = (data: Record<string, unknown>) =>
apiClient.graphql<{ createDoctor: { id: string } }>(
`mutation CreateDoctor($data: DoctorCreateInput!) {
createDoctor(data: $data) { id }
}`,
{ data },
);
const updateDoctorMutation = (id: string, data: Record<string, unknown>) =>
apiClient.graphql<{ updateDoctor: { id: string } }>(
`mutation UpdateDoctor($id: UUID!, $data: DoctorUpdateInput!) {
updateDoctor(id: $id, data: $data) { id }
}`,
{ id, data },
);
const createVisitSlotMutation = (data: Record<string, unknown>) =>
apiClient.graphql(
`mutation CreateDoctorVisitSlot($data: DoctorVisitSlotCreateInput!) {
createDoctorVisitSlot(data: $data) { id }
}`,
{ data },
);
const deleteVisitSlotMutation = (id: string) =>
apiClient.graphql(
`mutation DeleteDoctorVisitSlot($id: UUID!) {
deleteDoctorVisitSlot(id: $id) { id }
}`,
{ id },
);
// -- Page --------------------------------------------------------------------
export const DoctorsPage = () => {
const [doctors, setDoctors] = useState<DoctorNode[]>([]);
const [clinics, setClinics] = useState<ClinicLite[]>([]);
const [loading, setLoading] = useState(true);
const [slideoutOpen, setSlideoutOpen] = useState(false);
const [editTarget, setEditTarget] = useState<DoctorNode | null>(null);
const [formValues, setFormValues] = useState<DoctorFormValues>(emptyDoctorFormValues);
const [isSaving, setIsSaving] = useState(false);
const fetchAll = useCallback(async () => {
try {
const data = await apiClient.graphql<{
doctors: { edges: { node: DoctorNode }[] };
clinics: { edges: { node: ClinicLite }[] };
}>(DOCTORS_QUERY);
setDoctors(data.doctors.edges.map((e) => e.node));
setClinics(data.clinics.edges.map((e) => e.node));
} catch {
// toast already shown by apiClient
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const clinicOptions = useMemo(
() =>
clinics.map((c) => ({
id: c.id,
label: c.clinicName ?? 'Unnamed clinic',
})),
[clinics],
);
const clinicNameById = useMemo(() => {
const map = new Map<string, string>();
for (const c of clinics) {
if (c.clinicName) map.set(c.id, c.clinicName);
}
return map;
}, [clinics]);
const handleAdd = () => {
setEditTarget(null);
setFormValues(emptyDoctorFormValues());
setSlideoutOpen(true);
};
const handleEdit = (doctor: DoctorNode) => {
setEditTarget(doctor);
setFormValues(toFormValues(doctor));
setSlideoutOpen(true);
};
const handleSave = async (close: () => void) => {
if (!formValues.firstName.trim() || !formValues.lastName.trim()) {
notify.error('First and last name are required');
return;
}
setIsSaving(true);
try {
const coreInput = doctorCoreToGraphQLInput(formValues);
// 1. Upsert doctor
let doctorId: string;
if (editTarget) {
await updateDoctorMutation(editTarget.id, coreInput);
doctorId = editTarget.id;
} else {
const res = await createDoctorMutation(coreInput);
doctorId = res.createDoctor.id;
notify.success('Doctor added', `Dr. ${formValues.firstName} ${formValues.lastName}`);
markSetupStepComplete('doctors').catch(() => {});
}
// 2. Visit slots — delete-all-recreate, same pattern as
// clinics.tsx holidays/requiredDocs. Simple, always correct,
// acceptable overhead at human-edit frequency.
if (editTarget?.doctorVisitSlots?.edges?.length) {
await Promise.all(
editTarget.doctorVisitSlots.edges.map((e) => deleteVisitSlotMutation(e.node.id)),
);
}
const slotInputs = visitSlotInputsFromForm(formValues, doctorId);
if (slotInputs.length > 0) {
await Promise.all(slotInputs.map((data) => createVisitSlotMutation(data)));
}
if (editTarget) {
notify.success('Doctor updated', `Dr. ${formValues.firstName} ${formValues.lastName}`);
}
await fetchAll();
close();
} catch (err) {
console.error('[doctors] save failed', err);
} finally {
setIsSaving(false);
}
};
const activeCount = useMemo(() => doctors.filter((d) => d.active).length, [doctors]);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Doctors" subtitle="Manage clinicians and appointment booking" />
<div className="flex-1 overflow-y-auto p-6">
<TableCard.Root size="sm">
<TableCard.Header
title="Clinicians"
badge={doctors.length}
description={`${activeCount} accepting appointments`}
contentTrailing={
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPlus} className={className} />
)}
onClick={handleAdd}
>
Add doctor
</Button>
}
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading doctors...</p>
</div>
) : doctors.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<div className="flex size-12 items-center justify-center rounded-full bg-brand-secondary">
<FontAwesomeIcon icon={faStethoscope} className="size-5 text-fg-brand-primary" />
</div>
<p className="text-sm font-semibold text-primary">No doctors yet</p>
<p className="max-w-xs text-center text-xs text-tertiary">
{clinics.length === 0
? 'Add a clinic first, then add doctors to assign them.'
: 'Add your first doctor to start booking consultations.'}
</p>
<Button size="sm" color="primary" onClick={handleAdd} isDisabled={clinics.length === 0}>
Add doctor
</Button>
</div>
) : (
<Table>
<Table.Header>
<Table.Head label="DOCTOR" isRowHeader />
<Table.Head label="DEPARTMENT" />
<Table.Head label="VISITING SCHEDULE" />
<Table.Head label="FEE (NEW)" />
<Table.Head label="STATUS" />
<Table.Head label="" />
</Table.Header>
<Table.Body items={doctors}>
{(doctor) => {
const firstName = doctor.fullName?.firstName ?? '';
const lastName = doctor.fullName?.lastName ?? '';
const name = `Dr. ${firstName} ${lastName}`.trim();
return (
<Table.Row id={doctor.id}>
<Table.Cell>
<div className="flex flex-col">
<span className="text-sm font-medium text-primary">{name}</span>
{doctor.specialty && (
<span className="text-xs text-tertiary">{doctor.specialty}</span>
)}
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">
{doctor.department ? departmentLabel[doctor.department] : '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">
{summariseVisitSlots(doctor, clinicNameById)}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">
{formatFee(doctor.consultationFeeNew)}
</span>
</Table.Cell>
<Table.Cell>
<Badge
size="sm"
color={doctor.active ? 'success' : 'gray'}
type="pill-color"
>
{doctor.active ? 'Accepting' : 'Inactive'}
</Badge>
</Table.Cell>
<Table.Cell>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} />
)}
onClick={() => handleEdit(doctor)}
>
Edit
</Button>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</TableCard.Root>
</div>
<SlideoutMenu isOpen={slideoutOpen} onOpenChange={setSlideoutOpen} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faStethoscope} className="size-5 text-fg-brand-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-primary">
{editTarget ? 'Edit doctor' : 'Add doctor'}
</h2>
<p className="text-sm text-tertiary">
{editTarget
? 'Update clinician details and visiting schedule'
: 'Add a new clinician to your hospital'}
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<DoctorForm value={formValues} onChange={setFormValues} clinics={clinicOptions} />
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-end gap-3">
<Button size="md" color="secondary" onClick={close}>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={isSaving}
showTextWhileLoading
onClick={() => handleSave(close)}
>
{isSaving ? 'Saving...' : editTarget ? 'Save changes' : 'Add doctor'}
</Button>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
@@ -11,7 +11,6 @@ import { Input } from '@/components/base/input/input';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useThemeTokens } from '@/providers/theme-token-provider';
import { getSetupState } from '@/lib/setup-state';
export const LoginPage = () => {
const { loginWithUser } = useAuth();
@@ -20,46 +19,6 @@ export const LoginPage = () => {
const { isOpen, activeAction, close } = useMaintShortcuts();
const { tokens } = useThemeTokens();
// Load website widget on login page.
//
// Config comes from the admin-editable sidecar endpoint (not env vars)
// so it can be toggled / rotated / moved at runtime. The widget renders
// only when all of enabled + embed.loginPage + key are set.
//
// `url` may be empty (default for fresh configs) — in that case we fall
// back to the same origin the app is talking to for its API, since in
// practice the sidecar serves /widget.js alongside /api/*.
useEffect(() => {
let cancelled = false;
const apiUrl = import.meta.env.VITE_API_URL ?? '';
if (!apiUrl) return;
fetch(`${apiUrl}/api/config/widget`)
.then(r => (r.ok ? r.json() : null))
.then((cfg: { enabled?: boolean; key?: string; url?: string; embed?: { loginPage?: boolean } } | null) => {
if (cancelled || !cfg) return;
if (!cfg.enabled || !cfg.embed?.loginPage || !cfg.key) return;
if (document.getElementById('helix-widget-script')) return;
const host = cfg.url && cfg.url.length > 0 ? cfg.url : apiUrl;
const script = document.createElement('script');
script.id = 'helix-widget-script';
script.src = `${host}/widget.js`;
script.setAttribute('data-key', cfg.key);
document.body.appendChild(script);
})
.catch(err => {
// Never block login on a widget config failure.
console.warn('[widget] config fetch failed', err);
});
return () => {
cancelled = true;
document.getElementById('helix-widget-script')?.remove();
document.getElementById('helix-widget-host')?.remove();
};
}, []);
const saved = localStorage.getItem('helix_remember');
const savedCreds = saved ? JSON.parse(saved) : null;
@@ -115,22 +74,6 @@ export const LoginPage = () => {
});
refresh();
// First-run detection: if the workspace's setup is incomplete and
// the wizard hasn't been dismissed, route the admin to /setup so
// they finish onboarding before reaching the dashboard. Failures
// are non-blocking — we always have a fallback to /.
try {
const state = await getSetupState();
if (state.wizardRequired) {
navigate('/setup');
return;
}
} catch {
// Setup state endpoint may be unreachable on older sidecars —
// proceed to the normal landing page.
}
navigate('/');
} catch (err: any) {
setError(err.message);

View File

@@ -83,9 +83,7 @@ export const MyPerformancePage = () => {
useEffect(() => {
setLoading(true);
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const agentId = agentCfg.ozonetelAgentId ?? '';
apiClient.get<PerformanceData>(`/api/ozonetel/performance?date=${selectedDate}&agentId=${agentId}`, { silent: true })
apiClient.get<PerformanceData>(`/api/ozonetel/performance?date=${selectedDate}`, { silent: true })
.then(setData)
.catch(() => setData(null))
.finally(() => setLoading(false));

View File

@@ -1,153 +1,184 @@
import { useEffect, useState } from 'react';
import {
faBuilding,
faStethoscope,
faUserTie,
faPhone,
faRobot,
faGlobe,
faPalette,
faShieldHalved,
} from '@fortawesome/pro-duotone-svg-icons';
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faToggleOn } from '@fortawesome/pro-duotone-svg-icons';
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table, TableCard } from '@/components/application/table/table';
import { TopBar } from '@/components/layout/top-bar';
import { SectionCard } from '@/components/setup/section-card';
import {
SETUP_STEP_NAMES,
SETUP_STEP_LABELS,
type SetupState,
type SetupStepName,
getSetupState,
} from '@/lib/setup-state';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { getInitials } from '@/lib/format';
// Settings hub — the new /settings route. Replaces the old monolithic
// SettingsPage which had only the team listing. The team listing now lives
// at /settings/team via TeamSettingsPage.
//
// Each card links to a dedicated settings page. Pages built in earlier
// phases link to existing routes (branding, rules); pages coming in later
// phases link to placeholder routes that render "Coming soon" until those
// phases land.
//
// The completion status badges mirror the sidecar setup-state so an admin
// returning later sees what still needs attention. Sections without a
// matching wizard step (branding, widget, rules) don't show a badge.
const STEP_TO_STATUS = (state: SetupState | null, step: SetupStepName | null) => {
if (!state || !step) return 'unknown' as const;
return state.steps[step].completed ? ('complete' as const) : ('incomplete' as const);
type WorkspaceMember = {
id: string;
name: { firstName: string; lastName: string } | null;
userEmail: string;
avatarUrl: string | null;
roles: { id: string; label: string }[];
};
export const SettingsPage = () => {
const [state, setState] = useState<SetupState | null>(null);
const [members, setMembers] = useState<WorkspaceMember[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getSetupState()
.then(setState)
.catch(() => {
// Hub still works even if setup-state isn't reachable — just no badges.
});
const fetchMembers = async () => {
try {
// Roles are only accessible via user JWT, not API key
const data = await apiClient.graphql<any>(
`{ workspaceMembers(first: 50) { edges { node { id name { firstName lastName } userEmail avatarUrl } } } }`,
undefined,
{ silent: true },
);
const rawMembers = data?.workspaceMembers?.edges?.map((e: any) => e.node) ?? [];
// Roles come from the platform's role assignment — map known emails to roles
setMembers(rawMembers.map((m: any) => ({
...m,
roles: inferRoles(m.userEmail),
})));
} catch {
// silently fail
} finally {
setLoading(false);
}
};
fetchMembers();
}, []);
// Infer roles from email convention until platform roles API is accessible
const inferRoles = (email: string): { id: string; label: string }[] => {
if (email.includes('ramesh') || email.includes('admin')) return [{ id: 'mgr', label: 'HelixEngage Manager' }];
if (email.includes('cc')) return [{ id: 'cc', label: 'HelixEngage User (CC Agent)' }];
if (email.includes('marketing') || email.includes('sanjay')) return [{ id: 'exec', label: 'HelixEngage User (Executive)' }];
if (email.includes('dr.')) return [{ id: 'doc', label: 'HelixEngage User (Doctor)' }];
return [{ id: 'user', label: 'HelixEngage User' }];
};
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const PAGE_SIZE = 10;
const filtered = useMemo(() => {
if (!search.trim()) return members;
const q = search.toLowerCase();
return members.filter((m) => {
const name = `${m.name?.firstName ?? ''} ${m.name?.lastName ?? ''}`.toLowerCase();
return name.includes(q) || m.userEmail.toLowerCase().includes(q);
});
}, [members, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const handleResetPassword = (member: WorkspaceMember) => {
notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Settings" subtitle="Configure your hospital workspace" />
<TopBar title="Settings" subtitle="Team management and configuration" />
<div className="flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-5xl">
{/* Identity & branding */}
<SectionGroup title="Hospital identity" description="How your hospital appears across the platform.">
<SectionCard
title="Branding"
description="Hospital name, logo, colors, login copy, sidebar text."
icon={faPalette}
href="/branding"
status={STEP_TO_STATUS(state, 'identity')}
/>
</SectionGroup>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Employees section */}
<TableCard.Root size="sm">
<TableCard.Header
title="Employees"
badge={members.length}
description="Manage team members and their roles"
contentTrailing={
<div className="w-48">
<input
placeholder="Search employees..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full rounded-lg border border-secondary bg-primary px-3 py-1.5 text-sm text-primary placeholder:text-placeholder outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
/>
</div>
}
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading employees...</p>
</div>
) : (<>
<Table>
<Table.Header>
<Table.Head label="EMPLOYEE" isRowHeader />
<Table.Head label="EMAIL" />
<Table.Head label="ROLES" />
<Table.Head label="STATUS" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={paged}>
{(member) => {
const firstName = member.name?.firstName ?? '';
const lastName = member.name?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed';
const initials = getInitials(firstName || '?', lastName || '?');
const roles = member.roles?.map((r) => r.label) ?? [];
{/* Care delivery */}
<SectionGroup title="Care delivery" description="The clinics, doctors, and team members that run your operations.">
<SectionCard
title="Clinics"
description="Add hospital branches, addresses, and visiting hours."
icon={faBuilding}
href="/settings/clinics"
status={STEP_TO_STATUS(state, 'clinics')}
/>
<SectionCard
title={SETUP_STEP_LABELS.doctors.title}
description="Add clinicians, specialties, and clinic assignments."
icon={faStethoscope}
href="/settings/doctors"
status={STEP_TO_STATUS(state, 'doctors')}
/>
<SectionCard
title={SETUP_STEP_LABELS.team.title}
description="Invite supervisors and call-center agents."
icon={faUserTie}
href="/settings/team"
status={STEP_TO_STATUS(state, 'team')}
/>
</SectionGroup>
{/* Channels & automation */}
<SectionGroup title="Channels & automation" description="Telephony, AI assistant, and the public-facing widget.">
<SectionCard
title={SETUP_STEP_LABELS.telephony.title}
description="Connect Ozonetel and Exotel for inbound and outbound calls."
icon={faPhone}
href="/settings/telephony"
status={STEP_TO_STATUS(state, 'telephony')}
/>
<SectionCard
title={SETUP_STEP_LABELS.ai.title}
description="Choose your AI provider, model, and prompts."
icon={faRobot}
href="/settings/ai"
status={STEP_TO_STATUS(state, 'ai')}
/>
<SectionCard
title="Website widget"
description="Embed the chat + booking widget on your hospital website."
icon={faGlobe}
href="/settings/widget"
/>
<SectionCard
title="Routing rules"
description="Lead scoring, prioritisation, and assignment rules."
icon={faShieldHalved}
href="/rules"
/>
</SectionGroup>
{state && (
<p className="mt-8 text-xs text-tertiary">
{SETUP_STEP_NAMES.filter(s => state.steps[s].completed).length} of{' '}
{SETUP_STEP_NAMES.length} setup steps complete.
</p>
)}
</div>
return (
<Table.Row id={member.id}>
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar size="sm" initials={initials} src={member.avatarUrl ?? undefined} />
<span className="text-sm font-medium text-primary">{fullName}</span>
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">{member.userEmail}</span>
</Table.Cell>
<Table.Cell>
<div className="flex flex-wrap gap-1">
{roles.length > 0 ? roles.map((role) => (
<Badge key={role} size="sm" color={role.includes('Manager') ? 'brand' : 'gray'}>
{role}
</Badge>
)) : (
<span className="text-xs text-quaternary">No roles</span>
)}
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color="success" type="pill-color">
<FontAwesomeIcon icon={faToggleOn} className="mr-1 size-3" />
Active
</Badge>
</Table.Cell>
<Table.Cell>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faKey} className={className} />
)}
onClick={() => handleResetPassword(member)}
>
Reset Password
</Button>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
{(page - 1) * PAGE_SIZE + 1}&ndash;{Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length}
</span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 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={() => setPage(Math.min(totalPages, page + 1))} disabled={page === totalPages}
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>
)}
</> )}
</TableCard.Root>
</div>
</div>
);
};
const SectionGroup = ({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) => {
return (
<div className="mb-10 last:mb-0">
<div className="mb-4">
<h2 className="text-base font-bold text-primary">{title}</h2>
<p className="mt-0.5 text-xs text-tertiary">{description}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">{children}</div>
</div>
);
};

View File

@@ -1,134 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { WizardShell } from '@/components/setup/wizard-shell';
import { WizardStepIdentity } from '@/components/setup/wizard-step-identity';
import { WizardStepClinics } from '@/components/setup/wizard-step-clinics';
import { WizardStepDoctors } from '@/components/setup/wizard-step-doctors';
import { WizardStepTeam } from '@/components/setup/wizard-step-team';
import { WizardStepTelephony } from '@/components/setup/wizard-step-telephony';
import { WizardStepAi } from '@/components/setup/wizard-step-ai';
import type { WizardStepComponentProps } from '@/components/setup/wizard-step-types';
import {
SETUP_STEP_NAMES,
type SetupState,
type SetupStepName,
getSetupState,
markSetupStepComplete,
dismissSetupWizard,
} from '@/lib/setup-state';
import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider';
// Top-level onboarding wizard. Auto-shown by login.tsx redirect when the
// workspace has incomplete setup steps.
//
// Phase 5: each step is now backed by a real form component. The parent
// owns the shell navigation + per-step completion state, and passes a
// WizardStepComponentProps bundle to the dispatched child so the child
// can trigger save + mark-complete + advance from its own Save action.
const STEP_COMPONENTS: Record<SetupStepName, (p: WizardStepComponentProps) => React.ReactElement> = {
identity: WizardStepIdentity,
clinics: WizardStepClinics,
doctors: WizardStepDoctors,
team: WizardStepTeam,
telephony: WizardStepTelephony,
ai: WizardStepAi,
};
export const SetupWizardPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [state, setState] = useState<SetupState | null>(null);
const [activeStep, setActiveStep] = useState<SetupStepName>('identity');
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getSetupState()
.then((s) => {
if (cancelled) return;
setState(s);
// Land on the first incomplete step so the operator picks up
// where they left off.
const firstIncomplete = SETUP_STEP_NAMES.find((name) => !s.steps[name].completed);
if (firstIncomplete) setActiveStep(firstIncomplete);
})
.catch((err) => {
console.error('Failed to load setup state', err);
notify.error('Setup', 'Could not load setup state. Please reload.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (loading || !state) {
return (
<div className="flex min-h-screen items-center justify-center bg-primary">
<p className="text-sm text-tertiary">Loading setup</p>
</div>
);
}
const activeIndex = SETUP_STEP_NAMES.indexOf(activeStep);
const isLastStep = activeIndex === SETUP_STEP_NAMES.length - 1;
const onPrev = activeIndex > 0 ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex - 1]) : null;
const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null;
const handleComplete = async (step: SetupStepName) => {
// No try/catch here — if the setup-state PUT fails, we WANT
// the error to propagate up to the step's handleSave so the
// agent sees a toast AND the advance flow pauses until the
// issue is resolved. Silent swallowing hid the real failure
// mode during the Ramaiah local test.
const updated = await markSetupStepComplete(step, user?.email);
setState(updated);
};
const handleAdvance = () => {
if (!isLastStep) {
setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]);
}
};
const handleFinish = () => {
notify.success('Setup complete', 'Welcome to your workspace!');
navigate('/', { replace: true });
};
const handleDismiss = async () => {
try {
await dismissSetupWizard();
notify.success('Setup dismissed', 'You can finish setup later from Settings.');
navigate('/', { replace: true });
} catch {
notify.error('Setup', 'Could not dismiss the wizard. Please try again.');
}
};
const StepComponent = STEP_COMPONENTS[activeStep];
const stepProps: WizardStepComponentProps = {
isCompleted: state.steps[activeStep].completed,
isLast: isLastStep,
onPrev,
onNext,
onComplete: handleComplete,
onAdvance: handleAdvance,
onFinish: handleFinish,
};
return (
<WizardShell
state={state}
activeStep={activeStep}
onSelectStep={setActiveStep}
onDismiss={handleDismiss}
>
<StepComponent {...stepProps} />
</WizardShell>
);
};

View File

@@ -1,412 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faToggleOn, faUserPlus, faUserShield } from '@fortawesome/pro-duotone-svg-icons';
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Select } from '@/components/base/select/select';
import { Table, TableCard } from '@/components/application/table/table';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { TopBar } from '@/components/layout/top-bar';
import {
EmployeeCreateForm,
emptyEmployeeCreateFormValues,
type EmployeeCreateFormValues,
type RoleOption,
} from '@/components/forms/employee-create-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { getInitials } from '@/lib/format';
// /settings/team — in-place employee management. Fetches real roles + SIP
// seats from the platform and uses the sidecar's /api/team/members
// endpoint to create workspace members directly with a temp password
// that the admin hands out (no email invitations — see
// feedback-no-invites in memory). Also lets admins change a member's
// role via updateWorkspaceMemberRole.
type MemberRole = {
id: string;
label: string;
};
type WorkspaceMember = {
id: string;
name: { firstName: string; lastName: string } | null;
userEmail: string;
avatarUrl: string | null;
// Platform returns null (not []) for members with no role assigned —
// optional-chain when reading.
roles: MemberRole[] | null;
};
type CreatedMemberResponse = {
id: string;
userEmail: string;
firstName: string;
lastName: string;
roleId: string;
};
// Combined query — workspace members + assignable roles. Bundled to
// save a round-trip and keep the table consistent across the join.
const TEAM_QUERY = `{
workspaceMembers(first: 100) {
edges {
node {
id
name { firstName lastName }
userEmail
avatarUrl
roles { id label }
}
}
}
getRoles {
id
label
description
canBeAssignedToUsers
}
}`;
export const TeamSettingsPage = () => {
const [members, setMembers] = useState<WorkspaceMember[]>([]);
const [roles, setRoles] = useState<RoleOption[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [createValues, setCreateValues] = useState<EmployeeCreateFormValues>(
emptyEmployeeCreateFormValues,
);
const [isCreating, setIsCreating] = useState(false);
const fetchData = useCallback(async () => {
try {
const data = await apiClient.graphql<{
workspaceMembers: { edges: { node: WorkspaceMember }[] };
getRoles: {
id: string;
label: string;
description: string | null;
canBeAssignedToUsers: boolean;
}[];
}>(TEAM_QUERY, undefined, { silent: true });
setMembers(data.workspaceMembers.edges.map((e) => e.node));
const assignable = data.getRoles.filter((r) => r.canBeAssignedToUsers);
setRoles(
assignable.map((r) => ({
id: r.id,
label: r.label,
supportingText: r.description ?? undefined,
})),
);
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const PAGE_SIZE = 10;
const filtered = useMemo(() => {
if (!search.trim()) return members;
const q = search.toLowerCase();
return members.filter((m) => {
const name = `${m.name?.firstName ?? ''} ${m.name?.lastName ?? ''}`.toLowerCase();
return name.includes(q) || m.userEmail.toLowerCase().includes(q);
});
}, [members, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const handleResetPassword = (member: WorkspaceMember) => {
notify.info('Password Reset', `Password reset link would be sent to ${member.userEmail}`);
};
const handleChangeRole = async (memberId: string, newRoleId: string) => {
try {
await apiClient.graphql(
`mutation UpdateRole($workspaceMemberId: UUID!, $roleId: UUID!) {
updateWorkspaceMemberRole(workspaceMemberId: $workspaceMemberId, roleId: $roleId) {
id
}
}`,
{ workspaceMemberId: memberId, roleId: newRoleId },
);
const newRole = roles.find((r) => r.id === newRoleId);
setMembers((prev) =>
prev.map((m) =>
m.id === memberId
? { ...m, roles: newRole ? [{ id: newRole.id, label: newRole.label }] : [] }
: m,
),
);
notify.success('Role updated', newRole ? `Role set to ${newRole.label}` : 'Role updated');
} catch (err) {
console.error('[team] role update failed', err);
}
};
const handleCreateMember = async (close: () => void) => {
const firstName = createValues.firstName.trim();
const email = createValues.email.trim();
if (!firstName) {
notify.error('First name is required');
return;
}
if (!email) {
notify.error('Email is required');
return;
}
if (!createValues.password) {
notify.error('Temporary password is required');
return;
}
if (!createValues.roleId) {
notify.error('Pick a role');
return;
}
setIsCreating(true);
try {
await apiClient.post<CreatedMemberResponse>('/api/team/members', {
firstName,
lastName: createValues.lastName.trim(),
email,
password: createValues.password,
roleId: createValues.roleId,
});
notify.success(
'Employee created',
`${firstName} ${createValues.lastName.trim()}`.trim() || email,
);
setCreateValues(emptyEmployeeCreateFormValues);
await fetchData();
close();
} catch (err) {
console.error('[team] create failed', err);
} finally {
setIsCreating(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Team" subtitle="Manage workspace members and their roles" />
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<TableCard.Root size="sm">
<TableCard.Header
title="Employees"
badge={members.length}
description="Manage team members and their roles"
contentTrailing={
<div className="flex items-center gap-3">
<div className="w-48">
<input
placeholder="Search employees..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full rounded-lg border border-secondary bg-primary px-3 py-1.5 text-sm text-primary placeholder:text-placeholder outline-none focus:border-brand focus:ring-2 focus:ring-brand-100"
/>
</div>
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPlus} className={className} />
)}
onClick={() => {
setCreateValues(emptyEmployeeCreateFormValues);
setCreateOpen(true);
}}
>
Add employee
</Button>
</div>
}
/>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading employees...</p>
</div>
) : (
<>
<Table>
<Table.Header>
<Table.Head label="EMPLOYEE" isRowHeader />
<Table.Head label="EMAIL" />
<Table.Head label="ROLE" />
<Table.Head label="STATUS" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={paged}>
{(member) => {
const firstName = member.name?.firstName ?? '';
const lastName = member.name?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unnamed';
const initials = getInitials(firstName || '?', lastName || '?');
const memberRoles = member.roles ?? [];
const currentRoleId = memberRoles[0]?.id ?? null;
return (
<Table.Row id={member.id}>
<Table.Cell>
<div className="flex items-center gap-3">
<Avatar
size="sm"
initials={initials}
src={member.avatarUrl ?? undefined}
/>
<span className="text-sm font-medium text-primary">{fullName}</span>
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-tertiary">{member.userEmail}</span>
</Table.Cell>
<Table.Cell>
{roles.length > 0 ? (
<div className="w-56">
<Select
placeholder="No role"
placeholderIcon={
<FontAwesomeIcon
icon={faUserShield}
data-icon
className="size-4"
/>
}
items={roles}
selectedKey={currentRoleId}
onSelectionChange={(key) =>
handleChangeRole(member.id, key as string)
}
>
{(item) => (
<Select.Item id={item.id} label={item.label} />
)}
</Select>
</div>
) : memberRoles.length > 0 ? (
<Badge size="sm" color="gray">
{memberRoles[0].label}
</Badge>
) : (
<span className="text-xs text-quaternary">No role</span>
)}
</Table.Cell>
<Table.Cell>
<Badge size="sm" color="success" type="pill-color">
<FontAwesomeIcon icon={faToggleOn} className="mr-1 size-3" />
Active
</Badge>
</Table.Cell>
<Table.Cell>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faKey} className={className} />
)}
onClick={() => handleResetPassword(member)}
>
Reset Password
</Button>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
{(page - 1) * PAGE_SIZE + 1}&ndash;{Math.min(page * PAGE_SIZE, filtered.length)} of{' '}
{filtered.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 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={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
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>
)}
</>
)}
</TableCard.Root>
</div>
<SlideoutMenu isOpen={createOpen} onOpenChange={setCreateOpen} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex items-center gap-3 pr-8">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faUserPlus} className="size-5 text-fg-brand-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-primary">Add employee</h2>
<p className="text-sm text-tertiary">
Create supervisors, CC agents and admins in place
</p>
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
<EmployeeCreateForm
value={createValues}
onChange={setCreateValues}
roles={roles}
/>
<p className="mt-4 text-xs text-tertiary">
The employee logs in with this email and the temporary password
you set. Share both with them directly no email is sent.
</p>
</SlideoutMenu.Content>
<SlideoutMenu.Footer>
<div className="flex items-center justify-end gap-3">
<Button size="md" color="secondary" onClick={close}>
Cancel
</Button>
<Button
size="md"
color="primary"
isLoading={isCreating}
showTextWhileLoading
onClick={() => handleCreateMember(close)}
>
{isCreating ? 'Creating…' : 'Create employee'}
</Button>
</div>
</SlideoutMenu.Footer>
</>
)}
</SlideoutMenu>
</div>
);
};

View File

@@ -1,162 +0,0 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
TelephonyForm,
emptyTelephonyFormValues,
type TelephonyFormValues,
} from '@/components/forms/telephony-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/telephony — Pattern B page against the sidecar's
// /api/config/telephony endpoint. The sidecar masks secrets on GET (agent
// password + Exotel API token become '***masked***') and treats that sentinel
// as "no change" on PUT, so we just round-trip the form values directly.
//
// Changes take effect immediately — TelephonyConfigService keeps an in-memory
// cache that all consumers read via getters, no restart required.
export const TelephonySettingsPage = () => {
const [values, setValues] = useState<TelephonyFormValues>(emptyTelephonyFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<TelephonyFormValues>('/api/config/telephony');
setValues({
ozonetel: {
agentId: data.ozonetel?.agentId ?? '',
agentPassword: data.ozonetel?.agentPassword ?? '',
did: data.ozonetel?.did ?? '',
sipId: data.ozonetel?.sipId ?? '',
campaignName: data.ozonetel?.campaignName ?? '',
},
sip: {
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: data.sip?.wsPort ?? '444',
},
exotel: {
apiKey: data.exotel?.apiKey ?? '',
apiToken: data.exotel?.apiToken ?? '',
accountSid: data.exotel?.accountSid ?? '',
subdomain: data.exotel?.subdomain ?? 'api.exotel.com',
},
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
await apiClient.put('/api/config/telephony', {
ozonetel: values.ozonetel,
sip: values.sip,
exotel: values.exotel,
});
notify.success('Telephony updated', 'Changes are live — no restart needed.');
// Mark the wizard step complete if the required Ozonetel fields are
// all filled in. Keeps the setup hub badges in sync with reality.
const complete =
!!values.ozonetel.agentId &&
!!values.ozonetel.did &&
!!values.ozonetel.sipId &&
!!values.ozonetel.campaignName;
if (complete) {
markSetupStepComplete('telephony').catch(() => {});
}
await loadConfig();
} catch (err) {
console.error('[telephony] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset telephony settings to defaults? Your Ozonetel and Exotel credentials will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/telephony/reset');
notify.info('Telephony reset', 'All fields have been cleared.');
await loadConfig();
} catch (err) {
console.error('[telephony] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Telephony" subtitle="Connect Ozonetel and Exotel for calls and SMS" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faPhone} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">Credentials are stored locally</p>
<p className="text-xs text-tertiary">
Values are written to the sidecar's data/telephony.json. API tokens are masked
when loaded — leave the <code className="rounded bg-secondary px-1 py-0.5 font-mono">***masked***</code>{' '}
placeholder to keep the existing value.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading telephony settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<TelephonyForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,199 +0,0 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faCopy, faArrowsRotate } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
WidgetForm,
emptyWidgetFormValues,
type WidgetFormValues,
} from '@/components/forms/widget-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
// /settings/widget — Pattern B page for the website widget config. Uses the
// admin endpoint GET /api/config/widget/admin (the plain /api/config/widget
// endpoint returns only the public subset and is used by the embed page).
//
// The site key and site ID are read-only here — generated and rotated by the
// backend. The copy-to-clipboard button on the key helps the admin paste it
// into their website's embed snippet.
type ServerWidgetConfig = {
enabled: boolean;
key: string;
siteId: string;
url: string;
allowedOrigins: string[];
embed: { loginPage: boolean };
version?: number;
updatedAt?: string;
};
export const WidgetSettingsPage = () => {
const [values, setValues] = useState<WidgetFormValues>(emptyWidgetFormValues);
const [key, setKey] = useState<string>('');
const [siteId, setSiteId] = useState<string>('');
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isRotating, setIsRotating] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerWidgetConfig>('/api/config/widget/admin');
setValues({
enabled: data.enabled,
url: data.url ?? '',
allowedOrigins: data.allowedOrigins ?? [],
embed: {
loginPage: data.embed?.loginPage ?? false,
},
});
setKey(data.key ?? '');
setSiteId(data.siteId ?? '');
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
setIsSaving(true);
try {
await apiClient.put('/api/config/widget', {
enabled: values.enabled,
url: values.url,
allowedOrigins: values.allowedOrigins,
embed: values.embed,
});
notify.success('Widget updated', 'Changes take effect on next widget load.');
await loadConfig();
} catch (err) {
console.error('[widget] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleRotateKey = async () => {
if (
!confirm(
'Rotate the widget key? Any website embed using the old key will stop working until you update it.',
)
) {
return;
}
setIsRotating(true);
try {
await apiClient.post('/api/config/widget/rotate-key');
notify.success('Key rotated', 'Update every embed snippet with the new key.');
await loadConfig();
} catch (err) {
console.error('[widget] rotate failed', err);
} finally {
setIsRotating(false);
}
};
const handleCopyKey = async () => {
try {
await navigator.clipboard.writeText(key);
notify.success('Copied', 'Widget key copied to clipboard.');
} catch {
notify.error('Copy failed', 'Select the key manually and copy it.');
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Website Widget" subtitle="Configure the chat + booking widget for your hospital website" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faGlobe} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">One-line embed snippet</p>
<p className="text-xs text-tertiary">
Drop the script below into your hospital website's <code className="rounded bg-secondary px-1 py-0.5 font-mono">&lt;head&gt;</code> to
enable chat and appointment booking. Changing the key requires re-embedding.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading widget settings...</p>
</div>
) : (
<div className="flex flex-col gap-6">
{/* Site key card — read-only with copy + rotate */}
<div className="rounded-xl border border-secondary bg-primary p-5 shadow-xs">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-primary">Site key</h3>
<p className="text-xs text-tertiary">
Site ID: <span className="font-mono">{siteId || ''}</span>
</p>
</div>
<Button
size="sm"
color="tertiary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faArrowsRotate} className={className} />
)}
onClick={handleRotateKey}
isLoading={isRotating}
showTextWhileLoading
>
Rotate
</Button>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded-lg border border-secondary bg-secondary px-3 py-2 font-mono text-xs text-primary">
{key || ' no key yet '}
</code>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faCopy} className={className} />
)}
onClick={handleCopyKey}
isDisabled={!key}
>
Copy
</Button>
</div>
</div>
{/* Editable config */}
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<WidgetForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-end border-t border-secondary pt-4">
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -96,19 +96,10 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
}, []);
const logout = useCallback(async () => {
// Block logout during active call
const { isOutboundPending, disconnectSip } = await import('@/state/sip-manager');
const activeUcid = localStorage.getItem('helix_active_ucid');
if (isOutboundPending() || activeUcid) {
const confirmed = window.confirm(
'You have an active call. Logging out will disconnect the call. Are you sure?',
);
if (!confirmed) return;
}
// Disconnect SIP before logout
try {
disconnectSip(true);
const { disconnectSip } = await import('@/state/sip-manager');
disconnectSip();
} catch {}
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens

View File

@@ -23,6 +23,7 @@ import {
} from '@/lib/transforms';
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient, Appointment } from '@/types/entities';
import campaignsJson from '../../campaigns.json';
type DataContextType = {
leads: Lead[];
@@ -100,7 +101,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
]);
if (leadsData) setLeads(transformLeads(leadsData));
if (campaignsData) setCampaigns(transformCampaigns(campaignsData));
// Load campaigns from backend, fallback to local JSON if empty or failed
let campaignsLoaded = false;
if (campaignsData) {
try {
const backendCampaigns = transformCampaigns(campaignsData);
if (backendCampaigns.length > 0) {
setCampaigns(backendCampaigns);
campaignsLoaded = true;
}
} catch (err) {
// Silently fall back to JSON
}
}
// Fallback to local JSON campaigns if backend failed or returned no data
if (!campaignsLoaded) {
const jsonCampaigns: Campaign[] = campaignsJson.map((c: any) => ({
id: c.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
campaignName: c.title,
campaignType: 'FACEBOOK_AD' as const,
campaignStatus: 'ACTIVE' as const,
platform: 'FACEBOOK' as const,
startDate: null,
endDate: c.validUntil ? new Date(c.validUntil + ', 2024').toISOString() : null,
budget: null,
amountSpent: null,
impressionCount: 0,
clickCount: 0,
targetCount: 0,
contactedCount: 0,
convertedCount: 0,
leadCount: 0,
externalCampaignId: null,
platformUrl: null,
}));
setCampaigns(jsonCampaigns);
}
if (adsData) setAds(transformAds(adsData));
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, type PropsWithChildren } from 'react';
import { useEffect, useCallback, type PropsWithChildren } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
sipConnectionStatusAtom,
@@ -89,52 +89,15 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
// and resets to idle via the "Back to Worklist" button
// Layer 1: Warn on page refresh/close during active call
// Layer 2: Fire sendBeacon to auto-dispose if user confirms leave
// These two layers protect against the "agent refreshes mid-call → stuck in ACW" bug.
// Layer 3 (server-side ACW timeout) lives in supervisor.service.ts.
//
// IMPORTANT: beforeunload reads callState via a ref (not the dep array)
// because adding callState to deps causes the cleanup to fire on every
// state transition → disconnectSip() → kills the call mid-flight.
const callStateRef = useRef(callState);
callStateRef.current = callState;
// Cleanup on unmount + page unload
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const ucid = localStorage.getItem('helix_active_ucid');
const state = callStateRef.current;
// Layer 1: Show browser "Leave page?" dialog during active calls
if (state === 'active' || state === 'ringing-in' || state === 'ringing-out') {
e.preventDefault();
e.returnValue = '';
}
// Layer 2: Fire disposition beacon if there's an active UCID
if (ucid) {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const payload = JSON.stringify({
ucid,
disposition: 'CALLBACK_REQUESTED',
agentId: agentCfg.ozonetelAgentId,
autoDisposed: true,
});
navigator.sendBeacon('/api/ozonetel/dispose', new Blob([payload], { type: 'application/json' }));
localStorage.removeItem('helix_active_ucid');
}
};
const handleUnload = () => disconnectSip(true);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('unload', handleUnload);
const handleUnload = () => disconnectSip();
window.addEventListener('beforeunload', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('unload', handleUnload);
disconnectSip(true); // force — component is unmounting
window.removeEventListener('beforeunload', handleUnload);
disconnectSip();
};
}, []); // empty deps — runs once on mount, cleanup only on unmount
}, []);
return <>{children}</>;
};
@@ -156,22 +119,6 @@ export const useSip = () => {
// Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
// Block outbound calls when agent is on Break or Training
const agentCfg = localStorage.getItem('helix_agent_config');
if (agentCfg) {
const { useAgentState: _ } = await import('@/hooks/use-agent-state');
// Read state from the SSE endpoint directly (can't use hook here)
const agentId = JSON.parse(agentCfg).ozonetelAgentId;
try {
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
const stateData = await stateRes.json();
if (stateData.state === 'break' || stateData.state === 'training') {
const { notify } = await import('@/lib/toast');
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
return;
}
} catch {}
}
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
setCallState('ringing-out');
setCallerNumber(phoneNumber);
@@ -182,13 +129,7 @@ export const useSip = () => {
}, 30000);
try {
// Send agent config so the sidecar dials with the correct agent ID + campaign
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', {
phoneNumber,
agentId: agentConfig.ozonetelAgentId,
campaignName: agentConfig.campaignName,
});
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
console.log('[DIAL] Dial API response:', result);
clearTimeout(safetyTimeout);
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound

View File

@@ -6,7 +6,6 @@ let sipClient: SIPClient | null = null;
let connected = false;
let outboundPending = false;
let outboundActive = false;
let activeAgentId: string | null = null;
type StateUpdater = {
setConnectionStatus: (status: ConnectionStatus) => void;
@@ -43,16 +42,6 @@ export function connectSip(config: SIPConfig): void {
sipClient.disconnect();
}
// Resolve agent identity for logging
try {
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
activeAgentId = agentCfg.ozonetelAgentId ?? null;
const ext = config.uri?.match(/sip:(\d+)@/)?.[1] ?? 'unknown';
console.log(`[SIP] Connecting agent=${activeAgentId} ext=${ext} ws=${config.wsServer}`);
} catch {
console.log(`[SIP] Connecting uri=${config.uri}`);
}
connected = true;
stateUpdater?.setConnectionStatus('connecting');
@@ -73,7 +62,7 @@ export function connectSip(config: SIPConfig): void {
return;
}
console.log(`[SIP] ${activeAgentId} | state=${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outbound=${outboundActive}`);
console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`);
stateUpdater?.setCallState(state);
if (!outboundActive && number !== undefined) {
@@ -92,22 +81,13 @@ export function connectSip(config: SIPConfig): void {
sipClient.connect();
}
export function disconnectSip(force = false): void {
// Guard: don't disconnect SIP during an active or pending call
// unless explicitly forced (e.g., logout, page unload).
// This prevents React re-render cycles from killing the
// SIP WebSocket mid-dial.
if (!force && (outboundPending || outboundActive)) {
console.log('[SIP-MGR] Disconnect blocked — call in progress');
return;
}
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
export function disconnectSip(): void {
console.log('[SIP-MGR] Disconnecting SIP');
sipClient?.disconnect();
sipClient = null;
connected = false;
outboundPending = false;
outboundActive = false;
activeAgentId = null;
stateUpdater?.setConnectionStatus('disconnected');
stateUpdater?.setCallUcid(null);
}