From 5816cc0b5c2b8549fefe4c450609b736467d5945 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 14:41:31 +0530 Subject: [PATCH 1/9] fix: pinned header/chat input, numpad dialler, caller matching, appointment FK - AppShell: h-screen + overflow-hidden for pinned header - AI chat: input pinned to bottom, messages scroll independently - Dialler: numpad grid (1-9,*,0,#) replaces text input - Inbound calls: don't fall back to previously selected lead - Appointment: use lead.patientId instead of leadId for FK - Added .env.production for consistent builds Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.production | 5 + src/components/call-desk/active-call-card.tsx | 2 + src/components/call-desk/appointment-form.tsx | 20 +-- src/components/call-desk/context-panel.tsx | 148 ++++++++++++++---- src/components/layout/app-shell.tsx | 4 +- src/pages/call-desk.tsx | 91 ++++++++++- 6 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 .env.production diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..1dee9e7 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud +VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud +VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com +VITE_SIP_PASSWORD=523590 +VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index fde377d..6bd5ee6 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -226,6 +226,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete callerNumber={callerPhone} leadName={fullName || null} leadId={lead?.id ?? null} + patientId={(lead as any)?.patientId ?? null} onSaved={handleAppointmentSaved} /> @@ -340,6 +341,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete callerNumber={callerPhone} leadName={fullName || null} leadId={lead?.id ?? null} + patientId={(lead as any)?.patientId ?? null} onSaved={handleAppointmentSaved} /> diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index f312cdc..2263032 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -20,7 +20,7 @@ type ExistingAppointment = { doctorId?: string; department: string; reasonForVisit?: string; - appointmentStatus: string; + status: string; }; type AppointmentFormProps = { @@ -29,6 +29,7 @@ type AppointmentFormProps = { callerNumber?: string | null; leadName?: string | null; leadId?: string | null; + patientId?: string | null; onSaved?: () => void; existingAppointment?: ExistingAppointment | null; }; @@ -70,6 +71,7 @@ export const AppointmentForm = ({ callerNumber, leadName, leadId, + patientId, onSaved, existingAppointment, }: AppointmentFormProps) => { @@ -141,11 +143,11 @@ export const AppointmentForm = ({ `{ appointments(filter: { doctorId: { eq: "${doctor}" }, scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" } - }) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`, + }) { edges { node { id scheduledAt durationMin status } } } }`, ).then(data => { // Filter out cancelled/completed appointments client-side const activeAppointments = data.appointments.edges.filter(e => { - const status = e.node.appointmentStatus; + const status = e.node.status; return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW'; }); const slots = activeAppointments.map(e => { @@ -223,14 +225,14 @@ export const AppointmentForm = ({ notify.success('Appointment Updated'); } else { // Double-check slot availability before booking - const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>( + const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { status: string } }> } }>( `{ appointments(filter: { doctorId: { eq: "${doctor}" }, scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" } - }) { edges { node { appointmentStatus } } } }`, + }) { edges { node { status } } } }`, ); const activeBookings = checkResult.appointments.edges.filter(e => - e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW', + e.node.status !== 'CANCELLED' && e.node.status !== 'NO_SHOW', ); if (activeBookings.length > 0) { setError('This slot was just booked by someone else. Please select a different time.'); @@ -248,12 +250,12 @@ export const AppointmentForm = ({ scheduledAt, durationMin: 30, appointmentType: 'CONSULTATION', - appointmentStatus: 'SCHEDULED', + status: 'SCHEDULED', doctorName: selectedDoctor?.name ?? '', department: selectedDoctor?.department ?? '', doctorId: doctor, reasonForVisit: chiefComplaint || null, - ...(leadId ? { patientId: leadId } : {}), + ...(patientId ? { patientId } : {}), }, }, ); @@ -294,7 +296,7 @@ export const AppointmentForm = ({ }`, { id: existingAppointment.id, - data: { appointmentStatus: 'CANCELLED' }, + data: { status: 'CANCELLED' }, }, ); notify.success('Appointment Cancelled'); diff --git a/src/components/call-desk/context-panel.tsx b/src/components/call-desk/context-panel.tsx index 4123bbe..0b2ec26 100644 --- a/src/components/call-desk/context-panel.tsx +++ b/src/components/call-desk/context-panel.tsx @@ -1,14 +1,16 @@ import { useEffect, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons'; +import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; import { AiChatPanel } from './ai-chat-panel'; -import { LiveTranscript } from './live-transcript'; -import { useCallAssist } from '@/hooks/use-call-assist'; import { Badge } from '@/components/base/badges/badges'; +import { apiClient } from '@/lib/api-client'; import { formatPhone, formatShortDate } from '@/lib/format'; import { cx } from '@/utils/cx'; import type { Lead, LeadActivity } from '@/types/entities'; +const CalendarCheck = faIcon(faCalendarCheck); + type ContextTab = 'ai' | 'lead360'; interface ContextPanelProps { @@ -19,7 +21,7 @@ interface ContextPanelProps { callUcid?: string | null; } -export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => { +export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => { const [activeTab, setActiveTab] = useState('ai'); // Auto-switch to lead 360 when a lead is selected @@ -29,13 +31,6 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, } }, [selectedLead?.id]); - const { transcript, suggestions, connected: assistConnected } = useCallAssist( - isInCall ?? false, - callUcid ?? null, - selectedLead?.id ?? null, - callerPhone ?? null, - ); - const callerContext = selectedLead ? { callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone, leadId: selectedLead.id, @@ -68,30 +63,57 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, )} > - Lead 360 + {(selectedLead as any)?.patientId ? 'Patient 360' : 'Lead 360'} {/* Tab content */} -
- {activeTab === 'ai' && ( - isInCall ? ( - - ) : ( -
- -
- ) - )} - {activeTab === 'lead360' && ( + {activeTab === 'ai' && ( +
+ +
+ )} + {activeTab === 'lead360' && ( +
- )} -
+
+ )} ); }; const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => { + const [patientData, setPatientData] = useState(null); + const [loadingPatient, setLoadingPatient] = useState(false); + + // Fetch patient data when lead has a patientId (returning patient) + useEffect(() => { + const patientId = (lead as any)?.patientId; + if (!patientId) { + setPatientData(null); + return; + } + + setLoadingPatient(true); + apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>( + `query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node { + id fullName { firstName lastName } dateOfBirth gender patientType + phones { primaryPhoneNumber } emails { primaryEmail } + appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt status doctorName department reasonForVisit appointmentType + } } } + calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callStatus disposition direction startedAt durationSec agentName + } } } + } } } }`, + { id: patientId }, + { silent: true }, + ).then(data => { + setPatientData(data.patients.edges[0]?.node ?? null); + }).catch(() => setPatientData(null)) + .finally(() => setLoadingPatient(false)); + }, [(lead as any)?.patientId]); + if (!lead) { return (
@@ -112,6 +134,15 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA .sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime()) .slice(0, 10); + const isReturning = !!patientData; + const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? []; + const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? []; + + const patientAge = patientData?.dateOfBirth + ? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000)) + : null; + const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null; + return (
{/* Profile */} @@ -120,6 +151,12 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA {phone &&

{formatPhone(phone)}

} {email &&

{email}

}
+ {isReturning && ( + Returning Patient + )} + {patientAge !== null && patientGender && ( + {patientAge}y · {patientGender} + )} {lead.leadStatus && {lead.leadStatus}} {lead.leadSource && {lead.leadSource}} {lead.priority && lead.priority !== 'NORMAL' && ( @@ -129,11 +166,66 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA {lead.interestedService && (

Interested in: {lead.interestedService}

)} - {lead.leadScore !== null && lead.leadScore !== undefined && ( -

Lead score: {lead.leadScore}

- )}
+ {/* Returning patient: Appointments */} + {loadingPatient && ( +

Loading patient details...

+ )} + {isReturning && appointments.length > 0 && ( +
+

Appointments

+
+ {appointments.map((appt: any) => { + const statusColors: Record = { + COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand', + CANCELLED: 'error', NO_SHOW: 'warning', + }; + return ( +
+ +
+
+ + {appt.doctorName ?? 'Doctor'} · {appt.department ?? ''} + + {appt.status && ( + + {appt.status.toLowerCase()} + + )} +
+

+ {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''} + {appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ''} +

+
+
+ ); + })} +
+
+ )} + + {/* Returning patient: Recent calls */} + {isReturning && patientCalls.length > 0 && ( +
+

Recent Calls

+
+ {patientCalls.map((call: any) => ( +
+
+ + {call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} + {call.disposition ? ` — ${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''} + + {call.startedAt ? formatShortDate(call.startedAt) : ''} +
+ ))} +
+
+ )} + {/* AI Insight */} {(lead.aiSummary || lead.aiSuggestedAction) && (
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index e6437ed..fb2e532 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -15,9 +15,9 @@ export const AppShell = ({ children }: AppShellProps) => { return ( -
+
-
{children}
+
{children}
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && }
diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 5f9b2cc..04192bb 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; +import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useWorklist } from '@/hooks/use-worklist'; @@ -12,6 +12,8 @@ import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { Badge } from '@/components/base/badges/badges'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; import { cx } from '@/utils/cx'; export const CallDeskPage = () => { @@ -23,6 +25,24 @@ export const CallDeskPage = () => { const [contextOpen, setContextOpen] = useState(true); const [activeMissedCallId, setActiveMissedCallId] = useState(null); const [callDismissed, setCallDismissed] = useState(false); + const [diallerOpen, setDiallerOpen] = useState(false); + const [dialNumber, setDialNumber] = useState(''); + const [dialling, setDialling] = useState(false); + + const handleDial = async () => { + const num = dialNumber.replace(/[^0-9]/g, ''); + if (num.length < 10) { notify.error('Enter a valid phone number'); return; } + setDialling(true); + try { + await apiClient.post('/api/ozonetel/dial', { phoneNumber: num }); + setDiallerOpen(false); + setDialNumber(''); + } catch { + notify.error('Dial failed'); + } finally { + setDialling(false); + } + }; // Reset callDismissed when a new call starts (ringing in or out) if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) { @@ -35,7 +55,11 @@ export const CallDeskPage = () => { ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) : null; - const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead; + // For inbound calls, only use matched lead (don't fall back to previously selected worklist lead) + // For outbound (agent initiated from worklist), selectedLead is the intended target + const activeLead = isInCall + ? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null)) + : selectedLead; const activeLeadFull = activeLead as any; return ( @@ -102,6 +126,69 @@ export const CallDeskPage = () => { )}
+ + {/* Dialler FAB */} + {!isInCall && ( +
+ {diallerOpen && ( +
+
+ Dial + +
+ + {/* Number display */} +
+ + {dialNumber || Enter number} + + {dialNumber && ( + + )} +
+ + {/* Numpad */} +
+ {['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => ( + + ))} +
+ + {/* Call button */} + +
+ )} + +
+ )}
); }; From b9b7ee275f825f4514a8d9fdb5ea89c7ab5a5aee Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 21:08:23 +0530 Subject: [PATCH 2/9] feat: appointments page, data refresh on login, multi-agent spec + plan - Appointment Master page with status tabs, search, PhoneActionCell - Login calls DataProvider.refresh() to load data after auth - Sidebar: appointments nav for CC agents + executives - Multi-agent SIP + lockout spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-23-multi-agent-sip-lockout.md | 643 ++++++++++++++++++ .../2026-03-23-multi-agent-sip-lockout.md | 176 +++++ src/components/layout/sidebar.tsx | 4 + src/components/shared/global-search.tsx | 2 +- src/main.tsx | 2 + src/pages/appointments.tsx | 235 +++++++ src/pages/login.tsx | 3 + 7 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md create mode 100644 docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md create mode 100644 src/pages/appointments.tsx diff --git a/docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md b/docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md new file mode 100644 index 0000000..985852d --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-multi-agent-sip-lockout.md @@ -0,0 +1,643 @@ +# Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout. + +**Architecture:** Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive. + +**Tech Stack:** NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend + +**Spec:** `docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md` + +--- + +## File Map + +### Sidecar (`helix-engage-server/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `auth/session.service.ts` | Create | Redis session lock/unlock/refresh | +| `auth/agent-config.service.ts` | Create | Query Agent entity, cache agent configs | +| `auth/auth.controller.ts` | Modify | Use agent config + session locking on login, add logout + heartbeat | +| `auth/auth.module.ts` | Modify | Register new services, import Redis | +| `config/configuration.ts` | Modify | Add `REDIS_URL` + SIP domain config | + +### Frontend (`helix-engage/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `pages/login.tsx` | Modify | Store agentConfig, handle 403/409 errors | +| `providers/sip-provider.tsx` | Modify | Read SIP config from agentConfig instead of env vars | +| `components/layout/app-shell.tsx` | Modify | Add heartbeat interval for CC agents | +| `lib/api-client.ts` | Modify | Add logout API call | +| `providers/auth-provider.tsx` | Modify | Call sidecar logout on sign-out | + +### Docker + +| File | Action | Responsibility | +|------|--------|----------------| +| VPS `docker-compose.yml` | Modify | Add `REDIS_URL` to sidecar env | + +--- + +## Task 1: Install ioredis + Redis Session Service + +**Files:** +- Modify: `helix-engage-server/package.json` +- Create: `helix-engage-server/src/auth/session.service.ts` +- Modify: `helix-engage-server/src/config/configuration.ts` + +- [ ] **Step 1: Install ioredis** + +```bash +cd helix-engage-server && npm install ioredis +``` + +- [ ] **Step 2: Add Redis URL to config** + +In `config/configuration.ts`, add to the returned object: + +```typescript +redis: { + url: process.env.REDIS_URL ?? 'redis://localhost:6379', +}, +sip: { + domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com', + wsPort: process.env.SIP_WS_PORT ?? '444', +}, +``` + +- [ ] **Step 3: Create session service** + +```typescript +// src/auth/session.service.ts +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +const SESSION_TTL = 3600; // 1 hour + +@Injectable() +export class SessionService implements OnModuleInit { + private readonly logger = new Logger(SessionService.name); + private redis: Redis; + + constructor(private config: ConfigService) {} + + onModuleInit() { + const url = this.config.get('redis.url', 'redis://localhost:6379'); + this.redis = new Redis(url); + this.redis.on('connect', () => this.logger.log('Redis connected')); + this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`)); + } + + private key(agentId: string): string { + return `agent:session:${agentId}`; + } + + async lockSession(agentId: string, memberId: string): Promise { + await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL); + } + + async isSessionLocked(agentId: string): Promise { + return this.redis.get(this.key(agentId)); + } + + async refreshSession(agentId: string): Promise { + await this.redis.expire(this.key(agentId), SESSION_TTL); + } + + async unlockSession(agentId: string): Promise { + await this.redis.del(this.key(agentId)); + } +} +``` + +- [ ] **Step 4: Verify sidecar compiles** + +```bash +cd helix-engage-server && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts +git commit -m "feat: Redis session service for agent login lockout" +``` + +--- + +## Task 2: Agent Config Service + +**Files:** +- Create: `helix-engage-server/src/auth/agent-config.service.ts` + +- [ ] **Step 1: Create agent config service** + +```typescript +// src/auth/agent-config.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; + +export type AgentConfig = { + id: string; + ozonetelAgentId: string; + sipExtension: string; + sipPassword: string; + campaignName: string; + sipUri: string; + sipWsServer: string; +}; + +@Injectable() +export class AgentConfigService { + private readonly logger = new Logger(AgentConfigService.name); + private readonly cache = new Map(); + private readonly sipDomain: string; + private readonly sipWsPort: string; + + constructor( + private platform: PlatformGraphqlService, + private config: ConfigService, + ) { + this.sipDomain = config.get('sip.domain', 'blr-pub-rtc4.ozonetel.com'); + this.sipWsPort = config.get('sip.wsPort', '444'); + } + + async getByMemberId(memberId: string): Promise { + // Check cache first + const cached = this.cache.get(memberId); + if (cached) return cached; + + try { + const data = await this.platform.query( + `{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node { + id ozonetelagentid sipextension sippassword campaignname + } } } }`, + ); + + const node = data?.agents?.edges?.[0]?.node; + if (!node || !node.ozonetelagentid || !node.sipextension) return null; + + const agentConfig: AgentConfig = { + id: node.id, + ozonetelAgentId: node.ozonetelagentid, + sipExtension: node.sipextension, + sipPassword: node.sippassword ?? node.sipextension, + campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265', + sipUri: `sip:${node.sipextension}@${this.sipDomain}`, + sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`, + }; + + this.cache.set(memberId, agentConfig); + this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`); + return agentConfig; + } catch (err) { + this.logger.warn(`Failed to fetch agent config: ${err}`); + return null; + } + } + + getFromCache(memberId: string): AgentConfig | null { + return this.cache.get(memberId) ?? null; + } + + clearCache(memberId: string): void { + this.cache.delete(memberId); + } +} +``` + +- [ ] **Step 2: Verify sidecar compiles** + +```bash +cd helix-engage-server && npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/auth/agent-config.service.ts +git commit -m "feat: agent config service with platform query + in-memory cache" +``` + +--- + +## Task 3: Update Auth Module + Controller + +**Files:** +- Modify: `helix-engage-server/src/auth/auth.module.ts` +- Modify: `helix-engage-server/src/auth/auth.controller.ts` + +- [ ] **Step 1: Update auth module to register new services** + +Read `src/auth/auth.module.ts` and add imports: + +```typescript +import { SessionService } from './session.service'; +import { AgentConfigService } from './agent-config.service'; +import { PlatformModule } from '../platform/platform.module'; + +@Module({ + imports: [PlatformModule], + controllers: [AuthController], + providers: [SessionService, AgentConfigService], + exports: [SessionService, AgentConfigService], +}) +``` + +- [ ] **Step 2: Rewrite auth controller login for multi-agent** + +Inject new services into `AuthController`: + +```typescript +constructor( + private config: ConfigService, + private ozonetelAgent: OzonetelAgentService, + private sessionService: SessionService, + private agentConfigService: AgentConfigService, +) { ... } +``` + +Modify the CC agent section of `login()` (currently lines 115-128). Replace the hardcoded Ozonetel login with: + +```typescript +if (appRole === 'cc-agent') { + const memberId = workspaceMember?.id; + if (!memberId) throw new HttpException('Workspace member not found', 400); + + // Look up agent config from platform + const agentConfig = await this.agentConfigService.getByMemberId(memberId); + if (!agentConfig) { + throw new HttpException('Agent account not configured. Contact administrator.', 403); + } + + // Check for duplicate login + const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId); + if (existingSession && existingSession !== memberId) { + throw new HttpException('You are already logged in on another device. Please log out there first.', 409); + } + + // Lock session + await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId); + + // Login to Ozonetel with agent-specific credentials + const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$'; + this.ozonetelAgent.loginAgent({ + agentId: agentConfig.ozonetelAgentId, + password: ozAgentPassword, + phoneNumber: agentConfig.sipExtension, + mode: 'blended', + }).catch(err => { + this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`); + }); + + // Return agent config to frontend + return { + accessToken, + refreshToken: tokens.refreshToken.token, + user: { ... }, // same as today + agentConfig: { + ozonetelAgentId: agentConfig.ozonetelAgentId, + sipExtension: agentConfig.sipExtension, + sipPassword: agentConfig.sipPassword, + sipUri: agentConfig.sipUri, + sipWsServer: agentConfig.sipWsServer, + campaignName: agentConfig.campaignName, + }, + }; +} +``` + +Note: `workspaceMember.id` is already available from the profile query on line 87-88 of the existing code. + +- [ ] **Step 3: Add logout endpoint** + +Add after the `refresh` endpoint: + +```typescript +@Post('logout') +async logout(@Headers('authorization') auth: string) { + if (!auth) throw new HttpException('Authorization required', 401); + + try { + // Resolve workspace member from JWT + const profileRes = await axios.post(this.graphqlUrl, { + query: '{ currentUser { workspaceMember { id } } }', + }, { headers: { 'Content-Type': 'application/json', Authorization: auth } }); + + const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id; + if (!memberId) return { status: 'ok' }; + + const agentConfig = this.agentConfigService.getFromCache(memberId); + if (agentConfig) { + // Unlock Redis session + await this.sessionService.unlockSession(agentConfig.ozonetelAgentId); + + // Logout from Ozonetel + this.ozonetelAgent.logoutAgent({ + agentId: agentConfig.ozonetelAgentId, + password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$', + }).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`)); + + // Clear cache + this.agentConfigService.clearCache(memberId); + } + + return { status: 'ok' }; + } catch (err) { + this.logger.warn(`Logout cleanup failed: ${err}`); + return { status: 'ok' }; + } +} +``` + +- [ ] **Step 4: Add heartbeat endpoint** + +```typescript +@Post('heartbeat') +async heartbeat(@Headers('authorization') auth: string) { + if (!auth) throw new HttpException('Authorization required', 401); + + try { + const profileRes = await axios.post(this.graphqlUrl, { + query: '{ currentUser { workspaceMember { id } } }', + }, { headers: { 'Content-Type': 'application/json', Authorization: auth } }); + + const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id; + const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null; + + if (agentConfig) { + await this.sessionService.refreshSession(agentConfig.ozonetelAgentId); + } + + return { status: 'ok' }; + } catch { + return { status: 'ok' }; + } +} +``` + +- [ ] **Step 5: Verify sidecar compiles** + +```bash +cd helix-engage-server && npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/auth/auth.module.ts src/auth/auth.controller.ts +git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat" +``` + +--- + +## Task 4: Update Ozonetel Controller for Per-Agent Calls + +**Files:** +- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts` + +- [ ] **Step 1: Add AgentConfigService to Ozonetel controller** + +Import and inject `AgentConfigService`. Add a helper to resolve the agent config from the auth header: + +```typescript +import { AgentConfigService } from '../auth/agent-config.service'; + +// In constructor: +private readonly agentConfig: AgentConfigService, + +// Helper method: +private async resolveAgentId(authHeader: string): Promise { + try { + const data = await this.platform.queryWithAuth( + '{ currentUser { workspaceMember { id } } }', + undefined, authHeader, + ); + const memberId = data.currentUser?.workspaceMember?.id; + const config = memberId ? this.agentConfig.getFromCache(memberId) : null; + return config?.ozonetelAgentId ?? this.defaultAgentId; + } catch { + return this.defaultAgentId; + } +} +``` + +- [ ] **Step 2: Update dispose, agent-state, dial, and other endpoints** + +Replace `this.defaultAgentId` with `await this.resolveAgentId(authHeader)` in the endpoints that pass the auth header. The key endpoints to update: + +- `dispose()` — add `@Headers('authorization') auth: string` param, resolve agent ID +- `agentState()` — same +- `dial()` — same +- `agentReady()` — same + +For endpoints that don't currently take the auth header, add it as a parameter. + +- [ ] **Step 3: Update auth module to handle circular dependency** + +The `OzonetelAgentModule` now needs `AgentConfigService` from `AuthModule`. Use `forwardRef` if needed, or export `AgentConfigService` from a shared module. + +Simplest approach: move `AgentConfigService` export from `AuthModule` and import it in `OzonetelAgentModule`. + +- [ ] **Step 4: Verify sidecar compiles** + +```bash +cd helix-engage-server && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts +git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints" +``` + +--- + +## Task 5: Frontend — Store Agent Config + Dynamic SIP + +**Files:** +- Modify: `helix-engage/src/pages/login.tsx` +- Modify: `helix-engage/src/providers/sip-provider.tsx` +- Modify: `helix-engage/src/providers/auth-provider.tsx` + +- [ ] **Step 1: Store agentConfig on login** + +In `login.tsx`, after successful login, store the agent config: + +```typescript +if (response.agentConfig) { + localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig)); +} +``` + +Handle new error codes: +```typescript +} catch (err: any) { + if (err.message?.includes('not configured')) { + setError('Agent account not configured. Contact your administrator.'); + } else if (err.message?.includes('already logged in')) { + setError('You are already logged in on another device. Please log out there first.'); + } else { + setError(err.message); + } + setIsLoading(false); +} +``` + +- [ ] **Step 2: Update SIP provider to use stored agent config** + +In `sip-provider.tsx`, replace the hardcoded `DEFAULT_CONFIG`: + +```typescript +const getAgentSipConfig = (): SIPConfig => { + try { + const stored = localStorage.getItem('helix_agent_config'); + if (stored) { + const config = JSON.parse(stored); + return { + displayName: 'Helix Agent', + uri: config.sipUri, + password: config.sipPassword, + wsServer: config.sipWsServer, + stunServers: 'stun:stun.l.google.com:19302', + }; + } + } catch {} + // Fallback to env vars + return { + displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', + uri: import.meta.env.VITE_SIP_URI ?? '', + password: import.meta.env.VITE_SIP_PASSWORD ?? '', + wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', + stunServers: 'stun:stun.l.google.com:19302', + }; +}; +``` + +Use `getAgentSipConfig()` where `DEFAULT_CONFIG` was used. + +- [ ] **Step 3: Update auth provider logout to call sidecar** + +In `auth-provider.tsx`, modify `logout()` to call the sidecar first: + +```typescript +const logout = async () => { + try { + const token = localStorage.getItem('helix_access_token'); + if (token) { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => {}); + } + } finally { + localStorage.removeItem('helix_access_token'); + localStorage.removeItem('helix_refresh_token'); + localStorage.removeItem('helix_user'); + localStorage.removeItem('helix_agent_config'); + setUser(null); + } +}; +``` + +Note: `API_URL` needs to be available here. Import from `api-client.ts` or read from env. + +- [ ] **Step 4: Verify frontend compiles** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx +git commit -m "feat: dynamic SIP config from login response, logout cleanup" +``` + +--- + +## Task 6: Frontend — Heartbeat + +**Files:** +- Modify: `helix-engage/src/components/layout/app-shell.tsx` + +- [ ] **Step 1: Add heartbeat interval for CC agents** + +In `AppShell`, add a heartbeat effect: + +```typescript +const { isCCAgent } = useAuth(); + +useEffect(() => { + if (!isCCAgent) return; + + const interval = setInterval(() => { + const token = localStorage.getItem('helix_access_token'); + if (token) { + fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => {}); + } + }, 5 * 60 * 1000); // Every 5 minutes + + return () => clearInterval(interval); +}, [isCCAgent]); +``` + +- [ ] **Step 2: Verify frontend compiles** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/app-shell.tsx +git commit -m "feat: heartbeat every 5 min to keep agent session alive" +``` + +--- + +## Task 7: Docker + Deploy + +**Files:** +- Modify: VPS `docker-compose.yml` + +- [ ] **Step 1: Add REDIS_URL to sidecar in docker-compose** + +SSH to VPS and add `REDIS_URL: redis://redis:6379` to the sidecar environment section. Also add `redis` to the sidecar's `depends_on`. + +- [ ] **Step 2: Deploy using deploy script** + +```bash +./deploy.sh all +``` + +- [ ] **Step 3: Verify sidecar connects to Redis** + +```bash +ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis" +``` + +Expected: `Redis connected` + +- [ ] **Step 4: Test login flow** + +Login as rekha.cc → should get `agentConfig` in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error. + +- [ ] **Step 5: Commit docker-compose change and push all to Azure** + +```bash +cd helix-engage && git add . && git push origin dev +cd helix-engage-server && git add . && git push origin dev +``` diff --git a/docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md b/docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md new file mode 100644 index 0000000..638a44b --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md @@ -0,0 +1,176 @@ +# Multi-Agent SIP Credentials + Duplicate Login Lockout + +**Date**: 2026-03-23 +**Status**: Approved design + +--- + +## Problem + +Single Ozonetel agent account (`global`) and SIP extension (`523590`) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents. + +## Solution + +Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response. + +--- + +## 1. Data Model + +**Agent entity** (already created on platform via admin portal): + +| Field (GraphQL) | Type | Purpose | +|---|---|---| +| `wsmemberId` | Relation | Links to workspace member | +| `ozonetelagentid` | Text | Ozonetel agent ID (e.g. "global", "agent2") | +| `sipextension` | Text | SIP extension number (e.g. "523590") | +| `sippassword` | Text | SIP auth password | +| `campaignname` | Text | Ozonetel campaign (e.g. "Inbound_918041763265") | + +Custom fields use **all-lowercase** GraphQL names. One Agent record per CC user. + +--- + +## 2. Sidecar Changes + +### 2.1 Redis Integration + +Add `ioredis` dependency to `helix-engage-server`. Connect to `REDIS_URL` (default `redis://redis:6379`). + +New service: `src/auth/session.service.ts` + +``` +lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600 +isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null +refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600 +unlockSession(agentId) → DEL agent:session:{agentId} +``` + +### 2.2 Auth Controller — Login Flow + +Modify `POST /auth/login`: + +1. Authenticate with platform → get JWT + user profile + workspace member ID +2. Determine role (same as today) +3. **If CC agent:** + a. Query platform: `agents(filter: { wsmemberId: { eq: "" } })` using server API key + b. No Agent record → `403: "Agent account not configured. Contact administrator."` + c. Check Redis: `isSessionLocked(agent.ozonetelagentid)` + d. Locked by different user → `409: "You are already logged in on another device. Please log out there first."` + e. Locked by same user → refresh TTL (re-login from same browser) + f. Not locked → `lockSession(agent.ozonetelagentid, memberId)` + g. Login to Ozonetel with agent's specific credentials + h. Return `agentConfig` in response +4. **If manager/executive:** No Agent query, no Redis, no SIP. Same as today. + +**Login response** (CC agent): +```json +{ + "accessToken": "...", + "refreshToken": "...", + "user": { "id": "...", "role": "cc-agent", ... }, + "agentConfig": { + "ozonetelAgentId": "global", + "sipExtension": "523590", + "sipPassword": "523590", + "sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com", + "sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444", + "campaignName": "Inbound_918041763265" + } +} +``` + +SIP domain (`blr-pub-rtc4.ozonetel.com`) and WS port (`444`) remain from env vars — these are shared infrastructure, not per-agent. + +### 2.3 Auth Controller — Logout + +Modify `POST /auth/logout` (or add if doesn't exist): +1. Resolve agent from JWT +2. `unlockSession(agent.ozonetelagentid)` +3. Ozonetel agent logout + +### 2.4 Auth Controller — Heartbeat + +New endpoint: `POST /auth/heartbeat` +1. Resolve agent from JWT +2. `refreshSession(agent.ozonetelagentid)` → extends TTL to 1 hour +3. Return `{ status: 'ok' }` + +### 2.5 Agent Config Cache + +On login, store agent config in an in-memory `Map`. + +All Ozonetel controller endpoints currently use `this.defaultAgentId`. Change to: +1. Resolve workspace member from JWT (already done in worklist controller's `resolveAgentName`) +2. Lookup agent config from the in-memory map +3. Use the agent's `ozonetelagentid` for Ozonetel API calls + +This avoids querying Redis/platform on every API call. + +Clear the cache entry on logout. + +### 2.6 Config + +New env var: `REDIS_URL` (default: `redis://redis:6379`) + +Existing env vars (`OZONETEL_AGENT_ID`, `OZONETEL_SIP_ID`, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev). + +--- + +## 3. Frontend Changes + +### 3.1 Store Agent Config + +On login, store `agentConfig` from the response in localStorage (`helix_agent_config`). + +On logout, clear it. + +### 3.2 SIP Provider + +`sip-provider.tsx`: Read SIP credentials from stored `agentConfig` instead of env vars. + +``` +const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config')); +const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI; +const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD; +const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER; +``` + +If no `agentConfig` and no env vars → don't connect SIP. + +### 3.3 Heartbeat + +Add a heartbeat interval in `AppShell` (only for CC agents): +- Every 5 minutes: `POST /auth/heartbeat` +- If heartbeat fails with 401 → session expired, redirect to login + +### 3.4 Login Error Handling + +Handle new error codes from login: +- `403` → "Agent account not configured. Contact administrator." +- `409` → "You are already logged in on another device. Please log out there first." + +### 3.5 Logout + +On logout, call `POST /auth/logout` before clearing tokens (so sidecar can clean up Redis + Ozonetel). + +--- + +## 4. Docker Compose + +Add `REDIS_URL` to sidecar environment in `docker-compose.yml`: +```yaml +sidecar: + environment: + REDIS_URL: redis://redis:6379 +``` + +--- + +## 5. Edge Cases + +- **Sidecar restart**: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily). +- **Redis restart**: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry. +- **Browser crash (no logout)**: Heartbeat stops → Redis key expires in ≤1 hour → lock clears. +- **Same user, same browser re-login**: Detected by comparing `memberId` in Redis → refreshes TTL instead of blocking. +- **Agent record deleted while logged in**: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out. diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 5ce7b5e..2705633 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -10,6 +10,7 @@ import { faGear, faGrid2, faHospitalUser, + faCalendarCheck, faPhone, faPlug, faUsers, @@ -44,6 +45,7 @@ const IconPhone = faIcon(faPhone); const IconClockRewind = faIcon(faClockRotateLeft); const IconUsers = faIcon(faUsers); const IconHospitalUser = faIcon(faHospitalUser); +const IconCalendarCheck = faIcon(faCalendarCheck); type NavSection = { label: string; @@ -71,6 +73,7 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call History', href: '/call-history', icon: IconClockRewind }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, + { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, ]}, ]; @@ -81,6 +84,7 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Lead Workspace', href: '/', icon: IconGrid2 }, { label: 'All Leads', href: '/leads', icon: IconUsers }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, + { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, { label: 'Outreach', href: '/outreach', icon: IconCommentDots }, ]}, diff --git a/src/components/shared/global-search.tsx b/src/components/shared/global-search.tsx index 89eac19..15843de 100644 --- a/src/components/shared/global-search.tsx +++ b/src/components/shared/global-search.tsx @@ -102,7 +102,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => { id: a.id, type: 'appointment', title: a.doctorName ?? 'Appointment', - subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(' · '), + subtitle: [a.department, date, a.status].filter(Boolean).join(' · '), }); } diff --git a/src/main.tsx b/src/main.tsx index a5853ff..c1161b1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,7 @@ import { IntegrationsPage } from "@/pages/integrations"; import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { MyPerformancePage } from "@/pages/my-performance"; +import { AppointmentsPage } from "@/pages/appointments"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -55,6 +56,7 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/appointments.tsx b/src/pages/appointments.tsx new file mode 100644 index 0000000..f31689e --- /dev/null +++ b/src/pages/appointments.tsx @@ -0,0 +1,235 @@ +import { useEffect, useMemo, useState } from 'react'; +import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { TopBar } from '@/components/layout/top-bar'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { formatPhone } from '@/lib/format'; +import { apiClient } from '@/lib/api-client'; + +type AppointmentRecord = { + id: string; + scheduledAt: string | null; + durationMin: number | null; + appointmentType: string | null; + status: string | null; + doctorName: string | null; + department: string | null; + reasonForVisit: string | null; + patient: { + id: string; + fullName: { firstName: string; lastName: string } | null; + phones: { primaryPhoneNumber: string } | null; + } | null; + doctor: { + clinic: { clinicName: string } | null; + } | null; +}; + +type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED'; + +const STATUS_COLORS: Record = { + SCHEDULED: 'brand', + CONFIRMED: 'brand', + COMPLETED: 'success', + CANCELLED: 'error', + NO_SHOW: 'warning', + RESCHEDULED: 'warning', +}; + +const STATUS_LABELS: Record = { + SCHEDULED: 'Booked', + CONFIRMED: 'Confirmed', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', + NO_SHOW: 'No Show', + RESCHEDULED: 'Rescheduled', + FOLLOW_UP: 'Follow-up', + CONSULTATION: 'Consultation', +}; + +const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt durationMin appointmentType status + doctorName department reasonForVisit + patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } + doctor { clinic { clinicName } } +} } } }`; + +const formatDate = (iso: string): string => { + const d = new Date(iso); + return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); +}; + +const formatTime = (iso: string): string => { + const d = new Date(iso); + return d.toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }); +}; + +export const AppointmentsPage = () => { + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('all'); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true }) + .then(data => setAppointments(data.appointments.edges.map(e => e.node))) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const statusCounts = useMemo(() => { + const counts: Record = {}; + for (const a of appointments) { + const s = a.status ?? 'UNKNOWN'; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; + }, [appointments]); + + const filtered = useMemo(() => { + let rows = appointments; + + if (tab !== 'all') { + rows = rows.filter(a => a.status === tab); + } + + if (search.trim()) { + const q = search.toLowerCase(); + rows = rows.filter(a => { + const patientName = `${a.patient?.fullName?.firstName ?? ''} ${a.patient?.fullName?.lastName ?? ''}`.toLowerCase(); + const phone = a.patient?.phones?.primaryPhoneNumber ?? ''; + const doctor = (a.doctorName ?? '').toLowerCase(); + const dept = (a.department ?? '').toLowerCase(); + const branch = (a.doctor?.clinic?.clinicName ?? '').toLowerCase(); + return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q); + }); + } + + return rows; + }, [appointments, tab, search]); + + const tabItems = [ + { id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined }, + { id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined }, + { id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined }, + { id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined }, + { id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined }, + ]; + + return ( + <> + + +
+ {/* Tabs + search */} +
+ setTab(key as StatusTab)}> + + {(item) => } + + +
+ +
+
+ + {/* Table */} +
+ {loading ? ( +
+

Loading appointments...

+
+ ) : filtered.length === 0 ? ( +
+

{search ? 'No matching appointments' : 'No appointments found'}

+
+ ) : ( + + + + + + + + + + + + + {(appt) => { + const patientName = appt.patient + ? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown' + : 'Unknown'; + const phone = appt.patient?.phones?.primaryPhoneNumber ?? ''; + const branch = appt.doctor?.clinic?.clinicName ?? '—'; + const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; + const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; + + return ( + + +
+ + {patientName} + + {phone && ( + + )} +
+
+ + + {appt.scheduledAt ? formatDate(appt.scheduledAt) : '—'} + + + + + {appt.scheduledAt ? formatTime(appt.scheduledAt) : '—'} + + + + {appt.doctorName ?? '—'} + + + {appt.department ?? '—'} + + + {branch} + + + + {statusLabel} + + + + + {appt.reasonForVisit ?? '—'} + + +
+ ); + }} +
+
+ )} +
+
+ + ); +}; diff --git a/src/pages/login.tsx b/src/pages/login.tsx index c4f0abf..2385f3a 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons'; import { useAuth } from '@/providers/auth-provider'; +import { useData } from '@/providers/data-provider'; import { Button } from '@/components/base/buttons/button'; import { SocialButton } from '@/components/base/buttons/social-button'; import { Checkbox } from '@/components/base/checkbox/checkbox'; @@ -10,6 +11,7 @@ import { Input } from '@/components/base/input/input'; export const LoginPage = () => { const { loginWithUser } = useAuth(); + const { refresh } = useData(); const navigate = useNavigate(); const saved = localStorage.getItem('helix_remember'); @@ -59,6 +61,7 @@ export const LoginPage = () => { platformRoles: u?.platformRoles, }); + refresh(); navigate('/'); } catch (err: any) { setError(err.message); From 3afa4f20b272c07ea6e5a9563320ac86735f4461 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Mon, 23 Mar 2026 21:24:47 +0530 Subject: [PATCH 3/9] feat: dynamic SIP from agentConfig, logout cleanup, heartbeat - SIP provider reads credentials from agentConfig (login response) - Auth logout calls sidecar to unlock Redis + Ozonetel logout - AppShell heartbeat every 5 min for CC agents - Login stores agentConfig in localStorage Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/app-shell.tsx | 21 ++++++++++++++++++- src/pages/login.tsx | 7 +++++++ src/providers/auth-provider.tsx | 11 ++++++++++ src/providers/sip-provider.tsx | 31 +++++++++++++++++++++-------- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index fb2e532..5f24713 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { useLocation } from 'react-router'; import { Sidebar } from './sidebar'; import { SipProvider } from '@/providers/sip-provider'; @@ -13,6 +13,25 @@ export const AppShell = ({ children }: AppShellProps) => { const { pathname } = useLocation(); const { isCCAgent } = useAuth(); + // Heartbeat: keep agent session alive in Redis (CC agents only) + useEffect(() => { + if (!isCCAgent) return; + + const beat = () => { + const token = localStorage.getItem('helix_access_token'); + if (token) { + const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + fetch(`${apiUrl}/auth/heartbeat`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => {}); + } + }; + + const interval = setInterval(beat, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [isCCAgent]); + return (
diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 2385f3a..a02e80e 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -51,6 +51,13 @@ export const LoginPage = () => { localStorage.removeItem('helix_remember'); } + // Store agent config for SIP provider (CC agents only) + if ((response as any).agentConfig) { + localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig)); + } else { + localStorage.removeItem('helix_agent_config'); + } + loginWithUser({ id: u?.id, name, diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index 62ba492..6643974 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -96,10 +96,21 @@ export const AuthProvider = ({ children }: AuthProviderProps) => { }, []); const logout = useCallback(() => { + // Notify sidecar to unlock Redis session + Ozonetel logout + const token = localStorage.getItem('helix_access_token'); + if (token) { + const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + fetch(`${apiUrl}/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }).catch(() => {}); + } + setUser(DEFAULT_USER); setIsAuthenticated(false); localStorage.removeItem('helix_access_token'); localStorage.removeItem('helix_refresh_token'); + localStorage.removeItem('helix_agent_config'); localStorage.removeItem(STORAGE_KEY); }, []); diff --git a/src/providers/sip-provider.tsx b/src/providers/sip-provider.tsx index 5bbe777..bc9e156 100644 --- a/src/providers/sip-provider.tsx +++ b/src/providers/sip-provider.tsx @@ -13,12 +13,27 @@ import { import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager'; import type { SIPConfig } from '@/types/sip'; -const DEFAULT_CONFIG: SIPConfig = { - displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', - uri: import.meta.env.VITE_SIP_URI ?? '', - password: import.meta.env.VITE_SIP_PASSWORD ?? '', - wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', - stunServers: 'stun:stun.l.google.com:19302', +const getSipConfig = (): SIPConfig => { + try { + const stored = localStorage.getItem('helix_agent_config'); + if (stored) { + const config = JSON.parse(stored); + return { + displayName: 'Helix Agent', + uri: config.sipUri, + password: config.sipPassword, + wsServer: config.sipWsServer, + stunServers: 'stun:stun.l.google.com:19302', + }; + } + } catch {} + return { + displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent', + uri: import.meta.env.VITE_SIP_URI ?? '', + password: import.meta.env.VITE_SIP_PASSWORD ?? '', + wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '', + stunServers: 'stun:stun.l.google.com:19302', + }; }; export const SipProvider = ({ children }: PropsWithChildren) => { @@ -41,7 +56,7 @@ export const SipProvider = ({ children }: PropsWithChildren) => { // Auto-connect SIP on mount useEffect(() => { - connectSip(DEFAULT_CONFIG); + connectSip(getSipConfig()); }, []); // Call duration timer @@ -129,7 +144,7 @@ export const useSip = () => { isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState), ozonetelStatus: 'logged-in' as const, ozonetelError: null as string | null, - connect: () => connectSip(DEFAULT_CONFIG), + connect: () => connectSip(getSipConfig()), disconnect: disconnectSip, makeCall, answer, From 938f2a84d8f6a14e4177e122d576f1bef65df00e Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 08:11:22 +0530 Subject: [PATCH 4/9] fix: enquiry in post-call, appointment skip button, AI scroll containment - Enquiry button + form available during disposition stage (not just active call) - Skip & Return to Worklist button on post-call appointment booking - AI chat scroll uses parentElement.scrollTop instead of scrollIntoView Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 43 +++++++++++++------ src/components/call-desk/ai-chat-panel.tsx | 6 ++- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 6bd5ee6..9289b9b 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -208,7 +208,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - // Appointment booking after disposition + // Appointment booking after disposition — auto-return when form closes if (postCallStage === 'appointment') { return ( <> @@ -216,6 +216,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete

Booking Appointment

for {fullName || phoneDisplay}

+
-
-
- -
-
-

Call Ended — {fullName || phoneDisplay}

-

{formatDuration(callDuration)} · Log this call

+ <> +
+
+
+
+ +
+
+

Call Ended — {fullName || phoneDisplay}

+

{formatDuration(callDuration)} · Log this call

+
+
+
+
- -
+ { + setEnquiryOpen(false); + notify.success('Enquiry Logged'); + }} + /> + ); } diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index c1a7fd3..06da039 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -45,7 +45,11 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro const inputRef = useRef(null); const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + // Scroll within the messages container only — don't scroll the parent panel + const el = messagesEndRef.current; + if (el?.parentElement) { + el.parentElement.scrollTop = el.parentElement.scrollHeight; + } }, []); useEffect(() => { From 1df40f14ffd8a79220edebe7e4239af985c37731 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 09:55:37 +0530 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20disposition=20returns=20straight=20t?= =?UTF-8?q?o=20worklist=20=E2=80=94=20no=20intermediate=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disposition is the last step. After submission, handleReset() clears all state and returns to worklist immediately. Removed the "Call Completed" card, post-disposition appointment form, and "Skip" button. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 68 ++----------------- 1 file changed, 7 insertions(+), 61 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 9289b9b..8d6b39f 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, - faPause, faPlay, faCalendarPlus, faCheckCircle, + faPause, faPlay, faCalendarPlus, faPhoneArrowRight, faRecordVinyl, faClipboardQuestion, } from '@fortawesome/pro-duotone-svg-icons'; import { Button } from '@/components/base/buttons/button'; @@ -42,7 +42,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); const [postCallStage, setPostCallStage] = useState(null); - const [savedDisposition, setSavedDisposition] = useState(null); const [appointmentOpen, setAppointmentOpen] = useState(false); const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false); const [transferOpen, setTransferOpen] = useState(false); @@ -60,7 +59,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; const handleDisposition = async (disposition: CallDisposition, notes: string) => { - setSavedDisposition(disposition); // Submit disposition to sidecar — handles Ozonetel ACW release if (callUcid) { @@ -76,12 +74,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }).catch((err) => console.warn('Disposition failed:', err)); } - if (disposition === 'APPOINTMENT_BOOKED') { - setPostCallStage('appointment'); - setAppointmentOpen(true); - } else if (disposition === 'FOLLOW_UP_SCHEDULED') { - setPostCallStage('follow-up'); - // Create follow-up + // Side effects per disposition type + if (disposition === 'FOLLOW_UP_SCHEDULED') { try { await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, { data: { @@ -97,27 +91,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete } catch { notify.info('Follow-up', 'Could not auto-create follow-up'); } - setPostCallStage('done'); - } else { - notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); - setPostCallStage('done'); } + + // Disposition is the last step — return to worklist immediately + notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); + handleReset(); }; const handleAppointmentSaved = () => { setAppointmentOpen(false); notify.success('Appointment Booked', 'Payment link will be sent to the patient'); - // If booked during active call, don't skip to 'done' — wait for disposition after call ends if (callState === 'active') { setAppointmentBookedDuringCall(true); - } else { - setPostCallStage('done'); } }; const handleReset = () => { setPostCallStage(null); - setSavedDisposition(null); setCallState('idle'); setCallerNumber(null); setCallUcid(null); @@ -192,50 +182,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete // Post-call flow takes priority over active state (handles race between hangup + SIP ended event) if (postCallStage !== null || callState === 'ended' || callState === 'failed') { - // Done state - if (postCallStage === 'done') { - return ( -
- -

Call Completed

-

- {savedDisposition ? savedDisposition.replace(/_/g, ' ').toLowerCase() : 'logged'} -

- -
- ); - } - - // Appointment booking after disposition — auto-return when form closes - if (postCallStage === 'appointment') { - return ( - <> -
- -

Booking Appointment

-

for {fullName || phoneDisplay}

- -
- { - setAppointmentOpen(open); - if (!open) setPostCallStage('done'); - }} - callerNumber={callerPhone} - leadName={fullName || null} - leadId={lead?.id ?? null} - patientId={(lead as any)?.patientId ?? null} - onSaved={handleAppointmentSaved} - /> - - ); - } - // Disposition form + enquiry access return ( <> From dbd8391f2cc377688b27c014f339cf8b982709e5 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 10:54:56 +0530 Subject: [PATCH 6/9] fix: UUID type mismatch, slot conflict, appt/enquiry tabs, dialler in header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed $id: ID! to $id: UUID! in all update mutations (4 files) - Removed redundant slot availability check (UI already disables booked slots) - Book Appt and Enquiry act as toggle tabs — one closes the other - Dialler moved from FAB to header dropdown next to status toggle Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/active-call-card.tsx | 8 +- src/components/call-desk/appointment-form.tsx | 22 +--- src/components/call-desk/call-widget.tsx | 2 +- .../campaigns/campaign-edit-slideout.tsx | 2 +- src/pages/call-desk.tsx | 117 ++++++++---------- 5 files changed, 64 insertions(+), 87 deletions(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 8d6b39f..597731e 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -272,12 +272,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{/* Text+Icon primary actions */} - - + + onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); }}>Enquiry diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index 2263032..af09eb6 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -208,7 +208,7 @@ export const AppointmentForm = ({ if (isEditMode && existingAppointment) { // Update existing appointment await apiClient.graphql( - `mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) { + `mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { @@ -224,22 +224,6 @@ export const AppointmentForm = ({ ); notify.success('Appointment Updated'); } else { - // Double-check slot availability before booking - const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { status: string } }> } }>( - `{ appointments(filter: { - doctorId: { eq: "${doctor}" }, - scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" } - }) { edges { node { status } } } }`, - ); - const activeBookings = checkResult.appointments.edges.filter(e => - e.node.status !== 'CANCELLED' && e.node.status !== 'NO_SHOW', - ); - if (activeBookings.length > 0) { - setError('This slot was just booked by someone else. Please select a different time.'); - setIsSaving(false); - return; - } - // Create appointment await apiClient.graphql( `mutation CreateAppointment($data: AppointmentCreateInput!) { @@ -263,7 +247,7 @@ export const AppointmentForm = ({ // Update lead status if we have a matched lead if (leadId) { await apiClient.graphql( - `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { + `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { @@ -291,7 +275,7 @@ export const AppointmentForm = ({ setIsSaving(true); try { await apiClient.graphql( - `mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) { + `mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, { diff --git a/src/components/call-desk/call-widget.tsx b/src/components/call-desk/call-widget.tsx index 944864c..f46ca44 100644 --- a/src/components/call-desk/call-widget.tsx +++ b/src/components/call-desk/call-widget.tsx @@ -223,7 +223,7 @@ export const CallWidget = () => { const newStatus = statusMap[disposition]; if (newStatus) { await apiClient.graphql( - `mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) { + `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { diff --git a/src/components/campaigns/campaign-edit-slideout.tsx b/src/components/campaigns/campaign-edit-slideout.tsx index 00be675..1b2968b 100644 --- a/src/components/campaigns/campaign-edit-slideout.tsx +++ b/src/components/campaigns/campaign-edit-slideout.tsx @@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved } const budgetMicros = budget ? Number(budget) * 1_000_000 : null; await apiClient.graphql( - `mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) { + `mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) { updateCampaign(id: $id, data: $data) { id } }`, { diff --git a/src/pages/call-desk.tsx b/src/pages/call-desk.tsx index 04192bb..41b2cd8 100644 --- a/src/pages/call-desk.tsx +++ b/src/pages/call-desk.tsx @@ -72,6 +72,61 @@ export const CallDeskPage = () => {
+ {!isInCall && ( +
+ + {diallerOpen && ( +
+
+ Dial + +
+
+ + {dialNumber || Enter number} + + {dialNumber && ( + + )} +
+
+ {['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => ( + + ))} +
+ +
+ )} +
+ )} {totalPending > 0 && ( {totalPending} pending @@ -127,68 +182,6 @@ export const CallDeskPage = () => {
- {/* Dialler FAB */} - {!isInCall && ( -
- {diallerOpen && ( -
-
- Dial - -
- - {/* Number display */} -
- - {dialNumber || Enter number} - - {dialNumber && ( - - )} -
- - {/* Numpad */} -
- {['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => ( - - ))} -
- - {/* Call button */} - -
- )} - -
- )}
); }; From ad58888514a39a213ea0d3dbdf2d0429d016d279 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:22:31 +0530 Subject: [PATCH 7/9] docs: supervisor module spec + implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-supervisor-module.md | 531 ++++++++++++++++++ .../specs/2026-03-24-supervisor-module.md | 191 +++++++ 2 files changed, 722 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-supervisor-module.md create mode 100644 docs/superpowers/specs/2026-03-24-supervisor-module.md diff --git a/docs/superpowers/plans/2026-03-24-supervisor-module.md b/docs/superpowers/plans/2026-03-24-supervisor-module.md new file mode 100644 index 0000000..3d9dbdb --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-supervisor-module.md @@ -0,0 +1,531 @@ +# Supervisor Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the supervisor module with team performance dashboard (PP-5), live call monitor (PP-6), master data pages, and admin sidebar restructure. + +**Architecture:** Frontend pages query platform GraphQL directly for entity data (calls, appointments, leads, agents). Sidecar provides Ozonetel-specific data (agent time breakdown, active calls via event subscription). No hardcoded/mock data anywhere. + +**Tech Stack:** React + Tailwind + ECharts (frontend), NestJS sidecar (Ozonetel integration), Fortytwo platform GraphQL + +**Spec:** `docs/superpowers/specs/2026-03-24-supervisor-module.md` + +--- + +## File Map + +### Frontend (`helix-engage/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `pages/team-performance.tsx` | Create | PP-5 full dashboard | +| `pages/live-monitor.tsx` | Create | PP-6 active call table | +| `pages/call-recordings.tsx` | Create | Calls with recordings master | +| `pages/missed-calls.tsx` | Create | Missed calls master (supervisor view) | +| `components/layout/sidebar.tsx` | Modify | Admin nav restructure | +| `main.tsx` | Modify | Add new routes | + +### Sidecar (`helix-engage-server/src/`) + +| File | Action | Responsibility | +|------|--------|----------------| +| `supervisor/supervisor.service.ts` | Create | Team perf aggregation + active call tracking | +| `supervisor/supervisor.controller.ts` | Create | REST endpoints | +| `supervisor/supervisor.module.ts` | Create | Module registration | +| `app.module.ts` | Modify | Import SupervisorModule | + +--- + +## Task 1: Admin Sidebar Nav + Routes + +**Files:** +- Modify: `helix-engage/src/components/layout/sidebar.tsx` +- Modify: `helix-engage/src/main.tsx` + +- [ ] **Step 1: Add new icon imports to sidebar** + +In `sidebar.tsx`, add to the FontAwesome imports: + +```typescript +import { + // existing imports... + faRadio, + faFileAudio, + faPhoneMissed, + faChartLine, +} from '@fortawesome/pro-duotone-svg-icons'; +``` + +Add icon wrappers: + +```typescript +const IconRadio = faIcon(faRadio); +const IconFileAudio = faIcon(faFileAudio); +const IconPhoneMissed = faIcon(faPhoneMissed); +const IconChartLine = faIcon(faChartLine); +``` + +- [ ] **Step 2: Restructure admin nav** + +Replace the admin nav section (currently has Overview + Management + Admin groups) with: + +```typescript +if (role === 'admin') { + return [ + { label: 'Supervisor', items: [ + { label: 'Dashboard', href: '/', icon: IconGrid2 }, + { label: 'Team Performance', href: '/team-performance', icon: IconChartLine }, + { label: 'Live Call Monitor', href: '/live-monitor', icon: IconRadio }, + ]}, + { label: 'Data & Reports', items: [ + { label: 'Lead Master', href: '/leads', icon: IconUsers }, + { label: 'Patient Master', href: '/patients', icon: IconHospitalUser }, + { label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck }, + { label: 'Call Log Master', href: '/call-history', icon: IconClockRewind }, + { label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio }, + { label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed }, + ]}, + { label: 'Admin', items: [ + { label: 'Settings', href: '/settings', icon: IconGear }, + ]}, + ]; +} +``` + +- [ ] **Step 3: Add routes in main.tsx** + +Import new page components (they'll be created in later tasks — use placeholder components for now): + +```typescript +import { TeamPerformancePage } from "@/pages/team-performance"; +import { LiveMonitorPage } from "@/pages/live-monitor"; +import { CallRecordingsPage } from "@/pages/call-recordings"; +import { MissedCallsPage } from "@/pages/missed-calls"; +``` + +Add routes: + +```typescript +} /> +} /> +} /> +} /> +``` + +- [ ] **Step 4: Create placeholder pages** + +Create minimal placeholder files for each new page so the build doesn't fail: + +```typescript +// src/pages/team-performance.tsx +export const TeamPerformancePage = () =>
Team Performance — coming soon
; + +// src/pages/live-monitor.tsx +export const LiveMonitorPage = () =>
Live Call Monitor — coming soon
; + +// src/pages/call-recordings.tsx +export const CallRecordingsPage = () =>
Call Recordings — coming soon
; + +// src/pages/missed-calls.tsx +export const MissedCallsPage = () =>
Missed Calls — coming soon
; +``` + +- [ ] **Step 5: Verify build** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/layout/sidebar.tsx src/main.tsx src/pages/team-performance.tsx src/pages/live-monitor.tsx src/pages/call-recordings.tsx src/pages/missed-calls.tsx +git commit -m "feat: admin sidebar restructure + placeholder pages for supervisor module" +``` + +--- + +## Task 2: Call Recordings Page + +**Files:** +- Modify: `helix-engage/src/pages/call-recordings.tsx` + +- [ ] **Step 1: Implement call recordings page** + +Query platform for calls with recordings. Reuse patterns from `call-history.tsx`. + +```typescript +// Query: calls where recording primaryLinkUrl is not empty +const QUERY = `{ calls(first: 100, filter: { + recording: { primaryLinkUrl: { neq: "" } } +}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id direction callStatus callerNumber { primaryPhoneNumber } + agentName startedAt durationSec disposition + recording { primaryLinkUrl primaryLinkLabel } +} } } }`; +``` + +Table columns: Agent, Caller (PhoneActionCell), Type (In/Out badge), Date, Duration, Disposition, Recording (play button). + +Search by agent name or phone number. Date filter optional. + +- [ ] **Step 2: Verify build** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/call-recordings.tsx +git commit -m "feat: call recordings master page" +``` + +--- + +## Task 3: Missed Calls Page (Supervisor View) + +**Files:** +- Modify: `helix-engage/src/pages/missed-calls.tsx` + +- [ ] **Step 1: Implement missed calls page** + +Query platform for all missed calls — no agent filter (supervisor sees all). + +```typescript +const QUERY = `{ calls(first: 100, filter: { + callStatus: { eq: MISSED } +}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callerNumber { primaryPhoneNumber } agentName + startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat +} } } }`; +``` + +Table columns: Caller (PhoneActionCell), Date/Time, Branch (`callsourcenumber`), Agent, Callback Status (badge), SLA (computed from `startedAt`). + +Tabs: All | Pending (`PENDING_CALLBACK`) | Attempted (`CALLBACK_ATTEMPTED`) | Completed (`CALLBACK_COMPLETED` + `WRONG_NUMBER`). + +Search by phone or agent. + +- [ ] **Step 2: Verify build** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/missed-calls.tsx +git commit -m "feat: missed calls master page for supervisors" +``` + +--- + +## Task 4: Sidecar — Supervisor Module + +**Files:** +- Create: `helix-engage-server/src/supervisor/supervisor.service.ts` +- Create: `helix-engage-server/src/supervisor/supervisor.controller.ts` +- Create: `helix-engage-server/src/supervisor/supervisor.module.ts` +- Modify: `helix-engage-server/src/app.module.ts` + +- [ ] **Step 1: Create supervisor service** + +```typescript +// supervisor.service.ts +// Two responsibilities: +// 1. Aggregate Ozonetel agent summary across all agents +// 2. Track active calls from Ozonetel real-time events + +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PlatformGraphqlService } from '../platform/platform-graphql.service'; +import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service'; + +type ActiveCall = { + ucid: string; + agentId: string; + callerNumber: string; + callType: string; + startTime: string; + status: 'active' | 'on-hold'; +}; + +@Injectable() +export class SupervisorService implements OnModuleInit { + private readonly logger = new Logger(SupervisorService.name); + private readonly activeCalls = new Map(); + + constructor( + private platform: PlatformGraphqlService, + private ozonetel: OzonetelAgentService, + private config: ConfigService, + ) {} + + async onModuleInit() { + // Subscribe to Ozonetel events (fire and forget) + // Will be implemented when webhook URL is configured + this.logger.log('Supervisor service initialized'); + } + + // Called by webhook when Ozonetel pushes call events + handleCallEvent(event: any) { + const { action, ucid, agent_id, caller_id, call_type, event_time } = event; + if (action === 'Answered' || action === 'Calling') { + this.activeCalls.set(ucid, { + ucid, agentId: agent_id, callerNumber: caller_id, + callType: call_type, startTime: event_time, status: 'active', + }); + } else if (action === 'Disconnect') { + this.activeCalls.delete(ucid); + } + } + + // Called by webhook when Ozonetel pushes agent events + handleAgentEvent(event: any) { + this.logger.log(`Agent event: ${event.agentId} → ${event.action}`); + } + + getActiveCalls(): ActiveCall[] { + return Array.from(this.activeCalls.values()); + } + + // Aggregate time breakdown across all agents + async getTeamPerformance(date: string): Promise { + // Get all agent IDs from platform + const agentData = await this.platform.query( + `{ agents(first: 20) { edges { node { + id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent + } } } }`, + ); + const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? []; + + // Fetch Ozonetel summary per agent + const summaries = await Promise.all( + agents.map(async (agent: any) => { + try { + const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date); + return { ...agent, timeBreakdown: summary }; + } catch { + return { ...agent, timeBreakdown: null }; + } + }), + ); + + return { date, agents: summaries }; + } +} +``` + +- [ ] **Step 2: Create supervisor controller** + +```typescript +// supervisor.controller.ts +import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; +import { SupervisorService } from './supervisor.service'; + +@Controller('api/supervisor') +export class SupervisorController { + private readonly logger = new Logger(SupervisorController.name); + + constructor(private readonly supervisor: SupervisorService) {} + + @Get('active-calls') + getActiveCalls() { + return this.supervisor.getActiveCalls(); + } + + @Get('team-performance') + async getTeamPerformance(@Query('date') date?: string) { + const targetDate = date ?? new Date().toISOString().split('T')[0]; + return this.supervisor.getTeamPerformance(targetDate); + } + + @Post('call-event') + handleCallEvent(@Body() body: any) { + // Ozonetel pushes events here + const event = body.data ?? body; + this.logger.log(`Call event: ${event.action} ucid=${event.ucid} agent=${event.agent_id}`); + this.supervisor.handleCallEvent(event); + return { received: true }; + } + + @Post('agent-event') + handleAgentEvent(@Body() body: any) { + const event = body.data ?? body; + this.logger.log(`Agent event: ${event.action} agent=${event.agentId}`); + this.supervisor.handleAgentEvent(event); + return { received: true }; + } +} +``` + +- [ ] **Step 3: Create supervisor module and register** + +```typescript +// supervisor.module.ts +import { Module } from '@nestjs/common'; +import { PlatformModule } from '../platform/platform.module'; +import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module'; +import { SupervisorController } from './supervisor.controller'; +import { SupervisorService } from './supervisor.service'; + +@Module({ + imports: [PlatformModule, OzonetelAgentModule], + controllers: [SupervisorController], + providers: [SupervisorService], +}) +export class SupervisorModule {} +``` + +Add to `app.module.ts`: +```typescript +import { SupervisorModule } from './supervisor/supervisor.module'; +// Add to imports array +``` + +- [ ] **Step 4: Verify sidecar build** + +```bash +cd helix-engage-server && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/supervisor/ src/app.module.ts +git commit -m "feat: supervisor module with team performance + active calls endpoints" +``` + +--- + +## Task 5: Team Performance Dashboard (PP-5) + +**Files:** +- Modify: `helix-engage/src/pages/team-performance.tsx` + +This is the largest task. The page queries platform directly for calls/appointments/leads and the sidecar for time breakdown. + +- [ ] **Step 1: Build the full page** + +The page has 6 sections. Use `apiClient.graphql()` for platform data and `apiClient.get()` for sidecar data. + +**Queries needed:** +- Calls by date range: `calls(first: 500, filter: { startedAt: { gte: "...", lte: "..." } })` +- Appointments by date range: `appointments(first: 200, filter: { scheduledAt: { gte: "...", lte: "..." } })` +- Leads: `leads(first: 200)` +- Follow-ups: `followUps(first: 200)` +- Agents with thresholds: `agents(first: 20) { ... npsscore maxidleminutes minnpsthreshold minconversionpercent }` +- Sidecar: `GET /api/supervisor/team-performance?date=YYYY-MM-DD` + +**Date range logic:** +- Today: today start → now +- Week: Monday of current week → now +- Month: 1st of current month → now +- Year: Jan 1 → now +- Custom: user-selected range + +**Sections to implement:** +1. Key Metrics bar (6 cards in a row) +2. Call Breakdown Trends (2 ECharts line charts side by side) +3. Agent Performance table (sortable) +4. Time Breakdown (team average + per-agent stacked bars) +5. NPS + Conversion Metrics (donut + cards) +6. Performance Alerts (threshold comparison) + +Check if ECharts is already installed: +```bash +grep echarts helix-engage/package.json +``` +If not, install: `npm install echarts echarts-for-react` + +Follow the existing My Performance page (`my-performance.tsx`) for ECharts patterns. + +- [ ] **Step 2: Verify build** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 3: Test locally** + +```bash +cd helix-engage && npm run dev +``` + +Navigate to `/team-performance` as admin user. Verify all 6 sections render with real data. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/team-performance.tsx package.json package-lock.json +git commit -m "feat: team performance dashboard (PP-5) with 6 data sections" +``` + +--- + +## Task 6: Live Call Monitor (PP-6) + +**Files:** +- Modify: `helix-engage/src/pages/live-monitor.tsx` + +- [ ] **Step 1: Build the live monitor page** + +Page polls `GET /api/supervisor/active-calls` every 5 seconds. + +**Structure:** +1. TopBar: "Live Call Monitor" with subtitle "Listen, whisper, or barge into active calls" +2. Three KPI cards: Active Calls, On Hold, Avg Duration +3. Active Calls table: Agent, Caller, Type, Department, Duration (live counter), Status, Actions +4. Actions: Listen / Whisper / Barge buttons — all disabled with tooltip "Coming soon — pending Ozonetel API" +5. Empty state: headphones icon + "No active calls" + +Duration should be a live counter — calculated client-side from `startTime` in the active call data. Use `setInterval` to update every second. + +Caller name: attempt to match `callerNumber` against leads from `useData()`. If matched, show lead name + phone. If not, show phone only. + +- [ ] **Step 2: Verify build** + +```bash +cd helix-engage && npm run build +``` + +- [ ] **Step 3: Test locally** + +Navigate to `/live-monitor`. Verify empty state renders. If Ozonetel events are flowing, verify active calls appear. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/live-monitor.tsx +git commit -m "feat: live call monitor page (PP-6) with polling + KPI cards" +``` + +--- + +## Task 7: Local Testing + Final Verification + +- [ ] **Step 1: Run both locally** + +Terminal 1: `cd helix-engage-server && npm run start:dev` +Terminal 2: `cd helix-engage && npm run dev` + +- [ ] **Step 2: Test admin login** + +Login as admin (sanjay.marketing@globalhospital.com). Verify: +- Sidebar shows new nav structure (Supervisor + Data & Reports sections) +- Dashboard loads +- Team Performance shows data from platform +- Live Monitor shows empty state or active calls +- All master data pages load (Lead, Patient, Appointment, Call Log, Call Recordings, Missed Calls) + +- [ ] **Step 3: Commit any fixes** + +- [ ] **Step 4: Push to Azure** + +```bash +cd helix-engage && git push origin dev +cd helix-engage-server && git push origin dev +``` diff --git a/docs/superpowers/specs/2026-03-24-supervisor-module.md b/docs/superpowers/specs/2026-03-24-supervisor-module.md new file mode 100644 index 0000000..be8963b --- /dev/null +++ b/docs/superpowers/specs/2026-03-24-supervisor-module.md @@ -0,0 +1,191 @@ +# Supervisor Module — Team Performance, Live Call Monitor, Master Data + +**Date**: 2026-03-24 +**Jira**: PP-5 (Team Performance), PP-6 (Live Call Monitor) +**Status**: Approved design + +--- + +## Principle + +No hardcoded/mock data. All data from Ozonetel APIs or platform GraphQL queries. + +--- + +## 1. Admin Sidebar Nav Restructure + +``` +SUPERVISOR + Dashboard → / (existing team-dashboard.tsx — summary) + Team Performance → /team-performance (new — full PP-5) + Live Call Monitor → /live-monitor (new — PP-6) + +DATA & REPORTS + Lead Master → /leads (existing all-leads.tsx) + Patient Master → /patients (existing patients.tsx) + Appointment Master → /appointments (existing appointments.tsx) + Call Log Master → /call-history (existing call-history.tsx) + Call Recordings → /call-recordings (new — filtered calls with recordings) + Missed Calls → /missed-calls (new — standalone missed call table) +``` + +**Files**: `sidebar.tsx` (admin nav config), `main.tsx` (routes) + +--- + +## 2. Team Performance Dashboard (PP-5) + +**Route**: `/team-performance` +**Page**: `src/pages/team-performance.tsx` + +### Section 1: Key Metrics Bar +- Active Agents / On Call Now → sidecar (from active calls tracking) +- Total Calls → platform `calls` count by date range +- Appointments → platform `appointments` count +- Missed Calls → platform `calls` where `callStatus: MISSED` +- Conversion Rate → appointments / total calls +- Time filter: Today | Week | Month | Year | Custom + +### Section 2: Call Breakdown Trends +- Left: Inbound vs Outbound line chart (ECharts) by day +- Right: Leads vs Missed vs Follow-ups by day +- Data: platform `calls` grouped by date + direction + +### Section 3: Agent Performance Table +| Column | Source | +|--------|--------| +| Agent | Agent entity `name` | +| Calls | Platform `calls` filtered by `agentName` | +| Inbound | Platform `calls` where `direction: INBOUND` | +| Missed | Platform `calls` where `callStatus: MISSED` | +| Follow-ups | Platform `followUps` filtered by `assignedAgent` | +| Leads | Platform `leads` filtered by `assignedAgent` | +| Conv% | Derived: appointments / calls | +| NPS | Agent entity `npsscore` | +| Idle | Ozonetel `getAgentSummary` API | + +Sortable columns. Own time filter (Today/Week/Month/Year/Custom). + +### Section 4: Time Breakdown +- Team average: Active / Wrap / Idle / Break totals +- Per-agent horizontal stacked bars +- Data: Ozonetel `getAgentSummary` per agent +- Agents with idle > `maxidleminutes` threshold highlighted red + +### Section 5: NPS + Conversion Metrics +- NPS donut chart (average of all agents' `npsscore`) +- Per-agent NPS horizontal bars +- Call→Appointment % card (big number) +- Lead→Contact % card (big number) +- Per-agent conversion breakdown below cards + +### Section 6: Performance Alerts +- Compare actual metrics vs Agent entity thresholds: + - `maxidleminutes` → "Excessive Idle Time" + - `minnpsthreshold` → "Low NPS" + - `minconversionpercent` → "Low Lead-to-Contact" +- Red-highlighted alert cards with agent name, alert type, value + +### Sidecar Endpoint +`GET /api/supervisor/team-performance?date=YYYY-MM-DD` +- Aggregates Ozonetel `getAgentSummary` across all agents +- Returns per-agent time breakdown (active/wrap/idle/break in minutes) +- Uses Agent entity to get list of all agent IDs + +--- + +## 3. Live Call Monitor (PP-6) + +**Route**: `/live-monitor` +**Page**: `src/pages/live-monitor.tsx` + +### KPI Cards +- Active Calls count +- On Hold count +- Avg Duration + +### Active Calls Table +| Column | Source | +|--------|--------| +| Agent | Ozonetel event `agent_id` → mapped to Agent entity name | +| Caller | Event `caller_id` → matched against platform leads/patients | +| Type | Event `call_type` (InBound/Manual) | +| Department | From matched lead's `interestedService` or "—" | +| Duration | Live counter from `event_time` | +| Status | active / on-hold | +| Actions | Listen / Whisper / Barge buttons (disabled until API confirmed) | + +### Data Flow +1. Sidecar subscribes to Ozonetel real-time events on startup + - `POST https://subscription.ozonetel.com/events/subscribe` + - Body: `{ callEventsURL: "", agentEventsURL: "" }` +2. Sidecar receives events at `POST /webhooks/ozonetel/call-event` +3. In-memory map: `ucid → { agentId, callerNumber, callType, startTime, status }` + - `Calling` / `Answered` → add/update entry + - `Disconnect` → remove entry +4. `GET /api/supervisor/active-calls` → returns current map +5. Frontend polls every 5 seconds + +### Sidecar Changes +- New module: `src/supervisor/` + - `supervisor.controller.ts` — team-performance + active-calls endpoints + - `supervisor.service.ts` — Ozonetel event subscription, active call tracking + - `supervisor.module.ts` +- New webhook: `POST /webhooks/ozonetel/call-event` +- Ozonetel event subscription on `onModuleInit` + +--- + +## 4. Master Data Pages + +### Call Recordings (`/call-recordings`) +**Page**: `src/pages/call-recordings.tsx` +- Query: platform `calls` where `recording` is not null +- Table: Agent, Caller, Type, Date, Duration, Recording Player +- Search by agent/phone + date filter + +### Missed Calls (`/missed-calls`) +**Page**: `src/pages/missed-calls.tsx` +- Query: platform `calls` where `callStatus: MISSED` +- Table: Caller, Date/Time, Branch (`callsourcenumber`), Agent, Callback Status, SLA +- Tabs: All | Pending | Attempted | Completed (filter by `callbackstatus`) +- Not filtered by agent — supervisor sees all + +--- + +## 5. Agent Entity Fields (Already Configured) + +| GraphQL Field | Type | Purpose | +|---|---|---| +| `ozonetelagentid` | Text | Ozonetel agent ID | +| `sipextension` | Text | SIP extension | +| `sippassword` | Text | SIP password | +| `campaignname` | Text | Ozonetel campaign | +| `npsscore` | Number | Agent NPS score | +| `maxidleminutes` | Number | Idle time alert threshold | +| `minnpsthreshold` | Number | NPS alert threshold | +| `minconversionpercent` | Number | Conversion alert threshold | + +All custom fields use **all-lowercase** GraphQL names. + +--- + +## 6. File Map + +### New Files +| File | Purpose | +|------|---------| +| `helix-engage/src/pages/team-performance.tsx` | PP-5 dashboard | +| `helix-engage/src/pages/live-monitor.tsx` | PP-6 active call monitor | +| `helix-engage/src/pages/call-recordings.tsx` | Call recordings master | +| `helix-engage/src/pages/missed-calls.tsx` | Missed calls master | +| `helix-engage-server/src/supervisor/supervisor.controller.ts` | Supervisor endpoints | +| `helix-engage-server/src/supervisor/supervisor.service.ts` | Event subscription + active calls | +| `helix-engage-server/src/supervisor/supervisor.module.ts` | Module registration | + +### Modified Files +| File | Change | +|------|--------| +| `helix-engage/src/components/layout/sidebar.tsx` | Admin nav restructure | +| `helix-engage/src/main.tsx` | New routes | +| `helix-engage-server/src/app.module.ts` | Import SupervisorModule | From d21841ddd5f87d1ffa0fe7a6e11ea359d22cc698 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:52:53 +0530 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20supervisor=20module=20=E2=80=94=20t?= =?UTF-8?q?eam=20performance,=20live=20monitor,=20master=20data=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin sidebar restructured: Supervisor + Data & Reports + Admin groups - Team Performance (PP-5): 6 sections — KPIs, call trends, agent table, time breakdown, NPS/conversion, performance alerts - Live Call Monitor (PP-6): polling active calls, KPI cards, action buttons - Call Recordings: filtered call table with inline audio player - Missed Calls: supervisor view with status tabs and SLA tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/sidebar.tsx | 27 +- src/main.tsx | 8 + src/pages/call-recordings.tsx | 167 +++++++++++ src/pages/live-monitor.tsx | 178 ++++++++++++ src/pages/missed-calls.tsx | 189 +++++++++++++ src/pages/team-performance.tsx | 449 ++++++++++++++++++++++++++++++ 6 files changed, 1011 insertions(+), 7 deletions(-) create mode 100644 src/pages/call-recordings.tsx create mode 100644 src/pages/live-monitor.tsx create mode 100644 src/pages/missed-calls.tsx create mode 100644 src/pages/team-performance.tsx diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 2705633..9a5eeb5 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -12,9 +12,12 @@ import { faHospitalUser, faCalendarCheck, faPhone, - faPlug, faUsers, faArrowRightFromBracket, + faTowerBroadcast, + faChartLine, + faFileAudio, + faPhoneMissed, } from "@fortawesome/pro-duotone-svg-icons"; import { faIcon } from "@/lib/icon-wrapper"; import { useAtom } from "jotai"; @@ -39,13 +42,16 @@ const IconGrid2 = faIcon(faGrid2); const IconBullhorn = faIcon(faBullhorn); const IconCommentDots = faIcon(faCommentDots); const IconChartMixed = faIcon(faChartMixed); -const IconPlug = faIcon(faPlug); const IconGear = faIcon(faGear); const IconPhone = faIcon(faPhone); const IconClockRewind = faIcon(faClockRotateLeft); const IconUsers = faIcon(faUsers); const IconHospitalUser = faIcon(faHospitalUser); const IconCalendarCheck = faIcon(faCalendarCheck); +const IconTowerBroadcast = faIcon(faTowerBroadcast); +const IconChartLine = faIcon(faChartLine); +const IconFileAudio = faIcon(faFileAudio); +const IconPhoneMissed = faIcon(faPhoneMissed); type NavSection = { label: string; @@ -55,13 +61,20 @@ type NavSection = { const getNavSections = (role: string): NavSection[] => { if (role === 'admin') { return [ - { label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] }, - { label: 'Management', items: [ - { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, - { label: 'Analytics', href: '/reports', icon: IconChartMixed }, + { label: 'Supervisor', items: [ + { label: 'Dashboard', href: '/', icon: IconGrid2 }, + { label: 'Team Performance', href: '/team-performance', icon: IconChartLine }, + { label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast }, + ]}, + { label: 'Data & Reports', items: [ + { label: 'Lead Master', href: '/leads', icon: IconUsers }, + { label: 'Patient Master', href: '/patients', icon: IconHospitalUser }, + { label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck }, + { label: 'Call Log Master', href: '/call-history', icon: IconClockRewind }, + { label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio }, + { label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed }, ]}, { label: 'Admin', items: [ - { label: 'Integrations', href: '/integrations', icon: IconPlug }, { label: 'Settings', href: '/settings', icon: IconGear }, ]}, ]; diff --git a/src/main.tsx b/src/main.tsx index c1161b1..4739c2c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,10 @@ import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { MyPerformancePage } from "@/pages/my-performance"; import { AppointmentsPage } from "@/pages/appointments"; +import { TeamPerformancePage } from "@/pages/team-performance"; +import { LiveMonitorPage } from "@/pages/live-monitor"; +import { CallRecordingsPage } from "@/pages/call-recordings"; +import { MissedCallsPage } from "@/pages/missed-calls"; import { AuthProvider } from "@/providers/auth-provider"; import { DataProvider } from "@/providers/data-provider"; import { RouteProvider } from "@/providers/router-provider"; @@ -57,6 +61,10 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/call-recordings.tsx b/src/pages/call-recordings.tsx new file mode 100644 index 0000000..c118bbe --- /dev/null +++ b/src/pages/call-recordings.tsx @@ -0,0 +1,167 @@ +import { useEffect, useMemo, useState, useRef } from 'react'; +import { faMagnifyingGlass, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { TopBar } from '@/components/layout/top-bar'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone } from '@/lib/format'; + +type RecordingRecord = { + id: string; + direction: string | null; + callStatus: string | null; + callerNumber: { primaryPhoneNumber: string } | null; + agentName: string | null; + startedAt: string | null; + durationSec: number | null; + disposition: string | null; + recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null; +}; + +const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id direction callStatus callerNumber { primaryPhoneNumber } + agentName startedAt durationSec disposition + recording { primaryLinkUrl primaryLinkLabel } +} } } }`; + +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }); + +const formatDuration = (sec: number | null): string => { + if (!sec) return '—'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const RecordingPlayer = ({ url }: { url: string }) => { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + + const toggle = () => { + if (!audioRef.current) return; + if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); } + setPlaying(!playing); + }; + + return ( +
+ +
+ ); +}; + +export const CallRecordingsPage = () => { + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) + .then(data => { + const withRecordings = data.calls.edges + .map(e => e.node) + .filter(c => c.recording?.primaryLinkUrl); + setCalls(withRecordings); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const filtered = useMemo(() => { + if (!search.trim()) return calls; + const q = search.toLowerCase(); + return calls.filter(c => + (c.agentName ?? '').toLowerCase().includes(q) || + (c.callerNumber?.primaryPhoneNumber ?? '').includes(q), + ); + }, [calls, search]); + + return ( + <> + +
+
+ {filtered.length} recordings +
+ +
+
+ +
+ {loading ? ( +
+

Loading recordings...

+
+ ) : filtered.length === 0 ? ( +
+

{search ? 'No matching recordings' : 'No call recordings found'}

+
+ ) : ( + + + + + + + + + + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; + const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; + + return ( + + + {call.agentName || '—'} + + + {phone ? ( + + ) : } + + + {dirLabel} + + + {call.startedAt ? formatDate(call.startedAt) : '—'} + + + {formatDuration(call.durationSec)} + + + {call.disposition ? ( + + {call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())} + + ) : } + + + {call.recording?.primaryLinkUrl && ( + + )} + + + ); + }} + +
+ )} +
+
+ + ); +}; diff --git a/src/pages/live-monitor.tsx b/src/pages/live-monitor.tsx new file mode 100644 index 0000000..59afc87 --- /dev/null +++ b/src/pages/live-monitor.tsx @@ -0,0 +1,178 @@ +import { useEffect, useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { TopBar } from '@/components/layout/top-bar'; +import { Badge } from '@/components/base/badges/badges'; +import { Button } from '@/components/base/buttons/button'; +import { Table } from '@/components/application/table/table'; +import { apiClient } from '@/lib/api-client'; +import { useData } from '@/providers/data-provider'; + +type ActiveCall = { + ucid: string; + agentId: string; + callerNumber: string; + callType: string; + startTime: string; + status: 'active' | 'on-hold'; +}; + +const formatDuration = (startTime: string): string => { + const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000)); + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +}; + +const KpiCard = ({ value, label }: { value: string | number; label: string }) => ( +
+

{value}

+

{label}

+
+); + +export const LiveMonitorPage = () => { + const [activeCalls, setActiveCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [tick, setTick] = useState(0); + const { leads } = useData(); + + // Poll active calls every 5 seconds + useEffect(() => { + const fetchCalls = () => { + apiClient.get('/api/supervisor/active-calls', { silent: true }) + .then(setActiveCalls) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + fetchCalls(); + const interval = setInterval(fetchCalls, 5000); + return () => clearInterval(interval); + }, []); + + // Tick every second to update duration counters + useEffect(() => { + const interval = setInterval(() => setTick(t => t + 1), 1000); + return () => clearInterval(interval); + }, []); + + const onHold = activeCalls.filter(c => c.status === 'on-hold').length; + const avgDuration = useMemo(() => { + if (activeCalls.length === 0) return '0:00'; + const totalSec = activeCalls.reduce((sum, c) => { + return sum + Math.max(0, Math.floor((Date.now() - new Date(c.startTime).getTime()) / 1000)); + }, 0); + const avg = Math.floor(totalSec / activeCalls.length); + return `${Math.floor(avg / 60)}:${(avg % 60).toString().padStart(2, '0')}`; + }, [activeCalls, tick]); + + // Match caller to lead + const resolveCallerName = (phone: string): string | null => { + if (!phone) return null; + const clean = phone.replace(/\D/g, ''); + const lead = leads.find(l => { + const lp = (l.contactPhone?.[0]?.number ?? '').replace(/\D/g, ''); + return lp && (lp.endsWith(clean) || clean.endsWith(lp)); + }); + if (lead) { + return `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim() || null; + } + return null; + }; + + return ( + <> + + +
+ {/* KPI Cards */} +
+
+ + + +
+
+ + {/* Active Calls Table */} +
+

Active Calls

+ + {loading ? ( +
+

Loading...

+
+ ) : activeCalls.length === 0 ? ( +
+ +

No active calls

+

Active calls will appear here in real-time

+
+ ) : ( + + + + + + + + + + + {(call) => { + const callerName = resolveCallerName(call.callerNumber); + const typeLabel = call.callType === 'InBound' ? 'In' : 'Out'; + const typeColor = call.callType === 'InBound' ? 'blue' : 'brand'; + + return ( + + + {call.agentId} + + +
+ {callerName && {callerName}} + {call.callerNumber} +
+
+ + {typeLabel} + + + {formatDuration(call.startTime)} + + + + {call.status} + + + +
+ + + +
+
+
+ ); + }} +
+
+ )} +
+ + {/* Monitoring hint */} + {activeCalls.length > 0 && ( +
+
+ +

Select "Listen" on any active call to start monitoring

+

Agent will not be notified during listen mode

+
+
+ )} +
+ + ); +}; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx new file mode 100644 index 0000000..5df1d24 --- /dev/null +++ b/src/pages/missed-calls.tsx @@ -0,0 +1,189 @@ +import { useEffect, useMemo, useState } from 'react'; +import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +import { TopBar } from '@/components/layout/top-bar'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { apiClient } from '@/lib/api-client'; +import { formatPhone } from '@/lib/format'; + +type MissedCallRecord = { + id: string; + callerNumber: { primaryPhoneNumber: string } | null; + agentName: string | null; + startedAt: string | null; + callsourcenumber: string | null; + callbackstatus: string | null; + missedcallcount: number | null; + callbackattemptedat: string | null; +}; + +type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED'; + +const QUERY = `{ calls(first: 200, filter: { + callStatus: { eq: MISSED } +}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { + id callerNumber { primaryPhoneNumber } agentName + startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat +} } } }`; + +const formatDate = (iso: string): string => + new Date(iso).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }); + +const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => { + const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000)); + if (minutes < 15) return { label: `${minutes}m`, color: 'success' }; + if (minutes < 30) return { label: `${minutes}m`, color: 'warning' }; + if (minutes < 60) return { label: `${minutes}m`, color: 'error' }; + const hours = Math.floor(minutes / 60); + if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' }; + return { label: `${Math.floor(hours / 24)}d`, color: 'error' }; +}; + +const STATUS_LABELS: Record = { + PENDING_CALLBACK: 'Pending', + CALLBACK_ATTEMPTED: 'Attempted', + CALLBACK_COMPLETED: 'Completed', + WRONG_NUMBER: 'Wrong Number', + INVALID: 'Invalid', +}; + +const STATUS_COLORS: Record = { + PENDING_CALLBACK: 'warning', + CALLBACK_ATTEMPTED: 'brand', + CALLBACK_COMPLETED: 'success', + WRONG_NUMBER: 'error', + INVALID: 'gray', +}; + +export const MissedCallsPage = () => { + const [calls, setCalls] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('all'); + const [search, setSearch] = useState(''); + + useEffect(() => { + apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true }) + .then(data => setCalls(data.calls.edges.map(e => e.node))) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const statusCounts = useMemo(() => { + const counts: Record = {}; + for (const c of calls) { + const s = c.callbackstatus ?? 'PENDING_CALLBACK'; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; + }, [calls]); + + const filtered = useMemo(() => { + let rows = calls; + if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus); + else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'); + else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'); + + if (search.trim()) { + const q = search.toLowerCase(); + rows = rows.filter(c => + (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || + (c.agentName ?? '').toLowerCase().includes(q), + ); + } + return rows; + }, [calls, tab, search]); + + const tabItems = [ + { id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined }, + { id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined }, + { id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined }, + { id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined }, + ]; + + return ( + <> + +
+
+ setTab(key as StatusTab)}> + + {(item) => } + + +
+ +
+
+ +
+ {loading ? ( +
+

Loading missed calls...

+
+ ) : filtered.length === 0 ? ( +
+

{search ? 'No matching calls' : 'No missed calls'}

+
+ ) : ( + + + + + + + + + + + + {(call) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const status = call.callbackstatus ?? 'PENDING_CALLBACK'; + const sla = call.startedAt ? computeSla(call.startedAt) : null; + + return ( + + + {phone ? ( + + ) : Unknown} + + + {call.startedAt ? formatDate(call.startedAt) : '—'} + + + {call.callsourcenumber || '—'} + + + {call.agentName || '—'} + + + {call.missedcallcount && call.missedcallcount > 1 ? ( + {call.missedcallcount}x + ) : 1} + + + + {STATUS_LABELS[status] ?? status} + + + + {sla && {sla.label}} + + + ); + }} + +
+ )} +
+
+ + ); +}; diff --git a/src/pages/team-performance.tsx b/src/pages/team-performance.tsx new file mode 100644 index 0000000..7543260 --- /dev/null +++ b/src/pages/team-performance.tsx @@ -0,0 +1,449 @@ +import { useEffect, useMemo, useState } from 'react'; +import ReactECharts from 'echarts-for-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed, + faPercent, faTriangleExclamation, +} from '@fortawesome/pro-duotone-svg-icons'; +import { TopBar } from '@/components/layout/top-bar'; +import { Badge } from '@/components/base/badges/badges'; +import { Table } from '@/components/application/table/table'; +import { apiClient } from '@/lib/api-client'; +import { cx } from '@/utils/cx'; + +type DateRange = 'today' | 'week' | 'month' | 'year'; + +const getDateRange = (range: DateRange): { gte: string; lte: string } => { + const now = new Date(); + const lte = now.toISOString(); + const start = new Date(now); + if (range === 'today') start.setHours(0, 0, 0, 0); + else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); } + else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); } + else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); } + return { gte: start.toISOString(), lte }; +}; + +const parseTime = (timeStr: string): number => { + if (!timeStr) return 0; + const parts = timeStr.split(':').map(Number); + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return 0; +}; + +type AgentPerf = { + name: string; + ozonetelagentid: string; + npsscore: number | null; + maxidleminutes: number | null; + minnpsthreshold: number | null; + minconversionpercent: number | null; + calls: number; + inbound: number; + missed: number; + followUps: number; + leads: number; + appointments: number; + convPercent: number; + idleMinutes: number; + activeMinutes: number; + wrapMinutes: number; + breakMinutes: number; + timeBreakdown: any; +}; + +const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => ( +
+ {(['today', 'week', 'month', 'year'] as DateRange[]).map(r => ( + + ))} +
+); + +const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => ( +
+
+ +
+
+

{value}

+

{label}

+
+
+); + +export const TeamPerformancePage = () => { + const [range, setRange] = useState('today'); + const [agents, setAgents] = useState([]); + const [allCalls, setAllCalls] = useState([]); + const [allAppointments, setAllAppointments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const load = async () => { + setLoading(true); + const { gte, lte } = getDateRange(range); + const dateStr = new Date().toISOString().split('T')[0]; + + try { + const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([ + apiClient.graphql(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }), + apiClient.graphql(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }), + apiClient.get(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })), + ]); + + const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? []; + const appts = apptsData?.appointments?.edges?.map((e: any) => e.node) ?? []; + const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? []; + const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? []; + const teamAgents = teamData?.agents ?? []; + + setAllCalls(calls); + setAllAppointments(appts); + + // Build per-agent metrics + const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => { + const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid); + const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name); + const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name); + const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length; // approximate + const totalCalls = agentCalls.length; + const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length; + const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length; + + const tb = agent.timeBreakdown; + const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0; + const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0; + const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0; + const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0; + + return { + name: agent.name ?? agent.ozonetelagentid, + ozonetelagentid: agent.ozonetelagentid, + npsscore: agent.npsscore, + maxidleminutes: agent.maxidleminutes, + minnpsthreshold: agent.minnpsthreshold, + minconversionpercent: agent.minconversionpercent, + calls: totalCalls, + inbound, + missed, + followUps: agentFollowUps.length, + leads: agentLeads.length, + appointments: agentAppts, + convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0, + idleMinutes: Math.round(idleSec / 60), + activeMinutes: Math.round(activeSec / 60), + wrapMinutes: Math.round(wrapSec / 60), + breakMinutes: Math.round(breakSec / 60), + timeBreakdown: tb, + }; + }); + + setAgents(agentPerfs); + } catch (err) { + console.error('Failed to load team performance:', err); + } finally { + setLoading(false); + } + }; + load(); + }, [range]); + + // Aggregate KPIs + const totalCalls = allCalls.length; + const totalMissed = allCalls.filter(c => c.callStatus === 'MISSED').length; + const totalAppts = allAppointments.length; + const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0; + const activeAgents = agents.length; + + // Call trend by day + const callTrendOption = useMemo(() => { + const dayMap: Record = {}; + for (const c of allCalls) { + if (!c.startedAt) continue; + const day = new Date(c.startedAt).toLocaleDateString('en-IN', { weekday: 'short' }); + if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 }; + if (c.direction === 'INBOUND') dayMap[day].inbound++; + else dayMap[day].outbound++; + } + const days = Object.keys(dayMap); + return { + tooltip: { trigger: 'axis' }, + legend: { data: ['Inbound', 'Outbound'], bottom: 0 }, + grid: { top: 10, right: 10, bottom: 30, left: 40 }, + xAxis: { type: 'category', data: days }, + yAxis: { type: 'value' }, + series: [ + { name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' }, + { name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' }, + ], + }; + }, [allCalls]); + + // NPS + const avgNps = useMemo(() => { + const withNps = agents.filter(a => a.npsscore != null); + if (withNps.length === 0) return 0; + return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length); + }, [agents]); + + const npsOption = useMemo(() => ({ + tooltip: { trigger: 'item' }, + series: [{ + type: 'gauge', startAngle: 180, endAngle: 0, + min: 0, max: 100, + pointer: { show: false }, + progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } }, + axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } }, + axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, + detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' }, + data: [{ value: avgNps }], + }], + }), [avgNps]); + + // Performance alerts + const alerts = useMemo(() => { + const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = []; + for (const a of agents) { + if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) { + list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' }); + } + if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) { + list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' }); + } + if (a.minconversionpercent && a.convPercent < a.minconversionpercent) { + list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' }); + } + } + return list; + }, [agents]); + + // Team time averages + const teamAvg = useMemo(() => { + if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 }; + return { + active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length), + wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length), + idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length), + break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length), + }; + }, [agents]); + + if (loading) { + return ( + <> + +
+

Loading team performance...

+
+ + ); + } + + return ( + <> + + +
+ {/* Section 1: Key Metrics */} +
+
+

Key Metrics

+ +
+
+ + + + + +
+
+ + {/* Section 2: Call Breakdown Trends */} +
+
+

Call Breakdown Trends

+
+
+

Inbound vs Outbound

+ +
+
+
+
+ + {/* Section 3: Agent Performance Table */} +
+
+

Agent Performance

+ + + + + + + + + + + + + + {(agent) => ( + + {agent.name} + {agent.calls} + {agent.inbound} + {agent.missed} + {agent.followUps} + {agent.leads} + + = 25 ? 'text-success-primary' : 'text-error-primary')}> + {agent.convPercent}% + + + + = 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}> + {agent.npsscore ?? '—'} + + + + agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}> + {agent.idleMinutes}m + + + + )} + +
+
+
+ + {/* Section 4: Time Breakdown */} +
+
+

Time Breakdown

+
+
+
+ {teamAvg.active}m Active +
+
+
+ {teamAvg.wrap}m Wrap +
+
+
+ {teamAvg.idle}m Idle +
+
+
+ {teamAvg.break_}m Break +
+
+
+ {agents.map(agent => { + const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1; + const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes; + return ( +
+

{agent.name}

+
+
+
+
+
+
+
+ Active {agent.activeMinutes}m + Wrap {agent.wrapMinutes}m + Idle {agent.idleMinutes}m + Break {agent.breakMinutes}m +
+
+ ); + })} +
+
+
+ + {/* Section 5: NPS + Conversion */} +
+
+
+

Overall NPS

+ +
+ {agents.filter(a => a.npsscore != null).map(a => ( +
+ {a.name} +
+
= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} /> +
+ {a.npsscore} +
+ ))} +
+
+
+

Conversion Metrics

+
+
+

{convRate}%

+

Call → Appointment

+
+
+

+ {agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}% +

+

Lead → Contact

+
+
+
+ {agents.map(a => ( +
+ {a.name} + = 25 ? 'success' : 'error'}>{a.convPercent}% +
+ ))} +
+
+
+
+ + {/* Section 6: Performance Alerts */} + {alerts.length > 0 && ( +
+
+

+ + Performance Alerts ({alerts.length}) +

+
+ {alerts.map((alert, i) => ( +
+
+ + {alert.agent} + — {alert.type} +
+ {alert.value} +
+ ))} +
+
+
+ )} +
+ + ); +}; From d4f33d6c0651e0f7d79883dcd0e0f4108f54c65b Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 24 Mar 2026 13:59:09 +0530 Subject: [PATCH 9/9] fix: restore KPI card icons on live monitor page Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pages/live-monitor.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/live-monitor.tsx b/src/pages/live-monitor.tsx index 59afc87..4938012 100644 --- a/src/pages/live-monitor.tsx +++ b/src/pages/live-monitor.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faHeadset } from '@fortawesome/pro-duotone-svg-icons'; +import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons'; import { TopBar } from '@/components/layout/top-bar'; import { Badge } from '@/components/base/badges/badges'; import { Button } from '@/components/base/buttons/button'; @@ -24,8 +24,9 @@ const formatDuration = (startTime: string): string => { return `${m}:${s.toString().padStart(2, '0')}`; }; -const KpiCard = ({ value, label }: { value: string | number; label: string }) => ( +const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
+

{value}

{label}

@@ -89,9 +90,9 @@ export const LiveMonitorPage = () => { {/* KPI Cards */}
- - - + + +