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:
@@ -7,9 +7,12 @@ import { DailyStats } from '@/components/call-desk/daily-stats';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
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 Mode = 'demo' | 'live';
|
||||
|
||||
const isToday = (dateStr: string): boolean => {
|
||||
const d = new Date(dateStr);
|
||||
@@ -35,19 +38,30 @@ export const CallDeskPage = () => {
|
||||
const { calls, leadActivities, campaigns, addCall } = useData();
|
||||
const { leads, updateLead } = useLeads();
|
||||
|
||||
const [callState, setCallState] = useState<CallState>('idle');
|
||||
const [activeLead, setActiveLead] = useState<ReturnType<typeof useLeads>['leads'][number] | null>(null);
|
||||
// Mode toggle: demo (mock simulator) vs live (WebSocket)
|
||||
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 callStartRef = useRef<Date | null>(null);
|
||||
const ringingTimerRef = 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(
|
||||
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
||||
);
|
||||
|
||||
// Demo mode: simulate a call
|
||||
const handleSimulateCall = useCallback(() => {
|
||||
if (callState !== 'idle') return;
|
||||
if (localCallState !== 'idle') return;
|
||||
|
||||
// Prefer leads with aiSummary, fall back to any lead
|
||||
const leadsWithAi = leads.filter((l) => l.aiSummary !== null);
|
||||
@@ -55,19 +69,20 @@ export const CallDeskPage = () => {
|
||||
if (pool.length === 0) return;
|
||||
|
||||
const randomLead = pool[Math.floor(Math.random() * pool.length)];
|
||||
setActiveLead(randomLead);
|
||||
setCallState('ringing');
|
||||
setLocalActiveLead(randomLead);
|
||||
setLocalCallState('ringing');
|
||||
setCompletedDisposition(null);
|
||||
|
||||
ringingTimerRef.current = setTimeout(() => {
|
||||
setCallState('active');
|
||||
setLocalCallState('active');
|
||||
callStartRef.current = new Date();
|
||||
}, 1500);
|
||||
}, [callState, leads]);
|
||||
}, [localCallState, leads]);
|
||||
|
||||
const handleDisposition = useCallback(
|
||||
// Demo mode: log disposition locally
|
||||
const handleDemoDisposition = useCallback(
|
||||
(disposition: CallDisposition, notes: string) => {
|
||||
if (activeLead === null) return;
|
||||
if (localActiveLead === null) return;
|
||||
|
||||
const now = new Date();
|
||||
const startedAt = callStartRef.current ?? now;
|
||||
@@ -78,7 +93,7 @@ export const CallDeskPage = () => {
|
||||
createdAt: startedAt.toISOString(),
|
||||
callDirection: 'INBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: activeLead.contactPhone,
|
||||
callerNumber: localActiveLead.contactPhone,
|
||||
agentName: user.name,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: now.toISOString(),
|
||||
@@ -88,54 +103,150 @@ export const CallDeskPage = () => {
|
||||
callNotes: notes || null,
|
||||
patientId: null,
|
||||
appointmentId: null,
|
||||
leadId: activeLead.id,
|
||||
leadId: localActiveLead.id,
|
||||
leadName:
|
||||
`${activeLead.contactName?.firstName ?? ''} ${activeLead.contactName?.lastName ?? ''}`.trim() ||
|
||||
`${localActiveLead.contactName?.firstName ?? ''} ${localActiveLead.contactName?.lastName ?? ''}`.trim() ||
|
||||
'Unknown',
|
||||
leadPhone: activeLead.contactPhone?.[0]?.number ?? undefined,
|
||||
leadService: activeLead.interestedService ?? undefined,
|
||||
leadPhone: localActiveLead.contactPhone?.[0]?.number ?? undefined,
|
||||
leadService: localActiveLead.interestedService ?? undefined,
|
||||
};
|
||||
|
||||
addCall(newCall);
|
||||
|
||||
const newStatus = dispositionToStatus[disposition];
|
||||
if (newStatus !== undefined) {
|
||||
updateLead(activeLead.id, {
|
||||
updateLead(localActiveLead.id, {
|
||||
leadStatus: newStatus,
|
||||
lastContactedAt: now.toISOString(),
|
||||
contactAttempts: (activeLead.contactAttempts ?? 0) + 1,
|
||||
contactAttempts: (localActiveLead.contactAttempts ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
|
||||
setCompletedDisposition(disposition);
|
||||
setCallState('completed');
|
||||
setLocalCallState('completed');
|
||||
|
||||
completedTimerRef.current = setTimeout(() => {
|
||||
setCallState('idle');
|
||||
setActiveLead(null);
|
||||
setLocalCallState('idle');
|
||||
setLocalActiveLead(null);
|
||||
setCompletedDisposition(null);
|
||||
}, 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 (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-7">
|
||||
<CallSimulator
|
||||
onSimulate={handleSimulateCall}
|
||||
isCallActive={callState !== 'idle'}
|
||||
/>
|
||||
{/* 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
|
||||
onSimulate={handleSimulateCall}
|
||||
isCallActive={localCallState !== 'idle'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IncomingCallCard
|
||||
callState={callState}
|
||||
lead={activeLead}
|
||||
callState={effectiveCallState}
|
||||
lead={displayLead}
|
||||
activities={leadActivities}
|
||||
campaigns={campaigns}
|
||||
onDisposition={handleDisposition}
|
||||
completedDisposition={completedDisposition}
|
||||
completedDisposition={mode === 'demo' ? completedDisposition : null}
|
||||
/>
|
||||
<CallLog calls={todaysCalls} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user