merge: barge-whisper batch — today's P1/P2 fixes + dashboard merge + pagination
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Lands 40 commits from feature/barge-whisper into master as a single marker-commit for easy rollback.

Highlights:
- Call attribution chain fix (inbound transferred calls now get the right agent.id; CDR enrichment now indexes by both UCID + monitorUCID)
- Worklist patientId + Book Appt pill patientId overlay — returning callers see their prior appointment as a reschedule pill
- Supervisor Dashboard merge: Team Performance surfaces folded in, scrollable sections, time-breakdown rendered as a table
- Data-provider pagination: KPIs no longer capped at 100 rows
- Background poll no longer flashes a Loading state
- Campaign detail: leads inline, View Leads button removed
- All Leads: stray Back button gone, Export CSV wired up
- Maint OTP modal: agent picker (Locked/Free) after OTP, no more reliance on agent-config in localStorage
- Per-tenant HELIX_SETUP_MANAGED flag hides Setup nav + banner on managed workspaces
- Supervisor AI chat panel: supervisor-specific quick actions

Revert this entire batch with: git revert -m 1 <merge-sha>
This commit is contained in:
2026-04-15 19:04:21 +05:30
62 changed files with 5529 additions and 931 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); });

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

@@ -1,10 +1,11 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion, faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
@@ -12,12 +13,15 @@ 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 { Dialog, Modal, ModalOverlay } from '@/components/application/modals/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, formatShortDate } 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';
@@ -42,12 +46,64 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const [appointmentOpen, setAppointmentOpen] = useState(false); const [appointmentOpen, setAppointmentOpen] = useState(false);
// Which existing appointment is being edited (null = creating a new one).
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
// Clicking Edit on a pill sets this; clicking + New clears it.
const [editingApptId, setEditingApptId] = useState<string | null>(null);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false);
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);
});
};
// Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones.
const { appointments } = useData();
const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) return [];
const now = Date.now();
return appointments
.filter((a) =>
a.patientId === patientId
&& a.appointmentStatus !== 'CANCELLED'
&& a.appointmentStatus !== 'NO_SHOW'
&& a.appointmentStatus !== 'COMPLETED'
// Only future appointments make sense as reschedule targets.
// Past ones can't be edited — they already happened.
&& a.scheduledAt
&& new Date(a.scheduledAt).getTime() >= now,
)
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
}, [appointments, lead]);
const editingAppt = useMemo(
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
[leadAppointments, editingApptId],
);
// Pending pill click awaiting the reschedule-confirm modal. When the
// agent clicks a pill, we store the appointment id here + open the modal.
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
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 +155,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 +167,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 +178,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 +259,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 +286,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 +338,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,19 +366,92 @@ 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);
}} }}
/> />
)} )}
{appointmentOpen && leadAppointments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<button
type="button"
onClick={() => setEditingApptId(null)}
className={cx(
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
!editingApptId
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
)}
>
<FontAwesomeIcon icon={faPlus} className="size-3" />
New
</button>
{leadAppointments.map((appt) => (
<div
key={appt.id}
className={cx(
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
editingApptId === appt.id
? 'border-brand bg-brand-primary'
: 'border-secondary bg-primary',
)}
>
<div className="flex flex-col">
<span className="font-semibold text-primary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
</span>
<span className="text-[11px] text-tertiary">
{appt.doctorName ?? 'Doctor'}
</span>
</div>
<button
type="button"
onClick={() => setPendingApptId(appt.id)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
Edit
</button>
</div>
))}
</div>
)}
{/* Key forces a full remount when switching between
pills (or between edit/view modes) so the form's
internal state re-initializes from the new
existingAppointment prop instead of staying
stuck on the first-mounted values. */}
<AppointmentForm <AppointmentForm
key={`${editingApptId ?? 'new'}-${apptMode}`}
isOpen={appointmentOpen} isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen} onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) { setEditingApptId(null); setApptMode('edit'); }
}}
callerNumber={callerPhone} callerNumber={callerPhone}
leadName={fullName || null} leadName={fullName || null}
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved} readOnly={apptMode === 'view'}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt ?? '',
doctorName: editingAppt.doctorName ?? '',
doctorId: editingAppt.doctorId ?? undefined,
department: editingAppt.department ?? '',
clinicId: editingAppt.clinicId ?? undefined,
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
} : null}
onSaved={(outcome) => {
setEditingApptId(null);
setApptMode('edit');
handleAppointmentSaved(outcome);
}}
/> />
<EnquiryForm <EnquiryForm
@@ -327,22 +462,79 @@ 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>
)} )}
</div> </div>
{/* Reschedule confirm modal — fires when the agent clicks Edit
on an upcoming-appointment pill. Yes → open the form in
edit mode (fields editable, Save button). No → open in
view-only mode (fields disabled, Close button). */}
<ModalOverlay
isOpen={pendingApptId !== null}
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
isDismissable
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p>
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
color="secondary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('view');
setPendingApptId(null);
}
}}
>
No, just view
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('edit');
setPendingApptId(null);
}
}}
>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
{/* Disposition Modal — the ONLY path to end a call */} {/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal <DispositionModal
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

@@ -19,15 +19,31 @@ interface AiChatPanelProps {
onChatStart?: () => void; onChatStart?: () => void;
} }
// Supervisor has different quick-action prompts than the CC agent — they
// ask about team metrics, not patient / doctor info. Hardcoded here rather
// than in theme tokens because the prompts map 1:1 to the supervisor tool
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
// get_campaign_stats) — changing the tools means changing these prompts.
const SUPERVISOR_QUICK_ACTIONS = [
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
];
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens(); const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions; const isSupervisor = callerContext?.type === 'supervisor';
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatStartedRef = useRef(false); const chatStartedRef = useRef(false);
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 +65,40 @@ 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) or
// the call ends (leadId clears), 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.
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
// Call ended or no caller — wipe the panel so the next caller's
// context doesn't bleed over and the agent isn't staring at a stale
// summary in the worklist view between calls.
if (!leadId) {
if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null;
setMessages([]);
chatStartedRef.current = false;
}
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 });
}; };
@@ -60,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
<div className="flex flex-col items-center justify-center py-6 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-xs text-tertiary"> <p className="text-xs text-tertiary">
Ask me about doctors, clinics, packages, or patient info. {introText}
</p> </p>
<div className="mt-3 flex flex-wrap justify-center gap-1.5"> <div className="mt-3 flex flex-wrap justify-center gap-1.5">
{quickActions.map((action) => ( {quickActions.map((action) => (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
@@ -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,8 +30,16 @@ 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;
// When true, the form shows the existing appointment's data in a
// disabled state — no input editing, no Save/Cancel. Only a Close
// button. Used by the reschedule-confirm flow when the agent picks
// "No, just view" on an upcoming-appointment pill.
readOnly?: boolean;
}; };
type DoctorRecord = { id: string; name: string; department: string; clinic: string }; type DoctorRecord = { id: string; name: string; department: string; clinic: string };
@@ -56,6 +65,7 @@ export const AppointmentForm = ({
patientId, patientId,
onSaved, onSaved,
existingAppointment, existingAppointment,
readOnly = false,
}: AppointmentFormProps) => { }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment; const isEditMode = !!existingAppointment;
@@ -79,7 +89,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 +122,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[]>([]);
@@ -210,7 +242,19 @@ export const AppointmentForm = ({
const filteredDoctors = department const filteredDoctors = department
? doctors.filter(d => d.department === department) ? doctors.filter(d => d.department === department)
: doctors; : doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name })); // Always include the currently-selected doctor even if the department
// filter would exclude them. Needed for edit mode: the saved
// Appointment.department may be stored as a display string ("ENT") or
// a legacy value that doesn't match the doctor's current department
// enum — without this, the Select renders blank.
const doctorSelectItems = useMemo(() => {
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
if (doctor && !items.some(i => i.id === doctor)) {
const selected = doctors.find(d => d.id === doctor);
if (selected) items.unshift({ id: selected.id, label: selected.name });
}
return items;
}, [filteredDoctors, doctors, doctor]);
const timeSlotSelectItems = timeSlotItems.map(slot => ({ const timeSlotSelectItems = timeSlotItems.map(slot => ({
...slot, ...slot,
@@ -238,7 +282,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 +297,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 +364,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 +384,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 +423,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 +453,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 {
@@ -389,7 +489,7 @@ export const AppointmentForm = ({
placeholder="Full name" placeholder="Full name"
value={patientName} value={patientName}
onChange={setPatientName} onChange={setPatientName}
isDisabled={!isNameEditable} isDisabled={readOnly || !isNameEditable}
/> />
</div> </div>
{!isNameEditable && initialLeadName.length > 0 && ( {!isNameEditable && initialLeadName.length > 0 && (
@@ -462,7 +562,7 @@ export const AppointmentForm = ({
items={departmentItems} items={departmentItems}
selectedKey={department} selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)} onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0} isDisabled={readOnly || doctors.length === 0}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -473,7 +573,7 @@ export const AppointmentForm = ({
items={doctorSelectItems} items={doctorSelectItems}
selectedKey={doctor} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department} isDisabled={readOnly || !department}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -485,7 +585,7 @@ export const AppointmentForm = ({
value={date ? parseDate(date) : null} value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')} onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day" granularity="day"
isDisabled={!doctor} isDisabled={readOnly || !doctor}
/> />
</div> </div>
@@ -503,7 +603,7 @@ export const AppointmentForm = ({
<button <button
key={slot.id} key={slot.id}
type="button" type="button"
disabled={isBooked} disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)} onClick={() => setTimeSlot(slot.id)}
className={cx( className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear', 'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
@@ -531,6 +631,7 @@ export const AppointmentForm = ({
placeholder="Describe the reason for visit..." placeholder="Describe the reason for visit..."
value={chiefComplaint} value={chiefComplaint}
onChange={setChiefComplaint} onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2} rows={2}
/> />
@@ -567,7 +668,7 @@ export const AppointmentForm = ({
{/* Footer — pinned */} {/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary"> <div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div> <div>
{isEditMode && ( {isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}> <Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment Cancel Appointment
</Button> </Button>
@@ -577,9 +678,11 @@ export const AppointmentForm = ({
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}> <Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>
{!readOnly && (
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}> <Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'} {isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button> </Button>
)}
</div> </div>
</div> </div>

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

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

@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
View on Platform View on Platform
</Button> </Button>
)} )}
<Button
color="primary"
size="sm"
href={`/leads`}
>
View Leads
</Button>
</div> </div>
</div> </div>
</div> </div>

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

@@ -0,0 +1,401 @@
import { useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
// Shared rollup surfaces for the supervisor dashboard: agent performance
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
// so both the Team Dashboard and the legacy Team Performance page render
// identically from a single data fetch.
type DateRange = 'today' | 'week' | 'month' | 'year';
type AgentPerf = {
name: string;
ozonetelAgentId: string;
npsScore: number | null;
maxIdleMinutes: number | null;
minNpsThreshold: number | null;
minConversionPercent: number | null;
calls: number;
inbound: number;
missed: number;
followUps: number;
leads: number;
appointments: number;
convPercent: number;
idleMinutes: number;
activeMinutes: number;
wrapMinutes: number;
breakMinutes: number;
};
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
const now = new Date();
const lte = now.toISOString();
const start = new Date(now);
if (range === 'today') start.setHours(0, 0, 0, 0);
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
return { gte: start.toISOString(), lte };
};
const parseTime = (timeStr: string): number => {
if (!timeStr) return 0;
const parts = timeStr.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
export const useSupervisorRollup = (range: DateRange) => {
const [agents, setAgents] = useState<AgentPerf[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const { gte, lte } = getDateRange(range);
const dateStr = new Date().toISOString().split('T')[0];
try {
const [callsData, 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 agentId agent { id name ozonetelAgentId } } } } }`, 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.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
]);
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
const teamAgents = teamData?.agents ?? [];
let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) {
agentPerfs = teamAgents.map((agent: any) => {
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 agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
const tb = agent.timeBreakdown;
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return {
name: agent.name ?? agent.ozonetelAgentId,
ozonetelAgentId: agent.ozonetelAgentId,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversionPercent,
calls: totalCalls,
inbound,
missed,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: agentAppts,
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
idleMinutes: Math.round(idleSec / 60),
activeMinutes: Math.round(activeSec / 60),
wrapMinutes: Math.round(wrapSec / 60),
breakMinutes: Math.round(breakSec / 60),
};
});
} else {
const byKey = new Map<string, { key: string; name: 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 });
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
}
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
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 agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
return {
name,
ozonetelAgentId: name,
npsScore: null,
maxIdleMinutes: null,
minNpsThreshold: null,
minConversionPercent: null,
calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: completed,
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
idleMinutes: 0,
activeMinutes: 0,
wrapMinutes: 0,
breakMinutes: 0,
};
});
}
setAgents(agentPerfs);
} catch (err) {
console.error('Failed to load supervisor rollup:', err);
} finally {
setLoading(false);
}
};
load();
}, [range]);
return { agents, loading };
};
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Calls" />
<Table.Head label="Inbound" />
<Table.Head label="Missed" />
<Table.Head label="Follow-ups" />
<Table.Head label="Leads" />
<Table.Head label="Conv%" />
<Table.Head label="NPS" />
<Table.Head label="Idle" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<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 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.missed}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
{agent.convPercent}%
</span>
</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')}>
{agent.npsScore ?? '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m
</span>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
const teamAvg = useMemo(() => {
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
return {
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
};
}, [agents]);
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
// totals varied wildly, making the visual width comparison meaningless.
// Rendered as a table so the numbers speak for themselves; team-average
// row sits at the top as the reference point.
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable requires Ozonetel agent session data.</p>
)}
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Active" />
<Table.Head label="Wrap" />
<Table.Head label="Idle" />
<Table.Head label="Break" />
<Table.Head label="Total" />
</Table.Header>
<Table.Body
items={[
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
]}
>
{(item) => {
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
const total = active + wrap + idle + breakM;
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
return (
<Table.Row id={item.id}>
<Table.Cell>
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
{item.name}
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
{idle}m
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
);
};
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]);
const npsOption = useMemo(() => ({
tooltip: { trigger: 'item' },
series: [{
type: 'gauge', startAngle: 180, endAngle: 0,
min: 0, max: 100,
pointer: { show: false },
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
data: [{ value: avgNps }],
}],
}), [avgNps]);
return (
<div className="flex gap-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>
{agents.every(a => a.npsScore == null) ? (
<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>
</div>
) : (
<>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2">
{agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2">
<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={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>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div>
))}
</div>
</>
)}
</div>
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
<div className="flex gap-3 mb-4">
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
<p className="text-xs text-tertiary">Call Appointment</p>
</div>
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
</p>
<p className="text-xs text-tertiary">Lead Contact</p>
</div>
</div>
<div className="space-y-1">
{agents.map(a => (
<div key={a.name} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-28 truncate">{a.name}</span>
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
</div>
))}
</div>
</div>
</div>
);
};
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) {
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
}
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
}
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
}
}
return list;
}, [agents]);
if (alerts.length === 0) return null;
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-error-primary mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
Performance Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.map((alert, i) => (
<div key={i} className={cx(
'flex items-center justify-between rounded-lg px-4 py-3',
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
)}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
<span className="text-sm font-medium text-primary">{alert.agent}</span>
<span className="text-sm text-secondary"> {alert.type}</span>
</div>
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
</div>
))}
</div>
</div>
);
};

View File

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

View File

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

@@ -15,7 +15,6 @@ import {
faUsers, faUsers,
faArrowRightFromBracket, faArrowRightFromBracket,
faTowerBroadcast, faTowerBroadcast,
faChartLine,
faFileAudio, faFileAudio,
faPhoneMissed, faPhoneMissed,
} from "@fortawesome/pro-duotone-svg-icons"; } from "@fortawesome/pro-duotone-svg-icons";
@@ -30,6 +29,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
import type { NavItemType } from "@/components/application/app-navigation/config"; import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar"; import { Avatar } from "@/components/base/avatar/avatar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useUiFlags } from "@/hooks/use-ui-flags";
import { useAgentState } from "@/hooks/use-agent-state"; import { useAgentState } from "@/hooks/use-agent-state";
import { useThemeTokens } from "@/providers/theme-token-provider"; import { useThemeTokens } from "@/providers/theme-token-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state"; import { sidebarCollapsedAtom } from "@/state/sidebar-state";
@@ -49,7 +49,6 @@ const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser); const IconHospitalUser = faIcon(faHospitalUser);
const IconCalendarCheck = faIcon(faCalendarCheck); const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast); const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio); const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed); const IconPhoneMissed = faIcon(faPhoneMissed);
@@ -62,8 +61,11 @@ const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') { if (role === 'admin') {
return [ return [
{ label: 'Supervisor', items: [ { label: 'Supervisor', items: [
// Team Performance retired as a nav entry — its surfaces
// (time breakdown, NPS/conversion, alerts, richer agent
// table) are now rolled into the Dashboard. The route is
// kept alive for reference but not linked in the sidebar.
{ label: 'Dashboard', href: '/', icon: IconGrid2 }, { label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast }, { label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
]}, ]},
{ label: 'Data & Reports', items: [ { label: 'Data & Reports', items: [
@@ -132,7 +134,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;
@@ -149,7 +151,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
navigate('/login'); navigate('/login');
}; };
const navSections = getNavSections(user.role); const uiFlags = useUiFlags();
const navSections = getNavSections(user.role).map((section) => ({
...section,
items: uiFlags.setupManaged
// When setup is managed by the product team (per-tenant flag),
// hide the Settings entry from the nav. The route is also
// blocked in router-provider so a stray bookmark doesn't work.
? section.items.filter((item) => item.href !== '/settings')
: section.items,
})).filter((section) => section.items.length > 0);
const content = ( const content = (
<aside <aside
@@ -280,7 +291,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

@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
import { PinInput } from '@/components/base/pin-input/pin-input'; import { PinInput } from '@/components/base/pin-input/pin-input';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons'; import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from 'react'; import type { FC } from 'react';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const ShieldIcon: FC<{ className?: string }> = ({ className }) => ( const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faShieldKeyhole} className={className} /> <FontAwesomeIcon icon={faShieldKeyhole} className={className} />
@@ -20,9 +21,14 @@ type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string };
type FreeRow = { agentId: string; displayName: string };
type SessionStatus = { locked: LockedRow[]; free: FreeRow[] };
type MaintOtpModalProps = { type MaintOtpModalProps = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Phase-2 state: once the OTP passes and the action uses an agent
// picker, we swap the PIN input for a two-bucket list (Locked / Free)
// fetched from `agentPickerEndpoint`. The operator picks a locked
// agent, then Confirm posts to the real `endpoint`.
const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(null);
const [pickedAgentId, setPickedAgentId] = useState<string | null>(null);
// OTP is held across the two-phase flow so we don't force the user
// to re-enter it after the picker loads.
const [verifiedOtp, setVerifiedOtp] = useState<string | null>(null);
const reset = () => {
setOtp('');
setError(null);
setSessionStatus(null);
setPickedAgentId(null);
setVerifiedOtp(null);
setLoading(false);
};
const handleClose = () => {
onOpenChange(false);
reset();
};
const postMaint = async (endpoint: string, body: Record<string, any>, otpHeader: string) => {
const res = await fetch(`${API_URL}/api/maint/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, data };
};
const runPickerAction = async (pickedId: string, otpHeader: string) => {
if (!action) return;
setLoading(true);
setError(null);
const payload = { ...preStepPayload, agentId: pickedId };
const { ok, data } = await postMaint(action.endpoint, payload, otpHeader);
setLoading(false);
if (ok) {
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
reset();
} else {
setError(data.message ?? 'Failed');
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!action || otp.length < 6) return; if (!action || otp.length < 6) return;
@@ -43,45 +98,50 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
try { try {
// Two-phase agent-picker flow — OTP first, then fetch list,
// then the operator picks which agent to act on.
if (action.agentPickerEndpoint) {
const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp);
if (!ok) {
setError(data.message ?? 'Invalid maintenance code');
setLoading(false);
return;
}
setSessionStatus(data as SessionStatus);
setVerifiedOtp(otp);
setLoading(false);
return;
}
if (action.clientSideHandler) { if (action.clientSideHandler) {
// Client-side action — OTP verified by calling a dummy maint endpoint first const { ok, data } = await postMaint('force-ready', {}, otp);
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, { if (!ok) {
method: 'POST', setError(data.message ?? 'Invalid maintenance code');
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
});
if (!otpRes.ok) {
setError('Invalid maintenance code');
setLoading(false); setLoading(false);
return; return;
} }
const result = await action.clientSideHandler(preStepPayload); const result = await action.clientSideHandler(preStepPayload);
notify.success(action.label, result.message ?? 'Completed'); notify.success(action.label, result.message ?? 'Completed');
onOpenChange(false); onOpenChange(false);
setOtp(''); reset();
} else { return;
// Standard sidecar endpoint — include agentId from agent config }
// Default: single-shot endpoint with agentId from the CC agent's
// own local config (cc-agent context). Supervisors hitting this
// path without agent config used to get 400 — the agent-picker
// branch above is the fix.
const agentCfg = localStorage.getItem('helix_agent_config'); const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined; const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) }; const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
const { ok, data } = await postMaint(action.endpoint, payload, otp);
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { if (ok) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-maint-otp': otp,
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
console.log(`[MAINT] ${action.label}:`, data);
notify.success(action.label, data.message ?? 'Completed successfully'); notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false); onOpenChange(false);
setOtp(''); reset();
} else { } else {
setError(data.message ?? 'Failed'); setError(data.message ?? 'Failed');
} }
}
} catch { } catch {
setError('Request failed'); setError('Request failed');
} finally { } finally {
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
}; };
const handleClose = () => {
onOpenChange(false);
setOtp('');
setError(null);
};
if (!action) return null; if (!action) return null;
const showOtp = !action.needsPreStep || preStepReady; const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp);
const showOtp = (!action.needsPreStep || preStepReady) && !showPicker;
const confirmDisabled = showPicker
? !pickedAgentId || loading
: otp.length < 6 || loading || (action.needsPreStep && !preStepReady);
const handleConfirm = async () => {
if (showPicker && pickedAgentId && verifiedOtp) {
await runPickerAction(pickedAgentId, verifiedOtp);
} else {
await handleSubmit();
}
};
return ( return (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable> <ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-[400px]"> <Modal className="sm:max-w-[440px]">
<Dialog> <Dialog>
{() => ( {() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden"> <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
{/* Pre-step content (e.g., campaign selection) */} {/* Pre-step content (e.g., campaign selection) */}
{action.needsPreStep && preStepContent && ( {action.needsPreStep && preStepContent && !showPicker && (
<div className="px-6 pb-4"> <div className="px-6 pb-4">
{preStepContent} {preStepContent}
</div> </div>
)} )}
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
{showOtp && ( {showOtp && (
<div className="flex flex-col items-center gap-2 px-6 pb-5"> <div className="flex flex-col items-center gap-2 px-6 pb-5">
<PinInput size="sm"> <PinInput size="sm">
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
)} )}
{showPicker && sessionStatus && (
<div className="px-6 pb-5 space-y-4">
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Locked ({sessionStatus.locked.length})
</p>
</div>
{sessionStatus.locked.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.locked.map((row) => {
const selected = pickedAgentId === row.agentId;
return (
<button
key={row.agentId}
type="button"
onClick={() => setPickedAgentId(row.agentId)}
className={cx(
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
selected
? 'border-brand bg-brand-primary_alt'
: 'border-secondary hover:border-brand hover:bg-secondary',
)}
>
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
<p className="text-xs text-tertiary truncate">
<code className="font-mono">{row.agentId}</code> held by {row.heldByIp}
</p>
<p className="text-xs text-quaternary">
since {new Date(row.lockedAt).toLocaleString()}
</p>
</div>
{selected && (
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
)}
</button>
);
})}
</div>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Free ({sessionStatus.free.length})
</p>
</div>
{sessionStatus.free.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No free agents.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.free.map((row) => (
<div
key={row.agentId}
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
>
<div className="min-w-0">
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
<p className="text-xs text-quaternary truncate">
<code className="font-mono">{row.agentId}</code>
</p>
</div>
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
</div>
))}
</div>
)}
</div>
{error && (
<p className="text-sm text-error-primary">{error}</p>
)}
</div>
)}
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4"> <div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={handleClose} className="flex-1"> <Button size="md" color="secondary" onClick={handleClose} className="flex-1">
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
<Button <Button
size="md" size="md"
color="primary" color="primary"
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)} isDisabled={confirmDisabled}
isLoading={loading} isLoading={loading}
onClick={handleSubmit} onClick={handleConfirm}
className="flex-1" className="flex-1"
> >
Confirm Confirm

View File

@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state'; import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useUiFlags } from '@/hooks/use-ui-flags';
// Dismissible banner shown across the top of authenticated pages when // Dismissible banner shown across the top of authenticated pages when
// the hospital workspace has incomplete setup steps AND the admin has // the hospital workspace has incomplete setup steps AND the admin has
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
// - Not dismissed in the current browser session (resets on reload) // - Not dismissed in the current browser session (resets on reload)
export const ResumeSetupBanner = () => { export const ResumeSetupBanner = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { setupManaged } = useUiFlags();
const [state, setState] = useState<SetupState | null>(null); const [state, setState] = useState<SetupState | null>(null);
const [dismissed, setDismissed] = useState( const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1', () => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
); );
useEffect(() => { useEffect(() => {
if (!isAdmin || dismissed) return; if (!isAdmin || dismissed || setupManaged) return;
getSetupState() getSetupState()
.then(setState) .then(setState)
.catch(() => { .catch(() => {
// Non-fatal — if setup-state isn't reachable, just // Non-fatal — if setup-state isn't reachable, just
// skip the banner. The wizard still works. // skip the banner. The wizard still works.
}); });
}, [isAdmin, dismissed]); }, [isAdmin, dismissed, setupManaged]);
if (!isAdmin || !state || dismissed) return null; if (!isAdmin || !state || dismissed || setupManaged) return null;
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length; const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
if (incompleteCount === 0) return null; if (incompleteCount === 0) return null;

View File

@@ -10,27 +10,32 @@ type SectionCardProps = {
description: string; description: string;
icon: any; icon: any;
iconColor?: string; iconColor?: string;
href: string; // Either navigate (href) OR intercept the click (onClick). When onClick
// is provided, href is ignored and the card renders as a button. Used
// while self-serve setup is disabled — all clicks go through a
// "contact product team" modal in settings.tsx.
href?: string;
onClick?: () => void;
status?: SectionStatus; status?: SectionStatus;
}; };
// Settings hub card. Each card represents one setup-able section (Branding, // Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its // Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
// dedicated page. The status badge mirrors the wizard's setup-state so an // its dedicated page or triggers a parent-owned callback.
// admin can see at a glance which sections still need attention.
export const SectionCard = ({ export const SectionCard = ({
title, title,
description, description,
icon, icon,
iconColor = 'text-brand-primary', iconColor = 'text-brand-primary',
href, href,
onClick,
status = 'unknown', status = 'unknown',
}: SectionCardProps) => { }: SectionCardProps) => {
return ( const className = cx(
<Link 'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
to={href} );
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md" const body = (
> <>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary"> <div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
@@ -62,6 +67,19 @@ export const SectionCard = ({
)} )}
</div> </div>
)} )}
</>
);
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
return (
<Link to={href ?? '#'} className={className}>
{body}
</Link> </Link>
); );
}; };

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

@@ -5,6 +5,10 @@ export type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
// When set, after OTP passes the modal calls this endpoint to fetch
// `{ locked, free }` agent buckets and shows a picker. Confirm then
// POSTs to `endpoint` with { agentId } from the selection.
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
endpoint: 'force-ready', endpoint: 'force-ready',
label: 'Force Ready', label: 'Force Ready',
description: 'Logout and re-login the agent to force Ready state on Ozonetel.', description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
agentPickerEndpoint: 'session-status',
}, },
unlockAgent: { unlockAgent: {
endpoint: 'unlock-agent', endpoint: 'unlock-agent',
label: 'Unlock Agent', label: 'Unlock Agent',
description: 'Release the Redis session lock so the agent can log in again.', description: 'Release the Redis session lock so the agent can log in again.',
agentPickerEndpoint: 'session-status',
}, },
backfill: { backfill: {
endpoint: 'backfill-missed-calls', endpoint: 'backfill-missed-calls',

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 {
const dismissAll = () => { await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true }))); method: 'POST',
}; headers: { Authorization: `Bearer ${token}` },
});
const activeAlerts = alerts.filter(a => !a.dismissed); } catch {
load();
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll }; }
}, [load]);
return { alerts, allAlerts: alerts, dismiss, dismissAll };
}; };

50
src/hooks/use-ui-flags.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
// Per-tenant UI flags the sidecar controls via env vars. Read once at
// app mount; cached in module scope so every consumer gets the same
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
// respond (all flags off) so the UI stays functional.
export type UiFlags = {
setupManaged: boolean;
};
const DEFAULT_FLAGS: UiFlags = {
setupManaged: false,
};
let cachedFlags: UiFlags | null = null;
let inflight: Promise<UiFlags> | null = null;
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
const fetchFlags = (): Promise<UiFlags> => {
if (cachedFlags) return Promise.resolve(cachedFlags);
if (inflight) return inflight;
inflight = apiClient
.get<UiFlags>('/api/config/ui-flags', { silent: true })
.then((res) => {
cachedFlags = { ...DEFAULT_FLAGS, ...res };
return cachedFlags;
})
.catch(() => {
cachedFlags = { ...DEFAULT_FLAGS };
return cachedFlags;
})
.finally(() => {
inflight = null;
});
return inflight;
};
export const useUiFlags = (): UiFlags => {
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
useEffect(() => {
if (cachedFlags) {
setFlags(cachedFlags);
return;
}
fetchFlags().then(setFlags);
}, []);
return flags;
};

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

@@ -1,5 +1,36 @@
export type CSVRow = Record<string, string>; export type CSVRow = Record<string, string>;
// CSV write-side. Quote every value and escape embedded quotes. Prefix
// ="+-@ with a single quote so Excel doesn't interpret them as formulas
// (classic CSV-injection vector on exports opened in spreadsheet apps).
const escapeCsvCell = (raw: unknown): string => {
const value = raw == null ? '' : String(raw);
const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value;
return `"${sanitized.replace(/"/g, '""')}"`;
};
export const rowsToCsv = (headers: string[], rows: Array<Record<string, unknown>>): string => {
const lines = [headers.map(escapeCsvCell).join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvCell(row[h])).join(','));
}
return lines.join('\r\n');
};
export const downloadCsv = (filename: string, csv: string): void => {
// BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses.
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export type CSVParseResult = { export type CSVParseResult = {
headers: string[]; headers: string[];
rows: CSVRow[]; rows: CSVRow[];

View File

@@ -1,7 +1,21 @@
// GraphQL queries for platform entities // GraphQL queries for platform entities
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection // Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
//
// Each entity exports a query *builder* that accepts an optional `after`
// cursor. The data-provider paginates until `hasNextPage=false` so the
// dashboard KPIs reflect real totals instead of the first 100 rows. The
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
// quietly plateau at 100 on busy tenants.
//
// `pageSize` is intentionally large (200) to keep round-trips low. The
// platform Relay pagination accepts up to 1000 but 200 is a good balance
// between latency per page and number of pages on active workspaces.
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { const PAGE_SIZE = 200;
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
contactName { firstName lastName } contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode } contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
firstContacted lastContacted contactAttempts convertedAt firstContacted lastContacted contactAttempts convertedAt
patientId campaignId patientId campaignId
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
campaignName typeCustom status platform campaignName typeCustom status platform
startDate endDate startDate endDate
@@ -22,41 +36,61 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
amountSpent { amountMicros currencyCode } amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl { primaryLinkUrl } externalCampaignId platformUrl { primaryLinkUrl }
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
adName externalAdId status format adName externalAdId status format
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl } headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
impressions clicks conversions impressions clicks conversions
spend { amountMicros currencyCode } spend { amountMicros currencyCode }
campaignId campaignId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
typeCustom status scheduledAt completedAt typeCustom status scheduledAt completedAt
priority assignedAgent priority assignedAgent
patientId patientId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
activityType summary occurredAt performedBy activityType summary occurredAt performedBy
previousValue newValue previousValue newValue
channel durationSec outcome channel durationSec outcome
leadId leadId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
direction callStatus callerNumber { primaryPhoneNumber } agentName direction callStatus callerNumber { primaryPhoneNumber } agentName
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
} } pageInfo { hasNextPage endCursor } } }`;
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } pageInfo { hasNextPage endCursor } } }`;
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } pageInfo { hasNextPage endCursor } } }`;
// Doctors are a small reference set (< 50 per workspace) — no pagination
// needed. Left as a plain string for the single consumer that reads it.
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience department specialty qualifications yearsOfExperience
visitingHours visitingHours
@@ -65,18 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
active registrationNumber active registrationNumber
clinic { id name clinicName } clinic { id name clinicName }
} } } }`; } } } }`;
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

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

@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
import { AuthGuard } from "@/components/layout/auth-guard"; import { AuthGuard } from "@/components/layout/auth-guard";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard"; import { SetupWizardPage } from "@/pages/setup-wizard";
import { useUiFlags } from "@/hooks/use-ui-flags";
const AdminSetupGuard = () => { const AdminSetupGuard = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />; const { setupManaged } = useUiFlags();
if (!isAdmin) return <Navigate to="/" replace />;
// When setup is managed by the product team for this tenant, there's
// nothing for an admin to do in the wizard — bounce them to the
// dashboard instead of rendering a dead-end page.
if (setupManaged) return <Navigate to="/" replace />;
return <SetupWizardPage />;
}; };
const RequireAdmin = () => { const RequireAdmin = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <Outlet /> : <Navigate to="/" replace />; return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
}; };
const RequireSelfServeSetup = () => {
const { setupManaged } = useUiFlags();
// Blocks /settings/* when the tenant's setup is product-team managed.
// Sidebar already hides the nav entry, but this catches stray bookmarks
// and deep links.
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
};
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";
@@ -99,6 +114,7 @@ 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 />} />
<Route element={<RequireSelfServeSetup />}>
<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 />} />
@@ -107,6 +123,7 @@ createRoot(document.getElementById("root")!).render(
<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>
</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

@@ -2,9 +2,8 @@ import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } 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 { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />; const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
@@ -24,6 +23,8 @@ import { useLeads } from '@/hooks/use-leads';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
import { notify } from '@/lib/toast';
import type { Lead, LeadSource, LeadStatus } from '@/types/entities'; import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
type TabKey = 'new' | 'my-leads' | 'all'; type TabKey = 'new' | 'my-leads' | 'all';
@@ -165,6 +166,44 @@ export const AllLeadsPage = () => {
setSelectedIds([]); setSelectedIds([]);
}; };
const handleExportCsv = () => {
// Export exactly what the user currently sees — same filters, same
// sort, same tab/campaign scope. Ignores pagination so the file
// contains every matching row, not just the current page.
if (displayLeads.length === 0) {
notify.error('Export CSV', 'No leads to export');
return;
}
const headers = [
'Phone', 'First Name', 'Last Name', 'Email',
'Source', 'Status', 'Campaign', 'Assigned Agent',
'First Contact', 'Last Contact', 'Created', 'Age (days)',
];
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
const now = Date.now();
const rows = displayLeads.map((l) => {
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
return {
'Phone': l.contactPhone?.[0]?.number ?? '',
'First Name': l.contactName?.firstName ?? '',
'Last Name': l.contactName?.lastName ?? '',
'Email': l.contactEmail?.[0]?.address ?? '',
'Source': l.leadSource ?? '',
'Status': l.leadStatus ?? '',
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
'Assigned Agent': l.assignedAgent ?? '',
'First Contact': l.firstContactedAt ?? '',
'Last Contact': l.lastContactedAt ?? '',
'Created': l.createdAt ?? '',
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
};
});
const csv = rowsToCsv(headers, rows);
const today = new Date().toISOString().slice(0, 10);
downloadCsv(`leads-${tab}-${today}.csv`, csv);
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
};
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
setSelectedIds([]); setSelectedIds([]);
@@ -230,14 +269,6 @@ export const AllLeadsPage = () => {
{/* Tabs + Controls row */} {/* Tabs + Controls row */}
<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
href="/"
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back to workspace"
/>
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}> <Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
<TabList items={tabItems} type="button-gray" size="sm"> <TabList items={tabItems} type="button-gray" size="sm">
{(item) => ( {(item) => (
@@ -266,6 +297,7 @@ export const AllLeadsPage = () => {
size="sm" size="sm"
color="secondary" color="secondary"
iconLeading={Download01} iconLeading={Download01}
onClick={handleExportCsv}
> >
Export CSV Export CSV
</Button> </Button>

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) => {
@@ -108,9 +108,17 @@ export const CallDeskPage = () => {
} }
}, [isInCall]); }, [isInCall]);
// Build activeLead from resolved caller or fallback to client-side match // Build activeLead from resolved caller or fallback to client-side match.
// The resolver is the authoritative source for patientId (it just joined
// lead↔patient by phone), so overlay it on top of any worklist row that
// pre-dates the linkage. Without this, the Book Appt pills can't find
// a returning caller's prior appointments because the frontend loses
// sight of which patient they are.
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
const callerLead = resolvedCaller const callerLead = resolvedCaller
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? { ? workLead
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
: {
id: resolvedCaller.leadId, id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName }, contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }], contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
@@ -204,11 +212,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,7 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignHero } from '@/components/campaigns/campaign-hero'; import { CampaignHero } from '@/components/campaigns/campaign-hero';
import { KpiStrip } from '@/components/campaigns/kpi-strip'; import { KpiStrip } from '@/components/campaigns/kpi-strip';
import { AdCard } from '@/components/campaigns/ad-card'; import { AdCard } from '@/components/campaigns/ad-card';
@@ -9,28 +8,52 @@ import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
import { SourceBreakdown } from '@/components/campaigns/source-breakdown'; import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
import { BudgetBar } from '@/components/campaigns/budget-bar'; import { BudgetBar } from '@/components/campaigns/budget-bar';
import { HealthIndicator } from '@/components/campaigns/health-indicator'; import { HealthIndicator } from '@/components/campaigns/health-indicator';
import { Button } from '@/components/base/buttons/button'; import { LeadTable } from '@/components/leads/lead-table';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useCampaigns } from '@/hooks/use-campaigns'; import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads'; import { useLeads } from '@/hooks/use-leads';
import { useData } from '@/providers/data-provider';
import { formatCurrency, formatDateOnly } from '@/lib/format'; import { formatCurrency, formatDateOnly } from '@/lib/format';
import type { Lead } from '@/types/entities';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'leads', label: 'Leads' },
];
export const CampaignDetailPage = () => { export const CampaignDetailPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const { campaigns, ads } = useCampaigns(); const { campaigns, ads } = useCampaigns();
const { leads } = useLeads(); const { leads } = useLeads();
const { leadActivities } = useData();
const campaign = campaigns.find((c) => c.id === id); const campaign = campaigns.find((c) => c.id === id);
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]); const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]); const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [activityLead, setActivityLead] = useState<Lead | null>(null);
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
};
const sortedLeads = useMemo(() => {
const copy = [...campaignLeads];
const dir = sortDirection === 'asc' ? 1 : -1;
copy.sort((a, b) => {
const av = (a as any)[sortField] ?? '';
const bv = (b as any)[sortField] ?? '';
if (av === bv) return 0;
return av > bv ? dir : -dir;
});
return copy;
}, [campaignLeads, sortField, sortDirection]);
if (!campaign) { if (!campaign) {
return ( return (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
@@ -46,43 +69,52 @@ export const CampaignDetailPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* Hero header */}
<CampaignHero campaign={campaign} /> <CampaignHero campaign={campaign} />
{/* KPI strip */}
<KpiStrip campaign={campaign} /> <KpiStrip campaign={campaign} />
{/* Tabs */} {/* Main body: leads table on the left, campaign details + funnel + source on the right */}
<div className="px-7 pt-5"> <div className="px-7 pt-5 pb-7">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}> <div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
<TabList <div className="space-y-6">
type="underline" <div>
size="sm" <div className="mb-3 flex items-center justify-between">
items={detailTabs}
>
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
<TabPanel id="overview">
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
{/* Left: Ads list */}
<div className="space-y-3">
<h3 className="text-md font-bold text-primary"> <h3 className="text-md font-bold text-primary">
Ads ({campaignAds.length}) Leads ({campaignLeads.length})
</h3> </h3>
{campaignAds.map((ad) => ( </div>
<AdCard key={ad.id} ad={ad} /> {campaignLeads.length === 0 ? (
))} <div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
{campaignAds.length === 0 && ( No leads from this campaign yet.
<p className="py-8 text-center text-sm text-tertiary"> </div>
No ads for this campaign. ) : (
</p> <LeadTable
leads={sortedLeads}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
/>
)}
</div>
{campaignAds.length > 0 && (
<div>
<h3 className="mb-3 text-md font-bold text-primary">
Ads ({campaignAds.length})
</h3>
<div className="space-y-3">
{campaignAds.map((ad) => (
<AdCard key={ad.id} ad={ad} />
))}
</div>
</div>
)} )}
</div> </div>
{/* Right: Details + Funnel + Source */}
<div className="space-y-4"> <div className="space-y-4">
{/* Campaign Details card */}
<div className="rounded-xl border border-secondary bg-primary p-4"> <div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4> <h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs"> <dl className="space-y-2 text-xs">
@@ -138,34 +170,21 @@ export const CampaignDetailPage = () => {
</div> </div>
</div> </div>
{/* Conversion Funnel */}
<ConversionFunnel campaign={campaign} leads={campaignLeads} /> <ConversionFunnel campaign={campaign} leads={campaignLeads} />
{/* Source Breakdown */}
<SourceBreakdown leads={campaignLeads} /> <SourceBreakdown leads={campaignLeads} />
</div> </div>
</div> </div>
</TabPanel> </div>
<TabPanel id="leads"> {activityLead && (
<div className="mt-5 pb-7"> <LeadActivitySlideout
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center"> isOpen={!!activityLead}
<p className="text-md font-bold text-primary"> onOpenChange={(open) => !open && setActivityLead(null)}
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign lead={activityLead}
</p> activities={leadActivities}
<p className="mt-1 text-sm text-tertiary"> />
View the full leads table filtered by this campaign on the All Leads page. )}
</p>
<div className="mt-4">
<Button color="primary" size="sm" href="/leads">
Go to All Leads
</Button>
</div>
</div>
</div>
</TabPanel>
</Tabs>
</div>
</div> </div>
); );
}; };

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

@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useThemeTokens } from '@/providers/theme-token-provider'; import { useThemeTokens } from '@/providers/theme-token-provider';
import { getSetupState } from '@/lib/setup-state'; import { getSetupState } from '@/lib/setup-state';
import { getUiFlags } from '@/hooks/use-ui-flags';
export const LoginPage = () => { export const LoginPage = () => {
const { loginWithUser } = useAuth(); const { loginWithUser } = useAuth();
@@ -118,11 +119,13 @@ export const LoginPage = () => {
// First-run detection: if the workspace's setup is incomplete and // First-run detection: if the workspace's setup is incomplete and
// the wizard hasn't been dismissed, route the admin to /setup so // the wizard hasn't been dismissed, route the admin to /setup so
// they finish onboarding before reaching the dashboard. Failures // they finish onboarding before reaching the dashboard. Skip when
// are non-blocking — we always have a fallback to /. // the tenant's setup is product-team managed — there's nothing
// for the admin to do in the wizard. Failures are non-blocking —
// we always have a fallback to /.
try { try {
const state = await getSetupState(); const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
if (state.wizardRequired) { if (state.wizardRequired && !flags.setupManaged) {
navigate('/setup'); navigate('/setup');
return; return;
} }

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

@@ -51,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 = [
@@ -559,7 +563,7 @@ export const Patient360Page = () => {
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`, `mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } }, { data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
); );
setActivities(prev => [{ id: crypto.randomUUID(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), leadId: leadInfo.id }, ...prev]); setActivities(prev => [{ id: crypto.randomUUID(), createdAt: new Date().toISOString(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), channel: null, durationSeconds: null, outcome: null, leadId: leadInfo.id }, ...prev]);
setNoteText(''); setNoteText('');
notify.success('Note Added'); notify.success('Note Added');
} catch { } catch {

View File

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

@@ -3,13 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { AgentTable } from '@/components/dashboard/agent-table';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import {
RichAgentTable,
TimeBreakdown,
NpsConversion,
PerformanceAlerts,
useSupervisorRollup,
} from '@/components/dashboard/supervisor-rollup';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
type DateRange = 'today' | 'week' | 'month'; type DateRange = 'today' | 'week' | 'month';
type DashboardTab = 'agents' | 'missed' | 'campaigns';
const getDateRangeStart = (range: DateRange): Date => { const getDateRangeStart = (range: DateRange): Date => {
const now = new Date(); const now = new Date();
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [tab, setTab] = useState<DashboardTab>('agents');
const [aiOpen, setAiOpen] = useState(true); const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
// date-range semantics — map them through directly.
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
const filteredCalls = useMemo(() => { const filteredCalls = useMemo(() => {
const rangeStart = getDateRangeStart(dateRange); const rangeStart = getDateRangeStart(dateRange);
return calls.filter((call) => { return calls.filter((call) => {
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month'; const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
const tabs = [ const convRate = useMemo(() => {
{ id: 'agents' as const, label: 'Agent Performance' }, if (filteredCalls.length === 0) return 0;
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` }, const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` }, return Math.round((completed / filteredCalls.length) * 100);
]; }, [filteredCalls]);
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
@@ -76,54 +87,48 @@ export const TeamDashboardPage = () => {
</div> </div>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main content */} {/* Main content — scrollable column with KPIs pinned at the
top, then stacked supervisor sections (Agent table, Time
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
No tabs: everything is scroll-visible so a supervisor
doesn't have to hunt across surfaces for their metrics. */}
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI cards — always visible */}
<div className="px-6 pt-5 pb-3"> <div className="px-6 pt-5 pb-3">
<DashboardKpi calls={filteredCalls} leads={leads} /> <DashboardKpi calls={filteredCalls} leads={leads} />
</div> </div>
{/* Tabs */} <div className="flex-1 space-y-5 px-6 pb-8">
<div className="flex items-center gap-1 border-b border-secondary px-6"> {loading && rollupAgents.length === 0 ? (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
tab === t.id
? "border-brand text-brand-secondary"
: "border-transparent text-tertiary hover:text-secondary",
)}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 p-6">
{loading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p> <p className="text-sm text-tertiary">Loading...</p>
</div> </div>
)}
{!loading && tab === 'agents' && (
<AgentTable calls={filteredCalls} />
)}
{!loading && tab === 'missed' && (
<MissedQueue calls={filteredCalls} />
)}
{!loading && tab === 'campaigns' && (
<div className="space-y-3">
{campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
) : ( ) : (
campaigns.map((c) => ( <>
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs"> <RichAgentTable agents={rollupAgents} />
<TimeBreakdown agents={rollupAgents} />
<NpsConversion agents={rollupAgents} convRate={convRate} />
<PerformanceAlerts agents={rollupAgents} />
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">
Missed Queue ({missedQueueCount})
</h3>
<MissedQueue calls={filteredCalls} />
</div>
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">
Campaigns ({campaigns.length})
</h3>
{campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
) : (
<div className="space-y-3">
{campaigns.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
<div> <div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span> <span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary"> <div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
@@ -139,9 +144,11 @@ export const TeamDashboardPage = () => {
</span> </span>
)} )}
</div> </div>
)) ))}
</div>
)} )}
</div> </div>
</>
)} )}
</div> </div>
</div> </div>

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

@@ -1,15 +1,15 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { import {
LEADS_QUERY, leadsQuery,
CAMPAIGNS_QUERY, campaignsQuery,
ADS_QUERY, adsQuery,
FOLLOW_UPS_QUERY, followUpsQuery,
LEAD_ACTIVITIES_QUERY, leadActivitiesQuery,
CALLS_QUERY, callsQuery,
APPOINTMENTS_QUERY, appointmentsQuery,
PATIENTS_QUERY, patientsQuery,
} from '@/lib/queries'; } from '@/lib/queries';
import { import {
transformLeads, transformLeads,
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
// These don't have platform entities yet — empty for now // These don't have platform entities yet — empty for now
const [templates] = useState<WhatsAppTemplate[]>([]); const [templates] = useState<WhatsAppTemplate[]>([]);
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
return; return;
} }
// Only flip the global loading flag on the very first fetch. Background
// polls refresh data in place so the UI doesn't flash "Loading..." —
// QA reported this as the supervisor surfaces randomly refreshing.
if (!hasLoadedRef.current) {
setLoading(true); setLoading(true);
}
setError(null); setError(null);
try { try {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null); const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
// Generic Relay pagination. Keeps paging until hasNextPage=false
// or we hit MAX_PAGES (guard against runaway loops on bad data).
// Returned shape mirrors the original single-page response so
// transformX functions work unchanged — they already read
// `{ <rootField>: { edges } }`.
const MAX_PAGES = 25;
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
const allEdges: any[] = [];
let after: string | undefined = undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const data: any = await gql<any>(builder(after));
if (!data) return null;
const root: any = data[rootField];
if (!root) break;
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
if (!root.pageInfo?.hasNextPage) break;
after = root.pageInfo.endCursor;
if (!after) break;
}
return { [rootField]: { edges: allEdges } };
};
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([ const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
gql<any>(LEADS_QUERY), fetchAll('leads', leadsQuery),
gql<any>(CAMPAIGNS_QUERY), fetchAll('campaigns', campaignsQuery),
gql<any>(ADS_QUERY), fetchAll('ads', adsQuery),
gql<any>(FOLLOW_UPS_QUERY), fetchAll('followUps', followUpsQuery),
gql<any>(LEAD_ACTIVITIES_QUERY), fetchAll('leadActivities', leadActivitiesQuery),
gql<any>(CALLS_QUERY), fetchAll('calls', callsQuery),
gql<any>(APPOINTMENTS_QUERY), fetchAll('appointments', appointmentsQuery),
gql<any>(PATIENTS_QUERY), fetchAll('patients', patientsQuery),
]); ]);
if (leadsData) setLeads(transformLeads(leadsData)); if (leadsData) setLeads(transformLeads(leadsData));
@@ -110,12 +138,19 @@ export const DataProvider = ({ children }: DataProviderProps) => {
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to load data'); setError(err.message ?? 'Failed to load data');
} finally { } finally {
hasLoadedRef.current = true;
setLoading(false); setLoading(false);
} }
}, []); }, []);
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;
}; };