32 Commits

Author SHA1 Message Date
72cb192447 fix(appointments): preload clinic + keep saved time on edit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The appointment-edit form opened with clinic/time blank even for
well-formed appointments because the pipeline never carried clinicId
end-to-end. Four-layer audit + fix:

1. APPOINTMENTS_QUERY now fetches clinicId + clinic { id clinicName }
   + doctor.fullName (was only doctor.id).
2. transformAppointments populates real clinicId + clinicName from the
   relation instead of faking clinicName=department.
3. Appointment type gets clinicId: string | null.
4. context-panel passes clinicId through to AppointmentForm's
   existingAppointment prop; form initial-states clinic from it.

Also on edit: if the saved timeSlot isn't in the fresh slot list
(past-slot filter, schedule change, clinic mismatch) we inject it as
"HH:MM (current)" so the dropdown displays the existing value instead
of looking cleared.

Historical appointments with clinicId=null on the platform still fall
through to the auto-select-from-slot logic; a maint backfill for those
is a separate task.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:56:51 +05:30
d3cbf4d2bb fix: P2 defect batch + context-panel edit takeover
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
  is set. Previously AppointmentForm rendered inline BELOW the AI panel,
  crushing the AI area into a ~2-line strip that made the returning-
  patient summary + quick actions unusable. Edit view gets full height
  with a "Back to context" button.

P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
  per-disposition Record<CallDisposition,...> map (incoming-call-card,
  call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
  buttons on active-call-card are disabled until the call reaches the
  answered state (wasAnsweredRef). The disposition filter was already
  in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
  value on top of each bar; donut shows {d}% slice labels; team-
  performance day trend line shows per-point values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:48:39 +05:30
5632f15031 fix: P1 call-desk defects batch
- Mute persists across calls: sip-manager's "ended/failed" branch now
  resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
  the SIP track was unmuted, leaving the UI icon + toggle logic in a
  muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
  was missing an isRegistered check in its disabled prop, so it stayed
  clickable when SIP was down. Button now shows "Telephony unavailable"
  and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
  min constraint. Switched to a raw <input type="date"> with min set
  to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
  a "give me a quick summary of <caller>" request whenever the caller's
  leadId changes (new incoming call). Clears prior chat state so each
  caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:38:35 +05:30
d23cf9b857 fix(seed): clinic fields use the real Clinic schema
Seed script was writing weekdayHours / saturdayHours / sundayHours +
requiredDocuments as strings — neither exist on Clinic that way.
Switched to per-day booleans + opensAt/closesAt. requiredDocuments is
a relation, so dropped from the clinic payload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:31:07 +05:30
f4dcf6574f fix(enquiry-form): set name on createPatient
Patient records created from the enquiry form now get a platform title
from the typed name. Cosmetic fix — frontend was already showing the
fullName, only platform admin browsing showed "Untitled".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:32:33 +05:30
180613a2f3 feat(notifications): poll real PerformanceAlert rows from sidecar
usePerformanceAlerts now fetches /api/supervisor/performance-alerts
every 60s instead of computing client-side. Dismiss + dismiss-all hit
the sidecar so state survives reload. Toast fires when new alerts arrive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:02:20 +05:30
91a1f33d35 fix: notifications use real data + agent-detail follows new id scheme
1. notification-bell: drop the DEMO_ALERTS fallback (Riya Mehta etc.).
   Empty state ("No active alerts") shows when the live computation
   returns nothing — which is the truthful state until thresholds are
   set on Agent records.
2. use-performance-alerts: bucket calls by c.agentId === agent.id when
   the relation is set; fall back to legacy agentName matching only for
   un-enriched rows. Fixes conversion% calc going to 0 after backfill.
3. agent-table: Link target uses agent.id (UUID or "legacy:NAME") so
   the URL is a stable identifier instead of a display string.
4. agent-detail: parse the route param into UUID vs legacy:NAME, filter
   calls by c.agentId or c.agentName accordingly, and resolve display
   name via the platform Agents list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:47:57 +05:30
8de7d7d802 fix(team-dashboard): agent-table buckets by authoritative agent.id
The Agent Performance table on the team dashboard was bucketing by raw
call.agentName — the field that holds Ozonetel's transfer-chain string
("RamaiahAdmin -> GlobalHealthX") and collides for distinct AgentIDs
that share a Full Name. Result: 7 rows for 3 real agents.

Now buckets by call.agent.id when the CDR enrichment has populated it,
falls back to legacy agentName grouping otherwise. Calls without any
agent info are dropped from the agent rollup (instead of being
collapsed under "Unknown").

Pulls agent { id name ozonetelAgentId } + transferredTo + transferType
on CALLS_QUERY.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:07:05 +05:30
d00b066806 feat(team-performance): group calls by authoritative agent relation
Prefer call.agent.id (set by CDR enrichment) over call.agentName string
matching. Falls back to the raw agentName only when the row hasn't been
enriched yet. Eliminates the "RamaiahAdmin -> GlobalHealthX" transfer-chain
rows and the display-name collisions (two distinct AgentIDs with the same
Full Name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:43:49 +05:30
4590417536 docs: weekly status + PPT for Apr 6-11 + Ramaiah slots seed script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:41 +05:30
42e23a52ec feat: call-desk refresh — disposition modal, active-call UI, worklist + perf updates
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log
- Disposition modal: auto-lock based on actions taken, not-interested split
- Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format)
- Worklist-panel: pagination awareness, filter chips
- Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish
- SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner
- Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts
- Types: entities.ts extended

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:36 +05:30
642911fa6c fix: appointment clinic relation — save clinicId, query clinic.clinicName for branch column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:38:11 +05:30
8bc01d1a9f fix: QA defects — phone format E.164, call history shows phone not Unknown
- appointment-form: normalize phone to +91XXXXXXXXXX before patient create
- appointment-form: use empty string not 'Unknown' for name fallback
- call-history: show formatted phone number instead of 'Unknown' when no lead

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:57:59 +05:30
3296977a6a fix: branch column shows clinic name instead of department
Appointments page was using department for the Branch column. Now fetches
doctor.clinic.clinicName from the GraphQL query and displays that. Search
filter also updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:16:31 +05:30
d3e6934dcb fix: stop auto-creating Unknown leads on every call
Caller resolver now returns empty IDs for unrecognized numbers instead
of eagerly creating lead+patient records. Records are created when the
agent explicitly books an appointment or logs an enquiry — per PRD.

- caller-resolution.service.ts: return unresolved result, don't create
- call-desk.tsx: toast changed to 'No existing records found'
- appointment-form.tsx: create patient on save if none exists

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:22:23 +05:30
d24945a3af fix: update patient name on new callers — prevents 'Unknown' patients
When a new caller's patient record is created by the caller resolver,
the name is empty. The agent types a name in the appointment form but
the patient was never updated (Bug #527 removed all patient updates).
Now updates patient name only when the initial name was empty — existing
patients with names are not affected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:05:26 +05:30
d8f9174a55 fix: filter time slots by selected clinic/branch
Slots were fetched by doctor+date but not filtered by clinic. A doctor
visiting multiple branches showed all slots regardless of selected branch.
Now filters by clinicId when a clinic is selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:59:10 +05:30
8cccd55fb6 fix: add keepalive to logout fetch — prevents session lock orphan
The logout POST to /auth/logout was getting cancelled when the page
navigated to /login before the fetch completed. keepalive: true ensures
the request survives page unload so the Redis session lock is released.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:53:52 +05:30
28b59f36dc docs: add Grafana + Loki monitoring to architecture and runbook
- monitoring.healix360.net → Grafana (admin / Global@2026)
- Loki collects Docker container logs via loki-docker-driver plugin
- Updated topology diagram with loki, grafana, and all URL mappings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:34:07 +05:30
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
54 changed files with 5623 additions and 618 deletions

View File

@@ -1,5 +1,9 @@
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud # EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud # on the same domain, so VITE_API_URL is empty (same-origin).
VITE_API_URL=
# SIP defaults — used as fallback if login response doesn't include agent config.
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
VITE_SIP_PASSWORD=523590 VITE_SIP_PASSWORD=523590
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444

View File

@@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed)
├── global.engage.healix360.net → sidecar-global:4100 ├── global.engage.healix360.net → sidecar-global:4100
├── telephony.engage.healix360.net → telephony:4200 ├── telephony.engage.healix360.net → telephony:4200
├── *.app.healix360.net → server:4000 (platform) ├── *.app.healix360.net → server:4000 (platform)
├── monitoring.healix360.net → grafana:3000
├── operations.healix360.net → woodpecker-server:8000
├── git.healix360.net → gitea:3000
└── engage.healix360.net → 404 (no catchall) └── engage.healix360.net → 404 (no catchall)
Docker Compose stack (EC2 — 13.234.31.194): Docker Compose stack (EC2 — 13.234.31.194):
@@ -28,7 +31,9 @@ Docker Compose stack (EC2 — 13.234.31.194):
├── db — PostgreSQL 16 (workspace-per-schema) ├── db — PostgreSQL 16 (workspace-per-schema)
├── clickhouse — Analytics ├── clickhouse — Analytics
├── minio — S3-compatible object storage ├── minio — S3-compatible object storage
── redpanda — Event bus (Kafka-compatible) ── redpanda — Event bus (Kafka-compatible)
├── loki — Log aggregation (receives from Docker logging driver)
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
``` ```
--- ---
@@ -36,38 +41,35 @@ Docker Compose stack (EC2 — 13.234.31.194):
## EC2 Access ## EC2 Access
```bash ```bash
# SSH into EC2 # SSH into EC2 (key passphrase handled by sshpass)
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194 SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
``` ```
| Detail | Value | | Detail | Value |
|---|---| |---|---|
| Host | `13.234.31.194` | | Host | `13.234.31.194` |
| User | `ubuntu` | | User | `ubuntu` |
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) | | SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
| Passphrase | `SasiSuman@2007` |
| Docker compose dir | `/opt/fortytwo` | | Docker compose dir | `/opt/fortytwo` |
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` | | Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
| Caddyfile | `/opt/fortytwo/Caddyfile` | | Caddyfile | `/opt/fortytwo/Caddyfile` |
### SSH Key Setup ### SSH Helper
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`). The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
Create a decrypted copy for non-interactive use: No need to decrypt or copy the key — use the original file directly.
```bash ```bash
# One-time setup # SSH shorthand
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
chmod 600 /tmp/ramaiah-ec2-key
# Verify # Verify
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname eval $EC2_SSH hostname
``` ```
### Handy alias > **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
```bash
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
```
--- ---
@@ -80,6 +82,9 @@ alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234
| Ramaiah Platform | `https://ramaiah.app.healix360.net` | | Ramaiah Platform | `https://ramaiah.app.healix360.net` |
| Global Platform | `https://global.app.healix360.net` | | Global Platform | `https://global.app.healix360.net` |
| Telephony Dispatcher | `https://telephony.engage.healix360.net` | | Telephony Dispatcher | `https://telephony.engage.healix360.net` |
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
| CI/CD (Woodpecker) | `https://operations.healix360.net` |
| Git (Gitea) | `https://git.healix360.net` |
--- ---
@@ -155,29 +160,34 @@ REDIS_URL=redis://localhost:6379
### Frontend ### Frontend
```bash ```bash
# Helper — reuse in all commands below
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
cd helix-engage && npm run build cd helix-engage && npm run build
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \ rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/ dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
"cd /opt/fortytwo && sudo docker compose restart caddy"
``` ```
### Sidecar (quick — code only, no new dependencies) ### Sidecar
```bash ```bash
cd helix-engage-server cd helix-engage-server
# 1. Login to ECR
aws ecr get-login-password --region ap-south-1 | \ aws ecr get-login-password --region ap-south-1 | \
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# 2. Build and push Docker image
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \ -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
--push . --push .
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \ # 3. Pull and restart on EC2
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global" eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
``` ```
### How to decide ### How to decide

View File

@@ -0,0 +1,612 @@
/**
* Helix Engage — Weekly Update (Apr 611, 2026)
* "Clinical Precision" design — dark/light alternating, geometric, executive healthcare
*/
const PptxGenJS = require("pptxgenjs");
// ── Design System ───────────────────────────────────────────────
const P = {
// Dark palette (hero slides)
navyDeep: "0F172A", // slate-900
navyMid: "1E293B", // slate-800
navyLight: "334155", // slate-700
// Light palette (content slides)
white: "FFFFFF",
snow: "F8FAFC", // slate-50
mist: "F1F5F9", // slate-100
silver: "E2E8F0", // slate-200
// Text
inkDark: "0F172A",
inkMid: "475569", // slate-600
inkLight: "94A3B8", // slate-400
inkOnDark: "F1F5F9",
inkMuted: "64748B", // slate-500
// Accents — healthcare-inspired
teal: "0D9488", // primary brand
tealLight: "14B8A6",
tealPale: "CCFBF1", // teal-100
blue: "0284C7", // sky-600
blueLight: "38BDF8",
indigo: "4F46E5",
amber: "D97706",
rose: "E11D48",
emerald: "059669",
violet: "7C3AED",
};
const F = "Calibri"; // Clean, universally available
const FB = "Calibri Light";
// ── Helpers ─────────────────────────────────────────────────────
function sn(s, n) {
s.addText(`${n}`, {
x: 9.3, y: 5.15, w: 0.5, h: 0.3,
fontSize: 8, color: P.inkLight, fontFace: FB, align: "right",
});
}
function darkSlide(pptx) {
const s = pptx.addSlide();
s.background = { color: P.navyDeep };
return s;
}
function lightSlide(pptx) {
const s = pptx.addSlide();
s.background = { color: P.white };
return s;
}
// Thin teal accent line at top
function topLine(s, color) {
s.addShape("rect", { x: 0, y: 0, w: 10, h: 0.04, fill: { color: color || P.teal } });
}
// Section label pill
function pill(s, text, color, x, y) {
const w = text.length * 0.075 + 0.5;
s.addShape("roundRect", {
x, y, w, h: 0.26,
fill: { color, transparency: 85 },
rectRadius: 0.13,
});
s.addText(text.toUpperCase(), {
x, y, w, h: 0.26,
fontSize: 7, fontFace: F, bold: true, color,
align: "center", valign: "middle",
});
}
// Metric block (for dark slides)
function metric(s, { x, y, value, label, color, w = 2.0 }) {
// Subtle card
s.addShape("roundRect", {
x, y, w, h: 1.4,
fill: { color: P.navyMid },
line: { color: P.navyLight, width: 0.5 },
rectRadius: 0.08,
});
// Accent top bar
s.addShape("rect", { x: x + 0.15, y: y + 0.06, w: w - 0.3, h: 0.025, fill: { color } });
// Value
s.addText(value, {
x, y: y + 0.15, w, h: 0.75,
fontSize: 38, fontFace: F, bold: true, color,
align: "center", valign: "middle",
});
// Label
s.addText(label, {
x, y: y + 0.9, w, h: 0.35,
fontSize: 9, fontFace: FB, color: P.inkLight,
align: "center", valign: "top",
});
}
// Content card (for light slides)
function card(s, { x, y, w, h, title, accent, items }) {
// Card with left accent border
s.addShape("roundRect", {
x, y, w, h,
fill: { color: P.snow },
line: { color: P.silver, width: 0.5 },
rectRadius: 0.06,
});
// Left accent bar
s.addShape("rect", { x, y: y + 0.1, w: 0.035, h: h - 0.2, fill: { color: accent } });
// Title
s.addText(title, {
x: x + 0.25, y: y + 0.08, w: w - 0.4, h: 0.32,
fontSize: 10.5, fontFace: F, bold: true, color: accent,
});
// Items
if (items?.length) {
s.addText(
items.map(t => ({
text: t,
options: {
fontSize: 8.5, fontFace: FB, color: P.inkMid,
bullet: { code: "2022" }, // bullet dot
paraSpaceAfter: 3, breakLine: true,
},
})),
{ x: x + 0.25, y: y + 0.4, w: w - 0.5, h: h - 0.5, valign: "top", lineSpacingMultiple: 1.15 }
);
}
}
// Section heading for light slides
function sectionHead(s, title, subtitle) {
s.addText(title, {
x: 0.6, y: 0.35, w: 8, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.inkDark,
});
if (subtitle) {
s.addText(subtitle, {
x: 0.6, y: 0.78, w: 8, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkMuted,
});
}
}
// ═════════════════════════════════════════════════════════════════
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Apr 611, 2026)";
// ─── SLIDE 1: Title (Dark) ────────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
// Geometric accent — vertical teal line
s.addShape("rect", { x: 0.6, y: 1.2, w: 0.035, h: 2.8, fill: { color: P.teal } });
pill(s, "Weekly Status", P.tealLight, 0.85, 1.3);
s.addText("Helix Engage", {
x: 0.85, y: 1.7, w: 7, h: 0.9,
fontSize: 42, fontFace: F, bold: true, color: P.white,
});
s.addText("Engineering Progress Report", {
x: 0.85, y: 2.5, w: 7, h: 0.4,
fontSize: 16, fontFace: FB, color: P.inkLight,
});
// Date block
s.addShape("rect", { x: 0.85, y: 3.2, w: 2.2, h: 0.04, fill: { color: P.teal, transparency: 50 } });
s.addText("April 6 11, 2026", {
x: 0.85, y: 3.35, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.tealLight,
});
s.addText("Satya Suman Sari | FortyTwo Platform", {
x: 0.85, y: 4.8, w: 5, h: 0.25,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 1);
}
// ─── SLIDE 2: At a Glance (Dark) ─────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
pill(s, "Overview", P.tealLight, 0.5, 0.3);
s.addText("Week at a Glance", {
x: 0.5, y: 0.6, w: 5, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
metric(s, { x: 0.5, y: 1.25, value: "57", label: "Commits Shipped", color: P.blueLight, w: 2.05 });
metric(s, { x: 2.7, y: 1.25, value: "9", label: "Defects Resolved", color: P.rose, w: 2.05 });
metric(s, { x: 4.9, y: 1.25, value: "40", label: "E2E Tests Passing", color: P.emerald, w: 2.05 });
metric(s, { x: 7.1, y: 1.25, value: "17", label: "Docker Containers", color: P.violet, w: 2.05 });
// Key highlights
const highlights = [
"Multi-tenant EC2 architecture deployed — Ramaiah + Global on single instance",
"Woodpecker CI/CD pipeline operational with Teams notifications",
"Cross-tenant security vulnerability identified and patched",
"Complete documentation: architecture, runbook, CI/CD guide",
];
s.addText(
highlights.map(h => ({
text: h,
options: {
fontSize: 10, fontFace: FB, color: P.inkOnDark,
bullet: { code: "25B8" }, paraSpaceAfter: 6, breakLine: true,
},
})),
{ x: 0.6, y: 2.9, w: 8.5, h: 2.0, valign: "top", lineSpacingMultiple: 1.2 }
);
sn(s, 2);
}
// ─── SLIDE 3: Defect Fixes (Light) ────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.rose);
sectionHead(s, "Defect Resolution", "9 of 17 triaged bugs fixed and deployed this week");
const bugs = [
["#527", "Appointment creation overwrites patient details"],
["#529", "Break/Training status doesn't block outbound calls"],
["#531", "Agent can log out during an active call"],
["#533", "Redundant Call History page header"],
["#534", "Redundant Patients page header"],
["#536", "My Performance displays wrong agent data"],
["#538", "Supervisor dashboard metrics incorrect"],
["#540", "Ghost calls visible for logged-out agents"],
["#547", "SLA priority rules not reflected in worklist"],
];
const rows = [
[
{ text: "ID", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
{ text: "Description", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
{ text: "Status", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
],
...bugs.map(([id, desc], i) => [
{ text: id, options: { fontSize: 8.5, fontFace: F, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
{ text: desc, options: { fontSize: 8.5, fontFace: FB, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
{ text: "Resolved", options: { fontSize: 8.5, fontFace: F, bold: true, color: P.emerald, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
]),
];
s.addTable(rows, {
x: 0.5, y: 1.2, w: 9.0,
border: { type: "solid", pt: 0.3, color: P.silver },
colW: [0.7, 6.6, 1.7], rowH: 0.36,
});
s.addText("Deferred by product: #516 recordings | #517 AI transcription | #519 supervisor calling | #539 real-time missed calls | #541 whisper/barge", {
x: 0.5, y: 4.9, w: 9, h: 0.3,
fontSize: 7.5, fontFace: FB, color: P.inkLight, italic: true,
});
sn(s, 3);
}
// ─── SLIDE 4: Security Fix (Dark) ────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.rose);
pill(s, "Security", P.rose, 0.5, 0.3);
s.addText("Cross-Tenant Isolation Vulnerability", {
x: 0.5, y: 0.6, w: 9, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
s.addText("Discovered and patched within the same sprint", {
x: 0.5, y: 1.0, w: 9, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkLight,
});
// Problem
s.addShape("roundRect", {
x: 0.4, y: 1.5, w: 4.4, h: 2.6,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.rose } });
s.addText("Impact", {
x: 0.65, y: 1.55, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.rose,
});
s.addText(
[
"Shared OZONETEL_AGENT_ID env var across sidecars",
"6 endpoints used silent fallback to wrong agent",
"Ramaiah operations could modify Global's session",
"Agent state, disposition, dial, metrics all affected",
"No error or warning — completely silent",
].map(t => ({
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
})),
{ x: 0.65, y: 1.9, w: 3.9, h: 2.0, valign: "top" }
);
// Resolution
s.addShape("roundRect", {
x: 5.1, y: 1.5, w: 4.5, h: 2.6,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.emerald } });
s.addText("Resolution", {
x: 5.35, y: 1.55, w: 3, h: 0.3,
fontSize: 11, fontFace: F, bold: true, color: P.emerald,
});
s.addText(
[
"Removed all defaultAgentId fallbacks",
"All 6 endpoints now require agentId (400 if absent)",
"Frontend sends agentId from localStorage",
"OZONETEL_AGENT_ID removed from config entirely",
"Verified with 40 E2E tests — zero regressions",
].map(t => ({
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
})),
{ x: 5.35, y: 1.9, w: 4.0, h: 2.0, valign: "top" }
);
// Clean layers footer
s.addText("Unaffected layers: Login (DB lookup) | Telephony dispatcher (event payload) | Sidecar registration (GraphQL) | Supervisor (webhook events)", {
x: 0.5, y: 4.4, w: 9, h: 0.3,
fontSize: 7.5, fontFace: FB, color: P.inkLight,
});
sn(s, 4);
}
// ─── SLIDE 5: EC2 Architecture (Light) ────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.blue);
sectionHead(s, "AWS EC2 Multi-Tenant Architecture", "Single instance, strict tenant isolation, host-routed Caddy");
card(s, {
x: 0.4, y: 1.2, w: 4.4, h: 2.0,
title: "Shared Platform Layer", accent: P.blue,
items: [
"NestJS server — multi-tenant by Origin header",
"PostgreSQL 16 with workspace-per-schema",
"BullMQ worker, ClickHouse analytics, Redpanda events",
"MinIO S3-compatible object storage",
],
});
card(s, {
x: 5.1, y: 1.2, w: 4.5, h: 2.0,
title: "Isolated Sidecar Layer", accent: P.amber,
items: [
"Per-hospital: sidecar + Redis + data volume",
"Caddy host-routes — no catchall, no cross-tenant",
"ramaiah.engage.healix360.net \u2192 sidecar-ramaiah",
"global.engage.healix360.net \u2192 sidecar-global",
],
});
card(s, {
x: 0.4, y: 3.4, w: 4.4, h: 1.7,
title: "Telephony Dispatcher", accent: P.teal,
items: [
"Routes Ozonetel events by agentId via Redis lookup",
"Sidecars self-register on boot with heartbeat",
"Zero config when onboarding new hospitals",
],
});
card(s, {
x: 5.1, y: 3.4, w: 4.5, h: 1.7,
title: "Live Endpoints", accent: P.indigo,
items: [
"ramaiah.engage / global.engage — Hospital UIs",
"telephony.engage — Event dispatcher",
"operations — CI/CD dashboard",
"git — Gitea forge (mirrors Azure DevOps)",
],
});
sn(s, 5);
}
// ─── SLIDE 6: E2E Tests (Dark) ────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.emerald);
pill(s, "Quality Assurance", P.emerald, 0.5, 0.3);
s.addText("40 Automated E2E Tests", {
x: 0.5, y: 0.6, w: 9, h: 0.45,
fontSize: 22, fontFace: F, bold: true, color: P.white,
});
s.addText("Playwright smoke tests covering every page across both hospitals", {
x: 0.5, y: 1.0, w: 9, h: 0.3,
fontSize: 10, fontFace: FB, color: P.inkLight,
});
// Ramaiah
s.addShape("roundRect", {
x: 0.4, y: 1.5, w: 4.4, h: 2.4,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.amber } });
s.addText("Ramaiah Hospitals — 27 tests", {
x: 0.65, y: 1.55, w: 4, h: 0.3,
fontSize: 10.5, fontFace: F, bold: true, color: P.amber,
});
s.addText(
[
"Login flow: branding, credentials, auth guard (4)",
"CC Agent: call desk, history, patients, appointments, performance, sidebar, sign-out (10)",
"Supervisor: dashboard, team perf, live monitor, all data pages, settings (12)",
"Auth setup with auto session unlock (1)",
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
{ x: 0.65, y: 1.9, w: 3.9, h: 1.8, valign: "top" }
);
// Global
s.addShape("roundRect", {
x: 5.1, y: 1.5, w: 4.5, h: 2.4,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.blueLight } });
s.addText("Global Hospital — 13 tests", {
x: 5.35, y: 1.55, w: 4, h: 0.3,
fontSize: 10.5, fontFace: F, bold: true, color: P.blueLight,
});
s.addText(
[
"CC Agent: landing, history, patients, appointments, performance, sidebar, sign-out (7)",
"Supervisor: landing, patients, appointments, campaigns, settings (5)",
"Auth setup with auto session unlock (1)",
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
{ x: 5.35, y: 1.9, w: 4.0, h: 1.8, valign: "top" }
);
// Self-healing footer
s.addShape("roundRect", {
x: 0.4, y: 4.15, w: 9.2, h: 0.85,
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
});
s.addText("Self-Healing", {
x: 0.65, y: 4.2, w: 2, h: 0.25,
fontSize: 9, fontFace: F, bold: true, color: P.emerald,
});
s.addText("Auto-clears session locks before login | Completes sign-out after tests | Runs against live EC2, not mocked | ~6 min on Woodpecker CI", {
x: 0.65, y: 4.5, w: 8.5, h: 0.3,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 6);
}
// ─── SLIDE 7: CI/CD (Light) ───────────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.indigo);
sectionHead(s, "CI/CD Pipeline", "Automated testing, report publishing, and team notifications");
// Flow bar
s.addShape("roundRect", {
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
fill: { color: P.mist }, line: { color: P.silver, width: 0.5 }, rectRadius: 0.06,
});
s.addText("Azure DevOps \u2192 Gitea Mirror \u2192 Woodpecker Pipeline \u2192 MinIO Reports \u2192 Teams Alert", {
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
fontSize: 9.5, fontFace: F, bold: true, color: P.indigo, align: "center", valign: "middle",
});
card(s, {
x: 0.4, y: 1.75, w: 4.4, h: 1.7,
title: "Frontend Pipeline", accent: P.blue,
items: [
"TypeScript typecheck (yarn tsc --noEmit)",
"40 Playwright E2E tests against live EC2",
"HTML report uploaded to MinIO (S3 plugin)",
"Teams Adaptive Card with report link",
],
});
card(s, {
x: 5.1, y: 1.75, w: 4.5, h: 1.7,
title: "Sidecar Pipeline", accent: P.violet,
items: [
"Jest unit tests (npm ci + jest --ci)",
"Teams notification on pass or fail",
"Triggered on push or manual run",
],
});
card(s, {
x: 0.4, y: 3.65, w: 9.2, h: 1.4,
title: "Operations Dashboard", accent: P.teal,
items: [
"operations.healix360.net — Woodpecker CI with full build history and logs",
"operations.healix360.net/reports/{run}/ — Playwright HTML reports with screenshots (basic auth protected)",
"git.healix360.net — Gitea forge mirroring Azure DevOps every 15 minutes",
"Teams 'Deployment updates' channel receives Adaptive Cards with pass/fail count and report link",
],
});
sn(s, 7);
}
// ─── SLIDE 8: Timeline (Light) ────────────────────────────────
{
const s = lightSlide(pptx);
topLine(s, P.teal);
sectionHead(s, "Development Timeline");
const timeline = [
{ date: "Apr 6 Sun", title: "Onboarding Wizard", desc: "6-phase setup wizard, widget config, telephony/AI CRUD, team invite, clinic/doctor management", color: P.blue },
{ date: "Apr 7 Mon", title: "SIP & ACW Fixes", desc: "3-layer ACW protection, SIP disconnect guard, dispose agentId, setup wizard polish", color: P.teal },
{ date: "Apr 8 Tue", title: "Master Data", desc: "Dynamic clinic/doctor fetching, appointment form overhaul, Ramaiah 195 doctor seed", color: P.amber },
{ date: "Apr 9 Wed", title: "EC2 Deployment", desc: "Multi-tenant architecture, telephony dispatcher, Caddy host routing, 14 containers", color: P.indigo },
{ date: "Apr 10 Thu", title: "Defect Sprint", desc: "9 bugs fixed, 40 E2E tests, architecture docs, runbook, cross-tenant discovery", color: P.rose },
{ date: "Apr 11 Fri", title: "CI/CD Pipeline", desc: "Woodpecker + Gitea + MinIO, Teams notifications, defaultAgentId security patch", color: P.emerald },
];
// Vertical line
s.addShape("rect", { x: 1.25, y: 1.2, w: 0.02, h: 3.9, fill: { color: P.silver } });
timeline.forEach((e, i) => {
const y = 1.2 + i * 0.65;
// Dot
s.addShape("ellipse", {
x: 1.18, y: y + 0.06, w: 0.16, h: 0.16,
fill: { color: e.color }, line: { color: P.white, width: 2 },
});
// Date
s.addText(e.date, {
x: 1.55, y, w: 1.2, h: 0.22,
fontSize: 7.5, fontFace: F, bold: true, color: e.color,
});
// Title
s.addText(e.title, {
x: 2.8, y, w: 1.8, h: 0.22,
fontSize: 9.5, fontFace: F, bold: true, color: P.inkDark,
});
// Desc
s.addText(e.desc, {
x: 4.7, y, w: 4.8, h: 0.55,
fontSize: 8, fontFace: FB, color: P.inkMid, valign: "top",
});
});
sn(s, 8);
}
// ─── SLIDE 9: Closing (Dark) ──────────────────────────────────
{
const s = darkSlide(pptx);
topLine(s, P.teal);
s.addShape("rect", { x: 0.6, y: 1.6, w: 0.035, h: 1.8, fill: { color: P.teal } });
s.addText("57 commits across 3 repositories", {
x: 0.85, y: 1.6, w: 8, h: 0.6,
fontSize: 28, fontFace: F, bold: true, color: P.white,
});
s.addText("From single-tenant VPS to multi-tenant EC2 with automated CI/CD,\n40 end-to-end tests, and a fully integrated operations dashboard.", {
x: 0.85, y: 2.3, w: 7, h: 0.7,
fontSize: 12, fontFace: FB, color: P.inkLight, lineSpacingMultiple: 1.4,
});
// Achievement pills
const items = [
{ text: "Multi-Tenant EC2", color: P.blue },
{ text: "40 E2E Tests", color: P.emerald },
{ text: "CI/CD Pipeline", color: P.indigo },
{ text: "9 Bugs Fixed", color: P.rose },
{ text: "Teams Alerts", color: P.violet },
];
items.forEach((a, i) => {
const x = 0.85 + i * 1.7;
s.addShape("roundRect", {
x, y: 3.4, w: 1.5, h: 0.32,
fill: { color: P.navyMid },
line: { color: a.color, width: 1 },
rectRadius: 0.16,
});
s.addText(a.text, {
x, y: 3.4, w: 1.5, h: 0.32,
fontSize: 8, fontFace: F, bold: true, color: a.color,
align: "center", valign: "middle",
});
});
s.addText("Satya Suman Sari | FortyTwo Platform", {
x: 0.85, y: 4.8, w: 5, h: 0.25,
fontSize: 8, fontFace: FB, color: P.inkLight,
});
sn(s, 9);
}
await pptx.writeFile({ fileName: "docs/weekly-update-apr06-11.pptx" });
console.log("Generated: docs/weekly-update-apr06-11.pptx");
}
build().catch(err => { console.error(err); process.exit(1); });

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

@@ -0,0 +1,162 @@
# Helix Engage — Weekly Status Update
**Period:** April 6 April 11, 2026
**Team:** Engineering
---
## Executive Summary
Major infrastructure milestone — Helix Engage is now running on AWS EC2 with multi-tenant architecture supporting both Ramaiah Hospitals and Global Hospital on a single instance. A full CI/CD pipeline with automated E2E testing and Teams notifications is operational. 17 defects from QA were triaged, 8 fixed and deployed, and a cross-tenant security vulnerability in the telephony layer was discovered and patched.
---
## 1. AWS EC2 Deployment (Multi-Tenant)
**Status: Live**
Migrated from single-tenant VPS to multi-tenant EC2 architecture:
- **Instance:** m6i.xlarge, Mumbai (ap-south-1), 15GB RAM
- **14 Docker containers** running: platform, 2 sidecars, telephony dispatcher, 4 Redis instances, Caddy, PostgreSQL, ClickHouse, Redpanda, MinIO
- **Strict tenant isolation:** each hospital has its own sidecar container, Redis instance, and data volume
- **Host-routed Caddy:** cross-tenant webhook routing is physically impossible
**URLs deployed:**
- ramaiah.engage.healix360.net (Ramaiah Hospitals)
- global.engage.healix360.net (Global Hospital)
- ramaiah.app.healix360.net / global.app.healix360.net (Platform)
- telephony.engage.healix360.net (Event dispatcher)
- operations.healix360.net (CI/CD dashboard)
- git.healix360.net (Git forge)
---
## 2. Telephony Event Dispatcher
**Status: Live**
Built a NestJS service that routes Ozonetel agent/call events to the correct hospital's sidecar:
- Ozonetel event subscriptions are **account-level** (not per-campaign) — one URL for all agents
- Dispatcher receives all events, looks up `agentId` in Redis, forwards to the correct sidecar
- Sidecars self-register on boot with their agent list; heartbeat every 30s, TTL 90s
- No manual configuration needed when adding new hospitals
---
## 3. Cross-Tenant Security Fix (defaultAgentId)
**Status: Fixed and deployed**
Discovered that 6 sidecar endpoints used a hardcoded `OZONETEL_AGENT_ID` env var as a fallback when `agentId` wasn't provided by the frontend. In a multi-tenant setup, this caused Ramaiah sidecar operations to silently affect Global Hospital's agent.
**Impact:** Agent state changes, call disposition, outbound dialing, performance metrics, and maintenance commands could operate on the wrong hospital's agent with no error or warning.
**Fix:**
- Removed `defaultAgentId` getter and all hardcoded fallbacks (`agent3`, `Test123$`, `521814`)
- All 6 endpoints now require `agentId` from the caller (400 if missing)
- Frontend updated to send `agentId` from `localStorage.helix_agent_config` in all calls
- `OZONETEL_AGENT_ID` removed from env config entirely
---
## 4. Defect Fixes (8 of 17)
| Bug | Title | Status |
|-----|-------|--------|
| #527 | Appointment creation updates existing patient incorrectly | Fixed |
| #529 | Break/Training status doesn't block outbound calls | Fixed |
| #531 | Agent can log out during active call | Fixed |
| #533 | Redundant "Call History" header | Fixed |
| #534 | Redundant "Patients" header | Fixed |
| #536 | My Performance shows wrong agent's data | Fixed |
| #538 | Supervisor dashboard metrics incorrect | Fixed |
| #540 | Ghost calls visible for logged-out agents | Fixed |
| #547 | SLA rules not reflected in Call Desk | Fixed (config seeded) |
**Deferred (by product):** #516 (recordings real-time), #517/#548 (AI transcription), #519 (supervisor call — needs SIP seat), #539 (missed calls real-time), #541 (whisper/barge/listen)
---
## 5. E2E Test Suite (Playwright)
**Status: 40 tests, all passing**
Automated smoke tests covering every page for both hospitals:
- **Login (4):** branding, invalid creds, supervisor login, auth guard
- **Ramaiah CC Agent (10):** call desk, call history, patients, appointments, my performance, sidebar, sign-out
- **Ramaiah Supervisor (12):** dashboard, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
- **Global CC Agent (7):** all pages + sign-out
- **Global Supervisor (5):** all pages
Self-healing: auto-clears agent session locks before login, completes sign-out after tests.
---
## 6. CI/CD Pipeline (Woodpecker + Gitea)
**Status: Operational**
End-to-end CI/CD on EC2:
- **Gitea** mirrors Azure DevOps repos every 15 minutes
- **Woodpecker CI** triggers pipelines on push or manual run
- **Frontend pipeline:** TypeScript typecheck → 40 E2E tests → HTML report published to MinIO → Teams notification
- **Sidecar pipeline:** Jest unit tests → Teams notification
- **Reports:** Playwright HTML reports with screenshots at `operations.healix360.net/reports/{run}/index.html`
- **Teams notifications:** Adaptive Cards to "Deployment updates" channel with pass/fail summary + report link
---
## 7. Documentation
Three docs committed to the repo:
- **architecture.md** — Multi-tenant topology with Mermaid diagram, telephony dispatcher, failure modes
- **developer-operations-runbook.md** — SSH access, accounts, deploy steps, Redis ops, DB access, troubleshooting
- **ci-cd-operations.md** — Gitea, Woodpecker, MinIO, Teams notification setup and troubleshooting
---
## 8. Data Seeding
- **Ramaiah:** 195 real doctors scraped from msrmh.com, clinics, visit slots, campaign data
- **Global:** CC agent accounts (rekha.cc, ganesh.cc), marketing (sanjay), supervisor (dr.ramesh) created with proper roles
- **Rules engine:** 6 priority scoring rules seeded (missed call, follow-up, campaign lead, 2nd/3rd attempt, spam deprioritize)
- **Seed script:** idempotent `mkMember`, cleanup phase before seeding, runs against any workspace via env vars
---
## 9. Other Improvements
- **SIP agent tracing:** Browser console logs `agent=ramaiahadmin ext=524435` on every SIP connect/disconnect/state change for multi-agent debugging
- **ACW 3-layer protection:** beforeunload warning → sendBeacon auto-dispose → server 30s timer
- **Maint endpoints:** `force-ready` and `unlock-agent` now accept `agentId` from body (was hardcoded)
- **Security group automation:** SSH IP auto-updated via AWS CLI when ISP changes
---
## Metrics
| Metric | Value |
|--------|-------|
| Commits (frontend) | 35 |
| Commits (sidecar) | 20 |
| Commits (SDK app) | 2 |
| Bugs fixed | 9 |
| E2E tests | 40 |
| Docker containers | 17 (14 app + 3 CI) |
| DNS records | 6 |
| Uptime | EC2 live since Apr 9 |
---
## Next Week Priorities
1. Merge `feature/omnichannel-widget``master` (frontend)
2. Frontend Docker image (stop rsync, bake into image)
3. Appointment date validation (no past dates, auto-tomorrow after hours)
4. Pre-built CI Docker image (skip `yarn install` on every run)
5. Deferred defects: #516, #539 (real-time updates)

Binary file not shown.

View File

@@ -5,11 +5,13 @@
* Prerequisites: doctors already seeded via seed-data.ts * Prerequisites: doctors already seeded via seed-data.ts
* *
* Platform field mapping (SDK name → platform name): * Platform field mapping (SDK name → platform name):
* Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours, * Clinic: address→addressCustom,
* operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours, * per-day booleans openMonday..openSunday + opensAt/closesAt (HH:MM),
* clinicStatus→status, onlineBookingEnabled→onlineBooking, * clinicStatus→status, onlineBookingEnabled→onlineBooking,
* arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash, * arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash,
* paymentCard→acceptsCard, paymentUpi→acceptsUpi * paymentCard→acceptsCard, paymentUpi→acceptsUpi.
* requiredDocuments is a RELATION (ClinicRequiredDocument); seed rows
* separately — not a string on the Clinic itself.
* HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active * HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active
* InsurancePartner: planTypes→planTypesAccepted * InsurancePartner: planTypes→planTypesAccepted
*/ */
@@ -68,15 +70,16 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'koramangala@globalhospital.com' }, email: { primaryEmail: 'koramangala@globalhospital.com' },
weekdayHours: '8:00 AM 8:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '8:00 AM 8:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
sundayHours: '9:00 AM 2:00 PM', opensAt: '08:00',
closesAt: '20:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records', // requiredDocuments is a relation (ClinicRequiredDocument) — seed separately
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',
@@ -95,15 +98,15 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'whitefield@globalhospital.com' }, email: { primaryEmail: 'whitefield@globalhospital.com' },
weekdayHours: '8:00 AM 8:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '8:00 AM 8:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
sundayHours: 'Closed', opensAt: '08:00',
closesAt: '20:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records',
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',
@@ -122,15 +125,15 @@ async function main() {
}, },
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' }, phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
email: { primaryEmail: 'indiranagar@globalhospital.com' }, email: { primaryEmail: 'indiranagar@globalhospital.com' },
weekdayHours: '9:00 AM 7:00 PM', openMonday: true, openTuesday: true, openWednesday: true,
saturdayHours: '9:00 AM 7:00 PM', openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
sundayHours: '10:00 AM 1:00 PM', opensAt: '09:00',
closesAt: '19:00',
status: 'ACTIVE', status: 'ACTIVE',
walkInAllowed: true, walkInAllowed: true,
onlineBooking: true, onlineBooking: true,
cancellationWindowHours: 4, cancellationWindowHours: 4,
arriveEarlyMin: 15, arriveEarlyMin: 15,
requiredDocuments: 'ID proof + medical records',
acceptsCash: 'YES', acceptsCash: 'YES',
acceptsCard: 'YES', acceptsCard: 'YES',
acceptsUpi: 'YES', acceptsUpi: 'YES',

View File

@@ -0,0 +1,114 @@
/**
* Seed DoctorVisitSlots for all Ramaiah doctors.
* Assigns default visiting hours based on department patterns.
* Run after seed-ramaiah.ts has populated doctors + clinic.
*
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah-slots.ts
*/
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
const SUB = process.env.SEED_SUB ?? 'ramaiah';
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
let token = '';
async function gql(query: string, variables?: any) {
const h: Record<string, string> = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB };
if (token) h['Authorization'] = `Bearer ${token}`;
const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) });
const d: any = await r.json();
if (d.errors) throw new Error(d.errors[0].message);
return d.data;
}
async function auth() {
const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`);
const lt = d1.getLoginTokenFromCredentials.loginToken.token;
const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`);
token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
}
// Default schedule patterns by department type
const schedulePatterns: Record<string, { days: string[]; start: string; end: string }> = {
// Surgical departments: morning OPD
surgery: { days: ['MONDAY', 'WEDNESDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '13:00' },
// Medical departments: afternoon OPD
medicine: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '14:00', end: '17:00' },
// High-traffic: full day Mon-Sat
fullDay: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '17:00' },
// Emergency/Critical: all week
allWeek: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'], start: '08:00', end: '20:00' },
// Specialists: limited days
specialist: { days: ['TUESDAY', 'THURSDAY', 'SATURDAY'], start: '10:00', end: '14:00' },
};
function getPattern(department: string): { days: string[]; start: string; end: string } {
const d = department.toLowerCase();
if (d.includes('emergency') || d.includes('critical care')) return schedulePatterns.allWeek;
if (d.includes('general medicine') || d.includes('paediatrics') || d.includes('obstetrics')) return schedulePatterns.fullDay;
if (d.includes('surgery') || d.includes('ortho') || d.includes('neuro')) return schedulePatterns.surgery;
if (d.includes('cardiology') || d.includes('nephrology') || d.includes('oncology')) return schedulePatterns.medicine;
if (d.includes('dermatology') || d.includes('psychiatry') || d.includes('rheumatology') || d.includes('endocrinology')) return schedulePatterns.specialist;
// Default: Mon-Fri mornings
return { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '09:00', end: '13:00' };
}
async function main() {
console.log('🕐 Seeding visit slots for Ramaiah doctors...\n');
await auth();
console.log('✅ Auth OK\n');
// Fetch all doctors
const docData = await gql(`{ doctors(first: 500) { edges { node { id name department } } } }`);
const doctors = docData.doctors.edges.map((e: any) => e.node);
console.log(`📋 Found ${doctors.length} doctors\n`);
// Fetch clinic
const clinicData = await gql(`{ clinics(first: 1) { edges { node { id clinicName } } } }`);
const clinicId = clinicData.clinics.edges[0]?.node.id;
const clinicName = clinicData.clinics.edges[0]?.node.clinicName ?? 'Clinic';
if (!clinicId) { console.error('No clinic found!'); process.exit(1); }
console.log(`🏥 Clinic: ${clinicName} (${clinicId})\n`);
let created = 0;
let failed = 0;
for (let i = 0; i < doctors.length; i++) {
if (i > 0 && i % 40 === 0) {
await auth();
console.log(` (re-authed at ${i})`);
}
const doc = doctors[i];
const pattern = getPattern(doc.department ?? '');
for (const day of pattern.days) {
try {
await gql(
`mutation($data: DoctorVisitSlotCreateInput!) { createDoctorVisitSlot(data: $data) { id } }`,
{
data: {
name: `${doc.name}${day} ${pattern.start}${pattern.end}`,
doctorId: doc.id,
clinicId,
dayOfWeek: day,
startTime: pattern.start,
endTime: pattern.end,
},
},
);
created++;
} catch (err: any) {
failed++;
if (failed <= 5) console.error(`${doc.name} ${day}: ${err.message?.slice(0, 60)}`);
}
}
if ((i + 1) % 30 === 0) console.log(` ${i + 1}/${doctors.length} doctors processed (${created} slots)...`);
}
console.log(`\n✅ ${created} visit slots created, ${failed} failed`);
console.log(` ${doctors.length} doctors × avg ${Math.round(created / doctors.length)} days each`);
}
main().catch(e => { console.error('💥', e.message); process.exit(1); });

View File

@@ -12,12 +12,14 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
import { setOutboundPending } from '@/state/sip-manager'; import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { DispositionModal } from './disposition-modal'; import { DispositionModal } from './disposition-modal';
import type { CallAction } from './disposition-modal';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog'; import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form'; import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format'; import { formatPhone } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities'; import type { Lead, CallDisposition } from '@/types/entities';
@@ -47,7 +49,22 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const [enquiryOpen, setEnquiryOpen] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false);
const [dispositionOpen, setDispositionOpen] = useState(false); const [dispositionOpen, setDispositionOpen] = useState(false);
const [callerDisconnected, setCallerDisconnected] = useState(false); const [callerDisconnected, setCallerDisconnected] = useState(false);
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null); // Actions actually recorded during this call. Drives the disposition
// modal's priority-lock: if the agent booked an appointment and logged
// an enquiry, both badges render and the primary disposition is
// locked to APPOINTMENT_BOOKED.
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
const addActions = (...newActions: CallAction[]) => {
setActionsTaken((prev) => {
const next = new Set(prev);
for (const a of newActions) next.add(a);
return Array.from(next);
});
};
const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { supervisorPresence } = useAgentState(agentIdForState);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const wasAnsweredRef = useRef(callState === 'active');
@@ -99,6 +116,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
direction: callDirectionRef.current, direction: callDirectionRef.current,
durationSec: callDuration, durationSec: callDuration,
leadId: lead?.id ?? null, leadId: lead?.id ?? null,
leadName: fullName || null,
notes, notes,
missedCallId: missedCallId ?? undefined, missedCallId: missedCallId ?? undefined,
}; };
@@ -110,24 +128,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
console.warn('[DISPOSE] No callUcid — skipping disposition'); console.warn('[DISPOSE] No callUcid — skipping disposition');
} }
// Side effects // Follow-ups are created by the enquiry form (where the agent picks
if (disposition === 'FOLLOW_UP_SCHEDULED') { // the date + context). No second creation here — that was causing
try { // duplicate entries on every FOLLOW_UP_SCHEDULED call.
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
data: {
name: `Follow-up — ${fullName || phoneDisplay}`,
typeCustom: 'CALLBACK',
status: 'PENDING',
assignedAgent: null,
priority: 'NORMAL',
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
}, { silent: true });
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
} catch {
notify.info('Follow-up', 'Could not auto-create follow-up');
}
}
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback // Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
localStorage.removeItem('helix_active_ucid'); localStorage.removeItem('helix_active_ucid');
@@ -136,15 +139,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
handleReset(); handleReset();
}; };
const handleAppointmentSaved = () => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
setSuggestedDisposition('APPOINTMENT_BOOKED'); if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE');
notify.success('Appointment Rescheduled');
} else if (outcome === 'CANCELLED') {
addActions('CANCEL');
notify.success('Appointment Cancelled');
} else {
addActions('APPOINTMENT');
notify.success('Appointment Booked', 'Payment link will be sent to the patient'); notify.success('Appointment Booked', 'Payment link will be sent to the patient');
}
}; };
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
setCallUcid(null); setCallUcid(null);
@@ -208,7 +220,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
return ( return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center"> <div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
<p className="text-sm font-semibold text-primary">Missed Call</p> <p className="text-sm font-semibold text-primary">{fullName || 'Missed Call'}</p>
<p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p> <p className="text-xs text-tertiary mt-1">{phoneDisplay} not answered</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}> <Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist Back to Worklist
@@ -235,8 +247,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>} {fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<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> <Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div> </div>
</div>
{/* Call controls */} {/* Call controls */}
<div className="mt-3 flex items-center gap-1.5 flex-wrap"> <div className="mt-3 flex items-center gap-1.5 flex-wrap">
@@ -279,12 +299,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button> onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button> onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'} <Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button> onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto" <Button size="sm" color="primary-destructive" className="ml-auto"
@@ -304,7 +327,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onClose={() => setTransferOpen(false)} onClose={() => setTransferOpen(false)}
onTransferred={() => { onTransferred={() => {
setTransferOpen(false); setTransferOpen(false);
setSuggestedDisposition('FOLLOW_UP_SCHEDULED'); // A transfer implies the original agent handed the call
// off — treat that as a follow-up action so the
// disposition pre-locks to FOLLOW_UP_SCHEDULED.
addActions('FOLLOWUP');
setDispositionOpen(true); setDispositionOpen(true);
}} }}
/> />
@@ -327,10 +353,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
agentName={user.name} agentName={user.name}
onSaved={() => { onSaved={(actions) => {
setEnquiryOpen(false); setEnquiryOpen(false);
setSuggestedDisposition('INFO_PROVIDED'); addActions(...actions);
notify.success('Enquiry Logged');
}} }}
/> />
</div> </div>
@@ -342,7 +367,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={dispositionOpen} isOpen={dispositionOpen}
callerName={fullName || phoneDisplay} callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected} callerDisconnected={callerDisconnected}
defaultDisposition={suggestedDisposition} // wasAnsweredRef only flips true once callState reaches
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
actionsTaken={actionsTaken}
onSubmit={handleDisposition} onSubmit={handleDisposition}
onDismiss={() => { onDismiss={() => {
// Agent wants to continue the call — close modal, call stays active // Agent wants to continue the call — close modal, call stays active

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons'; import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
import type { OzonetelState } from '@/hooks/use-agent-state'; import type { OzonetelState } from '@/hooks/use-agent-state';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -33,7 +33,7 @@ type AgentStatusToggleProps = {
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => { export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null; const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
const ozonetelState = useAgentState(agentId); const { state: ozonetelState } = useAgentState(agentId);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [changing, setChanging] = useState(false); const [changing, setChanging] = useState(false);
@@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res)); console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else { } else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training'; const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
// transit through Ready. Insert a Ready hop whenever we're
// moving between two paused sub-states.
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
if (isPauseToPause) {
console.log(`[AGENT-STATE] ${ozonetelState}${newStatus}: sending Ready first, then Pause(${pauseReason})`);
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
await new Promise(resolve => setTimeout(resolve, 400));
}
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`); console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason }); const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res)); console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
disabled={changing || !canToggle} disabled={changing || !canToggle}
className={cx( className={cx(
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear', 'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default', canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
changing && 'opacity-50',
)} )}
> >
{changing ? (
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
) : (
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} /> <FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span> )}
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />} <span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
{changing ? 'Changing…' : current.label}
</span>
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
</button> </button>
{menuOpen && ( {menuOpen && (

View File

@@ -27,7 +27,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({ const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
api: `${API_URL}/api/ai/stream`, api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text', streamProtocol: 'text',
headers: { headers: {
@@ -49,6 +49,28 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
} }
}, [messages, onChatStart]); }, [messages, onChatStart]);
// Auto-fire a patient-summary request when a caller with a leadId appears
// on the panel. Resets whenever the caller changes (new incoming call) so
// each call starts fresh. The sidecar's AI agent inspects the leadId and
// replies with appointment/disposition/notes history when the caller is
// a returning patient, or a brief "net-new caller" ack otherwise.
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
if (!leadId) return;
if (autoFiredForLeadRef.current === leadId) return;
// New caller — clear any prior chat state and fire the summary prompt.
autoFiredForLeadRef.current = leadId;
setMessages([]);
chatStartedRef.current = false;
const name = callerContext?.leadName ?? 'this caller';
append({
role: 'user',
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
});
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
const handleQuickAction = (prompt: string) => { const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt }); append({ role: 'user', content: prompt });
}; };

View File

@@ -18,6 +18,7 @@ type ExistingAppointment = {
doctorName: string; doctorName: string;
doctorId?: string; doctorId?: string;
department: string; department: string;
clinicId?: string;
reasonForVisit?: string; reasonForVisit?: string;
status: string; status: string;
}; };
@@ -29,7 +30,10 @@ type AppointmentFormProps = {
leadName?: string | null; leadName?: string | null;
leadId?: string | null; leadId?: string | null;
patientId?: string | null; patientId?: string | null;
onSaved?: () => void; // Called after a successful save. Passes back what actually happened so
// the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs
// CANCELLED each map to distinct disposition outcomes).
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
existingAppointment?: ExistingAppointment | null; existingAppointment?: ExistingAppointment | null;
}; };
@@ -79,7 +83,11 @@ export const AppointmentForm = ({
const [patientPhone, setPatientPhone] = useState(callerNumber ?? ''); const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState(''); const [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null); const [gender, setGender] = useState<string | null>(null);
const [clinic, setClinic] = useState<string | null>(null); // Preload clinic from the existing appointment when editing — so the
// select lands on the right branch instead of being empty and forcing
// the agent to re-pick. Only historical rows that predate clinicId
// persistence will fall through to the auto-select-from-slot logic.
const [clinic, setClinic] = useState<string | null>(existingAppointment?.clinicId ?? null);
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]); const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null); const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null); const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
@@ -108,13 +116,31 @@ export const AppointmentForm = ({
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>( apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, `/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
).then(slots => { ).then(slots => {
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label }))); // Filter by selected clinic — doctor may visit multiple branches
// Auto-select clinic from the slot's clinic const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
if (slots.length > 0 && !clinic) { let items = filtered.map(s => ({ id: s.time, label: s.label }));
// In edit mode, the saved timeSlot may have been filtered out
// (past-slot filter, schedule change, clinic mismatch). Inject
// it as a synthetic option so the dropdown still shows the
// existing value — otherwise the agent sees a cleared field
// and assumes the save-time was lost.
if (timeSlot && !items.some(i => i.id === timeSlot)) {
items = [{ id: timeSlot, label: `${timeSlot} (current)` }, ...items];
}
setTimeSlotItems(items);
// Auto-select clinic from the slot's clinic only if no clinic chosen
if (filtered.length === 0 && slots.length > 0 && !clinic) {
setClinic(slots[0].clinicId); setClinic(slots[0].clinicId);
const autoItems = slots.filter(s => s.clinicId === slots[0].clinicId).map(s => ({ id: s.time, label: s.label }));
if (timeSlot && !autoItems.some(i => i.id === timeSlot)) {
autoItems.unshift({ id: timeSlot, label: `${timeSlot} (current)` });
}
setTimeSlotItems(autoItems);
} }
}).catch(() => setTimeSlotItems([])); }).catch(() => setTimeSlotItems([]));
}, [doctor, date]); }, [doctor, date, clinic, timeSlot]);
// Availability state // Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]); const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -238,7 +264,9 @@ export const AppointmentForm = ({
const selectedDoctor = doctors.find(d => d.id === doctor); const selectedDoctor = doctors.find(d => d.id === doctor);
if (isEditMode && existingAppointment) { if (isEditMode && existingAppointment) {
// Update existing appointment // Update existing appointment. Flip status to RESCHEDULED so
// the Appointments > Rescheduled tab reflects it and the
// patient timeline records the reschedule event.
await apiClient.graphql( await apiClient.graphql(
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) { `mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id } updateAppointment(id: $id, data: $data) { id }
@@ -251,18 +279,65 @@ export const AppointmentForm = ({
department: selectedDoctor?.department ?? '', department: selectedDoctor?.department ?? '',
doctorId: doctor, doctorId: doctor,
reasonForVisit: chiefComplaint || null, reasonForVisit: chiefComplaint || null,
status: 'RESCHEDULED',
}, },
}, },
); );
// Propagate name change during reschedule. Same gate as the
// create branch — nameChanged implies isNameEditable=true,
// which means the agent went through EditPatientConfirmModal.
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
if (nameChanged) {
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
if (patientId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: patientId, data: { fullName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
if (leadId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ id: leadId, data: { contactName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update lead name:', err));
}
}
notify.success('Appointment Updated'); notify.success('Appointment Updated');
} else { } else {
// If no patient record exists yet (new caller), create one now
let resolvedPatientId = patientId;
if (!resolvedPatientId && callerNumber) {
const trimmedName = patientName.trim();
const nameParts = {
firstName: trimmedName.split(' ')[0] || '',
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
// Normalize phone to +91XXXXXXXXXX format
const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10);
const phoneE164 = `+91${phoneDigits}`;
try {
const patientData: Record<string, any> = {
fullName: nameParts,
phones: { primaryPhoneNumber: phoneE164 },
patientType: 'NEW',
};
if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0];
if (gender) patientData.gender = gender.toUpperCase();
const created = await apiClient.graphql<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: patientData },
);
resolvedPatientId = created.createPatient.id;
} catch (err) {
console.warn('Failed to create patient:', err);
}
}
// Create appointment // Create appointment
await apiClient.graphql( const appointmentData: Record<string, any> = {
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
}`,
{
data: {
scheduledAt, scheduledAt,
durationMin: 30, durationMin: 30,
appointmentType: 'CONSULTATION', appointmentType: 'CONSULTATION',
@@ -271,9 +346,17 @@ export const AppointmentForm = ({
department: selectedDoctor?.department ?? '', department: selectedDoctor?.department ?? '',
doctorId: doctor, doctorId: doctor,
reasonForVisit: chiefComplaint || null, reasonForVisit: chiefComplaint || null,
...(patientId ? { patientId } : {}), ...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
}, ...(clinic ? { clinicId: clinic } : {}),
}, ...(agentNotes ? { agentNotes } : {}),
...(source ? { source } : {}),
};
console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData));
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
createAppointment(data: $data) { id }
}`,
{ data: appointmentData },
); );
// Determine whether the agent actually renamed the patient. // Determine whether the agent actually renamed the patient.
@@ -283,13 +366,19 @@ export const AppointmentForm = ({
const trimmedName = patientName.trim(); const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
// DO NOT update the shared Patient entity when name changes // Update patient name when the agent explicitly renamed.
// during appointment creation. The Patient record is shared // `nameChanged` already requires isNameEditable=true (the
// across all appointments — modifying it here would // agent went through EditPatientConfirmModal), so the
// retroactively change the name on all past appointments. // rename intent is unambiguous. Bug #527's silent-overwrite
// The patient name for THIS appointment is stored on the // case can no longer happen because the confirm modal
// Appointment entity itself (via doctorName/department). // gates the input.
// Bug #527: removed updatePatient() call. if (nameChanged && patientId) {
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: patientId, data: { fullName: nameParts } },
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Update lead status/lastContacted on every appointment book // Update lead status/lastContacted on every appointment book
// (those are genuinely about this appointment), but only // (those are genuinely about this appointment), but only
@@ -316,21 +405,14 @@ export const AppointmentForm = ({
// If the agent actually renamed the patient, kick off the // If the agent actually renamed the patient, kick off the
// side-effect chain: regenerate the AI summary against the // side-effect chain: regenerate the AI summary against the
// corrected identity AND invalidate the Redis caller // corrected identity. Fire-and-forget; the save toast
// resolution cache so the next incoming call from this // fires immediately regardless.
// phone picks up fresh data. Both are fire-and-forget —
// the save toast fires immediately either way.
if (nameChanged && leadId) { if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {}); apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
} else if (callerNumber) {
// No rename but still invalidate the cache so status +
// lastContacted updates propagate cleanly to the next
// lookup.
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
} }
} }
onSaved?.(); onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED');
} catch (err) { } catch (err) {
console.error('Failed to save appointment:', err); console.error('Failed to save appointment:', err);
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.'); setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
@@ -353,7 +435,7 @@ export const AppointmentForm = ({
}, },
); );
notify.success('Appointment Cancelled'); notify.success('Appointment Cancelled');
onSaved?.(); onSaved?.('CANCELLED');
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel appointment'); setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
} finally { } finally {

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

@@ -14,11 +14,15 @@ interface CallLogProps {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' }, INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' }, NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' }, WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' }, NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
}; };
const formatDuration = (seconds: number | null): string => { const formatDuration = (seconds: number | null): string => {

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faSparkles, faPhone, faChevronDown, faChevronUp, 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) => { export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const navigate = useNavigate();
const [contextExpanded, setContextExpanded] = useState(true); const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true); const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true); const [actionsExpanded, setActionsExpanded] = useState(true);
@@ -120,6 +122,44 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length); const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
// Edit mode takes over the whole right panel — otherwise the
// AppointmentForm competes with the AI panel + context blocks for
// vertical space and gets crushed into a tiny strip at the bottom.
if (editingAppointment) {
return (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
<button
onClick={() => setEditingAppointment(null)}
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
>
Back to context
</button>
</div>
<AppointmentForm
isOpen={true}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
clinicId: editingAppointment.clinicId ?? undefined,
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Lead header — always visible */} {/* Lead header — always visible */}
@@ -163,6 +203,16 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
</div> </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 */} {/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && ( {(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
<div> <div>
@@ -223,6 +273,12 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
{linkedPatient.patientType && ( {linkedPatient.patientType && (
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge> <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>
)} )}
</div> </div>
@@ -298,28 +354,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} /> <AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
</div> </div>
{/* Appointment edit form */}
{editingAppointment && (
<AppointmentForm
isOpen={!!editingAppointment}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white ring-transparent', activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success', defaultClass: 'bg-success-primary text-success-primary border-success',
}, },
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{ {
value: 'FOLLOW_UP_SCHEDULED', value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed', label: 'Follow-up Needed',
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary', defaultClass: 'bg-secondary text-secondary border-secondary',
}, },
{ {
value: 'CALLBACK_REQUESTED', value: 'NOT_INTERESTED',
label: 'Not Interested', label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent', activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error', defaultClass: 'bg-error-primary text-error-primary border-error',
}, },
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white ring-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
]; ];
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => { export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {

View File

@@ -1,13 +1,43 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal'; import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { Badge } from '@/components/base/badges/badges';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import type { FC } from 'react'; import type { FC } from 'react';
import type { CallDisposition } from '@/types/entities'; import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY';
// Maps a recorded action to the disposition it implies. The first action in
// the priority list (highest-ranked entry in actionsTaken) becomes the
// primary disposition. When any action is present, all other dispositions
// are locked out — an agent can't mark a call as "Not Interested" after
// they've already booked an appointment.
const ACTION_TO_DISPOSITION: Record<CallAction, CallDisposition> = {
APPOINTMENT: 'APPOINTMENT_BOOKED',
RESCHEDULE: 'APPOINTMENT_RESCHEDULED',
CANCEL: 'APPOINTMENT_CANCELLED',
FOLLOWUP: 'FOLLOW_UP_SCHEDULED',
ENQUIRY: 'INFO_PROVIDED',
};
const ACTION_META: Record<CallAction, { label: string; icon: typeof faCalendarCheck; color: 'success' | 'warning' | 'error' | 'brand' | 'blue-light' }> = {
APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' },
RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' },
CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' },
FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' },
ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' },
};
// Priority order — highest-rank action wins when multiple are taken. Booked
// > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means
// no booking, so it ranks below booking/rescheduling; but above a follow-up
// because cancellation is a definitive outcome on this call.
const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY'];
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => ( const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneHangup} className={className} /> <FontAwesomeIcon icon={faPhoneHangup} className={className} />
); );
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
activeClass: 'bg-success-solid text-white border-transparent', activeClass: 'bg-success-solid text-white border-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success', defaultClass: 'bg-success-primary text-success-primary border-success',
}, },
{
value: 'APPOINTMENT_RESCHEDULED',
label: 'Appt Rescheduled',
activeClass: 'bg-warning-solid text-white border-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'APPOINTMENT_CANCELLED',
label: 'Appt Cancelled',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
{ {
value: 'FOLLOW_UP_SCHEDULED', value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed', label: 'Follow-up Needed',
@@ -49,31 +91,74 @@ const dispositionOptions: Array<{
defaultClass: 'bg-secondary text-secondary border-secondary', defaultClass: 'bg-secondary text-secondary border-secondary',
}, },
{ {
value: 'CALLBACK_REQUESTED', value: 'NOT_INTERESTED',
label: 'Not Interested', label: 'Not Interested',
activeClass: 'bg-error-solid text-white border-transparent', activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error', defaultClass: 'bg-error-primary text-error-primary border-error',
}, },
{
value: 'CALLBACK_REQUESTED',
label: 'Callback Requested',
activeClass: 'bg-utility-blue-600 text-white border-transparent',
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
},
{
value: 'CALL_DROPPED',
label: 'Call Dropped',
activeClass: 'bg-secondary-solid text-white border-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
]; ];
type DispositionModalProps = { type DispositionModalProps = {
isOpen: boolean; isOpen: boolean;
callerName: string; callerName: string;
callerDisconnected: boolean; callerDisconnected: boolean;
defaultDisposition?: CallDisposition | null; // True once the call reached the active (answered) state. When false,
// the customer never picked up — only no-answer dispositions are
// valid; conversation-implying ones (Info Provided, Appointment
// Booked, Follow-up, Not Interested) are disabled. Defaults to
// true so existing callers don't accidentally lock everything out.
callAnswered?: boolean;
// Actions actually performed during the call (appointment booked, enquiry
// logged, follow-up scheduled). Drives the priority-based disposition
// lock — when any action is present, the primary disposition is forced
// and the other options are disabled.
actionsTaken?: CallAction[];
onSubmit: (disposition: CallDisposition, notes: string) => void; onSubmit: (disposition: CallDisposition, notes: string) => void;
onDismiss?: () => void; onDismiss?: () => void;
}; };
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => { // Dispositions that only make sense when the customer actually connected.
// Selecting these on an unanswered call would misrepresent SLA and
// conversation metrics.
const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet<CallDisposition> = new Set([
'INFO_PROVIDED',
'APPOINTMENT_BOOKED',
'APPOINTMENT_RESCHEDULED',
'APPOINTMENT_CANCELLED',
'FOLLOW_UP_SCHEDULED',
'NOT_INTERESTED',
]);
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null); const [selected, setSelected] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined); const appliedLockRef = useRef<CallDisposition | null | undefined>(undefined);
// Pre-select when modal opens with a suggestion // Rank actionsTaken to pick the primary (highest-priority) action. When
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) { // any action is present, that action's disposition becomes locked —
appliedDefaultRef.current = defaultDisposition; // the agent cannot override it to a contradictory outcome.
setSelected(defaultDisposition); const primaryAction = actionsTaken && actionsTaken.length > 0
? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null
: null;
const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null;
// Apply the lock once per open — agent can still re-select the same
// option, but switching to another value is prevented in the click handler.
if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) {
appliedLockRef.current = lockedDisposition;
setSelected(lockedDisposition);
} }
const handleSubmit = () => { const handleSubmit = () => {
@@ -81,11 +166,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
onSubmit(selected, notes); onSubmit(selected, notes);
setSelected(null); setSelected(null);
setNotes(''); setNotes('');
appliedDefaultRef.current = undefined; appliedLockRef.current = undefined;
}; };
return ( return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}> <ModalOverlay
isOpen={isOpen}
// When the caller disconnected on their own, dismissing the
// modal discards the call without any disposition — no record,
// no SLA signal. Force a selection in that path. When the
// agent opened the modal via End Call (callerDisconnected=false),
// dismissing just returns to the active call, so it's safe.
isDismissable={!callerDisconnected}
onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}
>
<Modal className="sm:max-w-md"> <Modal className="sm:max-w-md">
<Dialog> <Dialog>
{() => ( {() => (
@@ -108,16 +202,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
{/* Disposition options */} {/* Disposition options */}
<div className="px-6 pb-4"> <div className="px-6 pb-4">
{actionsTaken && actionsTaken.length > 0 && (
<div className="mb-3 flex flex-col gap-2 rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold uppercase tracking-wide text-tertiary">
Actions taken on this call
</span>
<div className="flex flex-wrap gap-1.5">
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => {
const meta = ACTION_META[action];
return (
<Badge key={action} size="sm" color={meta.color} type="pill-color">
<FontAwesomeIcon icon={meta.icon} className="size-3 mr-1" />
{meta.label}
</Badge>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{dispositionOptions.map((option) => { {dispositionOptions.map((option) => {
const isSelected = selected === option.value; const isSelected = selected === option.value;
// Two reasons an option can be disabled:
// (1) action lock — the agent already booked / scheduled
// something, so only the matching disposition is valid.
// (2) unanswered call — dispositions that imply the customer
// actually spoke with the agent (Info Provided, etc.)
// are disabled to prevent SLA-gaming.
const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition;
const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value);
const isDisabled = isLockedOut || isAnsweredOnlyBlocked;
return ( return (
<button <button
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setSelected(option.value)} disabled={isDisabled}
onClick={() => !isDisabled && setSelected(option.value)}
className={cx( className={cx(
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear', 'rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
isDisabled && 'cursor-not-allowed opacity-40',
!isDisabled && 'cursor-pointer',
isSelected isSelected
? cx(option.activeClass, 'ring-2 ring-brand') ? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass, : option.defaultClass,

View File

@@ -22,7 +22,11 @@ type EnquiryFormProps = {
leadId?: string | null; leadId?: string | null;
patientId?: string | null; patientId?: string | null;
agentName?: string | null; agentName?: string | null;
onSaved?: () => void; // Called after a successful save. Passes back the list of actions that
// were actually recorded — the parent uses this to drive the disposition
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
// the agent scheduled a callback.
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
}; };
@@ -79,17 +83,20 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
setError(null); setError(null);
try { try {
// Use passed leadId or resolve from phone // Resolve caller. Resolver returns isNew=true when no Lead/
// Patient exists for this phone — in that case we create both
// records inline with the typed name. Otherwise we update the
// existing records.
let leadId: string | null = propLeadId ?? null; let leadId: string | null = propLeadId ?? null;
if (!leadId && registeredPhone) { let resolvedPatientId: string | null = patientId || null;
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true }); let isNew = false;
leadId = resolved.leadId; if ((!leadId || !resolvedPatientId) && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = leadId || resolved.leadId || null;
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
isNew = !!resolved.isNew && !leadId;
} }
// Determine whether the agent actually renamed the patient.
// Only a non-empty, changed-from-initial name counts — empty
// strings or an unchanged name never trigger the rename
// chain, even if the field was unlocked.
const trimmedName = patientName.trim(); const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
const nameParts = { const nameParts = {
@@ -97,10 +104,49 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
lastName: trimmedName.split(' ').slice(1).join(' ') || '', lastName: trimmedName.split(' ').slice(1).join(' ') || '',
}; };
if (leadId) { if (isNew) {
// Update existing lead with enquiry details. Only touches // Net-new caller — create Patient + Lead with the typed
// contactName if the agent explicitly renamed — otherwise // name. Name is required (validated above).
// we leave the existing caller identity alone. if (!trimmedName) {
setError('Please enter the patient name.');
setIsSaving(false);
return;
}
try {
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
const patientData: Record<string, any> = {
name: trimmedName,
fullName: nameParts,
patientType: 'NEW',
};
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: patientData },
);
resolvedPatientId = pResult.createPatient.id;
} catch (err) {
console.warn('Failed to create patient:', err);
}
const leadData: Record<string, any> = {
name: `Enquiry — ${trimmedName}`,
contactName: nameParts,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
};
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: leadData },
);
leadId = lResult.createLead.id;
} else if (leadId) {
// Existing lead — update with enquiry details. Only touch
// contactName when the agent explicitly renamed (the name
// field is locked behind the Edit confirm modal for
// existing records).
await apiClient.graphql( await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ {
@@ -114,34 +160,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
}, },
}, },
); );
} else {
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
} }
// Update linked patient's name ONLY if the agent explicitly // Update linked patient's name when the agent renamed (edit
// renamed. Fixes the long-standing bug where typing a name // confirm path) on an existing record. Skipped for isNew
// into this form silently overwrote the existing patient // because the patient was just created with the right name.
// record. if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
if (nameChanged && patientId) {
await apiClient.graphql( await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ {
id: patientId, id: resolvedPatientId,
data: { data: {
fullName: nameParts, fullName: nameParts,
}, },
@@ -149,14 +177,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
).catch((err: unknown) => console.warn('Failed to update patient name:', err)); ).catch((err: unknown) => console.warn('Failed to update patient name:', err));
} }
// Post-save side-effects. If the agent actually renamed the // Post-save side-effect. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation. // patient, kick off AI summary regen. Fire-and-forget.
// Otherwise just invalidate the cache so the status update
// propagates.
if (nameChanged && leadId) { if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {}); apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
} }
// Create follow-up if needed // Create follow-up if needed
@@ -166,6 +190,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
setIsSaving(false); setIsSaving(false);
return; return;
} }
const today = new Date().toISOString().split('T')[0];
if (followUpDate < today) {
setError('Follow-up date cannot be in the past.');
setIsSaving(false);
return;
}
await apiClient.graphql( await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, `mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{ {
@@ -176,7 +206,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
priority: 'NORMAL', priority: 'NORMAL',
assignedAgent: agentName ?? undefined, assignedAgent: agentName ?? undefined,
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(), scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
patientId: patientId ?? undefined, patientId: resolvedPatientId || undefined,
}, },
}, },
{ silent: true }, { silent: true },
@@ -184,7 +214,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
} }
notify.success('Enquiry Logged', 'Contact details and query captured'); notify.success('Enquiry Logged', 'Contact details and query captured');
onSaved?.(); const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
if (followUpNeeded) actions.push('FOLLOWUP');
onSaved?.(actions);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save enquiry'); setError(err instanceof Error ? err.message : 'Failed to save enquiry');
} finally { } finally {
@@ -251,11 +283,22 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3">
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" /> <Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && ( {followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired /> <div className="flex-1 max-w-[180px]">
<input
type="date"
value={followUpDate}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setFollowUpDate(e.target.value)}
required
aria-label="Follow-up Date"
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
/>
</div>
)} )}
</div>
{error && ( {error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div> <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>

View File

@@ -51,11 +51,15 @@ const ActivityIcon = ({ type }: { type: string }) => {
const dispositionLabels: Record<CallDisposition, string> = { const dispositionLabels: Record<CallDisposition, string> = {
APPOINTMENT_BOOKED: 'Appointment Booked', APPOINTMENT_BOOKED: 'Appointment Booked',
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
FOLLOW_UP_SCHEDULED: 'Follow-up Needed', FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
INFO_PROVIDED: 'Info Provided', INFO_PROVIDED: 'Info Provided',
NO_ANSWER: 'No Answer', NO_ANSWER: 'No Answer',
WRONG_NUMBER: 'Wrong Number', WRONG_NUMBER: 'Wrong Number',
CALLBACK_REQUESTED: 'Not Interested', NOT_INTERESTED: 'Not Interested',
CALLBACK_REQUESTED: 'Callback Requested',
CALL_DROPPED: 'Call Dropped',
}; };
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => { export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {

View File

@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
const fetchTargets = async () => { const fetchTargets = async () => {
try { try {
const [agentsRes, doctorsRes] = await Promise.all([ const [agentsRes, doctorsRes] = await Promise.all([
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`), apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`),
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`), apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
]); ]);
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? []) const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
.map((e: any) => e.node) .map((e: any) => e.node)
.filter((a: any) => a.ozonetelagentid !== currentAgentId) .filter((a: any) => a.ozonetelAgentId !== currentAgentId)
.map((a: any) => ({ .map((a: any) => ({
id: a.id, id: a.id,
name: a.name, name: a.name,
type: 'agent' as const, type: 'agent' as const,
phoneNumber: `0${a.sipextension}`, phoneNumber: `0${a.sipExtension}`,
status: 'offline' as const, status: 'offline' as const,
})); }));

View File

@@ -36,6 +36,8 @@ type WorklistFollowUp = {
followUpStatus: string | null; followUpStatus: string | null;
scheduledAt: string | null; scheduledAt: string | null;
priority: string | null; priority: string | null;
patientName?: string;
patientPhone?: string;
}; };
type MissedCall = { type MissedCall = {
@@ -45,11 +47,12 @@ type MissedCall = {
callerNumber: { number: string; callingCode: string }[] | null; callerNumber: { number: string; callingCode: string }[] | null;
startedAt: string | null; startedAt: string | null;
leadId: string | null; leadId: string | null;
leadName: string | null;
disposition: string | null; disposition: string | null;
callbackstatus: string | null; callbackStatus: string | null;
callsourcenumber: string | null; callSourceNumber: string | null;
missedcallcount: number | null; missedCallCount: number | null;
callbackattemptedat: string | null; callbackAttemptedAt: string | null;
}; };
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
@@ -107,7 +110,9 @@ const followUpLabel: Record<string, string> = {
REVIEW_REQUEST: 'Review', REVIEW_REQUEST: 'Review',
}; };
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { // SLA for reactive work — missed calls / unanswered leads. Measures time
// elapsed since the trigger: longer wait = worse SLA.
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
if (minutes < 1) return { label: '<1m', color: 'success' }; if (minutes < 1) return { label: '<1m', color: 'success' };
if (minutes < 15) return { label: `${minutes}m`, color: 'success' }; if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
@@ -118,6 +123,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
return { label: `${Math.floor(hours / 24)}d`, color: 'error' }; return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
}; };
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
// until the scheduled slot. Green when comfortably ahead, warning when
// due soon, error when overdue.
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
if (minutes < 0) {
const overdueMins = -minutes;
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
const overdueHrs = Math.floor(overdueMins / 60);
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
}
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
const hours = Math.floor(minutes / 60);
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
};
const computeSla = (
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
): { label: string; color: 'success' | 'warning' | 'error' } => {
if (row.type === 'follow-up' || row.type === 'callback') {
// scheduledAt was written into lastContactedAt during row construction.
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
}
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
};
const formatTimeAgo = (dateStr: string): string => { const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000); const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now'; if (minutes < 1) return 'Just now';
@@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const call of missedCalls) { for (const call of missedCalls) {
const phone = call.callerNumber?.[0]; const phone = call.callerNumber?.[0];
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : ''; const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
const sourceSuffix = call.callsourcenumber ? `${call.callsourcenumber}` : ''; const sourceSuffix = call.callSourceNumber ? `${call.callSourceNumber}` : '';
rows.push({ rows.push({
id: `mc-${call.id}`, id: `mc-${call.id}`,
type: 'missed', type: 'missed',
priority: 'HIGH', priority: 'HIGH',
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge, name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
phone: phone ? formatPhone(phone) : '', phone: phone ? formatPhone(phone) : '',
phoneRaw: phone?.number ?? '', phoneRaw: phone?.number ?? '',
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound', direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
@@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}` ? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
: 'Missed call', : 'Missed call',
createdAt: call.createdAt, createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId, leadId: call.leadId,
originalLead: null, originalLead: null,
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt, lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
contactAttempts: 0, contactAttempts: 0,
source: call.callsourcenumber ?? null, source: call.callSourceNumber ?? null,
lastDisposition: call.disposition ?? null, lastDisposition: call.disposition ?? null,
missedCallId: call.id, missedCallId: call.id,
}); });
@@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
for (const fu of followUps) { for (const fu of followUps) {
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date()); const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up'; const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
// Sidecar enriches follow-ups with patient name/phone when a
// patientId is linked. Fall back to the generic type label when
// no patient is attached.
const displayName = fu.patientName?.trim() || label;
const phoneFormatted = fu.patientPhone
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
: '';
rows.push({ rows.push({
id: `fu-${fu.id}`, id: `fu-${fu.id}`,
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up', type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'), priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
name: label, name: displayName,
phone: '', phone: phoneFormatted,
phoneRaw: '', phoneRaw: fu.patientPhone ?? '',
direction: null, direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up', typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
reason: fu.scheduledAt reason: fu.scheduledAt
@@ -230,8 +270,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
}); });
} }
// Remove rows without a phone number — agent can't act on them // Keep all rows — follow-ups may have no phone and still need to be visible.
const actionableRows = rows.filter(r => r.phoneRaw); // The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
const actionableRows = rows;
// Sort by rules engine score if available, otherwise by priority + createdAt // Sort by rules engine score if available, otherwise by priority + createdAt
actionableRows.sort((a, b) => { actionableRows.sort((a, b) => {
@@ -248,14 +289,19 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending'); // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' }); // sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist.
const missedSubTab: MissedSubTab = 'pending';
// Default SLA sort is ascending — the bucket-sorted result puts the
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
const missedByStatus = useMemo(() => ({ const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus), pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'), attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'), completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'), invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
}), [missedCalls]); }), [missedCalls]);
const allRows = useMemo( const allRows = useMemo(
@@ -273,7 +319,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
let rows = allRows; let rows = allRows;
if (tab === 'missed') rows = missedSubTabRows; if (tab === 'missed') rows = missedSubTabRows;
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead'); else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up'); else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
@@ -295,8 +341,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
case 'name': case 'name':
return a.name.localeCompare(b.name) * dir; return a.name.localeCompare(b.name) * dir;
case 'sla': { case 'sla': {
// Mixed SLA sort: SLA means different things by row type
// (elapsed for reactive, remaining for scheduled). Bucket
// rows by urgency, then sort within bucket — Overdue
// first, then reactive (oldest-first), then scheduled
// (soonest-due first). `dir` flips the whole ordering
// so the user can still toggle ascending/descending.
const urgencyBucket = (row: WorklistRow): number => {
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
if (isScheduled) {
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
}
return 1; // reactive (missed / lead)
};
const ba = urgencyBucket(a);
const bb = urgencyBucket(b);
if (ba !== bb) return (ba - bb) * dir;
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime(); const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime(); const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
// Within a bucket, ascending time = most urgent first
// (oldest overdue, oldest reactive, soonest upcoming).
return (ta - tb) * dir; return (ta - tb) * dir;
} }
default: default:
@@ -310,7 +375,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const missedCount = allRows.filter((r) => r.type === 'missed').length; const missedCount = allRows.filter((r) => r.type === 'missed').length;
const leadCount = allRows.filter((r) => r.type === 'lead').length; const leadCount = allRows.filter((r) => r.type === 'lead').length;
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length; const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
// Notification for new missed calls // Notification for new missed calls
const prevMissedCount = useRef(missedCount); const prevMissedCount = useRef(missedCount);
@@ -377,30 +442,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</div> </div>
</div> </div>
{/* Missed call status sub-tabs */} {/* Missed-call sub-tabs removed per QA feedback — the Missed tab
{tab === 'missed' && ( now only shows pending callbacks. Attempted is redundant once
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary"> the worklist is the single source of truth. */}
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
onClick={() => { setMissedSubTab(sub); setPage(1); }}
className={cx(
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
missedSubTab === sub
? 'bg-brand-50 text-brand-700 border border-brand-200'
: 'text-tertiary hover:text-secondary hover:bg-secondary',
)}
>
{sub}
{sub === 'pending' && missedByStatus.pending.length > 0 && (
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
{missedByStatus.pending.length}
</span>
)}
</button>
))}
</div>
)}
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -421,7 +465,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(row) => { {(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row.lastContactedAt ?? row.createdAt); const sla = computeSla(row);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
// Sub-line: last interaction context // Sub-line: last interaction context

View File

@@ -26,14 +26,27 @@ interface AgentTableProps {
export const AgentTable = ({ calls }: AgentTableProps) => { export const AgentTable = ({ calls }: AgentTableProps) => {
const agents = useMemo(() => { const agents = useMemo(() => {
const agentMap = new Map<string, Call[]>(); // Bucket by authoritative agent.id when present (from CDR enrichment);
// fall back to raw agentName for legacy rows that haven't been
// enriched yet. Skips rows with no agent info at all.
const agentMap = new Map<string, { displayName: string; calls: Call[] }>();
for (const call of calls) { for (const call of calls) {
const agent = call.agentName ?? 'Unknown'; let key: string;
if (!agentMap.has(agent)) agentMap.set(agent, []); let displayName: string;
agentMap.get(agent)!.push(call); if (call.agent?.id) {
key = call.agent.id;
displayName = call.agent.name ?? call.agent.ozonetelAgentId ?? 'Unknown';
} else if (call.agentName) {
key = `legacy:${call.agentName}`;
displayName = call.agentName;
} else {
continue;
}
if (!agentMap.has(key)) agentMap.set(key, { displayName, calls: [] });
agentMap.get(key)!.calls.push(call);
} }
return Array.from(agentMap.entries()).map(([name, agentCalls]) => { return Array.from(agentMap.entries()).map(([key, { displayName, calls: agentCalls }]) => {
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length; const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length; const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length; const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
@@ -43,11 +56,11 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0; const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length; const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
const conversion = total > 0 ? (booked / total) * 100 : 0; const conversion = total > 0 ? (booked / total) * 100 : 0;
const nameParts = name.split(' '); const nameParts = displayName.split(' ');
return { return {
id: name, id: key,
name, name: displayName,
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''), initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
inbound, outbound, missed, total, avgHandle, conversion, inbound, outbound, missed, total, avgHandle, conversion,
}; };
@@ -82,7 +95,7 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
{(agent) => ( {(agent) => (
<Table.Row id={agent.id}> <Table.Row id={agent.id}>
<Table.Cell> <Table.Cell>
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline"> <Link to={`/agent/${encodeURIComponent(agent.id)}`} className="no-underline">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar size="xs" initials={agent.initials} /> <Avatar size="xs" initials={agent.initials} />
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span> <span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>

View File

@@ -17,6 +17,8 @@ export type TelephonyFormValues = {
did: string; did: string;
sipId: string; sipId: string;
campaignName: string; campaignName: string;
adminUsername: string;
adminPassword: string;
}; };
sip: { sip: {
domain: string; domain: string;
@@ -37,6 +39,8 @@ export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
did: '', did: '',
sipId: '', sipId: '',
campaignName: '', campaignName: '',
adminUsername: '',
adminPassword: '',
}, },
sip: { sip: {
domain: 'blr-pub-rtc4.ozonetel.com', domain: 'blr-pub-rtc4.ozonetel.com',
@@ -108,6 +112,27 @@ export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
value={value.ozonetel.campaignName} value={value.ozonetel.campaignName}
onChange={(v) => patchOzonetel({ campaignName: v })} onChange={(v) => patchOzonetel({ campaignName: v })}
/> />
<div>
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
<p className="mt-0.5 text-xs text-tertiary">
Ozonetel portal admin credentials required for supervisor barge/whisper/listen.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Admin username"
placeholder="Ozonetel portal admin login"
value={value.ozonetel.adminUsername}
onChange={(v) => patchOzonetel({ adminUsername: v })}
/>
<Input
label="Admin password"
type="password"
placeholder="Leave '***masked***' to keep current"
value={value.ozonetel.adminPassword}
onChange={(v) => patchOzonetel({ adminPassword: v })}
/>
</div>
</section> </section>
<section className="flex flex-col gap-4"> <section className="flex flex-col gap-4">

View File

@@ -15,6 +15,7 @@ import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status'; import { useNetworkStatus } from '@/hooks/use-network-status';
import { GlobalSearch } from '@/components/shared/global-search';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -119,7 +120,9 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */} {/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && ( {(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 />} {isAdmin && <NotificationBell />}
{hasAgentConfig && ( {hasAgentConfig && (
<> <>
@@ -141,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
</> </>
)} )}
</div> </div>
</div>
)} )}
<ResumeSetupBanner /> <ResumeSetupBanner />
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>

View File

@@ -2,46 +2,14 @@ import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons'; import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts'; import { usePerformanceAlerts } from '@/hooks/use-performance-alerts';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const DEMO_ALERTS: PerformanceAlert[] = [
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
];
export const NotificationBell = () => { export const NotificationBell = () => {
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts(); const { alerts, dismiss, dismissAll } = usePerformanceAlerts();
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS); const [open, setOpen] = useState(false);
const [open, setOpen] = useState(true);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
// Use live alerts if available, otherwise demo
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
const isDemo = liveAlerts.length === 0;
const dismiss = (id: string) => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
} else {
liveDismiss(id);
}
};
const dismissAll = () => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
} else {
liveDismissAll();
}
};
// Close on outside click // Close on outside click
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -123,7 +91,7 @@ export const NotificationBell = () => {
<p className="text-sm font-medium text-primary">{alert.agent}</p> <p className="text-sm font-medium text-primary">{alert.agent}</p>
<p className="text-xs text-tertiary">{alert.type}</p> <p className="text-xs text-tertiary">{alert.type}</p>
</div> </div>
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge> <Badge size="sm" color={alert.severity === 'error' ? 'error' : alert.severity === 'warning' ? 'warning' : 'gray'} type="pill-color">{alert.value}</Badge>
<button <button
onClick={() => dismiss(alert.id)} onClick={() => dismiss(alert.id)}
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear" className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"

View File

@@ -132,7 +132,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom); const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null; const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const ozonetelState = useAgentState(agentId); const { state: ozonetelState } = useAgentState(agentId);
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline'; const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
@@ -280,7 +280,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<div> <div>
<h3 className="text-lg font-semibold text-primary">Sign out?</h3> <h3 className="text-lg font-semibold text-primary">Sign out?</h3>
<p className="mt-1 text-sm text-tertiary"> <p className="mt-1 text-sm text-tertiary">
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected. You will be logged out of Helix Engage and your telephony account. Any active calls will be disconnected.
</p> </p>
</div> </div>
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">

View File

@@ -2,11 +2,13 @@ import { useState, useEffect, useRef } from 'react';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline'; export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
export type SupervisorPresence = 'none' | 'whisper' | 'barge';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const useAgentState = (agentId: string | null): OzonetelState => { export const useAgentState = (agentId: string | null): { state: OzonetelState; supervisorPresence: SupervisorPresence } => {
const [state, setState] = useState<OzonetelState>('offline'); const [state, setState] = useState<OzonetelState>('offline');
const [supervisorPresence, setSupervisorPresence] = useState<SupervisorPresence>('none');
const prevStateRef = useRef<OzonetelState>('offline'); const prevStateRef = useRef<OzonetelState>('offline');
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
@@ -50,12 +52,26 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
localStorage.removeItem('helix_agent_config'); localStorage.removeItem('helix_agent_config');
localStorage.removeItem('helix_user'); localStorage.removeItem('helix_user');
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {}); import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip(false, 'agent-state-offline')).catch(() => {});
setTimeout(() => { window.location.href = '/login'; }, 1500); setTimeout(() => { window.location.href = '/login'; }, 1500);
return; return;
} }
// Supervisor presence events — don't replace agent state
if (data.state === 'supervisor-whisper') {
setSupervisorPresence('whisper');
return;
}
if (data.state === 'supervisor-barge') {
setSupervisorPresence('barge');
return;
}
if (data.state === 'supervisor-left') {
setSupervisorPresence('none');
return;
}
prevStateRef.current = data.state; prevStateRef.current = data.state;
setState(data.state); setState(data.state);
} catch { } catch {
@@ -74,5 +90,5 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
}; };
}, [agentId]); }, [agentId]);
return state; return { state, supervisorPresence };
}; };

View File

@@ -1,102 +1,101 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
export type PerformanceAlert = { export type PerformanceAlert = {
id: string; id: string;
agent: string; agent: string;
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion'; agentId: string | null;
type: string;
value: string; value: string;
severity: 'error' | 'warning'; severity: 'error' | 'warning' | 'info';
message?: string | null;
firedAt?: string;
dismissed: boolean; dismissed: boolean;
}; };
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const POLL_INTERVAL_MS = 60_000;
const sevToFront = (s: string): 'error' | 'warning' | 'info' => {
const v = (s ?? '').toLowerCase();
if (v === 'critical') return 'error';
if (v === 'warning') return 'warning';
return 'info';
};
export const usePerformanceAlerts = () => { export const usePerformanceAlerts = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { calls, leads } = useData();
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]); const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
const [teamPerf, setTeamPerf] = useState<any>(null); const lastSeenIdsRef = useRef<Set<string>>(new Set());
const toastsFiredRef = useRef(false);
// Fetch team performance data from sidecar (same as team-performance page) const load = useCallback(async () => {
useEffect(() => {
if (!isAdmin) return; if (!isAdmin) return;
const today = new Date().toISOString().split('T')[0];
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, { try {
const res = await fetch(`${API_URL}/api/supervisor/performance-alerts`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) });
.then(r => r.ok ? r.json() : null) if (!res.ok) return;
.then(data => setTeamPerf(data)) const json = await res.json();
.catch(() => {}); const list: PerformanceAlert[] = (json?.alerts ?? []).map((a: any) => ({
id: a.id,
agent: a.agent,
agentId: a.agentId ?? null,
type: a.type,
value: a.value ?? '',
severity: sevToFront(a.severity),
message: a.message,
firedAt: a.firedAt,
dismissed: false,
}));
setAlerts(list);
// Toast for newly arrived alerts
const fresh = list.filter((a) => !lastSeenIdsRef.current.has(a.id));
if (fresh.length > 0 && lastSeenIdsRef.current.size > 0) {
notify.error('Performance Alerts', `${fresh.length} new alert(s)`);
}
lastSeenIdsRef.current = new Set(list.map((a) => a.id));
} catch {
// Silent — sidecar may be temporarily down
}
}, [isAdmin]); }, [isAdmin]);
// Compute alerts from team performance + entity data
useMemo(() => {
if (!isAdmin || !teamPerf?.agents) return;
const parseTime = (t: string): number => {
const parts = t.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
};
const list: PerformanceAlert[] = [];
let idx = 0;
for (const agent of teamPerf.agents) {
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const totalCalls = agentCalls.length;
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
const tb = agent.timeBreakdown;
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
}
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
}
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
}
}
setAlerts(list);
}, [isAdmin, teamPerf, calls, leads]);
// Fire toasts once when alerts first load
useEffect(() => { useEffect(() => {
if (toastsFiredRef.current || alerts.length === 0) return; if (!isAdmin) return;
toastsFiredRef.current = true; load();
const id = setInterval(load, POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [isAdmin, load]);
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length; const dismiss = useCallback(async (id: string) => {
const npsCount = alerts.filter(a => a.type === 'Low NPS').length; // Optimistic
const convCount = alerts.filter(a => a.type === 'Low Conversion').length; setAlerts((prev) => prev.filter((a) => a.id !== id));
const token = localStorage.getItem('helix_access_token') ?? '';
const parts: string[] = []; try {
if (idleCount > 0) parts.push(`${idleCount} excessive idle`); await fetch(`${API_URL}/api/supervisor/performance-alerts/${id}/dismiss`, {
if (npsCount > 0) parts.push(`${npsCount} low NPS`); method: 'POST',
if (convCount > 0) parts.push(`${convCount} low conversion`); headers: { Authorization: `Bearer ${token}` },
});
if (parts.length > 0) { } catch {
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`); // Reload on failure to restore truth
load();
} }
}, [alerts]); }, [load]);
const dismiss = (id: string) => { const dismissAll = useCallback(async () => {
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a)); setAlerts([]);
}; const token = localStorage.getItem('helix_access_token') ?? '';
try {
await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch {
load();
}
}, [load]);
const dismissAll = () => { return { alerts, allAlerts: alerts, dismiss, dismissAll };
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
};
const activeAlerts = alerts.filter(a => !a.dismissed);
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
}; };

View File

@@ -15,10 +15,11 @@ type MissedCall = {
disposition: string | null; disposition: string | null;
callNotes: string | null; callNotes: string | null;
leadId: string | null; leadId: string | null;
callbackstatus: string | null; leadName: string | null;
callsourcenumber: string | null; callbackStatus: string | null;
missedcallcount: number | null; callSourceNumber: string | null;
callbackattemptedat: string | null; missedCallCount: number | null;
callbackAttemptedAt: string | null;
}; };
type WorklistFollowUp = { type WorklistFollowUp = {
@@ -32,6 +33,8 @@ type WorklistFollowUp = {
assignedAgent: string | null; assignedAgent: string | null;
patientId: string | null; patientId: string | null;
callId: string | null; callId: string | null;
patientName?: string;
patientPhone?: string;
}; };
type WorklistLead = { type WorklistLead = {

View File

@@ -54,6 +54,8 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
startedAt endedAt durationSec startedAt endedAt durationSec
recording { primaryLinkUrl } disposition sla recording { primaryLinkUrl } disposition sla
patientId appointmentId leadId patientId appointmentId leadId
agentId agent { id name ozonetelAgentId }
transferredTo transferType
} } } }`; } } } }`;
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
@@ -71,7 +73,8 @@ export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ schedu
scheduledAt durationMin appointmentType status scheduledAt durationMin appointmentType status
doctorName department reasonForVisit doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id } doctor { id fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } } }`; } } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node { export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {

View File

@@ -7,6 +7,10 @@ export class SIPClient {
private ua: JsSIP.UA | null = null; private ua: JsSIP.UA | null = null;
private currentSession: RTCSession | null = null; private currentSession: RTCSession | null = null;
private audioElement: HTMLAudioElement | null = null; private audioElement: HTMLAudioElement | null = null;
// Watchdog that alerts if REGISTER never completes after a connect.
// Cleared on 'registered' / 'registrationFailed' / disconnect.
private registrationWatchdog: number | null = null;
private readonly REGISTRATION_TIMEOUT_MS = 15_000;
constructor( constructor(
private config: SIPConfig, private config: SIPConfig,
@@ -36,28 +40,43 @@ export class SIPClient {
this.ua = new JsSIP.UA(configuration); this.ua = new JsSIP.UA(configuration);
console.log(`[SIP] start() uri=${this.config.uri} ws=${this.config.wsServer} expires=${configuration.register_expires}s`);
this.ua.on('connecting', () => {
console.log('[SIP] WebSocket connecting…');
});
this.ua.on('connected', () => { this.ua.on('connected', () => {
console.log('[SIP] WebSocket connected'); console.log('[SIP] WebSocket connected — waiting for REGISTER');
this.onConnectionChange('connected'); this.onConnectionChange('connected');
}); });
this.ua.on('disconnected', () => { this.ua.on('disconnected', (e: any) => {
console.log('[SIP] WebSocket disconnected'); const code = e?.code ?? 'n/a';
const reason = e?.reason ?? 'unknown';
console.log(`[SIP] WebSocket disconnected — code=${code} reason=${reason}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected'); this.onConnectionChange('disconnected');
}); });
this.ua.on('registered', () => { this.ua.on('registered', () => {
console.log('[SIP] Registered successfully'); console.log('[SIP] Registered successfully');
this.clearRegistrationWatchdog();
this.onConnectionChange('registered'); this.onConnectionChange('registered');
}); });
this.ua.on('unregistered', () => { this.ua.on('unregistered', () => {
console.log('[SIP] Unregistered'); console.log('[SIP] Unregistered');
this.clearRegistrationWatchdog();
this.onConnectionChange('disconnected'); this.onConnectionChange('disconnected');
}); });
this.ua.on('registrationFailed', () => { this.ua.on('registrationFailed', (e: any) => {
console.error('[SIP] Registration failed'); const cause = e?.cause ?? 'unknown';
const statusCode = e?.response?.status_code ?? 'n/a';
const reasonPhrase = e?.response?.reason_phrase ?? '';
console.error(`[SIP] Registration failed — cause=${cause} status=${statusCode} ${reasonPhrase}`);
this.clearRegistrationWatchdog();
this.onConnectionChange('error'); this.onConnectionChange('error');
}); });
@@ -125,9 +144,25 @@ export class SIPClient {
}); });
this.ua.start(); this.ua.start();
// Arm the registration watchdog. If we don't hear 'registered' or
// 'registrationFailed' within the timeout, surface a visible error so
// the user isn't left staring at "Connecting to telephony…" forever.
this.registrationWatchdog = window.setTimeout(() => {
console.error(`[SIP] Registration timeout — no REGISTER response after ${this.REGISTRATION_TIMEOUT_MS}ms. Check SIP credentials / WebSocket reachability.`);
this.onConnectionChange('error');
}, this.REGISTRATION_TIMEOUT_MS);
}
private clearRegistrationWatchdog(): void {
if (this.registrationWatchdog !== null) {
window.clearTimeout(this.registrationWatchdog);
this.registrationWatchdog = null;
}
} }
disconnect(): void { disconnect(): void {
this.clearRegistrationWatchdog();
this.hangup(); this.hangup();
if (this.ua) { if (this.ua) {
this.ua.stop(); this.ua.stop();

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

@@ -150,26 +150,39 @@ export function transformCalls(data: any): Call[] {
patientId: n.patientId, patientId: n.patientId,
appointmentId: n.appointmentId, appointmentId: n.appointmentId,
leadId: n.leadId, leadId: n.leadId,
agentId: n.agentId ?? null,
agent: n.agent ?? null,
transferredTo: n.transferredTo ?? null,
transferType: n.transferType ?? null,
})); }));
} }
export function transformAppointments(data: any): Appointment[] { export function transformAppointments(data: any): Appointment[] {
return extractEdges(data, 'appointments').map((n) => ({ return extractEdges(data, 'appointments').map((n) => {
// Doctor name: prefer the relation's fullName (authoritative — pulled
// from the Doctor entity). Fall back to the denormalized doctorName
// field for legacy rows that predate the doctor relation being fetched.
const doctorFullName = n.doctor?.fullName
? `${n.doctor.fullName.firstName ?? ''} ${n.doctor.fullName.lastName ?? ''}`.trim()
: '';
return {
id: n.id, id: n.id,
createdAt: n.createdAt, createdAt: n.createdAt,
scheduledAt: n.scheduledAt, scheduledAt: n.scheduledAt,
durationMinutes: n.durationMin ?? 30, durationMinutes: n.durationMin ?? 30,
appointmentType: n.appointmentType, appointmentType: n.appointmentType,
appointmentStatus: n.status, appointmentStatus: n.status,
doctorName: n.doctorName, doctorName: doctorFullName || n.doctorName || null,
doctorId: n.doctor?.id ?? null, doctorId: n.doctor?.id ?? null,
department: n.department, department: n.department,
reasonForVisit: n.reasonForVisit, reasonForVisit: n.reasonForVisit,
patientId: n.patient?.id ?? null, patientId: n.patient?.id ?? null,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null, patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null, patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
clinicName: n.department ?? null, clinicId: n.clinicId ?? n.clinic?.id ?? null,
})); clinicName: n.clinic?.clinicName ?? null,
};
});
} }
export function transformPatients(data: any): Patient[] { export function transformPatients(data: any): Patient[] {

View File

@@ -10,6 +10,11 @@ const AdminSetupGuard = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />; 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 { RoleRouter } from "@/components/layout/role-router";
import { NotFound } from "@/pages/not-found"; import { NotFound } from "@/pages/not-found";
import { AllLeadsPage } from "@/pages/all-leads"; import { AllLeadsPage } from "@/pages/all-leads";
@@ -85,6 +90,8 @@ createRoot(document.getElementById("root")!).render(
<Route path="/call-desk" element={<CallDeskPage />} /> <Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/patients" element={<PatientsPage />} /> <Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} /> <Route path="/appointments" element={<AppointmentsPage />} />
{/* Admin-only routes */}
<Route element={<RequireAdmin />}>
<Route path="/team-performance" element={<TeamPerformancePage />} /> <Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} /> <Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} /> <Route path="/call-recordings" element={<CallRecordingsPage />} />
@@ -92,8 +99,6 @@ createRoot(document.getElementById("root")!).render(
<Route path="/team-dashboard" element={<TeamDashboardPage />} /> <Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} /> <Route path="/integrations" element={<IntegrationsPage />} />
{/* Settings hub + section pages */}
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/team" element={<TeamSettingsPage />} /> <Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} /> <Route path="/settings/clinics" element={<ClinicsPage />} />
@@ -101,6 +106,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/settings/telephony" element={<TelephonySettingsPage />} /> <Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} /> <Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} /> <Route path="/settings/widget" element={<WidgetSettingsPage />} />
</Route>
<Route path="/agent/:id" element={<AgentDetailPage />} /> <Route path="/agent/:id" element={<AgentDetailPage />} />
<Route path="/patient/:id" element={<Patient360Page />} /> <Route path="/patient/:id" element={<Patient360Page />} />

View File

@@ -65,11 +65,15 @@ const formatPhoneDisplay = (call: Call): string => {
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' }, INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' }, NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' }, WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' }, CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
}; };
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => { const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
@@ -84,20 +88,39 @@ const DirectionIcon = ({ direction, status }: { direction: CallDirection | null;
export const AgentDetailPage = () => { export const AgentDetailPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { calls, leads, loading } = useData(); const { calls, leads, agents, loading } = useData();
const agentName = id ? decodeURIComponent(id) : ''; // Route param is either a platform Agent UUID (new bucketing) or
// "legacy:<rawAgentName>" for calls that haven't been enriched yet.
// Older bookmarks may still pass the raw display name — handle that too.
const rawId = id ? decodeURIComponent(id) : '';
const isLegacy = rawId.startsWith('legacy:');
const agentUuid = !isLegacy ? rawId : null;
const legacyName = isLegacy ? rawId.slice('legacy:'.length) : null;
// Resolve display name: prefer Agent entity name, else the legacy string.
const agentName = useMemo(() => {
if (agentUuid) {
const a = agents.find((x: any) => x.id === agentUuid);
return a?.name ?? rawId;
}
return legacyName ?? '';
}, [agentUuid, legacyName, agents, rawId]);
const agentCalls = useMemo( const agentCalls = useMemo(
() => () =>
calls calls
.filter((c) => c.agentName === agentName) .filter((c) => {
if (agentUuid) return c.agentId === agentUuid;
if (legacyName) return !c.agentId && c.agentName === legacyName;
return false;
})
.sort((a, b) => { .sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0; const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return dateB - dateA; return dateB - dateA;
}), }),
[calls, agentName], [calls, agentUuid, legacyName],
); );
// Build lead name map for enrichment // Build lead name map for enrichment

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams, useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
@@ -38,6 +38,7 @@ const PAGE_SIZE = 15;
export const AllLeadsPage = () => { export const AllLeadsPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialSource = searchParams.get('source') as LeadSource | null; const initialSource = searchParams.get('source') as LeadSource | null;
const [tab, setTab] = useState<TabKey>('new'); const [tab, setTab] = useState<TabKey>('new');
@@ -231,11 +232,11 @@ export const AllLeadsPage = () => {
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary"> <div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button
href="/" onClick={() => navigate(-1)}
color="secondary" color="secondary"
size="sm" size="sm"
iconLeading={ArrowLeft} iconLeading={ArrowLeft}
aria-label="Back to workspace" aria-label="Back"
/> />
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}> <Tabs selectedKey={tab} onSelectionChange={handleTabChange}>

View File

@@ -27,8 +27,11 @@ type AppointmentRecord = {
fullName: { firstName: string; lastName: string } | null; fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null; phones: { primaryPhoneNumber: string } | null;
} | null; } | null;
clinic: {
clinicName: string;
} | null;
doctor: { doctor: {
clinic: { clinicName: string } | null; id: string;
} | null; } | null;
}; };
@@ -58,6 +61,7 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
id scheduledAt durationMin appointmentType status id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { clinicName }
doctor { id } doctor { id }
} } } }`; } } } }`;
@@ -103,7 +107,7 @@ export const AppointmentsPage = () => {
const phone = a.patient?.phones?.primaryPhoneNumber ?? ''; const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
const doctor = (a.doctorName ?? '').toLowerCase(); const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase(); const dept = (a.department ?? '').toLowerCase();
const branch = (a.department ?? '').toLowerCase(); const branch = (a.clinic?.clinicName ?? '').toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q); return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
}); });
} }
@@ -177,7 +181,7 @@ export const AppointmentsPage = () => {
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown' ? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
: 'Unknown'; : 'Unknown';
const phone = appt.patient?.phones?.primaryPhoneNumber ?? ''; const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
const branch = appt.department ?? '—'; const branch = appt.clinic?.clinicName ?? '—';
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
@@ -213,7 +217,7 @@ export const AppointmentsPage = () => {
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span> <span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span> <span className="text-xs text-tertiary truncate block max-w-[180px]" title={branch}>{branch}</span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color"> <Badge size="sm" color={statusColor} type="pill-color">

View File

@@ -19,7 +19,7 @@ import { cx } from '@/utils/cx';
export const CallDeskPage = () => { export const CallDeskPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound } = useSip(); const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
const { missedCalls, followUps, marketingLeads, loading } = useWorklist(); const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null); const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true);
@@ -91,7 +91,7 @@ export const CallDeskPage = () => {
.then((result) => { .then((result) => {
setResolvedCaller(result); setResolvedCaller(result);
if (result.isNew) { if (result.isNew) {
notify.info('New Caller', 'Lead and patient records created'); notify.info('New Caller', 'No existing records found for this number');
} }
}) })
.catch((err) => { .catch((err) => {
@@ -204,11 +204,11 @@ export const CallDeskPage = () => {
</div> </div>
<button <button
onClick={handleDial} onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10} disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear" className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
> >
<FontAwesomeIcon icon={faPhone} className="size-3.5" /> <FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : 'Call'} {dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
</button> </button>
</div> </div>
)} )}

View File

@@ -35,11 +35,15 @@ const filterItems = [
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' }, FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' }, INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' }, NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' }, WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' }, CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
}; };
const formatDuration = (seconds: number | null): string => { const formatDuration = (seconds: number | null): string => {
@@ -139,8 +143,9 @@ export const CallHistoryPage = () => {
return dateB - dateA; return dateB - dateA;
}); });
// Direction / status filter // Direction / status filter. "Inbound" shows answered inbound only — missed
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND'); // calls have their own dedicated filter so they don't double-appear.
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND'); else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED'); else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
@@ -234,7 +239,8 @@ export const CallHistoryPage = () => {
</Table.Header> </Table.Header>
<Table.Body items={pagedCalls}> <Table.Body items={pagedCalls}>
{(call) => { {(call) => {
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown'; const phoneRawForName = call.callerNumber?.[0]?.number ?? '';
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRawForName ? formatPhone({ number: phoneRawForName, callingCode: '+91' }) : 'Unknown');
const phoneDisplay = formatPhoneDisplay(call); const phoneDisplay = formatPhoneDisplay(call);
const phoneRaw = call.callerNumber?.[0]?.number ?? ''; const phoneRaw = call.callerNumber?.[0]?.number ?? '';
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null; const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;

View File

@@ -1,12 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons'; import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar'; import { TopBar } from '@/components/layout/top-bar';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { BargeControls } from '@/components/call-desk/barge-controls';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
type ActiveCall = { type ActiveCall = {
ucid: string; ucid: string;
@@ -17,6 +19,18 @@ type ActiveCall = {
status: 'active' | 'on-hold'; status: 'active' | 'on-hold';
}; };
type CallerContext = {
name: string;
phone: string;
source: string | null;
status: string | null;
interestedService: string | null;
aiSummary: string | null;
patientType: string | null;
leadId: string | null;
appointments: Array<{ id: string; scheduledAt: string; doctorName: string; department: string; status: string }>;
};
const formatDuration = (startTime: string): string => { const formatDuration = (startTime: string): string => {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000)); const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
@@ -25,10 +39,10 @@ const formatDuration = (startTime: string): string => {
}; };
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => ( const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6"> <div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-4">
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mb-1" />
<p className="text-3xl font-bold text-primary">{value}</p> <p className="text-xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary mt-1">{label}</p> <p className="text-[11px] text-tertiary">{label}</p>
</div> </div>
); );
@@ -36,13 +50,23 @@ export const LiveMonitorPage = () => {
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]); const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const [selectedCall, setSelectedCall] = useState<ActiveCall | null>(null);
const [callerContext, setCallerContext] = useState<CallerContext | null>(null);
const [contextLoading, setContextLoading] = useState(false);
const { leads } = useData(); const { leads } = useData();
// Poll active calls every 5 seconds // Poll active calls every 5 seconds
useEffect(() => { useEffect(() => {
const fetchCalls = () => { const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true }) apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(setActiveCalls) .then(calls => {
setActiveCalls(calls);
// If selected call ended, clear selection
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -50,9 +74,9 @@ export const LiveMonitorPage = () => {
fetchCalls(); fetchCalls();
const interval = setInterval(fetchCalls, 5000); const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [selectedCall?.ucid]);
// Tick every second to update duration counters // Tick every second for duration display
useEffect(() => { useEffect(() => {
const interval = setInterval(() => setTick(t => t + 1), 1000); const interval = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
@@ -82,14 +106,68 @@ export const LiveMonitorPage = () => {
return null; return null;
}; };
// Fetch caller context when a call is selected
const handleSelectCall = (call: ActiveCall) => {
setSelectedCall(call);
setContextLoading(true);
setCallerContext(null);
const phoneClean = call.callerNumber.replace(/\D/g, '');
// Search for lead by phone
apiClient.graphql<{ leads: { edges: Array<{ node: any }> } }>(
`{ leads(first: 5, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phoneClean.slice(-10)}" } } }) { edges { node {
id contactName { firstName lastName } source status interestedService aiSummary patientId
} } } }`,
).then(async (data) => {
const lead = data.leads.edges[0]?.node;
const name = lead
? `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim()
: resolveCallerName(call.callerNumber) ?? 'Unknown Caller';
let appointments: CallerContext['appointments'] = [];
if (lead?.patientId) {
try {
const apptData = await apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
`{ appointments(first: 5, filter: { patientId: { eq: "${lead.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt doctorName department status
} } } }`,
);
appointments = apptData.appointments.edges.map(e => e.node);
} catch { /* best effort */ }
}
setCallerContext({
name,
phone: call.callerNumber,
source: lead?.source ?? null,
status: lead?.status ?? null,
interestedService: lead?.interestedService ?? null,
aiSummary: lead?.aiSummary ?? null,
patientType: lead?.patientId ? 'RETURNING' : 'NEW',
leadId: lead?.id ?? null,
appointments,
});
}).catch(() => {
setCallerContext({
name: resolveCallerName(call.callerNumber) ?? 'Unknown Caller',
phone: call.callerNumber,
source: null, status: null, interestedService: null,
aiSummary: null, patientType: null, leadId: null, appointments: [],
});
}).finally(() => setContextLoading(false));
};
return ( return (
<> <>
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" /> <TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 overflow-hidden">
{/* Left panel — KPIs + call list */}
<div className="flex flex-1 flex-col overflow-y-auto border-r border-secondary">
{/* KPI Cards */} {/* KPI Cards */}
<div className="px-6 pt-5"> <div className="px-5 pt-4">
<div className="flex gap-4"> <div className="flex gap-3">
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} /> <KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
<KpiCard value={onHold} label="On Hold" icon={faPause} /> <KpiCard value={onHold} label="On Hold" icon={faPause} />
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} /> <KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
@@ -97,7 +175,7 @@ export const LiveMonitorPage = () => {
</div> </div>
{/* Active Calls Table */} {/* Active Calls Table */}
<div className="px-6 pt-6"> <div className="px-5 pt-5 pb-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3> <h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
{loading ? ( {loading ? (
@@ -118,16 +196,23 @@ export const LiveMonitorPage = () => {
<Table.Head label="Type" className="w-16" /> <Table.Head label="Type" className="w-16" />
<Table.Head label="Duration" className="w-20" /> <Table.Head label="Duration" className="w-20" />
<Table.Head label="Status" className="w-24" /> <Table.Head label="Status" className="w-24" />
<Table.Head label="Actions" className="w-48" />
</Table.Header> </Table.Header>
<Table.Body items={activeCalls}> <Table.Body items={activeCalls}>
{(call) => { {(call) => {
const callerName = resolveCallerName(call.callerNumber); const callerName = resolveCallerName(call.callerNumber);
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out'; const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand'; const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
const isSelected = selectedCall?.ucid === call.ucid;
return ( return (
<Table.Row id={call.ucid}> <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> <Table.Cell>
<span className="text-sm font-medium text-primary">{call.agentId}</span> <span className="text-sm font-medium text-primary">{call.agentId}</span>
</Table.Cell> </Table.Cell>
@@ -148,13 +233,6 @@ export const LiveMonitorPage = () => {
{call.status} {call.status}
</Badge> </Badge>
</Table.Cell> </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.Row>
); );
}} }}
@@ -162,18 +240,123 @@ export const LiveMonitorPage = () => {
</Table> </Table>
)} )}
</div> </div>
</div>
{/* Monitoring hint */} {/* Right panel — context + barge controls */}
{activeCalls.length > 0 && ( <div className="flex w-[380px] shrink-0 flex-col overflow-y-auto bg-primary">
<div className="px-6 pt-6 pb-8"> {!selectedCall ? (
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" /> <FontAwesomeIcon icon={faHeadset} className="size-10 text-fg-quaternary" />
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p> <p className="text-sm font-medium text-secondary">Select a call to monitor</p>
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p> <p className="text-xs text-tertiary">Click on any active call to see context and connect</p>
</div>
) : contextLoading ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading caller context...</p>
</div>
) : (
<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>
{/* 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>
<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>
</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> </div>
)} )}
</div> </div>
</div>
</> </>
); );
}; };

View File

@@ -22,10 +22,10 @@ type MissedCallRecord = {
callerNumber: { primaryPhoneNumber: string } | null; callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null; agentName: string | null;
startedAt: string | null; startedAt: string | null;
callsourcenumber: string | null; callSourceNumber: string | null;
callbackstatus: string | null; callbackStatus: string | null;
missedcallcount: number | null; missedCallCount: number | null;
callbackattemptedat: string | null; callbackAttemptedAt: string | null;
sla: number | null; sla: number | null;
}; };
@@ -35,7 +35,7 @@ const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED } callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
} } } }`; } } } }`;
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -92,7 +92,7 @@ export const MissedCallsPage = () => {
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const c of calls) { for (const c of calls) {
const s = c.callbackstatus ?? 'PENDING_CALLBACK'; const s = c.callbackStatus ?? 'PENDING_CALLBACK';
counts[s] = (counts[s] ?? 0) + 1; counts[s] = (counts[s] ?? 0) + 1;
} }
return counts; return counts;
@@ -100,16 +100,16 @@ export const MissedCallsPage = () => {
const filtered = useMemo(() => { const filtered = useMemo(() => {
let rows = calls; let rows = calls;
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus); if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'); else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'); else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(c => rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.agentName ?? '').toLowerCase().includes(q) || (c.agentName ?? '').toLowerCase().includes(q) ||
(c.callsourcenumber ?? '').toLowerCase().includes(q), (c.callSourceNumber ?? '').toLowerCase().includes(q),
); );
} }
@@ -122,7 +122,7 @@ export const MissedCallsPage = () => {
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0; const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return (ta - tb) * dir; return (ta - tb) * dir;
} }
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir; case 'count': return ((a.missedCallCount ?? 1) - (b.missedCallCount ?? 1)) * dir;
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir; case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir; case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
default: return 0; default: return 0;
@@ -190,7 +190,7 @@ export const MissedCallsPage = () => {
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(call) => { {(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? ''; const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackstatus ?? 'PENDING_CALLBACK'; const status = call.callbackStatus ?? 'PENDING_CALLBACK';
return ( return (
<Table.Row id={call.id}> <Table.Row id={call.id}>
@@ -213,7 +213,7 @@ export const MissedCallsPage = () => {
)} )}
{visibleColumns.has('branch') && ( {visibleColumns.has('branch') && (
<Table.Cell> <Table.Cell>
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span> <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
</Table.Cell> </Table.Cell>
)} )}
{visibleColumns.has('agent') && ( {visibleColumns.has('agent') && (
@@ -223,8 +223,8 @@ export const MissedCallsPage = () => {
)} )}
{visibleColumns.has('count') && ( {visibleColumns.has('count') && (
<Table.Cell> <Table.Cell>
{call.missedcallcount && call.missedcallcount > 1 ? ( {call.missedCallCount && call.missedCallCount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge> <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>} ) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell> </Table.Cell>
)} )}
@@ -256,10 +256,10 @@ export const MissedCallsPage = () => {
)} )}
{visibleColumns.has('callback') && ( {visibleColumns.has('callback') && (
<Table.Cell> <Table.Cell>
{call.callbackattemptedat ? ( {call.callbackAttemptedAt ? (
<div> <div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span> <span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span> <span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
</div> </div>
) : <span className="text-xs text-quaternary"></span>} ) : <span className="text-xs text-quaternary"></span>}
</Table.Cell> </Table.Cell>

View File

@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
], ],
barWidth: '50%', barWidth: '50%',
itemStyle: { borderRadius: [4, 4, 0, 0] }, itemStyle: { borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
}], }],
}} }}
style={{ height: 240 }} style={{ height: 240 }}
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
type: 'pie', type: 'pie',
radius: ['45%', '70%'], radius: ['45%', '70%'],
center: ['35%', '50%'], center: ['35%', '50%'],
avoidLabelOverlap: false, avoidLabelOverlap: true,
label: { show: false }, label: { show: true, formatter: '{d}%', fontSize: 11, color: '#344054', fontWeight: 600 },
labelLine: { show: true, length: 6, length2: 6 },
data: Object.entries(data.dispositions).map(([name, value], i) => ({ data: Object.entries(data.dispositions).map(([name, value], i) => ({
name, name,
value, value,

View File

@@ -15,8 +15,10 @@ import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-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 { apiClient } from '@/lib/api-client';
import { formatShortDate, getInitials } from '@/lib/format'; import { formatShortDate, getInitials } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities'; import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
@@ -49,11 +51,15 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = { const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
APPOINTMENT_BOOKED: 'success', APPOINTMENT_BOOKED: 'success',
APPOINTMENT_RESCHEDULED: 'warning',
APPOINTMENT_CANCELLED: 'error',
FOLLOW_UP_SCHEDULED: 'brand', FOLLOW_UP_SCHEDULED: 'brand',
INFO_PROVIDED: 'blue', INFO_PROVIDED: 'blue',
WRONG_NUMBER: 'error', WRONG_NUMBER: 'error',
NO_ANSWER: 'warning', NO_ANSWER: 'warning',
NOT_INTERESTED: 'error',
CALLBACK_REQUESTED: 'gray', CALLBACK_REQUESTED: 'gray',
CALL_DROPPED: 'gray',
}; };
const TABS = [ const TABS = [
@@ -96,15 +102,16 @@ type PatientData = {
}; };
// Appointment row component // 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 scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = { const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning', CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
}; };
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
return ( 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"> <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
<CalendarCheck className="size-4 text-fg-white" /> <CalendarCheck className="size-4 text-fg-white" />
</div> </div>
@@ -266,6 +273,9 @@ export const Patient360Page = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('appointments'); const [activeTab, setActiveTab] = useState<string>('appointments');
const [noteText, setNoteText] = useState(''); 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 [patient, setPatient] = useState<PatientData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]); const [activities, setActivities] = useState<LeadActivity[]>([]);
@@ -383,6 +393,11 @@ export const Patient360Page = () => {
{leadInfo.source.replace(/_/g, ' ')} {leadInfo.source.replace(/_/g, ' ')}
</Badge> </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> </div>
</div> </div>
@@ -423,7 +438,7 @@ export const Patient360Page = () => {
{phoneRaw && ( {phoneRaw && (
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" /> <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 Book Appointment
</Button> </Button>
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}> <Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
@@ -472,7 +487,7 @@ export const Patient360Page = () => {
) : ( ) : (
<div className="rounded-xl border border-secondary bg-primary"> <div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => ( {appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} /> <AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
))} ))}
</div> </div>
)} )}
@@ -538,7 +553,25 @@ export const Patient360Page = () => {
size="sm" size="sm"
color="primary" color="primary"
iconLeading={Plus} 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 Add Note
</Button> </Button>
@@ -563,6 +596,33 @@ export const Patient360Page = () => {
</Tabs> </Tabs>
</div> </div>
</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

@@ -6,12 +6,11 @@ import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
// Button removed — actions are icon-only now // Button removed — actions are icon-only now
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table'; import { Table, TableCard } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
@@ -86,8 +85,6 @@ export const PatientsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7"> <div className="flex flex-1 flex-col overflow-y-auto p-7">
<TableCard.Root size="sm"> <TableCard.Root size="sm">
@@ -136,12 +133,11 @@ export const PatientsPage = () => {
<Table.Header> <Table.Header>
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" /> <Table.Head label="CONTACT" />
<Table.Head label="TYPE" />
<Table.Head label="GENDER" /> <Table.Head label="GENDER" />
<Table.Head label="AGE" /> <Table.Head label="AGE" />
<Table.Head label="ACTIONS" /> <Table.Head label="ACTIONS" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
const displayName = getPatientDisplayName(patient); const displayName = getPatientDisplayName(patient);
const age = computeAge(patient.dateOfBirth); const age = computeAge(patient.dateOfBirth);
@@ -198,17 +194,6 @@ export const PatientsPage = () => {
</div> </div>
</Table.Cell> </Table.Cell>
{/* Type */}
<Table.Cell>
{patient.patientType ? (
<Badge size="sm" color="gray">
{patient.patientType}
</Badge>
) : (
<span className="text-sm text-placeholder"></span>
)}
</Table.Cell>
{/* Gender */} {/* Gender */}
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary"> <span className="text-sm text-secondary">

View File

@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
type AgentPerf = { type AgentPerf = {
name: string; name: string;
ozonetelagentid: string; ozonetelAgentId: string;
npsscore: number | null; npsScore: number | null;
maxidleminutes: number | null; maxIdleMinutes: number | null;
minnpsthreshold: number | null; minNpsThreshold: number | null;
minconversionpercent: number | null; minConversionPercent: number | null;
calls: number; calls: number;
inbound: number; inbound: number;
missed: number; missed: number;
@@ -90,7 +90,7 @@ export const TeamPerformancePage = () => {
try { try {
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([ const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }), apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }), apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }), apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }), apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
@@ -110,9 +110,15 @@ export const TeamPerformancePage = () => {
let agentPerfs: AgentPerf[]; let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) { if (teamAgents.length > 0) {
// Real Ozonetel data available // Real Ozonetel data available — prefer authoritative agent
// relation (set by CDR enrichment), fall back to agentName
// string for rows not yet enriched.
agentPerfs = teamAgents.map((agent: any) => { agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); const agentCalls = calls.filter((c: any) => {
if (c.agentId && c.agentId === agent.id) return true;
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
return false;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name); const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -127,12 +133,12 @@ export const TeamPerformancePage = () => {
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0; const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return { return {
name: agent.name ?? agent.ozonetelagentid, name: agent.name ?? agent.ozonetelAgentId,
ozonetelagentid: agent.ozonetelagentid, ozonetelAgentId: agent.ozonetelAgentId,
npsscore: agent.npsscore, npsScore: agent.npsScore,
maxidleminutes: agent.maxidleminutes, maxIdleMinutes: agent.maxIdleMinutes,
minnpsthreshold: agent.minnpsthreshold, minNpsThreshold: agent.minNpsThreshold,
minconversionpercent: agent.minconversionpercent, minConversionPercent: agent.minConversionPercent,
calls: totalCalls, calls: totalCalls,
inbound, inbound,
missed, missed,
@@ -148,10 +154,23 @@ export const TeamPerformancePage = () => {
}; };
}); });
} else { } else {
// Fallback: build agent list from call records // Fallback: build agent list from call records. Prefer
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[]; // the authoritative agent relation; fall back to the raw
agentPerfs = agentNames.map((name) => { // agentName string (Ozonetel transfer chain) only when
const agentCalls = calls.filter((c: any) => c.agentName === name); // we have nothing better.
const byKey = new Map<string, { key: string; name: string; ozonetelAgentId: string }>();
for (const c of calls) {
if (c.agent?.id) {
byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId, ozonetelAgentId: c.agent.ozonetelAgentId });
} else if (c.agentName) {
byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName, ozonetelAgentId: c.agentName });
}
}
agentPerfs = Array.from(byKey.values()).map(({ key, name, ozonetelAgentId: _ozonetelAgentId }) => {
const agentCalls = calls.filter((c: any) => {
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
return c.agent?.id === key;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === name); const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name); const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
@@ -159,11 +178,11 @@ export const TeamPerformancePage = () => {
return { return {
name, name,
ozonetelagentid: name, ozonetelAgentId: name,
npsscore: null, npsScore: null,
maxidleminutes: null, maxIdleMinutes: null,
minnpsthreshold: null, minNpsThreshold: null,
minconversionpercent: null, minConversionPercent: null,
calls: totalCalls, calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length, inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length, missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
@@ -215,17 +234,17 @@ export const TeamPerformancePage = () => {
xAxis: { type: 'category', data: days }, xAxis: { type: 'category', data: days },
yAxis: { type: 'value' }, yAxis: { type: 'value' },
series: [ series: [
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' }, { name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' }, { name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
], ],
}; };
}, [allCalls]); }, [allCalls]);
// NPS // NPS
const avgNps = useMemo(() => { const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsscore != null); const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0; if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length); return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]); }, [agents]);
const npsOption = useMemo(() => ({ const npsOption = useMemo(() => ({
@@ -246,13 +265,13 @@ export const TeamPerformancePage = () => {
const alerts = useMemo(() => { const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = []; const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) { for (const a of agents) {
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) { if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' }); list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
} }
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) { if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' }); list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
} }
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) { if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' }); list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
} }
} }
@@ -332,7 +351,7 @@ export const TeamPerformancePage = () => {
</Table.Header> </Table.Header>
<Table.Body items={agents}> <Table.Body items={agents}>
{(agent) => ( {(agent) => (
<Table.Row id={agent.ozonetelagentid || agent.name}> <Table.Row id={agent.ozonetelAgentId || agent.name}>
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell> <Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell> <Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell> <Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
@@ -345,12 +364,12 @@ export const TeamPerformancePage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className={cx('text-sm font-bold', (agent.npsscore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}> <span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsscore ?? '—'} {agent.npsScore ?? '—'}
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}> <span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m {agent.idleMinutes}m
</span> </span>
</Table.Cell> </Table.Cell>
@@ -389,7 +408,7 @@ export const TeamPerformancePage = () => {
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map(agent => { {agents.map(agent => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1; const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes; const isHighIdle = agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes;
return ( return (
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}> <div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p> <p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
@@ -417,7 +436,7 @@ export const TeamPerformancePage = () => {
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4"> <div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3> <h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
{agents.every(a => a.npsscore == null) ? ( {agents.every(a => a.npsScore == null) ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p> <p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p>
</div> </div>
@@ -425,13 +444,13 @@ export const TeamPerformancePage = () => {
<> <>
<ReactECharts option={npsOption} style={{ height: 150 }} /> <ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2"> <div className="space-y-1 mt-2">
{agents.filter(a => a.npsscore != null).map(a => ( {agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2"> <div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span> <span className="text-xs text-secondary w-28 truncate">{a.name}</span>
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden"> <div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
<div className={cx('h-full rounded-full', (a.npsscore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} /> <div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
</div> </div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span> <span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -36,6 +36,8 @@ export const TelephonySettingsPage = () => {
did: data.ozonetel?.did ?? '', did: data.ozonetel?.did ?? '',
sipId: data.ozonetel?.sipId ?? '', sipId: data.ozonetel?.sipId ?? '',
campaignName: data.ozonetel?.campaignName ?? '', campaignName: data.ozonetel?.campaignName ?? '',
adminUsername: data.ozonetel?.adminUsername ?? '',
adminPassword: data.ozonetel?.adminPassword ?? '',
}, },
sip: { sip: {
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com', domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',

View File

@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
// Disconnect SIP before logout // Disconnect SIP before logout
try { try {
disconnectSip(true); disconnectSip(true, 'logout');
} catch {} } catch {}
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens // Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
@@ -119,6 +119,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
await fetch(`${apiUrl}/auth/logout`, { await fetch(`${apiUrl}/auth/logout`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
keepalive: true, // survives page navigation — ensures session unlock completes
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
} catch (err) { } catch (err) {

View File

@@ -116,6 +116,12 @@ export const DataProvider = ({ children }: DataProviderProps) => {
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
// Poll every 30 seconds for fresh data (calls, leads, appointments)
const interval = setInterval(() => {
console.log('[DATA-PROVIDER] Polling for fresh data');
fetchData();
}, 30_000);
return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
const updateLead = (id: string, updates: Partial<Lead>) => { const updateLead = (id: string, updates: Partial<Lead>) => {

View File

@@ -12,6 +12,7 @@ import {
} from '@/state/sip-state'; } from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager'; import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import type { SIPConfig } from '@/types/sip'; import type { SIPConfig } from '@/types/sip';
// SIP config comes exclusively from the Agent entity (stored on login). // SIP config comes exclusively from the Agent entity (stored on login).
@@ -42,6 +43,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const setCallDuration = useSetAtom(sipCallDurationAtom); const setCallDuration = useSetAtom(sipCallDurationAtom);
const setCallStartTime = useSetAtom(sipCallStartTimeAtom); const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
// Register Jotai setters so the singleton SIP manager can update atoms // Register Jotai setters so the singleton SIP manager can update atoms
useEffect(() => { useEffect(() => {
@@ -50,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
setCallState, setCallState,
setCallerNumber, setCallerNumber,
setCallUcid, setCallUcid,
setIsMuted: setIsMutedGlobal,
setIsOnHold: setIsOnHoldGlobal,
}); });
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
// Auto-connect SIP on mount — only if Agent entity has SIP config // Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => { useEffect(() => {
@@ -125,14 +130,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
} }
}; };
const handleUnload = () => disconnectSip(true); const handleUnload = () => disconnectSip(true, 'page-unload');
window.addEventListener('beforeunload', handleBeforeUnload); window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('unload', handleUnload); window.addEventListener('unload', handleUnload);
return () => { return () => {
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('unload', handleUnload); window.removeEventListener('unload', handleUnload);
disconnectSip(true); // force — component is unmounting disconnectSip(true, 'sip-provider-unmount'); // force — component is unmounting
}; };
}, []); // empty deps — runs once on mount, cleanup only on unmount }, []); // empty deps — runs once on mount, cleanup only on unmount
@@ -156,6 +161,17 @@ export const useSip = () => {
// Ozonetel outbound dial — single path for all outbound calls // Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => { const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
// Hard guard — no dial is valid when SIP isn't registered, because
// the audio leg can't be established. Every entry point (worklist
// row, click-to-call, phone-action-cell, patient 360, etc.) funnels
// through this callback, so gating here is the single source of
// truth for "can this agent place a call right now?"
if (connectionStatus !== 'registered') {
notify.error('Telephony unavailable', 'Cannot place call — SIP is not registered. Check your connection.');
console.warn(`[DIAL] Blocked — SIP not registered (status=${connectionStatus})`);
return;
}
// Block outbound calls when agent is on Break or Training // Block outbound calls when agent is on Break or Training
const agentCfg = localStorage.getItem('helix_agent_config'); const agentCfg = localStorage.getItem('helix_agent_config');
if (agentCfg) { if (agentCfg) {
@@ -166,7 +182,6 @@ export const useSip = () => {
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`); const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
const stateData = await stateRes.json(); const stateData = await stateRes.json();
if (stateData.state === 'break' || stateData.state === 'training') { if (stateData.state === 'break' || stateData.state === 'training') {
const { notify } = await import('@/lib/toast');
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls'); notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
return; return;
} }
@@ -204,7 +219,7 @@ export const useSip = () => {
setCallerNumber(null); setCallerNumber(null);
throw new Error('Dial failed'); throw new Error('Dial failed');
} }
}, [setCallState, setCallerNumber, setCallUcid]); }, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
const answer = useCallback(() => getSipClient()?.answer(), []); const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []); const reject = useCallback(() => getSipClient()?.reject(), []);

View File

@@ -13,6 +13,8 @@ type StateUpdater = {
setCallState: (state: CallState) => void; setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void; setCallerNumber: (number: string | null) => void;
setCallUcid: (ucid: string | null) => void; setCallUcid: (ucid: string | null) => void;
setIsMuted: (muted: boolean) => void;
setIsOnHold: (onHold: boolean) => void;
}; };
let stateUpdater: StateUpdater | null = null; let stateUpdater: StateUpdater | null = null;
@@ -83,6 +85,13 @@ export function connectSip(config: SIPConfig): void {
if (ucid) stateUpdater?.setCallUcid(ucid); if (ucid) stateUpdater?.setCallUcid(ucid);
if (state === 'ended' || state === 'failed') { if (state === 'ended' || state === 'failed') {
// Reset both the SIP track AND the Recoil state — otherwise the
// UI icon + toggle-mute branch logic stay "muted" and the next
// call opens in a confusing half-muted state.
sipClient?.unmute();
sipClient?.unhold();
stateUpdater?.setIsMuted(false);
stateUpdater?.setIsOnHold(false);
outboundActive = false; outboundActive = false;
outboundPending = false; outboundPending = false;
} }
@@ -92,16 +101,16 @@ export function connectSip(config: SIPConfig): void {
sipClient.connect(); sipClient.connect();
} }
export function disconnectSip(force = false): void { export function disconnectSip(force = false, reason = 'unspecified'): void {
// Guard: don't disconnect SIP during an active or pending call // Guard: don't disconnect SIP during an active or pending call
// unless explicitly forced (e.g., logout, page unload). // unless explicitly forced (e.g., logout, page unload).
// This prevents React re-render cycles from killing the // This prevents React re-render cycles from killing the
// SIP WebSocket mid-dial. // SIP WebSocket mid-dial.
if (!force && (outboundPending || outboundActive)) { if (!force && (outboundPending || outboundActive)) {
console.log('[SIP-MGR] Disconnect blocked — call in progress'); console.log(`[SIP-MGR] Disconnect blocked — call in progress (reason=${reason})`);
return; return;
} }
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : '')); console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
sipClient?.disconnect(); sipClient?.disconnect();
sipClient = null; sipClient = null;
connected = false; connected = false;

View File

@@ -250,11 +250,15 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL'; export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
export type CallDisposition = export type CallDisposition =
| 'APPOINTMENT_BOOKED' | 'APPOINTMENT_BOOKED'
| 'APPOINTMENT_RESCHEDULED'
| 'APPOINTMENT_CANCELLED'
| 'FOLLOW_UP_SCHEDULED' | 'FOLLOW_UP_SCHEDULED'
| 'INFO_PROVIDED' | 'INFO_PROVIDED'
| 'WRONG_NUMBER' | 'WRONG_NUMBER'
| 'NO_ANSWER' | 'NO_ANSWER'
| 'CALLBACK_REQUESTED'; | 'NOT_INTERESTED'
| 'CALLBACK_REQUESTED'
| 'CALL_DROPPED';
export type Call = { export type Call = {
id: string; id: string;
@@ -273,6 +277,12 @@ export type Call = {
appointmentId: string | null; appointmentId: string | null;
leadId: string | null; leadId: string | null;
sla?: number | null; sla?: number | null;
// Authoritative agent link from CDR enrichment. agentName remains the
// raw Ozonetel string (may be a transfer chain) for display fallback.
agentId?: string | null;
agent?: { id: string; name: string | null; ozonetelAgentId: string | null } | null;
transferredTo?: string | null;
transferType?: string | null;
// Denormalized for display // Denormalized for display
leadName?: string; leadName?: string;
leadPhone?: string; leadPhone?: string;
@@ -313,6 +323,7 @@ export type Appointment = {
patientId: string | null; patientId: string | null;
patientName: string | null; patientName: string | null;
patientPhone: string | null; patientPhone: string | null;
clinicId: string | null;
clinicName: string | null; clinicName: string | null;
}; };