mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add Socket.IO client and useCallEvents hook for live CTI mode
This commit is contained in:
88
package-lock.json
generated
88
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hotkeys-hook": "^5.2.3",
|
"react-hotkeys-hook": "^5.2.3",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -3128,6 +3129,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "http://localhost:4873/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/core": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.15.18",
|
"version": "1.15.18",
|
||||||
"resolved": "http://localhost:4873/@swc/core/-/core-1.15.18.tgz",
|
"resolved": "http://localhost:4873/@swc/core/-/core-1.15.18.tgz",
|
||||||
@@ -4131,7 +4138,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "http://localhost:4873/debug/-/debug-4.4.3.tgz",
|
"resolved": "http://localhost:4873/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -4168,6 +4174,28 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "http://localhost:4873/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "http://localhost:4873/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.20.0",
|
"version": "5.20.0",
|
||||||
"resolved": "http://localhost:4873/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
"resolved": "http://localhost:4873/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||||
@@ -5071,7 +5099,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "http://localhost:4873/ms/-/ms-2.1.3.tgz",
|
"resolved": "http://localhost:4873/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -5655,6 +5682,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "http://localhost:4873/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.5",
|
||||||
|
"resolved": "http://localhost:4873/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||||
|
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -5933,6 +5988,35 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "http://localhost:4873/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "http://localhost:4873/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "http://localhost:4873/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "http://localhost:4873/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hotkeys-hook": "^5.2.3",
|
"react-hotkeys-hook": "^5.2.3",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
133
src/hooks/use-call-events.ts
Normal file
133
src/hooks/use-call-events.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { disconnectSocket, getSocket } from '@/lib/socket';
|
||||||
|
import type { CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
|
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||||
|
|
||||||
|
type EnrichedLead = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
source?: string;
|
||||||
|
status?: string;
|
||||||
|
campaign?: string;
|
||||||
|
interestedService?: string;
|
||||||
|
age: number;
|
||||||
|
aiSummary?: string;
|
||||||
|
aiSuggestedAction?: string;
|
||||||
|
recentActivities: { activityType: string; summary: string; occurredAt: string; performedBy: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type IncomingCallEvent = {
|
||||||
|
callSid: string;
|
||||||
|
eventType: 'ringing' | 'answered' | 'ended';
|
||||||
|
lead: EnrichedLead | null;
|
||||||
|
callerPhone: string;
|
||||||
|
agentName: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallEvents = (agentName: string) => {
|
||||||
|
const [callState, setCallState] = useState<CallState>('idle');
|
||||||
|
const [activeLead, setActiveLead] = useState<EnrichedLead | null>(null);
|
||||||
|
const [activeCallSid, setActiveCallSid] = useState<string | null>(null);
|
||||||
|
const [callStartTime, setCallStartTime] = useState<string | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const completedTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Connect to WebSocket and register agent
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
socket.emit('agent:register', agentName);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:registered', (data: { agentName: string }) => {
|
||||||
|
console.log(`Registered as agent: ${data.agentName}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call:incoming', (event: IncomingCallEvent) => {
|
||||||
|
if (event.eventType === 'ringing' || event.eventType === 'answered') {
|
||||||
|
setActiveCallSid(event.callSid);
|
||||||
|
setActiveLead(event.lead);
|
||||||
|
setCallStartTime(event.timestamp);
|
||||||
|
|
||||||
|
if (event.eventType === 'ringing') {
|
||||||
|
setCallState('ringing');
|
||||||
|
// Auto-transition to active after 1.5s (call is answered)
|
||||||
|
setTimeout(() => setCallState('active'), 1500);
|
||||||
|
} else {
|
||||||
|
setCallState('active');
|
||||||
|
}
|
||||||
|
} else if (event.eventType === 'ended') {
|
||||||
|
// Call ended from Exotel side (e.g. customer hung up)
|
||||||
|
setCallState('completed');
|
||||||
|
completedTimerRef.current = window.setTimeout(() => {
|
||||||
|
setCallState('idle');
|
||||||
|
setActiveLead(null);
|
||||||
|
setActiveCallSid(null);
|
||||||
|
setCallStartTime(null);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('call:disposition:ack', () => {
|
||||||
|
// Disposition saved on server
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (completedTimerRef.current) {
|
||||||
|
clearTimeout(completedTimerRef.current);
|
||||||
|
}
|
||||||
|
disconnectSocket();
|
||||||
|
};
|
||||||
|
}, [agentName]);
|
||||||
|
|
||||||
|
// Send disposition to server
|
||||||
|
const sendDisposition = useCallback(
|
||||||
|
(disposition: CallDisposition, notes: string) => {
|
||||||
|
const socket = getSocket();
|
||||||
|
const duration = callStartTime
|
||||||
|
? Math.floor((Date.now() - new Date(callStartTime).getTime()) / 1000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
socket.emit('call:disposition', {
|
||||||
|
callSid: activeCallSid,
|
||||||
|
leadId: activeLead?.id ?? null,
|
||||||
|
disposition,
|
||||||
|
notes,
|
||||||
|
agentName,
|
||||||
|
callerPhone: activeLead?.phone ?? '',
|
||||||
|
startedAt: callStartTime,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCallState('completed');
|
||||||
|
completedTimerRef.current = window.setTimeout(() => {
|
||||||
|
setCallState('idle');
|
||||||
|
setActiveLead(null);
|
||||||
|
setActiveCallSid(null);
|
||||||
|
setCallStartTime(null);
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
[activeCallSid, activeLead, agentName, callStartTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
callState,
|
||||||
|
activeLead,
|
||||||
|
activeCallSid,
|
||||||
|
isConnected,
|
||||||
|
sendDisposition,
|
||||||
|
};
|
||||||
|
};
|
||||||
22
src/lib/socket.ts
Normal file
22
src/lib/socket.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SIDECAR_URL = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
let socket: Socket | null = null;
|
||||||
|
|
||||||
|
export const getSocket = (): Socket => {
|
||||||
|
if (!socket) {
|
||||||
|
socket = io(`${SIDECAR_URL}/call-events`, {
|
||||||
|
autoConnect: false,
|
||||||
|
transports: ['websocket'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disconnectSocket = () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,9 +7,12 @@ import { DailyStats } from '@/components/call-desk/daily-stats';
|
|||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
import type { Call, CallDisposition, LeadStatus } from '@/types/entities';
|
import { useCallEvents } from '@/hooks/use-call-events';
|
||||||
|
import { BadgeWithDot } from '@/components/base/badges/badges';
|
||||||
|
import type { Call, CallDisposition, Lead, LeadStatus } from '@/types/entities';
|
||||||
|
|
||||||
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
type CallState = 'idle' | 'ringing' | 'active' | 'completed';
|
||||||
|
type Mode = 'demo' | 'live';
|
||||||
|
|
||||||
const isToday = (dateStr: string): boolean => {
|
const isToday = (dateStr: string): boolean => {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@@ -35,19 +38,30 @@ export const CallDeskPage = () => {
|
|||||||
const { calls, leadActivities, campaigns, addCall } = useData();
|
const { calls, leadActivities, campaigns, addCall } = useData();
|
||||||
const { leads, updateLead } = useLeads();
|
const { leads, updateLead } = useLeads();
|
||||||
|
|
||||||
const [callState, setCallState] = useState<CallState>('idle');
|
// Mode toggle: demo (mock simulator) vs live (WebSocket)
|
||||||
const [activeLead, setActiveLead] = useState<ReturnType<typeof useLeads>['leads'][number] | null>(null);
|
const [mode, setMode] = useState<Mode>('demo');
|
||||||
|
|
||||||
|
// --- Demo mode state ---
|
||||||
|
const [localCallState, setLocalCallState] = useState<CallState>('idle');
|
||||||
|
const [localActiveLead, setLocalActiveLead] = useState<ReturnType<typeof useLeads>['leads'][number] | null>(null);
|
||||||
const [completedDisposition, setCompletedDisposition] = useState<CallDisposition | null>(null);
|
const [completedDisposition, setCompletedDisposition] = useState<CallDisposition | null>(null);
|
||||||
const callStartRef = useRef<Date | null>(null);
|
const callStartRef = useRef<Date | null>(null);
|
||||||
const ringingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const ringingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const completedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// --- Live mode state (WebSocket) ---
|
||||||
|
const { callState: liveCallState, activeLead: liveLead, isConnected, sendDisposition } = useCallEvents(user.name);
|
||||||
|
|
||||||
|
// Effective state based on mode
|
||||||
|
const effectiveCallState = mode === 'live' ? liveCallState : localCallState;
|
||||||
|
|
||||||
const todaysCalls = calls.filter(
|
const todaysCalls = calls.filter(
|
||||||
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Demo mode: simulate a call
|
||||||
const handleSimulateCall = useCallback(() => {
|
const handleSimulateCall = useCallback(() => {
|
||||||
if (callState !== 'idle') return;
|
if (localCallState !== 'idle') return;
|
||||||
|
|
||||||
// Prefer leads with aiSummary, fall back to any lead
|
// Prefer leads with aiSummary, fall back to any lead
|
||||||
const leadsWithAi = leads.filter((l) => l.aiSummary !== null);
|
const leadsWithAi = leads.filter((l) => l.aiSummary !== null);
|
||||||
@@ -55,19 +69,20 @@ export const CallDeskPage = () => {
|
|||||||
if (pool.length === 0) return;
|
if (pool.length === 0) return;
|
||||||
|
|
||||||
const randomLead = pool[Math.floor(Math.random() * pool.length)];
|
const randomLead = pool[Math.floor(Math.random() * pool.length)];
|
||||||
setActiveLead(randomLead);
|
setLocalActiveLead(randomLead);
|
||||||
setCallState('ringing');
|
setLocalCallState('ringing');
|
||||||
setCompletedDisposition(null);
|
setCompletedDisposition(null);
|
||||||
|
|
||||||
ringingTimerRef.current = setTimeout(() => {
|
ringingTimerRef.current = setTimeout(() => {
|
||||||
setCallState('active');
|
setLocalCallState('active');
|
||||||
callStartRef.current = new Date();
|
callStartRef.current = new Date();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, [callState, leads]);
|
}, [localCallState, leads]);
|
||||||
|
|
||||||
const handleDisposition = useCallback(
|
// Demo mode: log disposition locally
|
||||||
|
const handleDemoDisposition = useCallback(
|
||||||
(disposition: CallDisposition, notes: string) => {
|
(disposition: CallDisposition, notes: string) => {
|
||||||
if (activeLead === null) return;
|
if (localActiveLead === null) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startedAt = callStartRef.current ?? now;
|
const startedAt = callStartRef.current ?? now;
|
||||||
@@ -78,7 +93,7 @@ export const CallDeskPage = () => {
|
|||||||
createdAt: startedAt.toISOString(),
|
createdAt: startedAt.toISOString(),
|
||||||
callDirection: 'INBOUND',
|
callDirection: 'INBOUND',
|
||||||
callStatus: 'COMPLETED',
|
callStatus: 'COMPLETED',
|
||||||
callerNumber: activeLead.contactPhone,
|
callerNumber: localActiveLead.contactPhone,
|
||||||
agentName: user.name,
|
agentName: user.name,
|
||||||
startedAt: startedAt.toISOString(),
|
startedAt: startedAt.toISOString(),
|
||||||
endedAt: now.toISOString(),
|
endedAt: now.toISOString(),
|
||||||
@@ -88,54 +103,150 @@ export const CallDeskPage = () => {
|
|||||||
callNotes: notes || null,
|
callNotes: notes || null,
|
||||||
patientId: null,
|
patientId: null,
|
||||||
appointmentId: null,
|
appointmentId: null,
|
||||||
leadId: activeLead.id,
|
leadId: localActiveLead.id,
|
||||||
leadName:
|
leadName:
|
||||||
`${activeLead.contactName?.firstName ?? ''} ${activeLead.contactName?.lastName ?? ''}`.trim() ||
|
`${localActiveLead.contactName?.firstName ?? ''} ${localActiveLead.contactName?.lastName ?? ''}`.trim() ||
|
||||||
'Unknown',
|
'Unknown',
|
||||||
leadPhone: activeLead.contactPhone?.[0]?.number ?? undefined,
|
leadPhone: localActiveLead.contactPhone?.[0]?.number ?? undefined,
|
||||||
leadService: activeLead.interestedService ?? undefined,
|
leadService: localActiveLead.interestedService ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
addCall(newCall);
|
addCall(newCall);
|
||||||
|
|
||||||
const newStatus = dispositionToStatus[disposition];
|
const newStatus = dispositionToStatus[disposition];
|
||||||
if (newStatus !== undefined) {
|
if (newStatus !== undefined) {
|
||||||
updateLead(activeLead.id, {
|
updateLead(localActiveLead.id, {
|
||||||
leadStatus: newStatus,
|
leadStatus: newStatus,
|
||||||
lastContactedAt: now.toISOString(),
|
lastContactedAt: now.toISOString(),
|
||||||
contactAttempts: (activeLead.contactAttempts ?? 0) + 1,
|
contactAttempts: (localActiveLead.contactAttempts ?? 0) + 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setCompletedDisposition(disposition);
|
setCompletedDisposition(disposition);
|
||||||
setCallState('completed');
|
setLocalCallState('completed');
|
||||||
|
|
||||||
completedTimerRef.current = setTimeout(() => {
|
completedTimerRef.current = setTimeout(() => {
|
||||||
setCallState('idle');
|
setLocalCallState('idle');
|
||||||
setActiveLead(null);
|
setLocalActiveLead(null);
|
||||||
setCompletedDisposition(null);
|
setCompletedDisposition(null);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
},
|
},
|
||||||
[activeLead, user.name, addCall, updateLead],
|
[localActiveLead, user.name, addCall, updateLead],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Route disposition to the correct handler based on mode
|
||||||
|
const handleDisposition = useCallback(
|
||||||
|
(disposition: CallDisposition, notes: string) => {
|
||||||
|
if (mode === 'live') {
|
||||||
|
sendDisposition(disposition, notes);
|
||||||
|
} else {
|
||||||
|
handleDemoDisposition(disposition, notes);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mode, sendDisposition, handleDemoDisposition],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the lead shape for IncomingCallCard — live leads come in EnrichedLead form, map to Lead
|
||||||
|
const displayLead: Lead | null = (() => {
|
||||||
|
if (mode === 'live' && liveLead !== null) {
|
||||||
|
const mapped: Lead = {
|
||||||
|
id: liveLead.id,
|
||||||
|
createdAt: liveLead.age ? new Date(Date.now() - liveLead.age * 24 * 60 * 60 * 1000).toISOString() : null,
|
||||||
|
updatedAt: null,
|
||||||
|
leadSource: (liveLead.source as Lead['leadSource']) ?? null,
|
||||||
|
leadStatus: (liveLead.status as Lead['leadStatus']) ?? null,
|
||||||
|
priority: null,
|
||||||
|
contactName: { firstName: liveLead.firstName, lastName: liveLead.lastName },
|
||||||
|
contactPhone: liveLead.phone ? [{ number: liveLead.phone, callingCode: '' }] : null,
|
||||||
|
contactEmail: liveLead.email ? [{ address: liveLead.email }] : null,
|
||||||
|
interestedService: liveLead.interestedService ?? null,
|
||||||
|
assignedAgent: null,
|
||||||
|
utmSource: null,
|
||||||
|
utmMedium: null,
|
||||||
|
utmCampaign: null,
|
||||||
|
utmContent: null,
|
||||||
|
utmTerm: null,
|
||||||
|
landingPageUrl: null,
|
||||||
|
referrerUrl: null,
|
||||||
|
leadScore: null,
|
||||||
|
spamScore: null,
|
||||||
|
isSpam: null,
|
||||||
|
isDuplicate: null,
|
||||||
|
duplicateOfLeadId: null,
|
||||||
|
firstContactedAt: null,
|
||||||
|
lastContactedAt: null,
|
||||||
|
contactAttempts: null,
|
||||||
|
convertedAt: null,
|
||||||
|
aiSummary: liveLead.aiSummary ?? null,
|
||||||
|
aiSuggestedAction: liveLead.aiSuggestedAction ?? null,
|
||||||
|
patientId: null,
|
||||||
|
campaignId: null,
|
||||||
|
adId: null,
|
||||||
|
};
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return localActiveLead;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto p-7">
|
<div className="flex-1 space-y-5 overflow-y-auto p-7">
|
||||||
|
{/* Mode toggle bar */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-secondary bg-primary p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('demo')}
|
||||||
|
className={`rounded-lg px-4 py-1.5 text-sm font-semibold transition duration-100 ease-linear ${
|
||||||
|
mode === 'demo'
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'text-secondary hover:text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Demo Mode
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('live')}
|
||||||
|
className={`rounded-lg px-4 py-1.5 text-sm font-semibold transition duration-100 ease-linear ${
|
||||||
|
mode === 'live'
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'text-secondary hover:text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Live Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'live' && (
|
||||||
|
<BadgeWithDot
|
||||||
|
color={isConnected ? 'success' : 'gray'}
|
||||||
|
size="md"
|
||||||
|
type="pill-color"
|
||||||
|
>
|
||||||
|
{isConnected ? 'Connected to call center' : 'Connecting...'}
|
||||||
|
</BadgeWithDot>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo mode simulator button */}
|
||||||
|
{mode === 'demo' && (
|
||||||
<CallSimulator
|
<CallSimulator
|
||||||
onSimulate={handleSimulateCall}
|
onSimulate={handleSimulateCall}
|
||||||
isCallActive={callState !== 'idle'}
|
isCallActive={localCallState !== 'idle'}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<IncomingCallCard
|
<IncomingCallCard
|
||||||
callState={callState}
|
callState={effectiveCallState}
|
||||||
lead={activeLead}
|
lead={displayLead}
|
||||||
activities={leadActivities}
|
activities={leadActivities}
|
||||||
campaigns={campaigns}
|
campaigns={campaigns}
|
||||||
onDisposition={handleDisposition}
|
onDisposition={handleDisposition}
|
||||||
completedDisposition={completedDisposition}
|
completedDisposition={mode === 'demo' ? completedDisposition : null}
|
||||||
/>
|
/>
|
||||||
<CallLog calls={todaysCalls} />
|
<CallLog calls={todaysCalls} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user