19 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:34:07 +05:30
42 changed files with 1891 additions and 482 deletions

View File

@@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed)
├── global.engage.healix360.net → sidecar-global:4100 ├── global.engage.healix360.net → sidecar-global:4100
├── telephony.engage.healix360.net → telephony:4200 ├── telephony.engage.healix360.net → telephony:4200
├── *.app.healix360.net → server:4000 (platform) ├── *.app.healix360.net → server:4000 (platform)
├── monitoring.healix360.net → grafana:3000
├── operations.healix360.net → woodpecker-server:8000
├── git.healix360.net → gitea:3000
└── engage.healix360.net → 404 (no catchall) └── engage.healix360.net → 404 (no catchall)
Docker Compose stack (EC2 — 13.234.31.194): Docker Compose stack (EC2 — 13.234.31.194):
@@ -28,7 +31,9 @@ Docker Compose stack (EC2 — 13.234.31.194):
├── db — PostgreSQL 16 (workspace-per-schema) ├── db — PostgreSQL 16 (workspace-per-schema)
├── clickhouse — Analytics ├── clickhouse — Analytics
├── minio — S3-compatible object storage ├── minio — S3-compatible object storage
── redpanda — Event bus (Kafka-compatible) ── redpanda — Event bus (Kafka-compatible)
├── loki — Log aggregation (receives from Docker logging driver)
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
``` ```
--- ---
@@ -77,6 +82,9 @@ eval $EC2_SSH hostname
| Ramaiah Platform | `https://ramaiah.app.healix360.net` | | Ramaiah Platform | `https://ramaiah.app.healix360.net` |
| Global Platform | `https://global.app.healix360.net` | | Global Platform | `https://global.app.healix360.net` |
| Telephony Dispatcher | `https://telephony.engage.healix360.net` | | Telephony Dispatcher | `https://telephony.engage.healix360.net` |
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
| CI/CD (Woodpecker) | `https://operations.healix360.net` |
| Git (Gitea) | `https://git.healix360.net` |
--- ---

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,6 +122,44 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length); const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
// Edit mode takes over the whole right panel — otherwise the
// AppointmentForm competes with the AI panel + context blocks for
// vertical space and gets crushed into a tiny strip at the bottom.
if (editingAppointment) {
return (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
<button
onClick={() => setEditingAppointment(null)}
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
>
Back to context
</button>
</div>
<AppointmentForm
isOpen={true}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
clinicId: editingAppointment.clinicId ?? undefined,
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
</div>
);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Lead header — always visible */} {/* Lead header — always visible */}
@@ -316,28 +354,6 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} /> <AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
</div> </div>
{/* Appointment edit form */}
{editingAppointment && (
<AppointmentForm
isOpen={!!editingAppointment}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
)}
</div> </div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -280,7 +280,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<div> <div>
<h3 className="text-lg font-semibold text-primary">Sign out?</h3> <h3 className="text-lg font-semibold text-primary">Sign out?</h3>
<p className="mt-1 text-sm text-tertiary"> <p className="mt-1 text-sm text-tertiary">
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected. You will be logged out of Helix Engage and your telephony account. Any active calls will be disconnected.
</p> </p>
</div> </div>
<div className="flex w-full gap-3"> <div className="flex w-full gap-3">

View File

@@ -52,7 +52,7 @@ export const useAgentState = (agentId: string | null): { state: OzonetelState; s
localStorage.removeItem('helix_agent_config'); localStorage.removeItem('helix_agent_config');
localStorage.removeItem('helix_user'); localStorage.removeItem('helix_user');
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {}); import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip(false, 'agent-state-offline')).catch(() => {});
setTimeout(() => { window.location.href = '/login'; }, 1500); setTimeout(() => { window.location.href = '/login'; }, 1500);
return; return;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,11 +51,15 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = { const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
APPOINTMENT_BOOKED: 'success', APPOINTMENT_BOOKED: 'success',
APPOINTMENT_RESCHEDULED: 'warning',
APPOINTMENT_CANCELLED: 'error',
FOLLOW_UP_SCHEDULED: 'brand', FOLLOW_UP_SCHEDULED: 'brand',
INFO_PROVIDED: 'blue', INFO_PROVIDED: 'blue',
WRONG_NUMBER: 'error', WRONG_NUMBER: 'error',
NO_ANSWER: 'warning', NO_ANSWER: 'warning',
NOT_INTERESTED: 'error',
CALLBACK_REQUESTED: 'gray', CALLBACK_REQUESTED: 'gray',
CALL_DROPPED: 'gray',
}; };
const TABS = [ const TABS = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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