mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +00:00
Compare commits
11 Commits
master
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 113b5a9277 | |||
| eadfa68aaa | |||
| 5a24bbde0a | |||
| 636badfa31 | |||
| ee9da619c1 | |||
| 42d1a03f9d | |||
| d19ca4f593 | |||
| 24b4e01292 | |||
| d730cda06d | |||
| af9657eaab | |||
| 38aacc374e |
@@ -1,5 +1,9 @@
|
|||||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
|
# EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
|
||||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
|
# on the same domain, so VITE_API_URL is empty (same-origin).
|
||||||
|
VITE_API_URL=
|
||||||
|
|
||||||
|
# SIP defaults — used as fallback if login response doesn't include agent config.
|
||||||
|
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
|
||||||
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
|
||||||
VITE_SIP_PASSWORD=523590
|
VITE_SIP_PASSWORD=523590
|
||||||
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444
|
||||||
|
|||||||
@@ -36,38 +36,35 @@ Docker Compose stack (EC2 — 13.234.31.194):
|
|||||||
## EC2 Access
|
## EC2 Access
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SSH into EC2
|
# SSH into EC2 (key passphrase handled by sshpass)
|
||||||
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||||
|
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||||
```
|
```
|
||||||
|
|
||||||
| Detail | Value |
|
| Detail | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Host | `13.234.31.194` |
|
| Host | `13.234.31.194` |
|
||||||
| User | `ubuntu` |
|
| User | `ubuntu` |
|
||||||
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
|
| SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
|
||||||
|
| Passphrase | `SasiSuman@2007` |
|
||||||
| Docker compose dir | `/opt/fortytwo` |
|
| Docker compose dir | `/opt/fortytwo` |
|
||||||
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
||||||
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
||||||
|
|
||||||
### SSH Key Setup
|
### SSH Helper
|
||||||
|
|
||||||
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
|
The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
|
||||||
Create a decrypted copy for non-interactive use:
|
No need to decrypt or copy the key — use the original file directly.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-time setup
|
# SSH shorthand
|
||||||
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
|
EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
chmod 600 /tmp/ramaiah-ec2-key
|
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
|
eval $EC2_SSH hostname
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handy alias
|
> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
|
||||||
|
|
||||||
```bash
|
|
||||||
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -155,29 +152,34 @@ REDIS_URL=redis://localhost:6379
|
|||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Helper — reuse in all commands below
|
||||||
|
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||||
|
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
cd helix-engage && npm run build
|
cd helix-engage && npm run build
|
||||||
|
|
||||||
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \
|
rsync -avz -e "$EC2_RSYNC" \
|
||||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
||||||
|
|
||||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||||
"cd /opt/fortytwo && sudo docker compose restart caddy"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sidecar (quick — code only, no new dependencies)
|
### Sidecar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd helix-engage-server
|
cd helix-engage-server
|
||||||
|
|
||||||
|
# 1. Login to ECR
|
||||||
aws ecr get-login-password --region ap-south-1 | \
|
aws ecr get-login-password --region ap-south-1 | \
|
||||||
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||||
|
|
||||||
|
# 2. Build and push Docker image
|
||||||
docker buildx build --platform linux/amd64 \
|
docker buildx build --platform linux/amd64 \
|
||||||
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
||||||
--push .
|
--push .
|
||||||
|
|
||||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
# 3. Pull and restart on EC2
|
||||||
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||||
```
|
```
|
||||||
|
|
||||||
### How to decide
|
### How to decide
|
||||||
|
|||||||
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
File diff suppressed because it is too large
Load Diff
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# Supervisor Barge / Whisper / Listen — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-12
|
||||||
|
**Branch:** `feature/barge-whisper`
|
||||||
|
**Prereq:** QA validates barge flow in Ozonetel's own admin UI first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Enable supervisors to monitor and intervene in live agent calls directly from Helix Engage's live monitor. Three modes: **Listen** (silent), **Whisper** (agent hears supervisor, patient doesn't), **Barge** (both hear supervisor). Supervisor connects via SIP WebRTC in the browser. Mode switching via DTMF tones.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Connection method | SIP only (PSTN later) | Supervisors are already on browser with headset |
|
||||||
|
| Agent indicator | Whisper/barge only (listen is silent) | Spec says show indicator; listen should be undetectable |
|
||||||
|
| SIP number | Dynamic from Ozonetel pool (apiId 139) | No need to pre-assign per supervisor. 3 SIP IDs available. |
|
||||||
|
| Barge UI location | Live monitor + context panel + barge controls | Supervisor needs call context to intervene effectively |
|
||||||
|
| Access control | Any admin can barge any agent | Flat RBAC, no team hierarchy |
|
||||||
|
| Call end behavior | Auto-disconnect supervisor | No orphaned sessions |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Supervisor Browser │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌────────────────────────────────┐ │
|
||||||
|
│ │ Live Monitor │ │ Context Panel + Barge Controls│ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Agent list │ │ Patient summary / AI insight │ │
|
||||||
|
│ │ Active calls │──│ Appointments / Recent calls │ │
|
||||||
|
│ │ Click → │ │ ─────────────────────────────│ │
|
||||||
|
│ │ │ │ [Connect] │ │
|
||||||
|
│ │ │ │ [Listen] [Whisper] [Barge] │ │
|
||||||
|
│ │ │ │ [Hang up] │ │
|
||||||
|
│ └──────────────┘ └────────────────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ poll /active-calls │ SIP WebRTC (kSip) │
|
||||||
|
│ │ every 5s │ DTMF 4/5/6 │
|
||||||
|
└─────────┼───────────────────────┼────────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Sidecar │ │ Ozonetel SIP Gateway │
|
||||||
|
│ │ │ (blr-pub-rtc4.ozonetel) │
|
||||||
|
│ POST /api/supervisor│ │ │
|
||||||
|
│ /barge │ │ SIP INVITE → supervisor │
|
||||||
|
│ /barge-mode │ │ audio mixing │
|
||||||
|
│ │ │ DTMF routing │
|
||||||
|
│ → Ozonetel admin API│ └──────────────────────────┘
|
||||||
|
│ dashboardApi │
|
||||||
|
│ apiId 63, 139 │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Ozonetel Cloud │
|
||||||
|
│ api.cloudagent. │
|
||||||
|
│ ozonetel.com │
|
||||||
|
│ │
|
||||||
|
│ /dashboardApi/ │
|
||||||
|
│ monitor/api │
|
||||||
|
│ apiId 63 → barge │
|
||||||
|
│ apiId 139 → SIP# │
|
||||||
|
│ /auth/login → JWT │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Sidecar — Ozonetel Admin Auth Service
|
||||||
|
|
||||||
|
**New file:** `src/ozonetel/ozonetel-admin-auth.service.ts`
|
||||||
|
|
||||||
|
Manages a persistent Ozonetel admin session for supervisor APIs. Credentials from TelephonyConfig.
|
||||||
|
|
||||||
|
**Config extension** (`telephony.defaults.ts`):
|
||||||
|
```typescript
|
||||||
|
ozonetel: {
|
||||||
|
// ...existing fields
|
||||||
|
adminUsername: string; // NEW
|
||||||
|
adminPassword: string; // NEW
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. On startup, read `adminUsername` + `adminPassword` from TelephonyConfig
|
||||||
|
2. `GET /api/auth/public-key` → `{ publicKey, keyId }`
|
||||||
|
3. RSA-encrypt credentials using `jsencrypt`
|
||||||
|
4. `POST /auth/login` → JWT token
|
||||||
|
5. Cache token in memory, decode expiry via `jwt-decode`
|
||||||
|
6. Auto-refresh before expiry
|
||||||
|
7. Expose `getAuthHeaders()` for other services
|
||||||
|
|
||||||
|
**Auth headers for all admin API calls:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${jwt}`,
|
||||||
|
'userId': userId,
|
||||||
|
'userName': userName,
|
||||||
|
'isSuperAdmin': 'true',
|
||||||
|
'dAccessType': 'false'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Sidecar — Supervisor Barge Endpoints
|
||||||
|
|
||||||
|
**New file:** `src/supervisor/supervisor-barge.controller.ts`
|
||||||
|
|
||||||
|
Three endpoints proxying to Ozonetel admin API:
|
||||||
|
|
||||||
|
#### `POST /api/supervisor/barge`
|
||||||
|
|
||||||
|
Initiates barge-in on an active call.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request
|
||||||
|
{ ucid: string, agentNumber: string }
|
||||||
|
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||||
|
{
|
||||||
|
apiId: 63,
|
||||||
|
ucid: "<ucid>",
|
||||||
|
action: "CALL_BARGEIN",
|
||||||
|
isSip: true,
|
||||||
|
phoneno: "<dynamic SIP number from pool>",
|
||||||
|
agentNumber: "<agent phone>",
|
||||||
|
cbURL: "<sidecar hostname>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ status: "success", sipNumber: "19810", sipPassword: "19810", sipDomain: "blr-sbc1.ozonetel.com", sipPort: "442" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Before calling barge, fetches an available SIP number:
|
||||||
|
|
||||||
|
#### `GET /api/supervisor/barge/sip-credentials`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI/endpoint/sipnumber/sipSubscribe
|
||||||
|
{ apiId: 139, sipURL: "<sip gateway>" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{ sip_number: "19810", password: "19810", pop_location: "blr-sbc1.ozonetel.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/supervisor/barge/end`
|
||||||
|
|
||||||
|
Cleanup: disconnect SIP, clear Redis tracking.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request
|
||||||
|
{ agentId: string, sipId: string }
|
||||||
|
|
||||||
|
// Sidecar calls:
|
||||||
|
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||||
|
{ apiId: 158, Action: "delete", AgentId: "<agentId>", Sip: "<sipId>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend — Supervisor SIP Client
|
||||||
|
|
||||||
|
**New file:** `src/lib/supervisor-sip-client.ts`
|
||||||
|
|
||||||
|
Lightweight SIP client for supervisor barge sessions. Modeled on Ozonetel's `kSip.tsx` — separate from the agent's `sip-client.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type SupervisorSipClient = {
|
||||||
|
init(domain: string, port: string, number: string, password: string): void;
|
||||||
|
register(): void;
|
||||||
|
isRegistered(): boolean;
|
||||||
|
isCallActive(): boolean;
|
||||||
|
sendDTMF(digit: string): void; // "4"=listen, "5"=whisper, "6"=barge
|
||||||
|
hangup(): void;
|
||||||
|
close(): void;
|
||||||
|
on(event: string, callback: Function): void;
|
||||||
|
off(event: string, callback: Function): void;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Events emitted:**
|
||||||
|
- `registered` — SIP registration successful
|
||||||
|
- `registrationFailed` — SIP registration error
|
||||||
|
- `callReceived` — incoming call from Ozonetel (auto-answer)
|
||||||
|
- `callConnected` — barge session active
|
||||||
|
- `callEnded` — call terminated (agent hung up or supervisor hung up)
|
||||||
|
|
||||||
|
**Audio:** Remote audio plays through a hidden `<audio>` element (same pattern as agent SIP). Supervisor's microphone is captured via `getUserMedia`.
|
||||||
|
|
||||||
|
**DTMF mode mapping:**
|
||||||
|
- `"4"` → Listen (supervisor hears all, nobody hears supervisor)
|
||||||
|
- `"5"` → Whisper/Training (agent hears supervisor, patient doesn't)
|
||||||
|
- `"6"` → Barge (both hear supervisor)
|
||||||
|
|
||||||
|
### 4. Frontend — Live Monitor Redesign
|
||||||
|
|
||||||
|
**Modified file:** `src/pages/live-monitor.tsx`
|
||||||
|
|
||||||
|
Current: full-width table with disabled barge buttons.
|
||||||
|
New: split layout — call list on the left, context panel + barge controls on the right.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┬──────────────────────────────┐
|
||||||
|
│ Active Calls (left, 60%) │ Context + Barge (right, 40%)│
|
||||||
|
│ │ │
|
||||||
|
│ ┌─ KPI cards ────────────┐ │ (nothing selected) │
|
||||||
|
│ │ Active: 3 Hold: 1 │ │ "Select a call to monitor" │
|
||||||
|
│ └────────────────────────┘ │ │
|
||||||
|
│ │ ── OR ── │
|
||||||
|
│ ┌─ Table ────────────────┐ │ │
|
||||||
|
│ │ Agent Caller Type Dur│ │ ┌─ Patient Summary ───────┐ │
|
||||||
|
│ │ rekha +9180.. In 2:34│ │ │ Name / Phone / Type │ │
|
||||||
|
│ │ ▶ selected row │ │ │ AI Insight │ │
|
||||||
|
│ │ ganesh +9199.. Out 0:45│ │ │ Appointments │ │
|
||||||
|
│ └────────────────────────┘ │ │ Recent calls │ │
|
||||||
|
│ │ └─────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ┌─ Barge Controls ───────┐ │
|
||||||
|
│ │ │ [Connect] │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ (after connect:) │ │
|
||||||
|
│ │ │ [Listen] [Whisper] [Barge]│
|
||||||
|
│ │ │ status: Connected 1:23 │ │
|
||||||
|
│ │ │ [Hang up] │ │
|
||||||
|
│ │ └─────────────────────────┘ │
|
||||||
|
└─────────────────────────────┴──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selection flow:**
|
||||||
|
1. Supervisor clicks a call row → row highlights
|
||||||
|
2. Right panel populates with caller context (fetched from platform via lead phone match)
|
||||||
|
3. "Connect" button becomes active
|
||||||
|
4. Click Connect → sidecar fetches SIP credentials → calls barge API → supervisor SIP client registers → auto-answers incoming call
|
||||||
|
5. Status: CONNECTING → CONNECTED
|
||||||
|
6. Mode tabs appear: Listen (default) / Whisper / Barge
|
||||||
|
7. Tab click sends DTMF tone via supervisor SIP client
|
||||||
|
8. Hang up → disconnect SIP, clean up, right panel resets
|
||||||
|
|
||||||
|
### 5. Frontend — Agent Barge Indicator
|
||||||
|
|
||||||
|
**Modified file:** `src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
When supervisor switches to whisper or barge mode, the agent sees an indicator.
|
||||||
|
|
||||||
|
**Detection:** The sidecar's supervisor service emits SSE events. Add a new event type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// New SSE event from /api/supervisor/agent-state/stream
|
||||||
|
{ state: "supervisor-whisper", timestamp: "..." }
|
||||||
|
{ state: "supervisor-barge", timestamp: "..." }
|
||||||
|
{ state: "supervisor-left", timestamp: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI:** Small badge on the active call card:
|
||||||
|
- Whisper mode: "Supervisor coaching" badge (blue)
|
||||||
|
- Barge mode: "Supervisor on call" badge (brand)
|
||||||
|
- Listen mode: no indicator (silent)
|
||||||
|
|
||||||
|
**Implementation:** The sidecar tracks barge state per agent. When a supervisor connects and switches mode, the sidecar emits the appropriate SSE event to the agent's stream. The agent's `use-agent-state.ts` hook picks it up and sets a Recoil atom. The `active-call-card.tsx` renders the badge conditionally.
|
||||||
|
|
||||||
|
### 6. Sidecar — Barge State Tracking
|
||||||
|
|
||||||
|
**Modified file:** `src/supervisor/supervisor.service.ts`
|
||||||
|
|
||||||
|
Track which supervisor is barged into which agent, and in what mode.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type BargeSession = {
|
||||||
|
supervisorId: string;
|
||||||
|
agentId: string;
|
||||||
|
sipNumber: string;
|
||||||
|
mode: 'listen' | 'whisper' | 'barge';
|
||||||
|
startedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory map (single sidecar per hospital)
|
||||||
|
private readonly bargeSessions = new Map<string, BargeSession>();
|
||||||
|
```
|
||||||
|
|
||||||
|
When mode changes, emit SSE event to the agent:
|
||||||
|
- `listen` → no event (silent)
|
||||||
|
- `whisper` → emit `supervisor-whisper` to agent's SSE stream
|
||||||
|
- `barge` → emit `supervisor-barge` to agent's SSE stream
|
||||||
|
- disconnect → emit `supervisor-left` to agent's SSE stream
|
||||||
|
|
||||||
|
**New endpoint for mode update:**
|
||||||
|
```typescript
|
||||||
|
POST /api/supervisor/barge/mode
|
||||||
|
{ agentId: string, mode: "listen" | "whisper" | "barge" }
|
||||||
|
```
|
||||||
|
|
||||||
|
This updates the in-memory session and emits the SSE event. The actual audio routing happens via DTMF on the SIP connection (frontend handles that).
|
||||||
|
|
||||||
|
## Data Flow — Full Barge Sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Supervisor clicks call row in live monitor
|
||||||
|
└→ Frontend fetches caller context from platform (lead by phone match)
|
||||||
|
└→ Right panel shows patient summary
|
||||||
|
|
||||||
|
2. Supervisor clicks "Connect"
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/sip-credentials
|
||||||
|
└→ Sidecar: calls Ozonetel apiId 139 → gets SIP number/password/domain
|
||||||
|
└→ Frontend: initializes supervisor-sip-client with credentials
|
||||||
|
└→ Frontend: POST /api/supervisor/barge { ucid, agentNumber }
|
||||||
|
└→ Sidecar: calls Ozonetel apiId 63 (CALL_BARGEIN, isSip: true)
|
||||||
|
└→ Ozonetel: bridges SIP number into active call
|
||||||
|
└→ Supervisor SIP client receives incoming call → auto-answers
|
||||||
|
└→ Status: CONNECTED, default mode: Listen (DTMF "4" sent)
|
||||||
|
└→ Sidecar: creates BargeSession in memory
|
||||||
|
|
||||||
|
3. Supervisor clicks "Whisper" tab
|
||||||
|
└→ Frontend: supervisor-sip-client.sendDTMF("5")
|
||||||
|
└→ Ozonetel: routes supervisor audio to agent only
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "whisper" }
|
||||||
|
└→ Sidecar: emits SSE { state: "supervisor-whisper" } to agent
|
||||||
|
└→ Agent: sees "Supervisor coaching" badge
|
||||||
|
|
||||||
|
4. Supervisor clicks "Barge" tab
|
||||||
|
└→ Frontend: supervisor-sip-client.sendDTMF("6")
|
||||||
|
└→ Ozonetel: routes supervisor audio to both
|
||||||
|
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "barge" }
|
||||||
|
└→ Sidecar: emits SSE { state: "supervisor-barge" } to agent
|
||||||
|
└→ Agent: sees "Supervisor on call" badge
|
||||||
|
|
||||||
|
5. Call ends (agent or patient hangs up)
|
||||||
|
└→ Supervisor SIP client: "callEnded" event fires
|
||||||
|
└→ Frontend: auto-disconnects, calls POST /api/supervisor/barge/end
|
||||||
|
└→ Sidecar: clears BargeSession, emits SSE { state: "supervisor-left" }
|
||||||
|
└→ Agent: badge disappears
|
||||||
|
└→ UI: right panel resets to "Select a call to monitor"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` | Ozonetel admin JWT management |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` | Barge proxy endpoints |
|
||||||
|
| `helix-engage/src/lib/supervisor-sip-client.ts` | Supervisor SIP client (modeled on kSip) |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `helix-engage-server/src/config/telephony.defaults.ts` | Add `adminUsername`, `adminPassword` |
|
||||||
|
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Add barge session tracking + SSE events |
|
||||||
|
| `helix-engage/src/pages/live-monitor.tsx` | Split layout, context panel, barge controls |
|
||||||
|
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Supervisor indicator badge |
|
||||||
|
| `helix-engage/src/hooks/use-agent-state.ts` | Handle supervisor SSE events |
|
||||||
|
| `helix-engage/src/components/setup/wizard-step-telephony.tsx` | Add admin credential fields |
|
||||||
|
|
||||||
|
### Reference Files (from Ozonetel source — study, don't copy)
|
||||||
|
| File | What to learn |
|
||||||
|
|------|--------------|
|
||||||
|
| `CA-Admin/.../BargeInDrawer/BargeInDrawer.tsx` | Normal barge flow, status states |
|
||||||
|
| `CA-Admin/.../BargeinDrawerSip/BargeinDrawerSip.tsx` | SIP barge, DTMF, continuous barge, session storage |
|
||||||
|
| `CA-Admin/.../utils/ksip.tsx` | SIP client wrapper pattern |
|
||||||
|
| `CA-Admin/.../services/api-service.ts:827-890` | Barge API payloads |
|
||||||
|
| `CA-Admin/.../services/auth-service.ts` | Admin auth flow |
|
||||||
|
| `cloudagent/.../services/websocket.service.js:367-460` | Agent-side barge event handling |
|
||||||
|
|
||||||
|
## Testing Plan
|
||||||
|
|
||||||
|
1. **Prereq:** QA validates barge in Ozonetel's own admin UI with the 3 SIP IDs
|
||||||
|
2. **Sidecar unit tests:** Admin auth service (login, token refresh, expiry)
|
||||||
|
3. **Sidecar integration test:** Barge endpoint → Ozonetel API (mock or live)
|
||||||
|
4. **Frontend manual test:** Connect → listen → whisper → barge → hang up
|
||||||
|
5. **Agent indicator test:** Verify badge appears on whisper/barge, disappears on listen/disconnect
|
||||||
|
6. **Auto-disconnect test:** Agent ends call → supervisor auto-disconnects
|
||||||
|
7. **Edge cases:** Supervisor navigates away mid-barge, network drop, agent goes to ACW
|
||||||
|
|
||||||
|
## Out of Scope (Future)
|
||||||
|
|
||||||
|
- PSTN barge (call supervisor's phone instead of SIP)
|
||||||
|
- Continuous barge (auto-reconnect to next call same agent handles)
|
||||||
|
- Barge audit logging (who barged whom, when, duration)
|
||||||
|
- Gemini AI whisper (separate feature, separate branch)
|
||||||
|
- Multi-supervisor on same call
|
||||||
@@ -18,6 +18,7 @@ import { EnquiryForm } from './enquiry-form';
|
|||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import type { Lead, CallDisposition } from '@/types/entities';
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
@@ -49,6 +50,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||||
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
||||||
|
|
||||||
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
|
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||||
|
|
||||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const wasAnsweredRef = useRef(callState === 'active');
|
||||||
|
|
||||||
@@ -235,7 +240,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
<div className="flex items-center gap-2">
|
||||||
|
{supervisorPresence === 'whisper' && (
|
||||||
|
<Badge size="sm" color="blue" type="pill-color">Supervisor coaching</Badge>
|
||||||
|
)}
|
||||||
|
{supervisorPresence === 'barge' && (
|
||||||
|
<Badge size="sm" color="brand" type="pill-color">Supervisor on call</Badge>
|
||||||
|
)}
|
||||||
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Call controls */}
|
{/* Call controls */}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type AgentStatusToggleProps = {
|
|||||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
||||||
const ozonetelState = useAgentState(agentId);
|
const { state: ozonetelState } = useAgentState(agentId);
|
||||||
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [changing, setChanging] = useState(false);
|
const [changing, setChanging] = useState(false);
|
||||||
|
|||||||
225
src/components/call-desk/barge-controls.tsx
Normal file
225
src/components/call-desk/barge-controls.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { supervisorSip } from '@/lib/supervisor-sip-client';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const HangupIcon = faIcon(faPhoneHangup);
|
||||||
|
const HeadsetIcon = faIcon(faHeadset);
|
||||||
|
|
||||||
|
type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended';
|
||||||
|
type BargeMode = 'listen' | 'whisper' | 'barge';
|
||||||
|
|
||||||
|
const MODE_DTMF: Record<BargeMode, string> = { listen: '4', whisper: '5', barge: '6' };
|
||||||
|
|
||||||
|
const MODE_CONFIG: Record<BargeMode, {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: any;
|
||||||
|
activeClass: string;
|
||||||
|
}> = {
|
||||||
|
listen: {
|
||||||
|
label: 'Listen',
|
||||||
|
description: 'Silent monitoring — nobody knows you are here',
|
||||||
|
icon: faHeadset,
|
||||||
|
activeClass: 'border-secondary bg-secondary',
|
||||||
|
},
|
||||||
|
whisper: {
|
||||||
|
label: 'Whisper',
|
||||||
|
description: 'Only the agent can hear you',
|
||||||
|
icon: faCommentDots,
|
||||||
|
activeClass: 'border-brand bg-brand-primary',
|
||||||
|
},
|
||||||
|
barge: {
|
||||||
|
label: 'Barge',
|
||||||
|
description: 'Both agent and patient can hear you',
|
||||||
|
icon: faUsers,
|
||||||
|
activeClass: 'border-error bg-error-primary',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type BargeControlsProps = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
agentNumber: string;
|
||||||
|
agentName: string;
|
||||||
|
onDisconnected?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BargeControls = ({ ucid, agentId, agentNumber, agentName, onDisconnected }: BargeControlsProps) => {
|
||||||
|
const [status, setStatus] = useState<BargeStatus>('idle');
|
||||||
|
const [mode, setMode] = useState<BargeMode>('listen');
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const connectedAtRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Duration counter
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'connected') return;
|
||||||
|
connectedAtRef.current = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (supervisorSip.isCallActive()) {
|
||||||
|
supervisorSip.close();
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
setStatus('connecting');
|
||||||
|
setMode('listen');
|
||||||
|
setDuration(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.post<{
|
||||||
|
sipNumber: string;
|
||||||
|
sipPassword: string;
|
||||||
|
sipDomain: string;
|
||||||
|
sipPort: string;
|
||||||
|
}>('/api/supervisor/barge', { ucid, agentId, agentNumber });
|
||||||
|
|
||||||
|
supervisorSip.on('registered', () => {
|
||||||
|
// Ozonetel will send incoming call after SIP registration
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callConnected', () => {
|
||||||
|
setStatus('connected');
|
||||||
|
supervisorSip.sendDTMF('4'); // default: listen mode
|
||||||
|
notify.success('Connected', `Monitoring ${agentName}'s call`);
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callEnded', () => {
|
||||||
|
setStatus('ended');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
onDisconnected?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('callFailed', (cause: string) => {
|
||||||
|
setStatus('ended');
|
||||||
|
notify.error('Connection Failed', cause ?? 'Could not connect to call');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.on('registrationFailed', (cause: string) => {
|
||||||
|
setStatus('ended');
|
||||||
|
notify.error('SIP Registration Failed', cause ?? 'Could not register');
|
||||||
|
});
|
||||||
|
|
||||||
|
supervisorSip.init({
|
||||||
|
domain: result.sipDomain,
|
||||||
|
port: result.sipPort,
|
||||||
|
number: result.sipNumber,
|
||||||
|
password: result.sipPassword,
|
||||||
|
});
|
||||||
|
supervisorSip.register();
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('idle');
|
||||||
|
notify.error('Barge Failed', err.message ?? 'Could not initiate barge');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModeChange = (newMode: BargeMode) => {
|
||||||
|
if (newMode === mode) return;
|
||||||
|
supervisorSip.sendDTMF(MODE_DTMF[newMode]);
|
||||||
|
setMode(newMode);
|
||||||
|
apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHangup = () => {
|
||||||
|
supervisorSip.close();
|
||||||
|
setStatus('ended');
|
||||||
|
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||||
|
onDisconnected?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (sec: number) => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idle / ended state
|
||||||
|
if (status === 'idle' || status === 'ended') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-secondary">{status === 'ended' ? 'Session ended' : 'Ready to monitor'}</p>
|
||||||
|
<Button size="sm" color="primary" iconLeading={HeadsetIcon} onClick={handleConnect}>
|
||||||
|
{status === 'ended' ? 'Reconnect' : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connecting state
|
||||||
|
if (status === 'connecting') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2 animate-pulse rounded-full bg-warning-solid" />
|
||||||
|
<span className="text-sm font-medium text-warning-primary">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-tertiary">Registering SIP and joining call</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected state
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-success-primary px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-success-solid" />
|
||||||
|
<span className="text-xs font-semibold text-success-primary">Connected</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-success-primary">{formatDuration(duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => {
|
||||||
|
const config = MODE_CONFIG[m];
|
||||||
|
const isActive = mode === m;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => handleModeChange(m)}
|
||||||
|
className={cx(
|
||||||
|
'flex flex-1 flex-col items-center gap-1 rounded-lg border-2 px-2 py-2.5 text-center transition duration-100 ease-linear',
|
||||||
|
isActive ? config.activeClass : 'border-secondary hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={config.icon}
|
||||||
|
className={cx('size-4', isActive ? 'text-fg-primary' : 'text-fg-quaternary')}
|
||||||
|
/>
|
||||||
|
<span className={cx('text-xs font-semibold', isActive ? 'text-primary' : 'text-tertiary')}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode description */}
|
||||||
|
<p className="text-center text-xs text-tertiary">{MODE_CONFIG[mode].description}</p>
|
||||||
|
|
||||||
|
{/* Hang up */}
|
||||||
|
<Button size="sm" color="primary-destructive" iconLeading={HangupIcon} onClick={handleHangup} className="w-full">
|
||||||
|
Hang Up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,6 +17,8 @@ export type TelephonyFormValues = {
|
|||||||
did: string;
|
did: string;
|
||||||
sipId: string;
|
sipId: string;
|
||||||
campaignName: string;
|
campaignName: string;
|
||||||
|
adminUsername: string;
|
||||||
|
adminPassword: string;
|
||||||
};
|
};
|
||||||
sip: {
|
sip: {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -37,6 +39,8 @@ export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
|
|||||||
did: '',
|
did: '',
|
||||||
sipId: '',
|
sipId: '',
|
||||||
campaignName: '',
|
campaignName: '',
|
||||||
|
adminUsername: '',
|
||||||
|
adminPassword: '',
|
||||||
},
|
},
|
||||||
sip: {
|
sip: {
|
||||||
domain: 'blr-pub-rtc4.ozonetel.com',
|
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||||
@@ -108,6 +112,27 @@ export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
|
|||||||
value={value.ozonetel.campaignName}
|
value={value.ozonetel.campaignName}
|
||||||
onChange={(v) => patchOzonetel({ campaignName: v })}
|
onChange={(v) => patchOzonetel({ campaignName: v })}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
|
||||||
|
<p className="mt-0.5 text-xs text-tertiary">
|
||||||
|
Ozonetel portal admin credentials — required for supervisor barge/whisper/listen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input
|
||||||
|
label="Admin username"
|
||||||
|
placeholder="Ozonetel portal admin login"
|
||||||
|
value={value.ozonetel.adminUsername}
|
||||||
|
onChange={(v) => patchOzonetel({ adminUsername: v })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Admin password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave '***masked***' to keep current"
|
||||||
|
value={value.ozonetel.adminPassword}
|
||||||
|
onChange={(v) => patchOzonetel({ adminPassword: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-4">
|
<section className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
||||||
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const ozonetelState = useAgentState(agentId);
|
const { state: ozonetelState } = useAgentState(agentId);
|
||||||
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
||||||
|
|
||||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
export type SupervisorPresence = 'none' | 'whisper' | 'barge';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
export const useAgentState = (agentId: string | null): OzonetelState => {
|
export const useAgentState = (agentId: string | null): { state: OzonetelState; supervisorPresence: SupervisorPresence } => {
|
||||||
const [state, setState] = useState<OzonetelState>('offline');
|
const [state, setState] = useState<OzonetelState>('offline');
|
||||||
|
const [supervisorPresence, setSupervisorPresence] = useState<SupervisorPresence>('none');
|
||||||
const prevStateRef = useRef<OzonetelState>('offline');
|
const prevStateRef = useRef<OzonetelState>('offline');
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
@@ -56,6 +58,20 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supervisor presence events — don't replace agent state
|
||||||
|
if (data.state === 'supervisor-whisper') {
|
||||||
|
setSupervisorPresence('whisper');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.state === 'supervisor-barge') {
|
||||||
|
setSupervisorPresence('barge');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.state === 'supervisor-left') {
|
||||||
|
setSupervisorPresence('none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
prevStateRef.current = data.state;
|
prevStateRef.current = data.state;
|
||||||
setState(data.state);
|
setState(data.state);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -74,5 +90,5 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
|||||||
};
|
};
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
return state;
|
return { state, supervisorPresence };
|
||||||
};
|
};
|
||||||
|
|||||||
196
src/lib/supervisor-sip-client.ts
Normal file
196
src/lib/supervisor-sip-client.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import JsSIP from 'jssip';
|
||||||
|
type RTCSession = any;
|
||||||
|
|
||||||
|
// Lightweight SIP client for supervisor barge sessions.
|
||||||
|
// Separate from the agent's sip-client.ts — different lifecycle.
|
||||||
|
// Modeled on Ozonetel's kSip utility (CA-Admin/.../utils/ksip.tsx).
|
||||||
|
//
|
||||||
|
// DTMF mode mapping (from Ozonetel CA-Admin BargeinDrawerSip.tsx):
|
||||||
|
// "4" → Listen (supervisor hears all, nobody hears supervisor)
|
||||||
|
// "5" → Whisper/Training (agent hears supervisor, patient doesn't)
|
||||||
|
// "6" → Barge (both hear supervisor)
|
||||||
|
|
||||||
|
type EventCallback = (...args: any[]) => void;
|
||||||
|
type SupervisorSipEvent =
|
||||||
|
| 'registered'
|
||||||
|
| 'registrationFailed'
|
||||||
|
| 'callReceived'
|
||||||
|
| 'callConnected'
|
||||||
|
| 'callEnded'
|
||||||
|
| 'callFailed';
|
||||||
|
|
||||||
|
type SupervisorSipConfig = {
|
||||||
|
domain: string;
|
||||||
|
port: string;
|
||||||
|
number: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SupervisorSipClient {
|
||||||
|
private ua: JsSIP.UA | null = null;
|
||||||
|
private session: RTCSession | null = null;
|
||||||
|
private listeners = new Map<string, Set<EventCallback>>();
|
||||||
|
private audioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
|
init(config: SupervisorSipConfig): void {
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
// Hidden audio element for remote call audio
|
||||||
|
this.audioElement = document.createElement('audio');
|
||||||
|
this.audioElement.id = 'supervisor-remote-audio';
|
||||||
|
this.audioElement.autoplay = true;
|
||||||
|
this.audioElement.setAttribute('playsinline', '');
|
||||||
|
document.body.appendChild(this.audioElement);
|
||||||
|
|
||||||
|
const socketUrl = `wss://${config.domain}:${config.port}`;
|
||||||
|
const socket = new JsSIP.WebSocketInterface(socketUrl);
|
||||||
|
|
||||||
|
this.ua = new JsSIP.UA({
|
||||||
|
sockets: [socket],
|
||||||
|
uri: `sip:${config.number}@${config.domain}`,
|
||||||
|
password: config.password,
|
||||||
|
registrar_server: `sip:${config.domain}`,
|
||||||
|
authorization_user: config.number,
|
||||||
|
session_timers: false,
|
||||||
|
register: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ua.on('registered', () => {
|
||||||
|
console.log('[SupervisorSIP] Registered');
|
||||||
|
this.emit('registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ua.on('registrationFailed', (e: any) => {
|
||||||
|
console.error('[SupervisorSIP] Registration failed:', e?.cause);
|
||||||
|
this.emit('registrationFailed', e?.cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ua.on('newRTCSession', (data: any) => {
|
||||||
|
const rtcSession = data.session as RTCSession;
|
||||||
|
if (rtcSession.direction !== 'incoming') return;
|
||||||
|
|
||||||
|
console.log('[SupervisorSIP] Incoming call — auto-answering');
|
||||||
|
this.session = rtcSession;
|
||||||
|
this.emit('callReceived');
|
||||||
|
|
||||||
|
rtcSession.on('accepted', () => {
|
||||||
|
console.log('[SupervisorSIP] Call accepted');
|
||||||
|
this.emit('callConnected');
|
||||||
|
});
|
||||||
|
|
||||||
|
rtcSession.on('confirmed', () => {
|
||||||
|
// Attach remote audio stream
|
||||||
|
const connection = rtcSession.connection;
|
||||||
|
if (connection && this.audioElement) {
|
||||||
|
// Modern browsers: track event
|
||||||
|
connection.addEventListener('track', (event: RTCTrackEvent) => {
|
||||||
|
if (event.streams[0] && this.audioElement) {
|
||||||
|
this.audioElement.srcObject = event.streams[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Fallback: getRemoteStreams (older browsers/JsSIP versions)
|
||||||
|
const remoteStreams = (connection as any).getRemoteStreams?.();
|
||||||
|
if (remoteStreams?.[0] && this.audioElement) {
|
||||||
|
this.audioElement.srcObject = remoteStreams[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rtcSession.on('ended', () => {
|
||||||
|
console.log('[SupervisorSIP] Call ended');
|
||||||
|
this.session = null;
|
||||||
|
this.emit('callEnded');
|
||||||
|
});
|
||||||
|
|
||||||
|
rtcSession.on('failed', (e: any) => {
|
||||||
|
console.error('[SupervisorSIP] Call failed:', e?.cause);
|
||||||
|
this.session = null;
|
||||||
|
this.emit('callFailed', e?.cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-answer with audio
|
||||||
|
rtcSession.answer({
|
||||||
|
mediaConstraints: { audio: true, video: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ua.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(): void {
|
||||||
|
this.ua?.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
isRegistered(): boolean {
|
||||||
|
return this.ua?.isRegistered() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCallActive(): boolean {
|
||||||
|
return this.session?.isEstablished() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendDTMF(digit: string): void {
|
||||||
|
if (!this.session?.isEstablished()) {
|
||||||
|
console.warn('[SupervisorSIP] Cannot send DTMF — no active session');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[SupervisorSIP] Sending DTMF: ${digit}`);
|
||||||
|
this.session.sendDTMF(digit, {
|
||||||
|
duration: 160,
|
||||||
|
interToneGap: 1200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hangup(): void {
|
||||||
|
if (this.session) {
|
||||||
|
try {
|
||||||
|
this.session.terminate();
|
||||||
|
} catch {
|
||||||
|
// Session may already be ended
|
||||||
|
}
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.hangup();
|
||||||
|
if (this.ua) {
|
||||||
|
try {
|
||||||
|
this.ua.unregister({ all: true });
|
||||||
|
this.ua.stop();
|
||||||
|
} catch {
|
||||||
|
// UA may already be stopped
|
||||||
|
}
|
||||||
|
this.ua = null;
|
||||||
|
}
|
||||||
|
this.cleanup();
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: SupervisorSipEvent, callback: EventCallback): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: SupervisorSipEvent, callback: EventCallback): void {
|
||||||
|
this.listeners.get(event)?.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: string, ...args: any[]): void {
|
||||||
|
this.listeners.get(event)?.forEach(cb => {
|
||||||
|
try { cb(...args); } catch (e) { console.error(`[SupervisorSIP] Event error [${event}]:`, e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
if (this.audioElement) {
|
||||||
|
this.audioElement.srcObject = null;
|
||||||
|
this.audioElement.remove();
|
||||||
|
this.audioElement = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supervisorSip = new SupervisorSipClient();
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons';
|
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { BargeControls } from '@/components/call-desk/barge-controls';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type ActiveCall = {
|
type ActiveCall = {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
@@ -17,6 +19,18 @@ type ActiveCall = {
|
|||||||
status: 'active' | 'on-hold';
|
status: 'active' | 'on-hold';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CallerContext = {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
source: string | null;
|
||||||
|
status: string | null;
|
||||||
|
interestedService: string | null;
|
||||||
|
aiSummary: string | null;
|
||||||
|
patientType: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
appointments: Array<{ id: string; scheduledAt: string; doctorName: string; department: string; status: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
const formatDuration = (startTime: string): string => {
|
const formatDuration = (startTime: string): string => {
|
||||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
|
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
@@ -25,10 +39,10 @@ const formatDuration = (startTime: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
|
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
|
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-4">
|
||||||
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mb-1" />
|
||||||
<p className="text-3xl font-bold text-primary">{value}</p>
|
<p className="text-xl font-bold text-primary">{value}</p>
|
||||||
<p className="text-xs text-tertiary mt-1">{label}</p>
|
<p className="text-[11px] text-tertiary">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,13 +50,23 @@ export const LiveMonitorPage = () => {
|
|||||||
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
|
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tick, setTick] = useState(0);
|
const [tick, setTick] = useState(0);
|
||||||
|
const [selectedCall, setSelectedCall] = useState<ActiveCall | null>(null);
|
||||||
|
const [callerContext, setCallerContext] = useState<CallerContext | null>(null);
|
||||||
|
const [contextLoading, setContextLoading] = useState(false);
|
||||||
const { leads } = useData();
|
const { leads } = useData();
|
||||||
|
|
||||||
// Poll active calls every 5 seconds
|
// Poll active calls every 5 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCalls = () => {
|
const fetchCalls = () => {
|
||||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||||
.then(setActiveCalls)
|
.then(calls => {
|
||||||
|
setActiveCalls(calls);
|
||||||
|
// If selected call ended, clear selection
|
||||||
|
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
|
||||||
|
setSelectedCall(null);
|
||||||
|
setCallerContext(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
@@ -50,9 +74,9 @@ export const LiveMonitorPage = () => {
|
|||||||
fetchCalls();
|
fetchCalls();
|
||||||
const interval = setInterval(fetchCalls, 5000);
|
const interval = setInterval(fetchCalls, 5000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [selectedCall?.ucid]);
|
||||||
|
|
||||||
// Tick every second to update duration counters
|
// Tick every second for duration display
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => setTick(t => t + 1), 1000);
|
const interval = setInterval(() => setTick(t => t + 1), 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -82,97 +106,256 @@ export const LiveMonitorPage = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch caller context when a call is selected
|
||||||
|
const handleSelectCall = (call: ActiveCall) => {
|
||||||
|
setSelectedCall(call);
|
||||||
|
setContextLoading(true);
|
||||||
|
setCallerContext(null);
|
||||||
|
|
||||||
|
const phoneClean = call.callerNumber.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Search for lead by phone
|
||||||
|
apiClient.graphql<{ leads: { edges: Array<{ node: any }> } }>(
|
||||||
|
`{ leads(first: 5, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phoneClean.slice(-10)}" } } }) { edges { node {
|
||||||
|
id contactName { firstName lastName } source status interestedService aiSummary patientId
|
||||||
|
} } } }`,
|
||||||
|
).then(async (data) => {
|
||||||
|
const lead = data.leads.edges[0]?.node;
|
||||||
|
const name = lead
|
||||||
|
? `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim()
|
||||||
|
: resolveCallerName(call.callerNumber) ?? 'Unknown Caller';
|
||||||
|
|
||||||
|
let appointments: CallerContext['appointments'] = [];
|
||||||
|
if (lead?.patientId) {
|
||||||
|
try {
|
||||||
|
const apptData = await apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||||
|
`{ appointments(first: 5, filter: { patientId: { eq: "${lead.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt doctorName department status
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
appointments = apptData.appointments.edges.map(e => e.node);
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallerContext({
|
||||||
|
name,
|
||||||
|
phone: call.callerNumber,
|
||||||
|
source: lead?.source ?? null,
|
||||||
|
status: lead?.status ?? null,
|
||||||
|
interestedService: lead?.interestedService ?? null,
|
||||||
|
aiSummary: lead?.aiSummary ?? null,
|
||||||
|
patientType: lead?.patientId ? 'RETURNING' : 'NEW',
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
|
appointments,
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
setCallerContext({
|
||||||
|
name: resolveCallerName(call.callerNumber) ?? 'Unknown Caller',
|
||||||
|
phone: call.callerNumber,
|
||||||
|
source: null, status: null, interestedService: null,
|
||||||
|
aiSummary: null, patientType: null, leadId: null, appointments: [],
|
||||||
|
});
|
||||||
|
}).finally(() => setContextLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
|
<TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* KPI Cards */}
|
{/* Left panel — KPIs + call list */}
|
||||||
<div className="px-6 pt-5">
|
<div className="flex flex-1 flex-col overflow-y-auto border-r border-secondary">
|
||||||
<div className="flex gap-4">
|
{/* KPI Cards */}
|
||||||
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
|
<div className="px-5 pt-4">
|
||||||
<KpiCard value={onHold} label="On Hold" icon={faPause} />
|
<div className="flex gap-3">
|
||||||
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
|
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
|
||||||
|
<KpiCard value={onHold} label="On Hold" icon={faPause} />
|
||||||
|
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Calls Table */}
|
||||||
|
<div className="px-5 pt-5 pb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-tertiary">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : activeCalls.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center rounded-xl border border-secondary bg-primary">
|
||||||
|
<FontAwesomeIcon icon={faHeadset} className="size-12 text-fg-quaternary mb-4" />
|
||||||
|
<p className="text-sm font-medium text-secondary">No active calls</p>
|
||||||
|
<p className="text-xs text-tertiary mt-1">Active calls will appear here in real-time</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="Agent" isRowHeader />
|
||||||
|
<Table.Head label="Caller" />
|
||||||
|
<Table.Head label="Type" className="w-16" />
|
||||||
|
<Table.Head label="Duration" className="w-20" />
|
||||||
|
<Table.Head label="Status" className="w-24" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={activeCalls}>
|
||||||
|
{(call) => {
|
||||||
|
const callerName = resolveCallerName(call.callerNumber);
|
||||||
|
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
|
||||||
|
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
|
||||||
|
const isSelected = selectedCall?.ucid === call.ucid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
id={call.ucid}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'bg-active' : 'hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
onAction={() => handleSelectCall(call)}
|
||||||
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm font-medium text-primary">{call.agentId}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div>
|
||||||
|
{callerName && <span className="text-sm font-medium text-primary block">{callerName}</span>}
|
||||||
|
<span className="text-xs text-tertiary">{call.callerNumber}</span>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={typeColor} type="pill-color">{typeLabel}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm font-mono text-primary">{formatDuration(call.startTime)}</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={call.status === 'on-hold' ? 'warning' : 'success'} type="pill-color">
|
||||||
|
{call.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Calls Table */}
|
{/* Right panel — context + barge controls */}
|
||||||
<div className="px-6 pt-6">
|
<div className="flex w-[380px] shrink-0 flex-col overflow-y-auto bg-primary">
|
||||||
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
|
{!selectedCall ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
{loading ? (
|
<FontAwesomeIcon icon={faHeadset} className="size-10 text-fg-quaternary" />
|
||||||
<div className="flex items-center justify-center py-12">
|
<p className="text-sm font-medium text-secondary">Select a call to monitor</p>
|
||||||
<p className="text-sm text-tertiary">Loading...</p>
|
<p className="text-xs text-tertiary">Click on any active call to see context and connect</p>
|
||||||
</div>
|
</div>
|
||||||
) : activeCalls.length === 0 ? (
|
) : contextLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center rounded-xl border border-secondary bg-primary">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<FontAwesomeIcon icon={faHeadset} className="size-12 text-fg-quaternary mb-4" />
|
<p className="text-sm text-tertiary">Loading caller context...</p>
|
||||||
<p className="text-sm font-medium text-secondary">No active calls</p>
|
|
||||||
<p className="text-xs text-tertiary mt-1">Active calls will appear here in real-time</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table size="sm">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
<Table.Header>
|
{/* Caller header */}
|
||||||
<Table.Head label="Agent" isRowHeader />
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<Table.Head label="Caller" />
|
<div className="flex items-center gap-3">
|
||||||
<Table.Head label="Type" className="w-16" />
|
<div className="flex size-10 items-center justify-center rounded-full bg-brand-secondary text-sm font-bold text-fg-white">
|
||||||
<Table.Head label="Duration" className="w-20" />
|
{(callerContext?.name ?? '?')[0].toUpperCase()}
|
||||||
<Table.Head label="Status" className="w-24" />
|
</div>
|
||||||
<Table.Head label="Actions" className="w-48" />
|
<div className="min-w-0 flex-1">
|
||||||
</Table.Header>
|
<p className="text-sm font-semibold text-primary truncate">{callerContext?.name}</p>
|
||||||
<Table.Body items={activeCalls}>
|
<p className="text-xs text-tertiary">{callerContext?.phone}</p>
|
||||||
{(call) => {
|
</div>
|
||||||
const callerName = resolveCallerName(call.callerNumber);
|
{callerContext?.patientType && (
|
||||||
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
|
<Badge size="sm" color={callerContext.patientType === 'RETURNING' ? 'brand' : 'gray'} type="pill-color">
|
||||||
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
|
{callerContext.patientType === 'RETURNING' ? 'Returning' : 'New'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
{/* Source + status */}
|
||||||
<Table.Row id={call.ucid}>
|
{(callerContext?.source || callerContext?.status) && (
|
||||||
<Table.Cell>
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
<span className="text-sm font-medium text-primary">{call.agentId}</span>
|
{callerContext.source && (
|
||||||
</Table.Cell>
|
<Badge size="sm" color="gray" type="pill-color">{callerContext.source.replace(/_/g, ' ')}</Badge>
|
||||||
<Table.Cell>
|
)}
|
||||||
<div>
|
{callerContext.status && (
|
||||||
{callerName && <span className="text-sm font-medium text-primary block">{callerName}</span>}
|
<Badge size="sm" color="brand" type="pill-color">{callerContext.status.replace(/_/g, ' ')}</Badge>
|
||||||
<span className="text-xs text-tertiary">{call.callerNumber}</span>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{callerContext?.interestedService && (
|
||||||
|
<p className="mt-2 text-xs text-tertiary">Interested in: {callerContext.interestedService}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Summary */}
|
||||||
|
{callerContext?.aiSummary && (
|
||||||
|
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs leading-relaxed text-primary">{callerContext.aiSummary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Appointments */}
|
||||||
|
{callerContext?.appointments && callerContext.appointments.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-3">
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Appointments</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{callerContext.appointments.map(appt => (
|
||||||
|
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-medium text-primary">{appt.doctorName ?? 'Appointment'}</span>
|
||||||
|
{appt.department && <span className="text-[11px] text-tertiary ml-1">{appt.department}</span>}
|
||||||
|
{appt.scheduledAt && (
|
||||||
|
<span className="text-[11px] text-tertiary ml-1">— {formatShortDate(appt.scheduledAt)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
<Badge size="sm" color={appt.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'} type="pill-color">
|
||||||
<Table.Cell>
|
{(appt.status ?? 'Scheduled').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
<Badge size="sm" color={typeColor} type="pill-color">{typeLabel}</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm font-mono text-primary">{formatDuration(call.startTime)}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge size="sm" color={call.status === 'on-hold' ? 'warning' : 'success'} type="pill-color">
|
|
||||||
{call.status}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</div>
|
||||||
<Table.Cell>
|
))}
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
|
</div>
|
||||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
|
)}
|
||||||
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
|
|
||||||
</div>
|
{/* Call info */}
|
||||||
</Table.Cell>
|
<div className="rounded-xl border border-secondary bg-primary p-3">
|
||||||
</Table.Row>
|
<div className="flex items-center gap-1 mb-2">
|
||||||
);
|
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-quaternary" />
|
||||||
}}
|
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Current Call</span>
|
||||||
</Table.Body>
|
</div>
|
||||||
</Table>
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div><span className="text-tertiary">Agent:</span> <span className="font-medium text-primary">{selectedCall.agentId}</span></div>
|
||||||
|
<div><span className="text-tertiary">Type:</span> <span className="font-medium text-primary">{selectedCall.callType === 'InBound' ? 'Inbound' : 'Outbound'}</span></div>
|
||||||
|
<div><span className="text-tertiary">Duration:</span> <span className="font-mono font-medium text-primary">{formatDuration(selectedCall.startTime)}</span></div>
|
||||||
|
<div><span className="text-tertiary">Status:</span> <span className="font-medium text-primary">{selectedCall.status}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barge Controls */}
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<BargeControls
|
||||||
|
ucid={selectedCall.ucid}
|
||||||
|
agentId={selectedCall.agentId}
|
||||||
|
agentNumber={selectedCall.agentId}
|
||||||
|
agentName={selectedCall.agentId}
|
||||||
|
onDisconnected={() => {
|
||||||
|
// Keep selection visible but controls reset to idle/ended
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monitoring hint */}
|
|
||||||
{activeCalls.length > 0 && (
|
|
||||||
<div className="px-6 pt-6 pb-8">
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center">
|
|
||||||
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" />
|
|
||||||
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
|
|
||||||
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ export const Patient360Page = () => {
|
|||||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
|
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
|
||||||
);
|
);
|
||||||
setActivities(prev => [{ id: crypto.randomUUID(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), leadId: leadInfo.id }, ...prev]);
|
setActivities(prev => [{ id: crypto.randomUUID(), createdAt: new Date().toISOString(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), channel: null, durationSeconds: null, outcome: null, leadId: leadInfo.id }, ...prev]);
|
||||||
setNoteText('');
|
setNoteText('');
|
||||||
notify.success('Note Added');
|
notify.success('Note Added');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const TelephonySettingsPage = () => {
|
|||||||
did: data.ozonetel?.did ?? '',
|
did: data.ozonetel?.did ?? '',
|
||||||
sipId: data.ozonetel?.sipId ?? '',
|
sipId: data.ozonetel?.sipId ?? '',
|
||||||
campaignName: data.ozonetel?.campaignName ?? '',
|
campaignName: data.ozonetel?.campaignName ?? '',
|
||||||
|
adminUsername: data.ozonetel?.adminUsername ?? '',
|
||||||
|
adminPassword: data.ozonetel?.adminPassword ?? '',
|
||||||
},
|
},
|
||||||
sip: {
|
sip: {
|
||||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
|||||||
Reference in New Issue
Block a user