11 Commits

Author SHA1 Message Date
113b5a9277 fix: restore SIP fallback env vars in .env.production
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
use-sip-phone.ts reads VITE_SIP_* as fallback before login response
provides per-agent config. Keep them for safety.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:11:04 +05:30
eadfa68aaa fix: update .env.production for EC2 — remove VPS sidecar URL
VITE_API_URL is now empty (same-origin). Caddy proxies /auth and /api
to the sidecar on the same domain. The old VPS URL caused 'User not
found' errors because the VPS has different workspace users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 09:09:14 +05:30
5a24bbde0a docs: update runbook — sshpass for EC2 SSH, no key decryption needed
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace openssl pkey decryption with direct sshpass passphrase handling.
Use original key file directly. Added VPN note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:50:58 +05:30
636badfa31 fix: build errors — JsSIP types, LeadActivity fields, telephony config
- supervisor-sip-client: use RTCSession as any (JsSIP types mismatch)
- patient-360: add missing LeadActivity fields (createdAt, channel, etc)
- telephony-settings: add adminUsername/adminPassword to loadConfig

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:32:02 +05:30
ee9da619c1 feat(frontend): supervisor presence indicator on agent call card
- useAgentState hook returns { state, supervisorPresence }
- SSE events: supervisor-whisper → "Supervisor coaching" (blue badge)
  supervisor-barge → "Supervisor on call" (brand badge)
  supervisor-left → badge disappears
- Listen mode is silent — no badge shown
- Updated call sites: sidebar.tsx, agent-status-toggle.tsx destructure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:16:31 +05:30
42d1a03f9d feat(frontend): live monitor split layout with context panel and barge
Left panel: KPI cards + clickable call table (row selection highlights).
Right panel (380px): caller context (name, phone, source, AI summary,
appointments) + BargeControls component. Fetches lead data by phone match
on selection. Auto-clears when selected call ends. Removed disabled
Listen/Whisper/Barge buttons — replaced with integrated barge drawer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:12:22 +05:30
d19ca4f593 feat(frontend): barge controls component — connect, mode tabs, hangup
BargeControls component with 4 states: idle → connecting → connected → ended.
Connected state shows Listen/Whisper/Barge mode tabs (DTMF 4/5/6), live
duration counter, hang up button. Auto-cleanup on unmount. Mode changes
notify sidecar for agent SSE events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:09:37 +05:30
24b4e01292 feat(frontend): supervisor SIP client — JsSIP wrapper for barge sessions
Separate from agent sip-client.ts — different lifecycle (on-demand per
barge session, not persistent). Auto-answers incoming Ozonetel calls.
DTMF mode switching: 4=listen, 5=whisper, 6=barge. Event-driven with
registered/callConnected/callEnded events. Audio via hidden <audio> element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:08:28 +05:30
d730cda06d feat(config): add Ozonetel admin credential fields to telephony form
Admin username + password inputs in the Ozonetel section for supervisor
barge/whisper/listen access. Follows existing masked password pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:58:29 +05:30
af9657eaab docs: barge/whisper/listen implementation plan
8 tasks: config extension, admin auth service, barge endpoints,
supervisor SIP client, barge controls component, live monitor redesign,
agent indicator, integration testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:50:35 +05:30
38aacc374e docs: barge/whisper/listen design spec
SIP-only supervisor barge with DTMF mode switching (4=listen, 5=whisper,
6=barge). Live monitor split layout with context panel. Agent indicator
on whisper/barge only. Auto-disconnect on call end. Dynamic SIP from
Ozonetel pool.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:06:50 +05:30
14 changed files with 2306 additions and 115 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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 */}

View File

@@ -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);

View 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>
);
};

View File

@@ -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">

View File

@@ -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;

View File

@@ -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 };
}; };

View 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();

View File

@@ -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>
</> </>
); );

View File

@@ -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 {

View File

@@ -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',