mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +00:00
Compare commits
13 Commits
feature/om
...
feature/ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 113b5a9277 | |||
| eadfa68aaa | |||
| 5a24bbde0a | |||
| 636badfa31 | |||
| ee9da619c1 | |||
| 42d1a03f9d | |||
| d19ca4f593 | |||
| 24b4e01292 | |||
| d730cda06d | |||
| af9657eaab | |||
| 38aacc374e | |||
| c044d2d143 | |||
| 85364c6d69 |
@@ -1,5 +1,9 @@
|
||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
# EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
|
||||
# on the same domain, so VITE_API_URL is empty (same-origin).
|
||||
VITE_API_URL=
|
||||
|
||||
# SIP defaults — used as fallback if login response doesn't include agent config.
|
||||
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
|
||||
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
|
||||
VITE_SIP_PASSWORD=523590
|
||||
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444
|
||||
|
||||
@@ -36,38 +36,35 @@ Docker Compose stack (EC2 — 13.234.31.194):
|
||||
## EC2 Access
|
||||
|
||||
```bash
|
||||
# SSH into EC2
|
||||
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||
# SSH into EC2 (key passphrase handled by sshpass)
|
||||
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||
```
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Host | `13.234.31.194` |
|
||||
| User | `ubuntu` |
|
||||
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
|
||||
| SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
|
||||
| Passphrase | `SasiSuman@2007` |
|
||||
| Docker compose dir | `/opt/fortytwo` |
|
||||
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
||||
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
||||
|
||||
### SSH Key Setup
|
||||
### SSH Helper
|
||||
|
||||
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
|
||||
Create a decrypted copy for non-interactive use:
|
||||
The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
|
||||
No need to decrypt or copy the key — use the original file directly.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
|
||||
chmod 600 /tmp/ramaiah-ec2-key
|
||||
# SSH shorthand
|
||||
EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
|
||||
# Verify
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
|
||||
eval $EC2_SSH hostname
|
||||
```
|
||||
|
||||
### Handy alias
|
||||
|
||||
```bash
|
||||
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
```
|
||||
> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
|
||||
|
||||
---
|
||||
|
||||
@@ -155,29 +152,34 @@ REDIS_URL=redis://localhost:6379
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Helper — reuse in all commands below
|
||||
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||
|
||||
cd helix-engage && npm run build
|
||||
|
||||
rsync -avz -e "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/
|
||||
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
```
|
||||
|
||||
### Sidecar (quick — code only, no new dependencies)
|
||||
### Sidecar
|
||||
|
||||
```bash
|
||||
cd helix-engage-server
|
||||
|
||||
# 1. Login to ECR
|
||||
aws ecr get-login-password --region ap-south-1 | \
|
||||
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||
|
||||
# 2. Build and push Docker image
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
||||
--push .
|
||||
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||
# 3. Pull and restart on EC2
|
||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||
```
|
||||
|
||||
### How to decide
|
||||
|
||||
102
docs/ozonetel-cdr-api-reference.md
Normal file
102
docs/ozonetel-cdr-api-reference.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Ozonetel CDR API Reference
|
||||
|
||||
> Source: [Ozonetel docs](https://docs.ozonetel.com/reference/get_ca-reports-fetchcdrdetails)
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Path | Use Case |
|
||||
|----------|------|----------|
|
||||
| Fetch CDR Detailed | `GET /ca_reports/fetchCDRDetails` | All CDR for a single day |
|
||||
| Fetch CDR by UCID | `GET /ca_reports/fetchCdrByUCID` | Single call lookup by UCID |
|
||||
| Fetch CDR Paginated | `GET /ca_reports/fetchCdrByPagination` | Paginated CDR with `totalCount` |
|
||||
|
||||
## Common Constraints
|
||||
|
||||
- **Auth**: Bearer token (via `POST /ca_apis/caToken/generateToken`)
|
||||
- **Rate limit**: 2 requests per minute (all CDR endpoints)
|
||||
- **Date range**: Single day only (`fromDate` and `toDate` must be same date)
|
||||
- **Lookback**: 15 days maximum from time of request
|
||||
- **Mandatory params**: `fromDate`, `toDate`, `userName` (+ `ucid` for UCID endpoint)
|
||||
- **Date format**: `YYYY-MM-DD HH:MM:SS`
|
||||
|
||||
## Domain
|
||||
|
||||
- Domestic: `in1-ccaas-api.ozonetel.com`
|
||||
- International: `api.ccaas.ozonetel.com`
|
||||
|
||||
## CDR Record Fields (42 fields)
|
||||
|
||||
| Field | Type | Description | Sidecar Status |
|
||||
|-------|------|-------------|----------------|
|
||||
| `AgentDialStatus` | string | Agent's dial attempt status (e.g., "answered") | Not mapped |
|
||||
| `AgentID` | string | Agent identifier | **Mapped** — filter CDR by agent |
|
||||
| `AgentName` | string | Agent name | **Mapped** — fallback filter |
|
||||
| `CallAudio` | string | URL to call recording (S3) | Not mapped (recording via platform) |
|
||||
| `CallDate` | string | Date of call (YYYY-MM-DD) | Not mapped |
|
||||
| `CallID` | number | Unique call identifier | Not mapped |
|
||||
| `CallerConfAudioFile` | string | Conference audio file | Not mapped |
|
||||
| `CallerID` | string | Caller's phone number | Not mapped |
|
||||
| `CampaignName` | string | Associated campaign name | Not mapped — **available for US-15** |
|
||||
| `Comments` | string | Additional comments | Not mapped |
|
||||
| `ConferenceDuration` | string | Conference duration (HH:MM:SS) | Not mapped |
|
||||
| `CustomerDialStatus` | string | Customer dial status | Not mapped |
|
||||
| `CustomerRingTime` | string | Customer phone ring time | Not mapped — **missed call analysis** |
|
||||
| `DID` | string | Direct inward dial number | Not mapped — **available for US-2 branch display** |
|
||||
| `DialOutName` | string | Dialed party name | Not mapped |
|
||||
| `DialStatus` | string | Overall dial status | Not mapped |
|
||||
| `DialedNumber` | string | Phone number dialed | Not mapped |
|
||||
| `Disposition` | string | Call disposition/outcome | **Mapped** — disposition breakdown |
|
||||
| `Duration` | string | Total call duration | Not mapped |
|
||||
| `DynamicDID` | string | Dynamic DID reference | Not mapped |
|
||||
| `E164` | string | E.164 formatted phone number | Not mapped |
|
||||
| `EndTime` | string | Call end time | Not mapped |
|
||||
| `Event` | string | Event type (e.g., "AgentDial") | Not mapped |
|
||||
| `HandlingTime` | string/null | Total handling time — **CAN BE NULL** | Not mapped — **available for US-13 avg handling** |
|
||||
| `HangupBy` | string | Who terminated call | Not mapped |
|
||||
| `HoldDuration` | string | Time on hold | Not mapped — **available for US-12** |
|
||||
| `Location` | string | Caller location | Not mapped |
|
||||
| `PickupTime` | string | When call was answered | Not mapped |
|
||||
| `Rating` | number | Call quality rating | Not mapped |
|
||||
| `RatingComments` | string | Rating comments | Not mapped |
|
||||
| `Skill` | string | Agent skill/queue name | Not mapped |
|
||||
| `StartTime` | string | Call start time | Not mapped |
|
||||
| `Status` | string | Call status (Answered/NotAnswered) | **Mapped** — inbound/missed split |
|
||||
| `TalkTime` | string | Active talk duration | **Mapped** — avg talk time calc |
|
||||
| `TimeToAnswer` | string | Duration until answer | Not mapped — **available for lead response KPI** |
|
||||
| `TransferType` | string | Type of transfer | Not mapped — **available for US-3 audit** |
|
||||
| `TransferredTo` / `TransferTo` | string | Transfer target — **field name varies by endpoint** | Not mapped |
|
||||
| `Type` | string | Call type (InBound/Manual/Progressive) | **Mapped** — inbound/outbound split |
|
||||
| `UCID` | number | Unique call identifier | Not mapped |
|
||||
| `UUI` | string | User-to-user information | Not mapped |
|
||||
| `WrapUpEndTime` | string/null | Wrapup completion time — **CAN BE NULL** | Not mapped |
|
||||
| `WrapUpStartTime` | string/null | Wrapup start time — **CAN BE NULL** | Not mapped |
|
||||
| `WrapupDuration` | string/null | Wrapup duration — **CAN BE NULL** | Not mapped — **available for US-12** |
|
||||
|
||||
## Pagination Endpoint Extra Fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `totalCount` | Total number of records matching the query |
|
||||
|
||||
## Known Issues / Gotchas
|
||||
|
||||
1. **`HandlingTime`, `WrapupDuration`, `WrapUpStartTime`, `WrapUpEndTime` can be `null`** — when agent didn't complete wrapup (seen in UCID endpoint example). Code must null-guard these.
|
||||
2. **Field name inconsistency**: `TransferredTo` in fetchCDRDetails vs `TransferTo` in pagination endpoint. Handle both.
|
||||
3. **`WrapUpEndTime` vs `WrapupEndTime`**: casing differs between endpoints (camelCase vs mixed). Handle both.
|
||||
4. **Single-day constraint**: `fromDate` and `toDate` must be the same date. For multi-day range, call once per day.
|
||||
5. **Rate limit 2 req/min**: For a 7-day weekly report that needs CDR + summary per day = 14 API calls = 7 minutes minimum. Consider caching daily results.
|
||||
|
||||
## Current Sidecar Usage
|
||||
|
||||
**Endpoint used**: `fetchCDRDetails` only (in `ozonetel-agent.service.ts`)
|
||||
|
||||
**Fields actively mapped** (6 of 42):
|
||||
- `AgentID` / `AgentName` — agent filtering
|
||||
- `Type` — inbound/outbound split
|
||||
- `Status` — answered/missed split
|
||||
- `TalkTime` — avg talk time calculation
|
||||
- `Disposition` — disposition breakdown chart
|
||||
|
||||
**Not yet used**:
|
||||
- `fetchCdrByUCID` — useful for Patient 360 single-call drill-down
|
||||
- `fetchCdrByPagination` — useful for high-volume days (current approach loads all records into memory)
|
||||
1219
docs/requirements.md
Normal file
1219
docs/requirements.md
Normal file
File diff suppressed because it is too large
Load Diff
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 { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useAgentState } from '@/hooks/use-agent-state';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
@@ -49,6 +50,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||
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 wasAnsweredRef = useRef(callState === 'active');
|
||||
|
||||
@@ -235,7 +240,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||
</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>
|
||||
|
||||
{/* Call controls */}
|
||||
|
||||
@@ -33,7 +33,7 @@ type AgentStatusToggleProps = {
|
||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
||||
const ozonetelState = useAgentState(agentId);
|
||||
const { state: ozonetelState } = useAgentState(agentId);
|
||||
|
||||
const [menuOpen, setMenuOpen] = 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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||
@@ -58,6 +59,7 @@ const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
||||
);
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [contextExpanded, setContextExpanded] = useState(true);
|
||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||
@@ -163,6 +165,16 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign info */}
|
||||
{(lead.utmCampaign || lead.campaignId) && (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
{lead.utmCampaign ?? lead.campaignId}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
@@ -223,6 +235,12 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
{linkedPatient.patientType && (
|
||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
View 360
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,8 @@ export type TelephonyFormValues = {
|
||||
did: string;
|
||||
sipId: string;
|
||||
campaignName: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
};
|
||||
sip: {
|
||||
domain: string;
|
||||
@@ -37,6 +39,8 @@ export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
|
||||
did: '',
|
||||
sipId: '',
|
||||
campaignName: '',
|
||||
adminUsername: '',
|
||||
adminPassword: '',
|
||||
},
|
||||
sip: {
|
||||
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||
@@ -108,6 +112,27 @@ export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
|
||||
value={value.ozonetel.campaignName}
|
||||
onChange={(v) => patchOzonetel({ campaignName: v })}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
Ozonetel portal admin credentials — required for supervisor barge/whisper/listen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Admin username"
|
||||
placeholder="Ozonetel portal admin login"
|
||||
value={value.ozonetel.adminUsername}
|
||||
onChange={(v) => patchOzonetel({ adminUsername: v })}
|
||||
/>
|
||||
<Input
|
||||
label="Admin password"
|
||||
type="password"
|
||||
placeholder="Leave '***masked***' to keep current"
|
||||
value={value.ozonetel.adminPassword}
|
||||
onChange={(v) => patchOzonetel({ adminPassword: v })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
import { GlobalSearch } from '@/components/shared/global-search';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -119,7 +120,9 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Persistent top bar — visible on all pages */}
|
||||
{(hasAgentConfig || isAdmin) && (
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||
<GlobalSearch />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isAdmin && <NotificationBell />}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
@@ -140,6 +143,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ResumeSetupBanner />
|
||||
|
||||
@@ -132,7 +132,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||
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 ozonetelState = useAgentState(agentId);
|
||||
const { state: ozonetelState } = useAgentState(agentId);
|
||||
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
||||
|
||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
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';
|
||||
|
||||
export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
export const useAgentState = (agentId: string | null): { state: OzonetelState; supervisorPresence: SupervisorPresence } => {
|
||||
const [state, setState] = useState<OzonetelState>('offline');
|
||||
const [supervisorPresence, setSupervisorPresence] = useState<SupervisorPresence>('none');
|
||||
const prevStateRef = useRef<OzonetelState>('offline');
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
@@ -56,6 +58,20 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
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;
|
||||
setState(data.state);
|
||||
} catch {
|
||||
@@ -74,5 +90,5 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
};
|
||||
}, [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();
|
||||
38
src/main.tsx
38
src/main.tsx
@@ -10,6 +10,11 @@ const AdminSetupGuard = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
const RequireAdmin = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
||||
};
|
||||
import { RoleRouter } from "@/components/layout/role-router";
|
||||
import { NotFound } from "@/pages/not-found";
|
||||
import { AllLeadsPage } from "@/pages/all-leads";
|
||||
@@ -85,22 +90,23 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||
<Route path="/missed-calls" element={<MissedCallsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
|
||||
{/* Settings hub + section pages */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
{/* Admin-only routes */}
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||
<Route path="/missed-calls" element={<MissedCallsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { BargeControls } from '@/components/call-desk/barge-controls';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type ActiveCall = {
|
||||
ucid: string;
|
||||
@@ -17,6 +19,18 @@ type ActiveCall = {
|
||||
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 seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
|
||||
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 }) => (
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
|
||||
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" />
|
||||
<p className="text-3xl font-bold text-primary">{value}</p>
|
||||
<p className="text-xs text-tertiary mt-1">{label}</p>
|
||||
<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-4 text-fg-quaternary mb-1" />
|
||||
<p className="text-xl font-bold text-primary">{value}</p>
|
||||
<p className="text-[11px] text-tertiary">{label}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,13 +50,23 @@ export const LiveMonitorPage = () => {
|
||||
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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();
|
||||
|
||||
// Poll active calls every 5 seconds
|
||||
useEffect(() => {
|
||||
const fetchCalls = () => {
|
||||
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(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
@@ -50,9 +74,9 @@ export const LiveMonitorPage = () => {
|
||||
fetchCalls();
|
||||
const interval = setInterval(fetchCalls, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [selectedCall?.ucid]);
|
||||
|
||||
// Tick every second to update duration counters
|
||||
// Tick every second for duration display
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick(t => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -82,97 +106,256 @@ export const LiveMonitorPage = () => {
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
{/* KPI Cards */}
|
||||
<div className="px-6 pt-5">
|
||||
<div className="flex gap-4">
|
||||
<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 className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel — KPIs + call list */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto border-r border-secondary">
|
||||
{/* KPI Cards */}
|
||||
<div className="px-5 pt-4">
|
||||
<div className="flex gap-3">
|
||||
<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>
|
||||
|
||||
{/* Active Calls Table */}
|
||||
<div className="px-6 pt-6">
|
||||
<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>
|
||||
{/* Right panel — context + barge controls */}
|
||||
<div className="flex w-[380px] shrink-0 flex-col overflow-y-auto bg-primary">
|
||||
{!selectedCall ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-10 text-fg-quaternary" />
|
||||
<p className="text-sm font-medium text-secondary">Select a call to monitor</p>
|
||||
<p className="text-xs text-tertiary">Click on any active call to see context and connect</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>
|
||||
) : contextLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading caller context...</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.Head label="Actions" className="w-48" />
|
||||
</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';
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* Caller header */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-brand-secondary text-sm font-bold text-fg-white">
|
||||
{(callerContext?.name ?? '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-primary truncate">{callerContext?.name}</p>
|
||||
<p className="text-xs text-tertiary">{callerContext?.phone}</p>
|
||||
</div>
|
||||
{callerContext?.patientType && (
|
||||
<Badge size="sm" color={callerContext.patientType === 'RETURNING' ? 'brand' : 'gray'} type="pill-color">
|
||||
{callerContext.patientType === 'RETURNING' ? 'Returning' : 'New'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Table.Row id={call.ucid}>
|
||||
<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>
|
||||
{/* Source + status */}
|
||||
{(callerContext?.source || callerContext?.status) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{callerContext.source && (
|
||||
<Badge size="sm" color="gray" type="pill-color">{callerContext.source.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
{callerContext.status && (
|
||||
<Badge size="sm" color="brand" type="pill-color">{callerContext.status.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
</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>
|
||||
</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 size="sm" color={appt.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'} type="pill-color">
|
||||
{(appt.status ?? 'Scheduled').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
|
||||
<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>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call info */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-3">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -15,8 +15,10 @@ import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatShortDate, getInitials } from '@/lib/format';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
|
||||
|
||||
@@ -96,15 +98,16 @@ type PatientData = {
|
||||
};
|
||||
|
||||
// Appointment row component
|
||||
const AppointmentRow = ({ appt }: { appt: any }) => {
|
||||
const AppointmentRow = ({ appt, onEdit }: { appt: any; onEdit?: (appt: any) => void }) => {
|
||||
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
|
||||
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
|
||||
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
|
||||
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
|
||||
};
|
||||
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0">
|
||||
<div className={cx('flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0', canEdit && onEdit && 'cursor-pointer hover:bg-primary_hover transition duration-100 ease-linear')} onClick={() => canEdit && onEdit?.(appt)}>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
|
||||
<CalendarCheck className="size-4 text-fg-white" />
|
||||
</div>
|
||||
@@ -266,6 +269,9 @@ export const Patient360Page = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<string>('appointments');
|
||||
const [noteText, setNoteText] = useState('');
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const [apptFormOpen, setApptFormOpen] = useState(false);
|
||||
const [editingAppt, setEditingAppt] = useState<any>(null);
|
||||
const [patient, setPatient] = useState<PatientData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activities, setActivities] = useState<LeadActivity[]>([]);
|
||||
@@ -383,6 +389,11 @@ export const Patient360Page = () => {
|
||||
{leadInfo.source.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
{leadInfo?.status && (
|
||||
<Badge size="sm" type="pill-color" color={leadInfo.status === 'CONVERTED' ? 'success' : leadInfo.status === 'NEW' ? 'brand' : 'gray'}>
|
||||
{leadInfo.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +434,7 @@ export const Patient360Page = () => {
|
||||
{phoneRaw && (
|
||||
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" />
|
||||
)}
|
||||
<Button size="sm" color="secondary" iconLeading={Calendar}>
|
||||
<Button size="sm" color="secondary" iconLeading={Calendar} onClick={() => { setEditingAppt(null); setApptFormOpen(true); }}>
|
||||
Book Appointment
|
||||
</Button>
|
||||
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
|
||||
@@ -472,7 +483,7 @@ export const Patient360Page = () => {
|
||||
) : (
|
||||
<div className="rounded-xl border border-secondary bg-primary">
|
||||
{appointments.map((appt: any) => (
|
||||
<AppointmentRow key={appt.id} appt={appt} />
|
||||
<AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -538,7 +549,25 @@ export const Patient360Page = () => {
|
||||
size="sm"
|
||||
color="primary"
|
||||
iconLeading={Plus}
|
||||
isDisabled={noteText.trim() === ''}
|
||||
isDisabled={noteText.trim() === '' || noteSaving}
|
||||
isLoading={noteSaving}
|
||||
onClick={async () => {
|
||||
if (!noteText.trim() || !leadInfo?.id) return;
|
||||
setNoteSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
|
||||
);
|
||||
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('');
|
||||
notify.success('Note Added');
|
||||
} catch {
|
||||
notify.error('Failed', 'Could not save note');
|
||||
} finally {
|
||||
setNoteSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Note
|
||||
</Button>
|
||||
@@ -563,6 +592,33 @@ export const Patient360Page = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppointmentForm
|
||||
isOpen={apptFormOpen}
|
||||
onOpenChange={setApptFormOpen}
|
||||
callerNumber={phoneRaw || null}
|
||||
leadName={fullName !== 'Unknown Patient' ? fullName : null}
|
||||
leadId={leadInfo?.id ?? null}
|
||||
patientId={id ?? null}
|
||||
existingAppointment={editingAppt ? {
|
||||
id: editingAppt.id,
|
||||
scheduledAt: editingAppt.scheduledAt,
|
||||
doctorName: editingAppt.doctorName ?? '',
|
||||
department: editingAppt.department ?? '',
|
||||
reasonForVisit: editingAppt.reasonForVisit,
|
||||
status: editingAppt.status,
|
||||
} : null}
|
||||
onSaved={() => {
|
||||
setApptFormOpen(false);
|
||||
setEditingAppt(null);
|
||||
// Refresh patient data
|
||||
if (id) {
|
||||
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
|
||||
PATIENT_QUERY, { id }, { silent: true },
|
||||
).then(data => setPatient(data.patients.edges[0]?.node ?? null)).catch(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ export const TelephonySettingsPage = () => {
|
||||
did: data.ozonetel?.did ?? '',
|
||||
sipId: data.ozonetel?.sipId ?? '',
|
||||
campaignName: data.ozonetel?.campaignName ?? '',
|
||||
adminUsername: data.ozonetel?.adminUsername ?? '',
|
||||
adminPassword: data.ozonetel?.adminPassword ?? '',
|
||||
},
|
||||
sip: {
|
||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
|
||||
Reference in New Issue
Block a user