mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
19 Commits
113b5a9277
...
72cb192447
| Author | SHA1 | Date | |
|---|---|---|---|
| 72cb192447 | |||
| d3cbf4d2bb | |||
| 5632f15031 | |||
| d23cf9b857 | |||
| f4dcf6574f | |||
| 180613a2f3 | |||
| 91a1f33d35 | |||
| 8de7d7d802 | |||
| d00b066806 | |||
| 4590417536 | |||
| 42e23a52ec | |||
| 642911fa6c | |||
| 8bc01d1a9f | |||
| 3296977a6a | |||
| d3e6934dcb | |||
| d24945a3af | |||
| d8f9174a55 | |||
| 8cccd55fb6 | |||
| 28b59f36dc |
@@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed)
|
||||
├── global.engage.healix360.net → sidecar-global:4100
|
||||
├── telephony.engage.healix360.net → telephony:4200
|
||||
├── *.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)
|
||||
|
||||
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)
|
||||
├── clickhouse — Analytics
|
||||
├── 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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -77,6 +82,9 @@ eval $EC2_SSH hostname
|
||||
| Ramaiah Platform | `https://ramaiah.app.healix360.net` |
|
||||
| Global Platform | `https://global.app.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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
612
docs/generate-pptx-apr06-11.cjs
Normal file
612
docs/generate-pptx-apr06-11.cjs
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Helix Engage — Weekly Update (Apr 6–11, 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 6–11, 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); });
|
||||
162
docs/weekly-status-apr06-11.md
Normal file
162
docs/weekly-status-apr06-11.md
Normal 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)
|
||||
BIN
docs/weekly-update-apr06-11.pptx
Normal file
BIN
docs/weekly-update-apr06-11.pptx
Normal file
Binary file not shown.
@@ -5,11 +5,13 @@
|
||||
* Prerequisites: doctors already seeded via seed-data.ts
|
||||
*
|
||||
* Platform field mapping (SDK name → platform name):
|
||||
* Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours,
|
||||
* operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours,
|
||||
* Clinic: address→addressCustom,
|
||||
* per-day booleans openMonday..openSunday + opensAt/closesAt (HH:MM),
|
||||
* clinicStatus→status, onlineBookingEnabled→onlineBooking,
|
||||
* 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
|
||||
* InsurancePartner: planTypes→planTypesAccepted
|
||||
*/
|
||||
@@ -68,15 +70,16 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'koramangala@globalhospital.com' },
|
||||
weekdayHours: '8:00 AM – 8:00 PM',
|
||||
saturdayHours: '8:00 AM – 8:00 PM',
|
||||
sundayHours: '9:00 AM – 2:00 PM',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||
opensAt: '08:00',
|
||||
closesAt: '20:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
// requiredDocuments is a relation (ClinicRequiredDocument) — seed separately
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
@@ -95,15 +98,15 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'whitefield@globalhospital.com' },
|
||||
weekdayHours: '8:00 AM – 8:00 PM',
|
||||
saturdayHours: '8:00 AM – 8:00 PM',
|
||||
sundayHours: 'Closed',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||
opensAt: '08:00',
|
||||
closesAt: '20:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
@@ -122,15 +125,15 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'indiranagar@globalhospital.com' },
|
||||
weekdayHours: '9:00 AM – 7:00 PM',
|
||||
saturdayHours: '9:00 AM – 7:00 PM',
|
||||
sundayHours: '10:00 AM – 1:00 PM',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||
opensAt: '09:00',
|
||||
closesAt: '19:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
|
||||
114
scripts/seed-ramaiah-slots.ts
Normal file
114
scripts/seed-ramaiah-slots.ts
Normal 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); });
|
||||
@@ -12,6 +12,7 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
|
||||
import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionModal } from './disposition-modal';
|
||||
import type { CallAction } from './disposition-modal';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { TransferDialog } from './transfer-dialog';
|
||||
import { EnquiryForm } from './enquiry-form';
|
||||
@@ -48,7 +49,18 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
||||
// Actions actually recorded during this call. Drives the disposition
|
||||
// modal's priority-lock: if the agent booked an appointment and logged
|
||||
// an enquiry, both badges render and the primary disposition is
|
||||
// locked to APPOINTMENT_BOOKED.
|
||||
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
|
||||
const addActions = (...newActions: CallAction[]) => {
|
||||
setActionsTaken((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const a of newActions) next.add(a);
|
||||
return Array.from(next);
|
||||
});
|
||||
};
|
||||
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
@@ -104,6 +116,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
direction: callDirectionRef.current,
|
||||
durationSec: callDuration,
|
||||
leadId: lead?.id ?? null,
|
||||
leadName: fullName || null,
|
||||
notes,
|
||||
missedCallId: missedCallId ?? undefined,
|
||||
};
|
||||
@@ -115,24 +128,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||
}
|
||||
|
||||
// Side effects
|
||||
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
||||
try {
|
||||
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');
|
||||
}
|
||||
}
|
||||
// Follow-ups are created by the enquiry form (where the agent picks
|
||||
// the date + context). No second creation here — that was causing
|
||||
// duplicate entries on every FOLLOW_UP_SCHEDULED call.
|
||||
|
||||
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||
localStorage.removeItem('helix_active_ucid');
|
||||
@@ -141,15 +139,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const handleAppointmentSaved = () => {
|
||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDispositionOpen(false);
|
||||
setCallerDisconnected(false);
|
||||
setActionsTaken([]);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
setCallUcid(null);
|
||||
@@ -213,7 +220,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||
<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>
|
||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||
Back to Worklist
|
||||
@@ -292,12 +299,15 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
@@ -317,7 +327,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
@@ -340,10 +353,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
agentName={user.name}
|
||||
onSaved={() => {
|
||||
onSaved={(actions) => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
addActions(...actions);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -355,7 +367,13 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
isOpen={dispositionOpen}
|
||||
callerName={fullName || phoneDisplay}
|
||||
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}
|
||||
onDismiss={() => {
|
||||
// Agent wants to continue the call — close modal, call stays active
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
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 type { OzonetelState } from '@/hooks/use-agent-state';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||
} else {
|
||||
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}`);
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
||||
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
disabled={changing || !canToggle}
|
||||
className={cx(
|
||||
'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',
|
||||
changing && 'opacity-50',
|
||||
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
{changing ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{menuOpen && (
|
||||
|
||||
@@ -27,7 +27,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
|
||||
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`,
|
||||
streamProtocol: 'text',
|
||||
headers: {
|
||||
@@ -49,6 +49,28 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) so
|
||||
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
||||
// replies with appointment/disposition/notes history when the caller is
|
||||
// a returning patient, or a brief "net-new caller" ack otherwise.
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
if (!leadId) return;
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
|
||||
// New caller — clear any prior chat state and fire the summary prompt.
|
||||
autoFiredForLeadRef.current = leadId;
|
||||
setMessages([]);
|
||||
chatStartedRef.current = false;
|
||||
const name = callerContext?.leadName ?? 'this caller';
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
||||
});
|
||||
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
append({ role: 'user', content: prompt });
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ type ExistingAppointment = {
|
||||
doctorName: string;
|
||||
doctorId?: string;
|
||||
department: string;
|
||||
clinicId?: string;
|
||||
reasonForVisit?: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -29,7 +30,10 @@ type AppointmentFormProps = {
|
||||
leadName?: string | null;
|
||||
leadId?: 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;
|
||||
};
|
||||
|
||||
@@ -79,7 +83,11 @@ export const AppointmentForm = ({
|
||||
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||
const [age, setAge] = useState('');
|
||||
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 [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||
@@ -108,13 +116,31 @@ export const AppointmentForm = ({
|
||||
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
|
||||
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
|
||||
).then(slots => {
|
||||
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label })));
|
||||
// Auto-select clinic from the slot's clinic
|
||||
if (slots.length > 0 && !clinic) {
|
||||
// Filter by selected clinic — doctor may visit multiple branches
|
||||
const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
|
||||
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);
|
||||
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([]));
|
||||
}, [doctor, date]);
|
||||
}, [doctor, date, clinic, timeSlot]);
|
||||
|
||||
// Availability state
|
||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||
@@ -238,7 +264,9 @@ export const AppointmentForm = ({
|
||||
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||
|
||||
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(
|
||||
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
@@ -251,18 +279,65 @@ export const AppointmentForm = ({
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
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');
|
||||
} 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
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
createAppointment(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
const appointmentData: Record<string, any> = {
|
||||
scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
@@ -271,9 +346,17 @@ export const AppointmentForm = ({
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
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.
|
||||
@@ -283,13 +366,19 @@ export const AppointmentForm = ({
|
||||
const trimmedName = patientName.trim();
|
||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||
|
||||
// DO NOT update the shared Patient entity when name changes
|
||||
// during appointment creation. The Patient record is shared
|
||||
// across all appointments — modifying it here would
|
||||
// retroactively change the name on all past appointments.
|
||||
// The patient name for THIS appointment is stored on the
|
||||
// Appointment entity itself (via doctorName/department).
|
||||
// Bug #527: removed updatePatient() call.
|
||||
// Update patient name when the agent explicitly renamed.
|
||||
// `nameChanged` already requires isNameEditable=true (the
|
||||
// agent went through EditPatientConfirmModal), so the
|
||||
// rename intent is unambiguous. Bug #527's silent-overwrite
|
||||
// case can no longer happen because the confirm modal
|
||||
// gates the input.
|
||||
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
|
||||
// (those are genuinely about this appointment), but only
|
||||
@@ -316,21 +405,14 @@ export const AppointmentForm = ({
|
||||
|
||||
// If the agent actually renamed the patient, kick off the
|
||||
// side-effect chain: regenerate the AI summary against the
|
||||
// corrected identity AND invalidate the Redis caller
|
||||
// resolution cache so the next incoming call from this
|
||||
// phone picks up fresh data. Both are fire-and-forget —
|
||||
// the save toast fires immediately either way.
|
||||
// corrected identity. Fire-and-forget; the save toast
|
||||
// fires immediately regardless.
|
||||
if (nameChanged && leadId) {
|
||||
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) {
|
||||
console.error('Failed to save appointment:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||
@@ -353,7 +435,7 @@ export const AppointmentForm = ({
|
||||
},
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved?.();
|
||||
onSaved?.('CANCELLED');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||
} finally {
|
||||
|
||||
@@ -14,11 +14,15 @@ interface CallLogProps {
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
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' },
|
||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
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 => {
|
||||
|
||||
@@ -122,6 +122,44 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
|
||||
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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 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">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
|
||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||
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',
|
||||
label: 'Follow-up Needed',
|
||||
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
value: 'NOT_INTERESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||
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) => {
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
import { useState, useRef } from 'react';
|
||||
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 { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import type { FC } from 'react';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
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 }) => (
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||
);
|
||||
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
|
||||
activeClass: 'bg-success-solid text-white border-transparent',
|
||||
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',
|
||||
label: 'Follow-up Needed',
|
||||
@@ -49,31 +91,74 @@ const dispositionOptions: Array<{
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
value: 'NOT_INTERESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white border-transparent',
|
||||
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 = {
|
||||
isOpen: boolean;
|
||||
callerName: string;
|
||||
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;
|
||||
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 [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
|
||||
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
|
||||
appliedDefaultRef.current = defaultDisposition;
|
||||
setSelected(defaultDisposition);
|
||||
// Rank actionsTaken to pick the primary (highest-priority) action. When
|
||||
// any action is present, that action's disposition becomes locked —
|
||||
// the agent cannot override it to a contradictory outcome.
|
||||
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 = () => {
|
||||
@@ -81,11 +166,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
||||
onSubmit(selected, notes);
|
||||
setSelected(null);
|
||||
setNotes('');
|
||||
appliedDefaultRef.current = undefined;
|
||||
appliedLockRef.current = undefined;
|
||||
};
|
||||
|
||||
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">
|
||||
<Dialog>
|
||||
{() => (
|
||||
@@ -108,16 +202,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
||||
|
||||
{/* Disposition options */}
|
||||
<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">
|
||||
{dispositionOptions.map((option) => {
|
||||
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 (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelected(option.value)}
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && setSelected(option.value)}
|
||||
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
|
||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||
: option.defaultClass,
|
||||
|
||||
@@ -22,7 +22,11 @@ type EnquiryFormProps = {
|
||||
leadId?: string | null;
|
||||
patientId?: 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);
|
||||
|
||||
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;
|
||||
if (!leadId && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = resolved.leadId;
|
||||
let resolvedPatientId: string | null = patientId || null;
|
||||
let isNew = false;
|
||||
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 nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||
const nameParts = {
|
||||
@@ -97,10 +104,49 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||
};
|
||||
|
||||
if (leadId) {
|
||||
// Update existing lead with enquiry details. Only touches
|
||||
// contactName if the agent explicitly renamed — otherwise
|
||||
// we leave the existing caller identity alone.
|
||||
if (isNew) {
|
||||
// Net-new caller — create Patient + Lead with the typed
|
||||
// name. Name is required (validated above).
|
||||
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(
|
||||
`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
|
||||
// renamed. Fixes the long-standing bug where typing a name
|
||||
// into this form silently overwrote the existing patient
|
||||
// record.
|
||||
if (nameChanged && patientId) {
|
||||
// Update linked patient's name when the agent renamed (edit
|
||||
// confirm path) on an existing record. Skipped for isNew
|
||||
// because the patient was just created with the right name.
|
||||
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: patientId,
|
||||
id: resolvedPatientId,
|
||||
data: {
|
||||
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));
|
||||
}
|
||||
|
||||
// Post-save side-effects. If the agent actually renamed the
|
||||
// patient, kick off AI summary regen + cache invalidation.
|
||||
// Otherwise just invalidate the cache so the status update
|
||||
// propagates.
|
||||
// Post-save side-effect. If the agent actually renamed the
|
||||
// patient, kick off AI summary regen. Fire-and-forget.
|
||||
if (nameChanged && leadId) {
|
||||
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
|
||||
@@ -166,6 +190,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
setIsSaving(false);
|
||||
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(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
{
|
||||
@@ -176,7 +206,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
priority: 'NORMAL',
|
||||
assignedAgent: agentName ?? undefined,
|
||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||
patientId: patientId ?? undefined,
|
||||
patientId: resolvedPatientId || undefined,
|
||||
},
|
||||
},
|
||||
{ silent: true },
|
||||
@@ -184,7 +214,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
}
|
||||
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||
} finally {
|
||||
@@ -251,11 +283,22 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||
|
||||
{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 && (
|
||||
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||
|
||||
@@ -51,11 +51,15 @@ const ActivityIcon = ({ type }: { type: string }) => {
|
||||
|
||||
const dispositionLabels: Record<CallDisposition, string> = {
|
||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
|
||||
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
|
||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||
INFO_PROVIDED: 'Info Provided',
|
||||
NO_ANSWER: 'No Answer',
|
||||
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) => {
|
||||
|
||||
@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
|
||||
const fetchTargets = async () => {
|
||||
try {
|
||||
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 } } } } }`),
|
||||
]);
|
||||
|
||||
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
|
||||
.filter((a: any) => a.ozonetelAgentId !== currentAgentId)
|
||||
.map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: 'agent' as const,
|
||||
phoneNumber: `0${a.sipextension}`,
|
||||
phoneNumber: `0${a.sipExtension}`,
|
||||
status: 'offline' as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ type WorklistFollowUp = {
|
||||
followUpStatus: string | null;
|
||||
scheduledAt: string | null;
|
||||
priority: string | null;
|
||||
patientName?: string;
|
||||
patientPhone?: string;
|
||||
};
|
||||
|
||||
type MissedCall = {
|
||||
@@ -45,11 +47,12 @@ type MissedCall = {
|
||||
callerNumber: { number: string; callingCode: string }[] | null;
|
||||
startedAt: string | null;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
disposition: string | null;
|
||||
callbackstatus: string | null;
|
||||
callsourcenumber: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
callbackStatus: string | null;
|
||||
callSourceNumber: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
};
|
||||
|
||||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||
@@ -107,7 +110,9 @@ const followUpLabel: Record<string, string> = {
|
||||
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));
|
||||
if (minutes < 1) return { label: '<1m', 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' };
|
||||
};
|
||||
|
||||
// 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 minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
@@ -150,13 +183,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
|
||||
for (const call of missedCalls) {
|
||||
const phone = call.callerNumber?.[0];
|
||||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
||||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
||||
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||
const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : '';
|
||||
rows.push({
|
||||
id: `mc-${call.id}`,
|
||||
type: 'missed',
|
||||
priority: 'HIGH',
|
||||
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
||||
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw: phone?.number ?? '',
|
||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||
@@ -165,12 +198,12 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||
: 'Missed call',
|
||||
createdAt: call.createdAt,
|
||||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
leadId: call.leadId,
|
||||
originalLead: null,
|
||||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
||||
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||
contactAttempts: 0,
|
||||
source: call.callsourcenumber ?? null,
|
||||
source: call.callSourceNumber ?? null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
missedCallId: call.id,
|
||||
});
|
||||
@@ -179,13 +212,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
for (const fu of followUps) {
|
||||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||
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({
|
||||
id: `fu-${fu.id}`,
|
||||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||
name: label,
|
||||
phone: '',
|
||||
phoneRaw: '',
|
||||
name: displayName,
|
||||
phone: phoneFormatted,
|
||||
phoneRaw: fu.patientPhone ?? '',
|
||||
direction: null,
|
||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||
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
|
||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||
// Keep all rows — follow-ups may have no phone and still need to be visible.
|
||||
// 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
|
||||
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) => {
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||||
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||
// 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(() => ({
|
||||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||||
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
||||
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
|
||||
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
|
||||
}), [missedCalls]);
|
||||
|
||||
const allRows = useMemo(
|
||||
@@ -273,7 +319,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
let rows = allRows;
|
||||
if (tab === 'missed') rows = missedSubTabRows;
|
||||
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()) {
|
||||
const q = search.toLowerCase();
|
||||
@@ -295,8 +341,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
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 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;
|
||||
}
|
||||
default:
|
||||
@@ -310,7 +375,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
const missedCount = allRows.filter((r) => r.type === 'missed').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
|
||||
const prevMissedCount = useRef(missedCount);
|
||||
@@ -377,30 +442,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missed call status sub-tabs */}
|
||||
{tab === 'missed' && (
|
||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||
{(['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>
|
||||
)}
|
||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||
now only shows pending callbacks. Attempted is redundant once
|
||||
the worklist is the single source of truth. */}
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<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}>
|
||||
{(row) => {
|
||||
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;
|
||||
|
||||
// Sub-line: last interaction context
|
||||
|
||||
@@ -26,14 +26,27 @@ interface AgentTableProps {
|
||||
|
||||
export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
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) {
|
||||
const agent = call.agentName ?? 'Unknown';
|
||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||
agentMap.get(agent)!.push(call);
|
||||
let key: string;
|
||||
let displayName: string;
|
||||
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 outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').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 booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||
const nameParts = name.split(' ');
|
||||
const nameParts = displayName.split(' ');
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
id: key,
|
||||
name: displayName,
|
||||
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
||||
inbound, outbound, missed, total, avgHandle, conversion,
|
||||
};
|
||||
@@ -82,7 +95,7 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.id}>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -2,46 +2,14 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
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';
|
||||
|
||||
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 = () => {
|
||||
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts();
|
||||
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS);
|
||||
const [open, setOpen] = useState(true);
|
||||
const { alerts, dismiss, dismissAll } = usePerformanceAlerts();
|
||||
const [open, setOpen] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -123,7 +91,7 @@ export const NotificationBell = () => {
|
||||
<p className="text-sm font-medium text-primary">{alert.agent}</p>
|
||||
<p className="text-xs text-tertiary">{alert.type}</p>
|
||||
</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
|
||||
onClick={() => dismiss(alert.id)}
|
||||
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
|
||||
|
||||
@@ -280,7 +280,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useAgentState = (agentId: string | null): { state: OzonetelState; s
|
||||
localStorage.removeItem('helix_agent_config');
|
||||
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);
|
||||
return;
|
||||
|
||||
@@ -1,102 +1,101 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type PerformanceAlert = {
|
||||
id: string;
|
||||
agent: string;
|
||||
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
value: string;
|
||||
severity: 'error' | 'warning';
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message?: string | null;
|
||||
firedAt?: string;
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { calls, leads } = useData();
|
||||
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
||||
const [teamPerf, setTeamPerf] = useState<any>(null);
|
||||
const toastsFiredRef = useRef(false);
|
||||
const lastSeenIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch team performance data from sidecar (same as team-performance page)
|
||||
useEffect(() => {
|
||||
const load = useCallback(async () => {
|
||||
if (!isAdmin) return;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
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}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => setTeamPerf(data))
|
||||
.catch(() => {});
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
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]);
|
||||
|
||||
// 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(() => {
|
||||
if (toastsFiredRef.current || alerts.length === 0) return;
|
||||
toastsFiredRef.current = true;
|
||||
if (!isAdmin) return;
|
||||
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 npsCount = alerts.filter(a => a.type === 'Low NPS').length;
|
||||
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
|
||||
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
|
||||
if (convCount > 0) parts.push(`${convCount} low conversion`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
// Optimistic
|
||||
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/${id}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
// Reload on failure to restore truth
|
||||
load();
|
||||
}
|
||||
}, [alerts]);
|
||||
}, [load]);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
};
|
||||
|
||||
const dismissAll = () => {
|
||||
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter(a => !a.dismissed);
|
||||
|
||||
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
const dismissAll = useCallback(async () => {
|
||||
setAlerts([]);
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
load();
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
return { alerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
};
|
||||
|
||||
@@ -15,10 +15,11 @@ type MissedCall = {
|
||||
disposition: string | null;
|
||||
callNotes: string | null;
|
||||
leadId: string | null;
|
||||
callbackstatus: string | null;
|
||||
callsourcenumber: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
leadName: string | null;
|
||||
callbackStatus: string | null;
|
||||
callSourceNumber: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
};
|
||||
|
||||
type WorklistFollowUp = {
|
||||
@@ -32,6 +33,8 @@ type WorklistFollowUp = {
|
||||
assignedAgent: string | null;
|
||||
patientId: string | null;
|
||||
callId: string | null;
|
||||
patientName?: string;
|
||||
patientPhone?: string;
|
||||
};
|
||||
|
||||
type WorklistLead = {
|
||||
|
||||
@@ -54,6 +54,8 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
|
||||
startedAt endedAt durationSec
|
||||
recording { primaryLinkUrl } disposition sla
|
||||
patientId appointmentId leadId
|
||||
agentId agent { id name ozonetelAgentId }
|
||||
transferredTo transferType
|
||||
} } } }`;
|
||||
|
||||
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
@@ -71,7 +73,8 @@ export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ schedu
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
clinicId clinic { id clinicName }
|
||||
} } } }`;
|
||||
|
||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
||||
|
||||
@@ -7,6 +7,10 @@ export class SIPClient {
|
||||
private ua: JsSIP.UA | null = null;
|
||||
private currentSession: RTCSession | 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(
|
||||
private config: SIPConfig,
|
||||
@@ -36,28 +40,43 @@ export class SIPClient {
|
||||
|
||||
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', () => {
|
||||
console.log('[SIP] WebSocket connected');
|
||||
console.log('[SIP] WebSocket connected — waiting for REGISTER');
|
||||
this.onConnectionChange('connected');
|
||||
});
|
||||
|
||||
this.ua.on('disconnected', () => {
|
||||
console.log('[SIP] WebSocket disconnected');
|
||||
this.ua.on('disconnected', (e: any) => {
|
||||
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.ua.on('registered', () => {
|
||||
console.log('[SIP] Registered successfully');
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('registered');
|
||||
});
|
||||
|
||||
this.ua.on('unregistered', () => {
|
||||
console.log('[SIP] Unregistered');
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('disconnected');
|
||||
});
|
||||
|
||||
this.ua.on('registrationFailed', () => {
|
||||
console.error('[SIP] Registration failed');
|
||||
this.ua.on('registrationFailed', (e: any) => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -125,9 +144,25 @@ export class SIPClient {
|
||||
});
|
||||
|
||||
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 {
|
||||
this.clearRegistrationWatchdog();
|
||||
this.hangup();
|
||||
if (this.ua) {
|
||||
this.ua.stop();
|
||||
|
||||
@@ -150,26 +150,39 @@ export function transformCalls(data: any): Call[] {
|
||||
patientId: n.patientId,
|
||||
appointmentId: n.appointmentId,
|
||||
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[] {
|
||||
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,
|
||||
createdAt: n.createdAt,
|
||||
scheduledAt: n.scheduledAt,
|
||||
durationMinutes: n.durationMin ?? 30,
|
||||
appointmentType: n.appointmentType,
|
||||
appointmentStatus: n.status,
|
||||
doctorName: n.doctorName,
|
||||
doctorName: doctorFullName || n.doctorName || null,
|
||||
doctorId: n.doctor?.id ?? null,
|
||||
department: n.department,
|
||||
reasonForVisit: n.reasonForVisit,
|
||||
patientId: n.patient?.id ?? null,
|
||||
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : 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[] {
|
||||
|
||||
@@ -65,11 +65,15 @@ const formatPhoneDisplay = (call: Call): string => {
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
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' },
|
||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
||||
@@ -84,20 +88,39 @@ const DirectionIcon = ({ direction, status }: { direction: CallDirection | null;
|
||||
|
||||
export const AgentDetailPage = () => {
|
||||
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(
|
||||
() =>
|
||||
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) => {
|
||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}),
|
||||
[calls, agentName],
|
||||
[calls, agentUuid, legacyName],
|
||||
);
|
||||
|
||||
// Build lead name map for enrichment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useSearchParams, useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
@@ -38,6 +38,7 @@ const PAGE_SIZE = 15;
|
||||
|
||||
export const AllLeadsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||
const [tab, setTab] = useState<TabKey>('new');
|
||||
@@ -231,11 +232,11 @@ export const AllLeadsPage = () => {
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
href="/"
|
||||
onClick={() => navigate(-1)}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
iconLeading={ArrowLeft}
|
||||
aria-label="Back to workspace"
|
||||
aria-label="Back"
|
||||
/>
|
||||
|
||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||
|
||||
@@ -27,8 +27,11 @@ type AppointmentRecord = {
|
||||
fullName: { firstName: string; lastName: string } | null;
|
||||
phones: { primaryPhoneNumber: string } | null;
|
||||
} | null;
|
||||
clinic: {
|
||||
clinicName: string;
|
||||
} | null;
|
||||
doctor: {
|
||||
clinic: { clinicName: string } | null;
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -58,6 +61,7 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { clinicName }
|
||||
doctor { id }
|
||||
} } } }`;
|
||||
|
||||
@@ -103,7 +107,7 @@ export const AppointmentsPage = () => {
|
||||
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
const doctor = (a.doctorName ?? '').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);
|
||||
});
|
||||
}
|
||||
@@ -177,7 +181,7 @@ export const AppointmentsPage = () => {
|
||||
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
|
||||
: 'Unknown';
|
||||
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
const branch = appt.department ?? '—';
|
||||
const branch = appt.clinic?.clinicName ?? '—';
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
|
||||
@@ -213,7 +217,7 @@ export const AppointmentsPage = () => {
|
||||
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
|
||||
</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>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { cx } from '@/utils/cx';
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
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 [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
@@ -91,7 +91,7 @@ export const CallDeskPage = () => {
|
||||
.then((result) => {
|
||||
setResolvedCaller(result);
|
||||
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) => {
|
||||
@@ -204,11 +204,11 @@ export const CallDeskPage = () => {
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
||||
{dialling ? 'Dialling...' : 'Call'}
|
||||
{dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,11 +35,15 @@ const filterItems = [
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
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' },
|
||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||
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 => {
|
||||
@@ -139,8 +143,9 @@ export const CallHistoryPage = () => {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Direction / status filter
|
||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
||||
// Direction / status filter. "Inbound" shows answered inbound only — missed
|
||||
// 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 === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||
|
||||
@@ -234,7 +239,8 @@ export const CallHistoryPage = () => {
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedCalls}>
|
||||
{(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 phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||
|
||||
@@ -22,10 +22,10 @@ type MissedCallRecord = {
|
||||
callerNumber: { primaryPhoneNumber: string } | null;
|
||||
agentName: string | null;
|
||||
startedAt: string | null;
|
||||
callsourcenumber: string | null;
|
||||
callbackstatus: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
callSourceNumber: string | null;
|
||||
callbackStatus: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
sla: number | null;
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ const QUERY = `{ calls(first: 200, filter: {
|
||||
callStatus: { eq: MISSED }
|
||||
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id callerNumber { primaryPhoneNumber } agentName
|
||||
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
|
||||
startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
|
||||
} } } }`;
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
@@ -92,7 +92,7 @@ export const MissedCallsPage = () => {
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const c of calls) {
|
||||
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
|
||||
const s = c.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -100,16 +100,16 @@ export const MissedCallsPage = () => {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = calls;
|
||||
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_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
|
||||
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_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(c =>
|
||||
(c.callerNumber?.primaryPhoneNumber ?? '').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;
|
||||
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 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||
default: return 0;
|
||||
@@ -190,7 +190,7 @@ export const MissedCallsPage = () => {
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
@@ -213,7 +213,7 @@ export const MissedCallsPage = () => {
|
||||
)}
|
||||
{visibleColumns.has('branch') && (
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
|
||||
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('agent') && (
|
||||
@@ -223,8 +223,8 @@ export const MissedCallsPage = () => {
|
||||
)}
|
||||
{visibleColumns.has('count') && (
|
||||
<Table.Cell>
|
||||
{call.missedcallcount && call.missedcallcount > 1 ? (
|
||||
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
|
||||
{call.missedCallCount && call.missedCallCount > 1 ? (
|
||||
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||
) : <span className="text-xs text-quaternary">1</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
@@ -256,10 +256,10 @@ export const MissedCallsPage = () => {
|
||||
)}
|
||||
{visibleColumns.has('callback') && (
|
||||
<Table.Cell>
|
||||
{call.callbackattemptedat ? (
|
||||
{call.callbackAttemptedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
|
||||
@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
|
||||
],
|
||||
barWidth: '50%',
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||
}],
|
||||
}}
|
||||
style={{ height: 240 }}
|
||||
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
avoidLabelOverlap: true,
|
||||
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) => ({
|
||||
name,
|
||||
value,
|
||||
|
||||
@@ -51,11 +51,15 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
|
||||
|
||||
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
|
||||
APPOINTMENT_BOOKED: 'success',
|
||||
APPOINTMENT_RESCHEDULED: 'warning',
|
||||
APPOINTMENT_CANCELLED: 'error',
|
||||
FOLLOW_UP_SCHEDULED: 'brand',
|
||||
INFO_PROVIDED: 'blue',
|
||||
WRONG_NUMBER: 'error',
|
||||
NO_ANSWER: 'warning',
|
||||
NOT_INTERESTED: 'error',
|
||||
CALLBACK_REQUESTED: 'gray',
|
||||
CALL_DROPPED: 'gray',
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
|
||||
@@ -6,12 +6,11 @@ import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
// Button removed — actions are icon-only now
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
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 { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
@@ -86,8 +85,6 @@ export const PatientsPage = () => {
|
||||
|
||||
return (
|
||||
<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 flex-col overflow-y-auto p-7">
|
||||
<TableCard.Root size="sm">
|
||||
@@ -136,12 +133,11 @@ export const PatientsPage = () => {
|
||||
<Table.Header>
|
||||
<Table.Head label="PATIENT" isRowHeader />
|
||||
<Table.Head label="CONTACT" />
|
||||
<Table.Head label="TYPE" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedPatients}>
|
||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||
{(patient) => {
|
||||
const displayName = getPatientDisplayName(patient);
|
||||
const age = computeAge(patient.dateOfBirth);
|
||||
@@ -198,17 +194,6 @@ export const PatientsPage = () => {
|
||||
</div>
|
||||
</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 */}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
|
||||
@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
|
||||
|
||||
type AgentPerf = {
|
||||
name: string;
|
||||
ozonetelagentid: string;
|
||||
npsscore: number | null;
|
||||
maxidleminutes: number | null;
|
||||
minnpsthreshold: number | null;
|
||||
minconversionpercent: number | null;
|
||||
ozonetelAgentId: string;
|
||||
npsScore: number | null;
|
||||
maxIdleMinutes: number | null;
|
||||
minNpsThreshold: number | null;
|
||||
minConversionPercent: number | null;
|
||||
calls: number;
|
||||
inbound: number;
|
||||
missed: number;
|
||||
@@ -90,7 +90,7 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
try {
|
||||
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>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, 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[];
|
||||
|
||||
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) => {
|
||||
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 agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||
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;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelagentid,
|
||||
ozonetelagentid: agent.ozonetelagentid,
|
||||
npsscore: agent.npsscore,
|
||||
maxidleminutes: agent.maxidleminutes,
|
||||
minnpsthreshold: agent.minnpsthreshold,
|
||||
minconversionpercent: agent.minconversionpercent,
|
||||
name: agent.name ?? agent.ozonetelAgentId,
|
||||
ozonetelAgentId: agent.ozonetelAgentId,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversionPercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
@@ -148,10 +154,23 @@ export const TeamPerformancePage = () => {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Fallback: build agent list from call records
|
||||
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
|
||||
agentPerfs = agentNames.map((name) => {
|
||||
const agentCalls = calls.filter((c: any) => c.agentName === name);
|
||||
// Fallback: build agent list from call records. Prefer
|
||||
// the authoritative agent relation; fall back to the raw
|
||||
// agentName string (Ozonetel transfer chain) only when
|
||||
// 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 agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
@@ -159,11 +178,11 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
return {
|
||||
name,
|
||||
ozonetelagentid: name,
|
||||
npsscore: null,
|
||||
maxidleminutes: null,
|
||||
minnpsthreshold: null,
|
||||
minconversionpercent: null,
|
||||
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,
|
||||
@@ -215,17 +234,17 @@ export const TeamPerformancePage = () => {
|
||||
xAxis: { type: 'category', data: days },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
|
||||
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
|
||||
{ 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', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||
],
|
||||
};
|
||||
}, [allCalls]);
|
||||
|
||||
// NPS
|
||||
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;
|
||||
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]);
|
||||
|
||||
const npsOption = useMemo(() => ({
|
||||
@@ -246,13 +265,13 @@ export const TeamPerformancePage = () => {
|
||||
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) {
|
||||
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.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) {
|
||||
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
@@ -332,7 +351,7 @@ export const TeamPerformancePage = () => {
|
||||
</Table.Header>
|
||||
<Table.Body items={agents}>
|
||||
{(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 text-primary">{agent.calls}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||
@@ -345,12 +364,12 @@ export const TeamPerformancePage = () => {
|
||||
</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 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')}>
|
||||
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||
{agent.idleMinutes}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
@@ -389,7 +408,7 @@ export const TeamPerformancePage = () => {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{agents.map(agent => {
|
||||
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 (
|
||||
<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>
|
||||
@@ -417,7 +436,7 @@ export const TeamPerformancePage = () => {
|
||||
<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) ? (
|
||||
{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>
|
||||
@@ -425,13 +444,13 @@ export const TeamPerformancePage = () => {
|
||||
<>
|
||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||
<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">
|
||||
<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 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>
|
||||
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
|
||||
// Disconnect SIP before logout
|
||||
try {
|
||||
disconnectSip(true);
|
||||
disconnectSip(true, 'logout');
|
||||
} catch {}
|
||||
|
||||
// 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`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true, // survives page navigation — ensures session unlock completes
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -116,6 +116,12 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
const updateLead = (id: string, updates: Partial<Lead>) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/state/sip-state';
|
||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { SIPConfig } from '@/types/sip';
|
||||
|
||||
// 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 setCallDuration = useSetAtom(sipCallDurationAtom);
|
||||
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
||||
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
|
||||
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
|
||||
|
||||
// Register Jotai setters so the singleton SIP manager can update atoms
|
||||
useEffect(() => {
|
||||
@@ -50,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
setCallState,
|
||||
setCallerNumber,
|
||||
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
|
||||
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('unload', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
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
|
||||
|
||||
@@ -156,6 +161,17 @@ export const useSip = () => {
|
||||
|
||||
// Ozonetel outbound dial — single path for all outbound calls
|
||||
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
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
if (agentCfg) {
|
||||
@@ -166,7 +182,6 @@ export const useSip = () => {
|
||||
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
|
||||
const stateData = await stateRes.json();
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +219,7 @@ export const useSip = () => {
|
||||
setCallerNumber(null);
|
||||
throw new Error('Dial failed');
|
||||
}
|
||||
}, [setCallState, setCallerNumber, setCallUcid]);
|
||||
}, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
|
||||
|
||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||
|
||||
@@ -13,6 +13,8 @@ type StateUpdater = {
|
||||
setCallState: (state: CallState) => void;
|
||||
setCallerNumber: (number: string | null) => void;
|
||||
setCallUcid: (ucid: string | null) => void;
|
||||
setIsMuted: (muted: boolean) => void;
|
||||
setIsOnHold: (onHold: boolean) => void;
|
||||
};
|
||||
|
||||
let stateUpdater: StateUpdater | null = null;
|
||||
@@ -83,6 +85,13 @@ export function connectSip(config: SIPConfig): void {
|
||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||
|
||||
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;
|
||||
outboundPending = false;
|
||||
}
|
||||
@@ -92,16 +101,16 @@ export function connectSip(config: SIPConfig): void {
|
||||
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
|
||||
// unless explicitly forced (e.g., logout, page unload).
|
||||
// This prevents React re-render cycles from killing the
|
||||
// SIP WebSocket mid-dial.
|
||||
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;
|
||||
}
|
||||
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
|
||||
console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
|
||||
sipClient?.disconnect();
|
||||
sipClient = null;
|
||||
connected = false;
|
||||
|
||||
@@ -250,11 +250,15 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
|
||||
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
|
||||
export type CallDisposition =
|
||||
| 'APPOINTMENT_BOOKED'
|
||||
| 'APPOINTMENT_RESCHEDULED'
|
||||
| 'APPOINTMENT_CANCELLED'
|
||||
| 'FOLLOW_UP_SCHEDULED'
|
||||
| 'INFO_PROVIDED'
|
||||
| 'WRONG_NUMBER'
|
||||
| 'NO_ANSWER'
|
||||
| 'CALLBACK_REQUESTED';
|
||||
| 'NOT_INTERESTED'
|
||||
| 'CALLBACK_REQUESTED'
|
||||
| 'CALL_DROPPED';
|
||||
|
||||
export type Call = {
|
||||
id: string;
|
||||
@@ -273,6 +277,12 @@ export type Call = {
|
||||
appointmentId: string | null;
|
||||
leadId: string | 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
|
||||
leadName?: string;
|
||||
leadPhone?: string;
|
||||
@@ -313,6 +323,7 @@ export type Appointment = {
|
||||
patientId: string | null;
|
||||
patientName: string | null;
|
||||
patientPhone: string | null;
|
||||
clinicId: string | null;
|
||||
clinicName: string | null;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user