13 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
c044d2d143 feat: quick wins — global search, P360 actions, context panel, route guards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Wire GlobalSearch component into app shell top bar (US-10)
- P360: Book Appointment button opens AppointmentForm (US-8)
- P360: Add Note button creates leadActivity via GraphQL (US-8)
- P360: Appointment rows clickable for edit (active statuses only) (US-8)
- P360: Display lead status badge (was fetched but not rendered) (US-8)
- Context panel: "View 360" link on linked patient → /patient/:id (US-6)
- Context panel: Display campaign info from lead.utmCampaign (US-6)
- Route guards: Admin-only routes wrapped in RequireAdmin (US-1, US-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:31:56 +05:30
85364c6d69 docs: add requirements tracker and Ozonetel CDR API reference
- requirements.md: full 16-user-story tracker with verified implementation
  status, code references, Ozonetel API findings, platform capability notes,
  and implementation guides for search (includeInSearch), barge/whisper, and
  appointment notifications
- ozonetel-cdr-api-reference.md: all 42 CDR fields, 3 endpoints (detailed,
  UCID, paginated), sidecar mapping status, known gotchas (nullable fields,
  field name inconsistency, rate limits)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:53:33 +05:30
19 changed files with 3732 additions and 136 deletions

View File

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

View File

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

View 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

File diff suppressed because it is too large Load Diff

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -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(() => {});
}
}}
/>
</>
);
};

View File

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